Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 46 additions & 2 deletions promise-types/appstreams/README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# AppStreams Promise Type

A CFEngine custom promise type for managing AppStream modules on compatible systems.

## Overview
Expand All @@ -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

Expand Down Expand Up @@ -83,13 +84,56 @@ 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:

- `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

Expand Down
186 changes: 158 additions & 28 deletions promise-types/appstreams/appstreams.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand All @@ -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()
Expand Down Expand Up @@ -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
):
Expand All @@ -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"):
Expand Down Expand Up @@ -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)
Expand All @@ -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()

Expand Down Expand Up @@ -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):
Expand Down
45 changes: 34 additions & 11 deletions promise-types/appstreams/test_appstreams_logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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
Expand Down
Loading
Loading