Skip to content

Commit 07974e7

Browse files
committed
Add DNF options support and stream switching to appstreams
This commit adds three major enhancements to the appstreams promise type: 1. Generic DNF options support via new 'options' attribute - Accepts list of "key=value" strings (e.g., ["install_weak_deps=false"]) - Uses DNF's set_or_append_opt_value() for generic option handling - Enables any DNF configuration option without hardcoding 2. Automatic stream switching detection and handling - Detects when installed stream differs from requested stream - Uses ModuleBase.switch_to() API for proper stream transitions - Supports both upgrades and downgrades (e.g., 8.2→8.1, 8.1→8.3) 3. ModuleBase API integration for proper module context - Replaces mpc.enable/install/save with ModuleBase.install() - Ensures module-specific package versions are installed - Maintains upstream's sack reset and explicit package download logic Technical details: - Added _apply_dnf_options() method for generic option handling - Added _switch_module() method using ModuleBase.switch_to() - Updated _install_module() to use ModuleBase.install() and accept options - Updated evaluate_promise() to detect stream switches and pass options - Preserves upstream's handle/comment DNF history tracking - Maintains compatibility with upstream's refactored structure Example usage: appstreams: "php" state => "installed", stream => "8.2", profile => "minimal", options => { "install_weak_deps=false" };
1 parent fecb785 commit 07974e7

1 file changed

Lines changed: 138 additions & 30 deletions

File tree

promise-types/appstreams/appstreams.py

Lines changed: 138 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
import sys
3232
import dnf
3333
import dnf.exceptions
34+
import dnf.module.module_base
3435
import re
3536
from cfengine_module_library import PromiseModule, ValidationError, Result
3637

@@ -64,6 +65,12 @@ def __init__(self, **kwargs):
6465
x, "profile name", required=False
6566
),
6667
)
68+
self.add_attribute(
69+
"options",
70+
list,
71+
required=False,
72+
default=[],
73+
)
6774

6875
# Standard CFEngine promise attributes — passed through by the agent
6976
# and used to populate the DNF history comment for audit traceability.
@@ -104,6 +111,7 @@ def evaluate_promise(self, promiser, attributes, metadata):
104111
state = attributes.get("state", "enabled")
105112
stream = attributes.get("stream", None)
106113
profile = attributes.get("profile", None)
114+
options = attributes.get("options", [])
107115

108116
# Build a descriptive argv so dnf history records a meaningful
109117
# "Command Line" entry instead of leaving it blank.
@@ -112,6 +120,8 @@ def evaluate_promise(self, promiser, attributes, metadata):
112120
_cmdline.append(f"stream={stream!r}")
113121
if profile:
114122
_cmdline.append(f"profile={profile!r}")
123+
if options:
124+
_cmdline.append(f"options={options!r}")
115125
_orig_argv, sys.argv = sys.argv, _cmdline
116126

117127
base = dnf.Base()
@@ -198,6 +208,22 @@ def evaluate_promise(self, promiser, attributes, metadata):
198208
return self._disable_module(mpc, base, module_name)
199209

200210
elif state == "installed":
211+
# Check if we need to switch streams
212+
try:
213+
enabled_stream = mpc.getEnabledStream(module_name)
214+
if stream and enabled_stream and enabled_stream != stream:
215+
# Stream switch needed
216+
self.log_info(
217+
f"Switching module {module_name} from stream "
218+
f"{enabled_stream} to {stream}"
219+
)
220+
return self._switch_module(
221+
mpc, base, module_name, stream, profile, options
222+
)
223+
except RuntimeError:
224+
# Module not enabled yet, proceed with normal install
225+
pass
226+
201227
if self._is_module_installed_with_packages(
202228
mpc, base, module_name, stream, profile
203229
):
@@ -206,7 +232,7 @@ def evaluate_promise(self, promiser, attributes, metadata):
206232
f"profile: {profile}) is already present"
207233
)
208234
return Result.KEPT
209-
return self._install_module(mpc, base, module_name, stream, profile)
235+
return self._install_module(mpc, base, module_name, stream, profile, options)
210236

211237
elif state == "removed":
212238
if current_state in ("removed", "disabled"):
@@ -342,8 +368,102 @@ def _log_failed_packages(self, failed_packages):
342368
for pkg, error in failed_packages:
343369
self.log_error(f" Package {pkg} failed: {error}")
344370

