Skip to content

Commit 8d34c03

Browse files
committed
Added DNF options support and stream switching to appstreams
This commit adds three major enhancements to the appstreams promise type: Generic DNF options support via '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 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) 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 Example usage: appstreams: "php" state => "installed", stream => "8.2", profile => "minimal", options => { "install_weak_deps=false" };
1 parent fecb785 commit 8d34c03

1 file changed

Lines changed: 138 additions & 28 deletions

File tree

promise-types/appstreams/appstreams.py

Lines changed: 138 additions & 28 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,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

Comments
 (0)