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,9 @@ 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 (
236+ mpc , base , module_name , stream , profile , options
237+ )
210238
211239 elif state == "removed" :
212240 if current_state in ("removed" , "disabled" ):
@@ -342,8 +370,102 @@ def _log_failed_packages(self, failed_packages):
342370 for pkg , error in failed_packages :
343371 self .log_error (f" Package { pkg } failed: { error } " )
344372
345- def _install_module (self , mpc , base , module_name , stream , profile ):
373+ def _apply_dnf_options (self , base , options ):
374+ """Apply DNF configuration options generically"""
375+ if not options :
376+ return
377+
378+ for option in options :
379+ if "=" in option :
380+ key , value = option .split ("=" , 1 )
381+ key = key .strip ()
382+ value = value .strip ()
383+
384+ try :
385+ # Use DNF's set_or_append_opt_value to handle options generically
386+ base .conf .set_or_append_opt_value (key , value )
387+ self .log_verbose (f"Set DNF option: { key } ={ value } " )
388+ except dnf .exceptions .ConfigError as e :
389+ self .log_warning (f"Failed to set DNF option '{ key } ={ value } ': { e } " )
390+ except Exception as e :
391+ self .log_warning (
392+ f"Unexpected error setting DNF option '{ key } ={ value } ': { e } "
393+ )
394+
395+ def _switch_module (self , mpc , base , module_name , stream , profile , options = None ):
396+ """Switch a module to a different stream using ModuleBase.switch_to()"""
397+ if options is None :
398+ options = []
399+
400+ # Apply DNF configuration options
401+ self ._apply_dnf_options (base , options )
402+
403+ if not stream :
404+ self .log_error ("Stream must be specified for module switch" )
405+ return Result .NOT_KEPT
406+
407+ if not profile :
408+ profile = mpc .getDefaultProfiles (module_name , stream )
409+ profile = profile [0 ] if profile else None
410+
411+ if not profile :
412+ self .log_error (
413+ f"No profile specified and no default found for { module_name } :{ stream } "
414+ )
415+ return Result .NOT_KEPT
416+
417+ # Use ModuleBase API to switch streams
418+ module_spec = f"{ module_name } :{ stream } /{ profile } "
419+ self .log_verbose (f"Switching to module spec: { module_spec } " )
420+
421+ # Build command line for DNF history (shown in dnf history list)
422+ cmdline_parts = ["module" , "switch-to" , "-y" , module_spec ]
423+ if options :
424+ for opt in options :
425+ cmdline_parts .append (f"--setopt={ opt } " )
426+ base .args = cmdline_parts
427+
428+ try :
429+ # Create ModuleBase wrapper around base
430+ module_base = dnf .module .module_base .ModuleBase (base )
431+ module_base .switch_to ([module_spec ])
432+ except dnf .exceptions .Error as e :
433+ self .log_error (f"Failed to switch module { module_spec } : { e } " )
434+ return Result .NOT_KEPT
435+
436+ # Resolve and execute transaction
437+ base .resolve ()
438+
439+ # Download packages before transaction (following DNF CLI pattern)
440+ pkgs_to_download = list (base .transaction .install_set )
441+ if pkgs_to_download :
442+ base .download_packages (pkgs_to_download )
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 (
460+ f"Failed to verify module switch for { module_name } :{ stream } /{ profile } "
461+ )
462+ return Result .NOT_KEPT
463+
464+ def _install_module (self , mpc , base , module_name , stream , profile , options = None ):
346465 """Enable a module stream and install the given (or default) profile's packages."""
466+ # Apply DNF options if specified
467+ self ._apply_dnf_options (base , options )
468+
347469 if not stream :
348470 try :
349471 stream = mpc .getEnabledStream (module_name )
@@ -361,33 +483,22 @@ def _install_module(self, mpc, base, module_name, stream, profile):
361483 )
362484 return Result .NOT_KEPT
363485
364- mpc .enable (module_name , stream )
365- mpc .install (module_name , stream , profile )
366- mpc .save ()
367- mpc .moduleDefaultsResolve ()
486+ # Use ModuleBase API for proper module context
487+ spec = f"{ module_name } :{ stream } /{ profile } "
368488
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
489+ # Build command line for DNF history (shown in dnf history list)
490+ cmdline_parts = ["module" , "install" , "-y" , spec ]
491+ if options :
492+ for opt in options :
493+ cmdline_parts .append (f"--setopt={ opt } " )
494+ base .args = cmdline_parts
377495
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 )))
496+ try :
497+ module_base = dnf .module .module_base .ModuleBase (base )
498+ module_base .install ([spec ])
499+ except dnf .exceptions .Error as e :
500+ self .log_error (f"Failed to install module { spec } : { e } " )
501+ return Result .NOT_KEPT
391502
392503 base .resolve ()
393504
@@ -415,7 +526,6 @@ def _install_module(self, mpc, base, module_name, stream, profile):
415526 return Result .REPAIRED
416527 else :
417528 self .log_error (f"Failed to install module { module_name } :{ stream } /{ profile } " )
418- self ._log_failed_packages (failed_packages )
419529 return Result .NOT_KEPT
420530
421531 def _remove_module (self , mpc , base , module_name , stream , profile ):
0 commit comments