diff --git a/promise-types/appstreams/README.md b/promise-types/appstreams/README.md index 65ea883..34d288d 100644 --- a/promise-types/appstreams/README.md +++ b/promise-types/appstreams/README.md @@ -1,5 +1,3 @@ -# AppStreams Promise Type - A CFEngine custom promise type for managing AppStream modules on compatible systems. ## Overview @@ -10,6 +8,9 @@ The `appstreams` promise type allows you to manage AppStream modules, which are - Enable, disable, install, and remove AppStream modules - Support for specifying streams and profiles +- Automatic stream switching (upgrades and downgrades) +- Generic DNF configuration options support +- Audit trail support via handle and comment attributes ## Installation @@ -83,6 +84,46 @@ bundle agent main } ``` +### Stream switching (upgrade or downgrade) + +When a module is already installed with a different stream, the promise type automatically switches to the requested stream: + +``` +bundle agent main +{ + appstreams: + "php" + handle => "main_php_stream_82", + comment => "Upgrade PHP from 8.1 to 8.2 for new features", + state => "installed", + stream => "8.2", + profile => "minimal"; +} +``` + +This will automatically switch from any currently installed stream (e.g., 8.1) to stream 8.2. + +### Using DNF options + +You can pass generic DNF configuration options to control package installation behavior: + +``` +bundle agent main +{ + appstreams: + "php" + state => "installed", + stream => "8.2", + profile => "minimal", + options => { + "install_weak_deps=false", + "best=true" + }; +} +``` + +This installs PHP 8.2 minimal profile without weak dependencies (like httpd). + ## Attributes The promise type supports the following attributes: @@ -90,6 +131,9 @@ 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. +- `options` (optional) - List of DNF configuration options as "key=value" strings (e.g., `{ "install_weak_deps=false", "best=true" }`). Invalid options will cause the promise to fail. +- `handle` (optional) - CFEngine handle for the promise, recorded in DNF history for audit traceability. +- `comment` (optional) - CFEngine comment for the promise, recorded in DNF history for audit traceability. ## Requirements diff --git a/promise-types/appstreams/appstreams.py b/promise-types/appstreams/appstreams.py index dd78520..7c3a4f6 100644 --- a/promise-types/appstreams/appstreams.py +++ b/promise-types/appstreams/appstreams.py @@ -34,6 +34,12 @@ import re from cfengine_module_library import PromiseModule, ValidationError, Result +# Import ModuleBase if available (not available in test environment) +try: + import dnf.module.module_base +except (ImportError, ModuleNotFoundError): + dnf.module = None # type: ignore + class AppStreamsPromiseTypeModule(PromiseModule): def __init__(self, **kwargs): @@ -64,6 +70,12 @@ def __init__(self, **kwargs): x, "profile name", required=False ), ) + self.add_attribute( + "options", + list, + required=False, + default=[], + ) # Standard CFEngine promise attributes — passed through by the agent # and used to populate the DNF history comment for audit traceability. @@ -104,6 +116,7 @@ def evaluate_promise(self, promiser, attributes, metadata): state = attributes.get("state", "enabled") stream = attributes.get("stream", None) profile = attributes.get("profile", None) + options = attributes.get("options", []) # Build a descriptive argv so dnf history records a meaningful # "Command Line" entry instead of leaving it blank. @@ -112,6 +125,8 @@ def evaluate_promise(self, promiser, attributes, metadata): _cmdline.append(f"stream={stream!r}") if profile: _cmdline.append(f"profile={profile!r}") + if options: + _cmdline.append(f"options={options!r}") _orig_argv, sys.argv = sys.argv, _cmdline base = dnf.Base() @@ -198,6 +213,22 @@ def evaluate_promise(self, promiser, attributes, metadata): return self._disable_module(mpc, base, module_name) elif state == "installed": + # Check if we need to switch streams + try: + enabled_stream = mpc.getEnabledStream(module_name) + if stream and enabled_stream and enabled_stream != stream: + # Stream switch needed + self.log_info( + f"Switching module {module_name} from stream " + f"{enabled_stream} to {stream}" + ) + return self._switch_module( + mpc, base, module_name, stream, profile, options + ) + except RuntimeError: + # Module not enabled yet, proceed with normal install + pass + if self._is_module_installed_with_packages( mpc, base, module_name, stream, profile ): @@ -206,7 +237,9 @@ def evaluate_promise(self, promiser, attributes, metadata): f"profile: {profile}) is already present" ) return Result.KEPT - return self._install_module(mpc, base, module_name, stream, profile) + return self._install_module( + mpc, base, module_name, stream, profile, options + ) elif state == "removed": if current_state in ("removed", "disabled"): @@ -342,8 +375,117 @@ def _log_failed_packages(self, failed_packages): for pkg, error in failed_packages: self.log_error(f" Package {pkg} failed: {error}") - def _install_module(self, mpc, base, module_name, stream, profile): + def _apply_dnf_options(self, base, options): + """Apply DNF configuration options, raising ConfigError on invalid options""" + if not options: + return + + for option in options: + if "=" in option: + key, value = option.split("=", 1) + key = key.strip() + value = value.strip() + + # Raises dnf.exceptions.ConfigError if option is invalid + base.conf.set_or_append_opt_value(key, value) + self.log_verbose(f"Set DNF option: {key}={value}") + + def _switch_module(self, mpc, base, module_name, stream, profile, options=None): + """Switch a module to a different stream using ModuleBase.switch_to()""" + if options is None: + options = [] + + # Apply DNF configuration options + try: + self._apply_dnf_options(base, options) + except dnf.exceptions.ConfigError as e: + self.log_error(f"Invalid DNF option: {e}") + return Result.NOT_KEPT + + if not stream: + self.log_error("Stream must be specified for module switch") + return Result.NOT_KEPT + + if not profile: + profile = mpc.getDefaultProfiles(module_name, stream) + profile = profile[0] if profile else None + + if not profile: + self.log_error( + f"No profile specified and no default found for {module_name}:{stream}" + ) + return Result.NOT_KEPT + + # Use ModuleBase API to switch streams + module_spec = f"{module_name}:{stream}/{profile}" + self.log_verbose(f"Switching to module spec: {module_spec}") + + # Build command line for DNF history (shown in dnf history list) + cmdline_parts = ["module", "switch-to", "-y", module_spec] + if options: + for opt in options: + cmdline_parts.append(f"--setopt={opt}") + base.args = cmdline_parts + + try: + # Create ModuleBase wrapper around base + module_base = dnf.module.module_base.ModuleBase(base) + module_base.switch_to([module_spec]) + except dnf.exceptions.Error as e: + self.log_error(f"Failed to switch module {module_spec}: {e}") + return Result.NOT_KEPT + + # Resolve and execute transaction + base.resolve() + + # Download packages before transaction (following DNF CLI pattern) + pkgs_to_download = list(base.transaction.install_set) + if pkgs_to_download: + base.download_packages(pkgs_to_download) + + base.do_transaction() + + # Verify switch succeeded + try: + enabled_stream = mpc.getEnabledStream(module_name) + except RuntimeError: + self.log_error( + f"Failed to get enabled stream for {module_name} after switch" + ) + return Result.NOT_KEPT + + if enabled_stream != stream: + self.log_error( + f"Module {module_name} stream is {enabled_stream}, expected {stream}" + ) + return Result.NOT_KEPT + + try: + installed_profiles = mpc.getInstalledProfiles(module_name) + except RuntimeError: + self.log_error( + f"Failed to get installed profiles for {module_name} after switch" + ) + return Result.NOT_KEPT + + if profile not in installed_profiles: + self.log_error( + f"Profile {profile} not in installed profiles {installed_profiles}" + ) + return Result.NOT_KEPT + + self.log_info(f"Module {module_name}:{stream}/{profile} switched successfully") + return Result.REPAIRED + + def _install_module(self, mpc, base, module_name, stream, profile, options=None): """Enable a module stream and install the given (or default) profile's packages.""" + # Apply DNF options if specified + try: + self._apply_dnf_options(base, options) + except dnf.exceptions.ConfigError as e: + self.log_error(f"Invalid DNF option: {e}") + return Result.NOT_KEPT + if not stream: try: stream = mpc.getEnabledStream(module_name) @@ -361,33 +503,22 @@ def _install_module(self, mpc, base, module_name, stream, profile): ) return Result.NOT_KEPT - mpc.enable(module_name, stream) - mpc.install(module_name, stream, profile) - mpc.save() - mpc.moduleDefaultsResolve() + # Use ModuleBase API for proper module context + spec = f"{module_name}:{stream}/{profile}" - # Rebuild the sack so module stream filtering reflects the newly enabled - # stream. fill_sack() applies DNF module exclusions at call time, so - # packages from the new stream are invisible to base.upgrade() unless - # the sack is rebuilt after enable(). - base.reset(sack=True) - base.fill_sack(load_system_repo=True) - if hasattr(base.sack, "_moduleContainer"): - mpc = base.sack._moduleContainer + # Build command line for DNF history (shown in dnf history list) + cmdline_parts = ["module", "install", "-y", spec] + if options: + for opt in options: + cmdline_parts.append(f"--setopt={opt}") + base.args = cmdline_parts - failed_packages = [] - for pkg in self._get_profile_packages(mpc, module_name, stream, profile): - # Try upgrade first to handle stream switches where the package - # is already installed at a different stream's version. Fall back - # to install for packages not yet present on the system. - try: - base.upgrade(pkg) - except dnf.exceptions.Error: - 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))) + try: + module_base = dnf.module.module_base.ModuleBase(base) + module_base.install([spec]) + except dnf.exceptions.Error as e: + self.log_error(f"Failed to install module {spec}: {e}") + return Result.NOT_KEPT base.resolve() @@ -415,7 +546,6 @@ def _install_module(self, mpc, base, module_name, stream, profile): return Result.REPAIRED else: self.log_error(f"Failed to install module {module_name}:{stream}/{profile}") - self._log_failed_packages(failed_packages) return Result.NOT_KEPT def _remove_module(self, mpc, base, module_name, stream, profile): diff --git a/promise-types/appstreams/test_appstreams_logic.py b/promise-types/appstreams/test_appstreams_logic.py index 2ff9ec4..8ceaf60 100644 --- a/promise-types/appstreams/test_appstreams_logic.py +++ b/promise-types/appstreams/test_appstreams_logic.py @@ -13,8 +13,12 @@ # Mock dnf module before importing the promise module mock_dnf = MagicMock() mock_dnf.exceptions = MagicMock() +mock_dnf.module = MagicMock() +mock_dnf.module.module_base = MagicMock() sys.modules["dnf"] = mock_dnf sys.modules["dnf.exceptions"] = mock_dnf.exceptions +sys.modules["dnf.module"] = mock_dnf.module +sys.modules["dnf.module.module_base"] = mock_dnf.module.module_base import appstreams as appstreams_module # noqa: E402 @@ -113,21 +117,14 @@ def test_install_profile_repaired(module, mock_base, mock_mpc): # First call (pre-install check) returns [], second call (post-install verify) returns ["common"] mock_mpc.getInstalledProfiles.side_effect = [[], ["common"]] - # 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") + # We now use ModuleBase API instead of mpc.install + # Verify the transaction was executed + mock_base.resolve.assert_called() + mock_base.do_transaction.assert_called() assert result == Result.REPAIRED @@ -289,6 +286,32 @@ def test_profile_default_not_found(module, mock_base, mock_mpc): assert result == Result.NOT_KEPT +def test_invalid_dnf_option_not_kept(module, mock_base, mock_mpc): + """Test that invalid DNF options cause NOT_KEPT (ConfigError from DNF)""" + # Mock ConfigError to be raised when invalid option is set + mock_base.conf.set_or_append_opt_value.side_effect = ( + mock_dnf.exceptions.ConfigError('Cannot set "invalid_option" to "value"') + ) + + mock_mpc.getModuleState.return_value = mock_mpc.ModuleState_ENABLED + mock_mpc.getEnabledStream.return_value = "12" + mock_mpc.getInstalledProfiles.return_value = [] + + result = module.evaluate_promise( + "nodejs", + { + "state": "installed", + "stream": "12", + "profile": "common", + "options": ["invalid_option=value"], + }, + {}, + ) + + # Should fail because invalid option raises ConfigError + 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 diff --git a/promise-types/appstreams/test_integration.sh b/promise-types/appstreams/test_integration.sh new file mode 100755 index 0000000..2ba938f --- /dev/null +++ b/promise-types/appstreams/test_integration.sh @@ -0,0 +1,248 @@ +#!/bin/bash +# Integration test for the appstreams promise type. +# Requires a bootstrapped CFEngine agent on Rocky Linux 8 or 9. +# +# Usage: +# ./test_integration.sh +# +# The test policy (test_appstreams_coverage.cf) must be deployed to +# /var/cfengine/inputs/services/cfbs/ and the appstreams promise type +# must be registered in services/init.cf before running this script. + +set -euo pipefail + +PASS=0 +FAIL=0 +BUNDLE="test_appstreams_coverage" +ROLE="role_node_js_app_server_enabled" + +pass() { echo " PASS: $*"; PASS=$((PASS + 1)); } +fail() { echo " FAIL: $*"; FAIL=$((FAIL + 1)); } + +run_agent() { + local classes="$1" + cf-agent -KI -b "$BUNDLE" -D "$ROLE,$classes" 2>&1 || true +} + +get_history_id() { + dnf history list 2>/dev/null | awk '/^[[:space:]]*[0-9]/ {print $1; exit}' | tr -d ' ' || true +} + +# Assert a new DNF history transaction was created since before_id. +# If pkg is provided, verify the transaction mentions that package. +assert_history_entry() { + local before_id="$1" desc="$2" pkg="${3:-}" + local after_id + after_id=$(get_history_id) + if [ "$after_id" = "$before_id" ]; then + fail "dnf history: no transaction recorded ($desc)" + return + fi + pass "dnf history: transaction $after_id recorded ($desc)" + if [ -n "$pkg" ]; then + if dnf history info "$after_id" 2>/dev/null | grep -qi "$pkg"; then + pass "dnf history: transaction $after_id mentions $pkg" + else + fail "dnf history: transaction $after_id does not mention $pkg" + dnf history info "$after_id" 2>/dev/null \ + | grep -iE "Package|Install|Remove|Upgrade|Module" \ + | head -10 | sed 's/^/ /' + fi + fi +} + +# Assert no new DNF history transaction was created since before_id. +assert_no_history_entry() { + local before_id="$1" desc="$2" + local after_id + after_id=$(get_history_id) + if [ "$after_id" = "$before_id" ]; then + pass "dnf history: no transaction for idempotent run ($desc)" + else + fail "dnf history: unexpected transaction $after_id for idempotent run ($desc)" + fi +} + +# Assert the Comment field of a history transaction contains a pattern. +assert_history_comment() { + local id="$1" pattern="$2" + if dnf history info "$id" 2>/dev/null | grep -qP "^Comment\s*:.*$pattern"; then + pass "dnf history comment contains: $pattern" + else + fail "dnf history comment missing: $pattern" + dnf history info "$id" 2>/dev/null | grep "^Comment" | sed 's/^/ /' + fi +} + +assert_repaired() { + local output="$1" pattern="$2" + if echo "$output" | grep -q "info:.*$pattern"; then + pass "$pattern" + else + fail "expected repair: $pattern" + echo "$output" | grep -E "info:|error:|CRITICAL" | sed 's/^/ /' + fi +} + +assert_kept() { + local output="$1" + if echo "$output" | grep -qE "^\s*(info:.*Repaired|error:|CRITICAL)"; then + fail "expected KEPT but got repairs or errors" + echo "$output" | grep -E "info:|error:|CRITICAL" | sed 's/^/ /' + else + pass "idempotent (no repairs)" + fi +} + +assert_rpm_installed() { + local pkg="$1" + if rpm -q "$pkg" &>/dev/null; then + pass "$pkg is installed" + else + fail "$pkg is not installed" + fi +} + +assert_rpm_absent() { + local pkg="$1" + if ! rpm -q "$pkg" &>/dev/null; then + pass "$pkg is absent" + else + fail "$pkg should not be installed" + fi +} + +assert_module_stream() { + local module="$1" stream="$2" marker="$3" desc="$4" + if dnf module list "$module" 2>/dev/null | grep -qP "$stream\s.*\[$marker\]"; then + pass "$desc" + else + fail "$desc" + dnf module list "$module" 2>/dev/null | grep "$module" | sed 's/^/ /' + fi +} + +assert_module_default() { + local module="$1" + # Filter the Hint line (which contains [e], [x], [i] as legend text) + local listing + listing=$(dnf module list "$module" 2>/dev/null | grep -v "^Hint:") + if echo "$listing" | grep -qP "$module\s" && \ + ! echo "$listing" | grep -qP "\[e\]|\[x\]|\[i\]"; then + pass "$module is in default state (no markers)" + else + fail "$module should have no stream markers" + echo "$listing" | grep "$module" | sed 's/^/ /' + fi +} + +echo "========================================" +echo " appstreams promise type integration test" +echo "========================================" +echo + +# ------------------------------------------------------------------ +echo "Setup: resetting module state to a known baseline..." +dnf module reset ruby -y &>/dev/null || true +dnf module reset postgresql -y &>/dev/null || true +dnf remove postgresql-server -y &>/dev/null || true +echo + +# ------------------------------------------------------------------ +echo "Phase 1: enabled" +out=$(run_agent phase_enabled) +assert_repaired "$out" "ruby.*enabled" +assert_module_stream ruby 3.3 e "ruby:3.3 is enabled" +echo " idempotency check" +out=$(run_agent phase_enabled) +assert_kept "$out" +echo + +# ------------------------------------------------------------------ +echo "Phase 2: installed (explicit profile)" +hid=$(get_history_id) +out=$(run_agent phase_installed) +assert_repaired "$out" "postgresql.*installed" +assert_rpm_installed postgresql-server +assert_history_entry "$hid" "installed postgresql:15/server" "postgresql" +last_id=$(get_history_id) +assert_history_comment "$last_id" "test_appstreams_coverage_postgresql_15_server_installed" +assert_history_comment "$last_id" "Install postgresql 15 server profile" + +echo " idempotency check" +hid=$(get_history_id) +out=$(run_agent phase_installed) +assert_kept "$out" +assert_no_history_entry "$hid" "installed postgresql:15/server" +echo + +# ------------------------------------------------------------------ +echo "Phase 3: disabled" +out=$(run_agent phase_disabled) +assert_repaired "$out" "ruby.*disabled" +assert_module_stream ruby 3.3 x "ruby:3.3 is disabled" +echo " idempotency check" +out=$(run_agent phase_disabled) +assert_kept "$out" +echo + +# ------------------------------------------------------------------ +echo "Phase 4: removed" +hid=$(get_history_id) +out=$(run_agent phase_removed) +assert_repaired "$out" "postgresql.*removed" +assert_rpm_absent postgresql-server +assert_history_entry "$hid" "removed postgresql:15" "postgresql" +last_id=$(get_history_id) +assert_history_comment "$last_id" "test_appstreams_coverage_postgresql_15_removed" + +echo " idempotency check" +hid=$(get_history_id) +out=$(run_agent phase_removed) +assert_kept "$out" +assert_no_history_entry "$hid" "removed postgresql:15" +echo + +# ------------------------------------------------------------------ +echo "Phase 5: reset" +out=$(run_agent phase_reset) +assert_repaired "$out" "ruby.*reset" +assert_module_default ruby +echo " idempotency check" +out=$(run_agent phase_reset) +assert_kept "$out" +echo + +# ------------------------------------------------------------------ +echo "Phase 6: stream switch (nodejs 20 -> 22)" +# Ensure we start from stream 20 for a meaningful switch test. +# dnf module install won't downgrade RPMs, so explicitly remove and reinstall. +dnf module reset nodejs -y &>/dev/null || true +dnf remove nodejs npm -y &>/dev/null || true +dnf module install nodejs:20/common -y &>/dev/null || true + +hid=$(get_history_id) +out=$(run_agent phase_stream_switch) +assert_repaired "$out" "nodejs.*installed" +node_ver=$(node --version 2>/dev/null || echo "not found") +if echo "$node_ver" | grep -q "^v22\."; then + pass "node version is $node_ver (stream 22)" +else + fail "expected v22.x, got $node_ver" +fi +assert_history_entry "$hid" "stream switch nodejs 20->22" "nodejs" +last_id=$(get_history_id) +assert_history_comment "$last_id" "test_appstreams_coverage_nodejs_22_stream_switch" + +echo " idempotency check" +hid=$(get_history_id) +out=$(run_agent phase_stream_switch) +assert_kept "$out" +assert_no_history_entry "$hid" "stream switch nodejs 20->22" +echo + +# ------------------------------------------------------------------ +echo "========================================" +echo " Results: $PASS passed, $FAIL failed" +echo "========================================" +[ "$FAIL" -eq 0 ]