345-
def _install_module(self, mpc, base, module_name, stream, profile):
371+
def _apply_dnf_options(self, base, options):
372+
"""Apply DNF configuration options generically"""
373+
if not options:
374+
return
375+
376+
for option in options:
377+
if "=" in option:
378+
key, value = option.split("=", 1)
379+
key = key.strip()
380+
value = value.strip()
381+
382+
try:
383+
# Use DNF's set_or_append_opt_value to handle options generically
384+
base.conf.set_or_append_opt_value(key, value)
385+
self.log_verbose(f"Set DNF option: {key}={value}")
386+
except dnf.exceptions.ConfigError as e:
387+
self.log_warning(f"Failed to set DNF option '{key}={value}': {e}")
388+
except Exception as e:
389+
self.log_warning(f"Unexpected error setting DNF option '{key}={value}': {e}")
390+
391+
def _switch_module(self, mpc, base, module_name, stream, profile, options=None):
392+
"""Switch a module to a different stream using ModuleBase.switch_to()"""
393+
if options is None:
394+
options = []
395+
396+
# Apply DNF configuration options
397+
self._apply_dnf_options(base, options)
398+
399+
if not stream:
400+
self.log_error("Stream must be specified for module switch")
401+
return Result.NOT_KEPT
402+
403+
if not profile:
404+
profile = mpc.getDefaultProfiles(module_name, stream)
405+
profile = profile[0] if profile else None
406+
407+
if not profile:
408+
self.log_error(
409+
f"No profile specified and no default found for {module_name}:{stream}"
410+
)
411+
return Result.NOT_KEPT
412+
413+
# Use ModuleBase API to switch streams
414+
module_spec = f"{module_name}:{stream}/{profile}"
415+
self.log_verbose(f"Switching to module spec: {module_spec}")
416+
417+
# Build command line for DNF history (shown in dnf history list)
418+
cmdline_parts = ["module", "switch-to", "-y", module_spec]
419+
if options:
420+
for opt in options:
421+
cmdline_parts.append(f"--setopt={opt}")
422+
base.args = cmdline_parts
423+
424+
try:
425+
# Create ModuleBase wrapper around base
426+
module_base = dnf.module.module_base.ModuleBase(base)
427+
module_base.switch_to([module_spec])
428+
except dnf.exceptions.Error as e:
429+
self.log_error(f"Failed to switch module {module_spec}: {e}")
430+
return Result.NOT_KEPT
431+
432+
# Resolve and execute transaction
433+
base.resolve()
434+
435+
# Download packages before transaction (following DNF CLI pattern)
436+
if base.transaction:
437+
install_pkgs = []
438+
for tsi in base.transaction:
439+
if tsi.action in dnf.transaction.FORWARD_ACTIONS:
440+
install_pkgs.append(tsi.pkg)
441+
if install_pkgs:
442+
base.download_packages(install_pkgs)
443+
444+
base.do_transaction()
445+
446+
# Verify switch succeeded
447+
try:
448+
enabled_stream = mpc.getEnabledStream(module_name)
449+
if enabled_stream == stream:
450+
installed_profiles = mpc.getInstalledProfiles(module_name)
451+
if profile in installed_profiles:
452+
self.log_info(
453+
f"Module {module_name}:{stream}/{profile} switched successfully"
454+
)
455+
return Result.REPAIRED
456+
except RuntimeError:
457+
pass
458+
459+
self.log_error(f"Failed to verify module switch for {module_name}:{stream}/{profile}")
460+
return Result.NOT_KEPT
461+
462+
def _install_module(self, mpc, base, module_name, stream, profile, options=None):
346463
"""Enable a module stream and install the given (or default) profile's packages."""
464+
# Apply DNF options if specified
465+
self._apply_dnf_options(base, options)
466+
347467
if not stream:
348468
try:
349469
stream = mpc.getEnabledStream(module_name)
@@ -361,33 +481,22 @@ def _install_module(self, mpc, base, module_name, stream, profile):
361481
)
362482
return Result.NOT_KEPT
363483

364-
mpc.enable(module_name, stream)
365-
mpc.install(module_name, stream, profile)
366-
mpc.save()
367-
mpc.moduleDefaultsResolve()
368-
369-
# Rebuild the sack so module stream filtering reflects the newly enabled
370-
# stream. fill_sack() applies DNF module exclusions at call time, so
371-
# packages from the new stream are invisible to base.upgrade() unless
372-
# the sack is rebuilt after enable().
373-
base.reset(sack=True)
374-
base.fill_sack(load_system_repo=True)
375-
if hasattr(base.sack, "_moduleContainer"):
376-
mpc = base.sack._moduleContainer
377-
378-
failed_packages = []
379-
for pkg in self._get_profile_packages(mpc, module_name, stream, profile):
380-
# Try upgrade first to handle stream switches where the package
381-
# is already installed at a different stream's version. Fall back
382-
# to install for packages not yet present on the system.
383-
try:
384-
base.upgrade(pkg)
385-
except dnf.exceptions.Error:
386-
try:
387-
base.install(pkg)
388-
except dnf.exceptions.Error as e:
389-
self.log_verbose(f"Failed to install package {pkg}: {e}")
390-
failed_packages.append((pkg, str(e)))
484+
# Use ModuleBase API for proper module context
485+
spec = f"{module_name}:{stream}/{profile}"
486+
487+
# Build command line for DNF history (shown in dnf history list)
488+
cmdline_parts = ["module", "install", "-y", spec]
489+
if options:
490+
for opt in options:
491+
cmdline_parts.append(f"--setopt={opt}")
492+
base.args = cmdline_parts
493+
494+
try:
495+
module_base = dnf.module.module_base.ModuleBase(base)
496+
module_base.install([spec])
497+
except dnf.exceptions.Error as e:
498+
self.log_error(f"Failed to install module {spec}: {e}")
499+
return Result.NOT_KEPT
391500

392501
base.resolve()
393502

@@ -415,7 +524,6 @@ def _install_module(self, mpc, base, module_name, stream, profile):
415524
return Result.REPAIRED
416525
else:
417526
self.log_error(f"Failed to install module {module_name}:{stream}/{profile}")
418-
self._log_failed_packages(failed_packages)
419527
return Result.NOT_KEPT
420528

421529
def _remove_module(self, mpc, base, module_name, stream, profile):

0 commit comments

Comments
 (0)