3131import sys
3232import dnf
3333import dnf .exceptions
34+ import dnf .module .module_base
3435import re
3536from 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