From 0e3bf53fa8226760133518b8e082ae88cdab0bac Mon Sep 17 00:00:00 2001
From: David Kubek <dkubek@redhat.com>
Date: Thu, 22 Aug 2024 11:54:37 +0200
Subject: [PATCH] Restructure hybrid image detection

Previosly detection of Azure hybrid image was tightly coupled with
process of converting grubenv symlink to a regular file. Since there
exists other issues relating to hybrid images it is worth to separate
these two concepts.

This commit modifies the CheckHybridImage actor so that it produces a
message whel WALinuxAgent is detected or we are booted in bios and ESP
partition is mounted and we are running on Hyper-V(sign of a hybrid
image).

New CheckGrubenvToFile actor is responsible for detection of grubenv
symlink on hybrid images and tasks ConvertGrubenvToFile that is later
responsible for the actual conversion.
---
 .../actors/cloud/checkgrubenvtofile/actor.py  |  34 ++++++
 .../libraries/checkgrubenvtofile.py           |  50 ++++++++
 .../tests/test_checkgrubenvtofile.py          |  58 +++++++++
 .../actors/cloud/checkhybridimage/actor.py    |   7 +-
 .../libraries/checkhybridimage.py             |  75 +++++++-----
 .../tests/test_checkhybridimage.py            | 115 ++++++++++--------
 .../cloud/convertgrubenvtofile/actor.py       |  21 ++++
 .../libraries/convertgrubenvtofile.py}        |   8 ++
 .../tests/test_convertgrubenvtofile.py        |  51 ++++++++
 .../actors/cloud/grubenvtofile/actor.py       |  28 -----
 .../grubenvtofile/tests/test_grubenvtofile.py |  43 -------
 repos/system_upgrade/common/models/grubenv.py |  11 +-
 .../common/models/hybridimage.py              |  11 ++
 .../cloud/checkvalidgrubcfghybrid/actor.py    |  32 +++++
 .../libraries/checkvalidgrubcfghybrid.py      |  27 ++++
 .../tests/test_checkvalidgrubcfghybrid.py     |  25 ++++
 .../cloud/ensurevalidgrubcfghybrid/actor.py   |  14 +--
 17 files changed, 439 insertions(+), 171 deletions(-)
 create mode 100644 repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/actor.py
 create mode 100644 repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/libraries/checkgrubenvtofile.py
 create mode 100644 repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/tests/test_checkgrubenvtofile.py
 create mode 100644 repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/actor.py
 rename repos/system_upgrade/common/actors/cloud/{grubenvtofile/libraries/grubenvtofile.py => convertgrubenvtofile/libraries/convertgrubenvtofile.py} (79%)
 create mode 100644 repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/tests/test_convertgrubenvtofile.py
 delete mode 100644 repos/system_upgrade/common/actors/cloud/grubenvtofile/actor.py
 delete mode 100644 repos/system_upgrade/common/actors/cloud/grubenvtofile/tests/test_grubenvtofile.py
 create mode 100644 repos/system_upgrade/common/models/hybridimage.py
 create mode 100644 repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/actor.py
 create mode 100644 repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/libraries/checkvalidgrubcfghybrid.py
 create mode 100644 repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/tests/test_checkvalidgrubcfghybrid.py

diff --git a/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/actor.py b/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/actor.py
new file mode 100644
index 0000000000..4b8ce82504
--- /dev/null
+++ b/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/actor.py
@@ -0,0 +1,34 @@
+from leapp.actors import Actor
+from leapp.libraries.actor import checkgrubenvtofile
+from leapp.models import ConvertGrubenvTask, FirmwareFacts, HybridImage
+from leapp.reporting import Report
+from leapp.tags import ChecksPhaseTag, IPUWorkflowTag
+
+
+class CheckGrubenvToFile(Actor):
+    """
+    Check whether grubenv is a symlink on Azure hybrid images using BIOS.
+
+    Azure images provided by Red Hat aim for hybrid (BIOS/EFI) functionality,
+    however, currently GRUB is not able to see the "grubenv" file if it is a
+    symlink to a different partition (default on EFI with grub2-efi pkg
+    installed) and fails on BIOS systems.
+
+    These images have a default relative symlink to EFI partition even when
+    booted using BIOS and in such cases GRUB is not able to find "grubenv" and
+    fails to get the kernel cmdline options resulting in system failing to boot
+    after upgrade.
+
+    The symlink needs to be converted to a normal file with the content of
+    grubenv on the EFI partition in case the system is using BIOS and running
+    on the Azure cloud. This action is reported in the preupgrade phase.
+
+    """
+
+    name = 'check_grubenv_to_file'
+    consumes = (FirmwareFacts, HybridImage,)
+    produces = (ConvertGrubenvTask, Report)
+    tags = (ChecksPhaseTag, IPUWorkflowTag)
+
+    def process(self):
+        checkgrubenvtofile.process()
diff --git a/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/libraries/checkgrubenvtofile.py b/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/libraries/checkgrubenvtofile.py
new file mode 100644
index 0000000000..c9fc2ed1ba
--- /dev/null
+++ b/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/libraries/checkgrubenvtofile.py
@@ -0,0 +1,50 @@
+import os
+
+from leapp import reporting
+from leapp.libraries.stdlib import api
+from leapp.models import ConvertGrubenvTask, FirmwareFacts, HybridImage
+
+GRUBENV_BIOS_PATH = '/boot/grub2/grubenv'
+GRUBENV_EFI_PATH = '/boot/efi/EFI/redhat/grubenv'
+
+
+def process():
+    hybrid_image = next(api.consume(HybridImage), None)
+
+    if hybrid_image and is_bios() and is_grubenv_symlink_to_efi():
+        reporting.create_report([
+            reporting.Title(
+                'Azure hybrid (BIOS/EFI) image detected. "grubenv" symlink will be converted to a regular file'
+            ),
+            reporting.Summary(
+                'Leapp detected the system is running on Azure cloud, booted using BIOS and '
+                'the "/boot/grub2/grubenv" file is a symlink to "../efi/EFI/redhat/grubenv". In case of such a '
+                'hybrid image scenario GRUB is not able to locate "grubenv" as it is a symlink to different '
+                'partition and fails to boot. If the system needs to be run in EFI mode later, please re-create '
+                'the relative symlink again.'
+            ),
+            reporting.Severity(reporting.Severity.HIGH),
+            reporting.Groups([reporting.Groups.PUBLIC_CLOUD]),
+        ])
+
+        api.produce(ConvertGrubenvTask())
+
+
+def is_bios():
+    """
+    Check whether system is booted into BIOS
+    """
+
+    ff = next(api.consume(FirmwareFacts), None)
+    return ff and ff.firmware == 'bios'
+
+
+def is_grubenv_symlink_to_efi():
+    """
+    Check whether '/boot/grub2/grubenv' is a relative symlink to '/boot/efi/EFI/redhat/grubenv'.
+    """
+
+    is_symlink = os.path.islink(GRUBENV_BIOS_PATH)
+    realpaths_match = os.path.realpath(GRUBENV_BIOS_PATH) == os.path.realpath(GRUBENV_EFI_PATH)
+
+    return is_symlink and realpaths_match
diff --git a/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/tests/test_checkgrubenvtofile.py b/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/tests/test_checkgrubenvtofile.py
new file mode 100644
index 0000000000..27201747c9
--- /dev/null
+++ b/repos/system_upgrade/common/actors/cloud/checkgrubenvtofile/tests/test_checkgrubenvtofile.py
@@ -0,0 +1,58 @@
+import pytest
+
+from leapp import reporting
+from leapp.libraries.actor import checkgrubenvtofile
+from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked
+from leapp.libraries.stdlib import api
+from leapp.models import FirmwareFacts, HybridImage
+
+BIOS_FIRMWARE = FirmwareFacts(firmware='bios')
+EFI_FIRMWARE = FirmwareFacts(firmware='efi')
+
+
+@pytest.mark.parametrize('is_symlink', [True, False])
+@pytest.mark.parametrize('realpath_match', [True, False])
+def test_is_grubenv_symlink_to_efi(monkeypatch, tmpdir, is_symlink, realpath_match):
+    grubenv_efi = tmpdir.join('grubenv_efi')
+    grubenv_efi.write('grubenv')
+    grubenv_efi.write('nope')
+    grubenv_boot = tmpdir.join('grubenv_boot')
+
+    grubenv_efi_false = tmpdir.join('grubenv_efi_false')
+
+    monkeypatch.setattr(checkgrubenvtofile, 'GRUBENV_BIOS_PATH', grubenv_boot.strpath)
+    monkeypatch.setattr(checkgrubenvtofile, 'GRUBENV_EFI_PATH', grubenv_efi.strpath)
+
+    grubenv_target = grubenv_efi if realpath_match else grubenv_efi_false
+    if is_symlink:
+        grubenv_boot.mksymlinkto(grubenv_target)
+
+    result = checkgrubenvtofile.is_grubenv_symlink_to_efi()
+
+    assert result == (is_symlink and realpath_match)
+
+
+@pytest.mark.parametrize('is_hybrid', [True, False])
+@pytest.mark.parametrize('is_bios', [True, False])
+@pytest.mark.parametrize('is_symlink', [True, False])
+def test_check_grubenv_to_file(monkeypatch, tmpdir, is_hybrid, is_bios, is_symlink):
+
+    should_report = all([is_hybrid, is_bios, is_symlink])
+
+    monkeypatch.setattr(reporting, 'create_report', create_report_mocked())
+
+    firmware = BIOS_FIRMWARE if is_bios else EFI_FIRMWARE
+    msgs = [firmware] + ([HybridImage()] if is_hybrid else [])
+    monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=msgs))
+    monkeypatch.setattr(api, "produce", produce_mocked())
+    monkeypatch.setattr(checkgrubenvtofile, "is_grubenv_symlink_to_efi", lambda: is_symlink)
+
+    checkgrubenvtofile.process()
+
+    if should_report:
+        assert reporting.create_report.called == 1
+        assert 'hybrid' in reporting.create_report.report_fields['title']
+        assert api.produce.called == 1
+    else:
+        assert reporting.create_report.called == 0
+        assert api.produce.called == 0
diff --git a/repos/system_upgrade/common/actors/cloud/checkhybridimage/actor.py b/repos/system_upgrade/common/actors/cloud/checkhybridimage/actor.py
index 3cd2d8645f..53b1f41a23 100644
--- a/repos/system_upgrade/common/actors/cloud/checkhybridimage/actor.py
+++ b/repos/system_upgrade/common/actors/cloud/checkhybridimage/actor.py
@@ -8,14 +8,9 @@
 class CheckHybridImage(Actor):
     """
     Check if the system is using Azure hybrid image.
-
-    These images have a default relative symlink to EFI
-    partition even when booted using BIOS and in such cases
-    GRUB is not able find "grubenv" to get the kernel cmdline
-    options and fails to boot after upgrade`.
     """
 
-    name = 'checkhybridimage'
+    name = 'check_hybrid_image'
     consumes = (InstalledRPM, FirmwareFacts)
     produces = (HybridImage, Report)
     tags = (ChecksPhaseTag, IPUWorkflowTag)
diff --git a/repos/system_upgrade/common/actors/cloud/checkhybridimage/libraries/checkhybridimage.py b/repos/system_upgrade/common/actors/cloud/checkhybridimage/libraries/checkhybridimage.py
index a4eb6fa19a..070f67917a 100644
--- a/repos/system_upgrade/common/actors/cloud/checkhybridimage/libraries/checkhybridimage.py
+++ b/repos/system_upgrade/common/actors/cloud/checkhybridimage/libraries/checkhybridimage.py
@@ -1,26 +1,32 @@
 import os
 
-from leapp import reporting
 from leapp.libraries.common import rhui
 from leapp.libraries.common.config.version import get_source_major_version
 from leapp.libraries.common.rpms import has_package
-from leapp.libraries.stdlib import api
+from leapp.libraries.stdlib import api, CalledProcessError, run
 from leapp.models import FirmwareFacts, HybridImage, InstalledRPM
 
-BIOS_PATH = '/boot/grub2/grubenv'
-EFI_PATH = '/boot/efi/EFI/redhat/grubenv'
+EFI_MOUNTPOINT = '/boot/efi/'
+AZURE_HYPERVISOR_ID = 'microsoft'
 
 
-def is_grubenv_symlink_to_efi():
+def check_hybrid_image():
     """
-    Check whether '/boot/grub2/grubenv' is a relative symlink to
-    '/boot/efi/EFI/redhat/grubenv'.
+    Check whether the system is using Azure hybrid image.
     """
-    return os.path.islink(BIOS_PATH) and os.path.realpath(BIOS_PATH) == os.path.realpath(EFI_PATH)
+
+    hybrid_image_condition_1 = is_azure_agent_installed() and is_bios()
+    hybrid_image_condition_2 = has_efi_partition() and is_bios() and is_running_on_azure_hypervisor()
+
+    if any([hybrid_image_condition_1, hybrid_image_condition_2]):
+        api.produce(HybridImage())
 
 
 def is_azure_agent_installed():
-    """Check whether 'WALinuxAgent' package is installed."""
+    """
+    Check whether 'WALinuxAgent' package is installed.
+    """
+
     src_ver_major = get_source_major_version()
 
     family = rhui.RHUIFamily(rhui.RHUIProvider.AZURE)
@@ -39,27 +45,40 @@ def is_azure_agent_installed():
     return has_package(InstalledRPM, agent_pkg)
 
 
+def has_efi_partition():
+    """
+    Check whether ESP partition exists and is mounted.
+    """
+
+    return os.path.exists(EFI_MOUNTPOINT) and os.path.ismount(EFI_MOUNTPOINT)
+
+
 def is_bios():
-    """Check whether system is booted into BIOS"""
+    """
+    Check whether system is booted into BIOS
+    """
+
     ff = next(api.consume(FirmwareFacts), None)
     return ff and ff.firmware == 'bios'
 
 
-def check_hybrid_image():
-    """Check whether the system is using Azure hybrid image."""
-    if all([is_grubenv_symlink_to_efi(), is_azure_agent_installed(), is_bios()]):
-        api.produce(HybridImage(detected=True))
-        reporting.create_report([
-            reporting.Title(
-                'Azure hybrid (BIOS/EFI) image detected. "grubenv" symlink will be converted to a regular file'
-            ),
-            reporting.Summary(
-                'Leapp detected the system is running on Azure cloud, booted using BIOS and '
-                'the "/boot/grub2/grubenv" file is a symlink to "../efi/EFI/redhat/grubenv". In case of such a '
-                'hybrid image scenario GRUB is not able to locate "grubenv" as it is a symlink to different '
-                'partition and fails to boot. If the system needs to be run in EFI mode later, please re-create '
-                'the relative symlink again.'
-            ),
-            reporting.Severity(reporting.Severity.HIGH),
-            reporting.Groups([reporting.Groups.PUBLIC_CLOUD]),
-        ])
+def is_running_on_azure_hypervisor():
+    """
+    Check if system is running on Azure hypervisor (Hyper-V)
+    """
+
+    return detect_virt() == AZURE_HYPERVISOR_ID
+
+
+def detect_virt():
+    """
+    Detect execution in a virtualized environment
+    """
+
+    try:
+        result = run('systemd-detect-virt')
+    except CalledProcessError as e:
+        api.current_logger().warning('Unable to detect virtualization environment! {}'.format(e))
+        return ''
+
+    return result['stdout']
diff --git a/repos/system_upgrade/common/actors/cloud/checkhybridimage/tests/test_checkhybridimage.py b/repos/system_upgrade/common/actors/cloud/checkhybridimage/tests/test_checkhybridimage.py
index 16fbb44c37..88d90811e9 100644
--- a/repos/system_upgrade/common/actors/cloud/checkhybridimage/tests/test_checkhybridimage.py
+++ b/repos/system_upgrade/common/actors/cloud/checkhybridimage/tests/test_checkhybridimage.py
@@ -2,10 +2,9 @@
 
 from leapp import reporting
 from leapp.libraries.actor import checkhybridimage
-from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked
-from leapp.libraries.stdlib import api
-from leapp.models import FirmwareFacts, InstalledRPM, RPM
-from leapp.reporting import Report
+from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, logger_mocked, produce_mocked
+from leapp.libraries.stdlib import api, CalledProcessError
+from leapp.models import FirmwareFacts, HybridImage, InstalledRPM, RPM
 
 RH_PACKAGER = 'Red Hat, Inc. <http://bugzilla.redhat.com/bugzilla>'
 WA_AGENT_RPM = RPM(
@@ -27,56 +26,70 @@
 EFI_PATH = '/boot/efi/EFI/redhat/grubenv'
 
 
-def test_hybrid_image(monkeypatch, tmpdir):
-    grubenv_efi = tmpdir.join('grubenv_efi')
-    grubenv_efi.write('grubenv')
+def raise_call_error(args=None):
+    raise CalledProcessError(
+        message='A Leapp Command Error occurred.',
+        command=args,
+        result={'signal': None, 'exit_code': 1, 'pid': 0, 'stdout': 'fake', 'stderr': 'fake'}
+    )
 
-    grubenv_boot = tmpdir.join('grubenv_boot')
-    grubenv_boot.mksymlinkto('grubenv_efi')
 
-    monkeypatch.setattr(checkhybridimage, 'BIOS_PATH', grubenv_boot.strpath)
-    monkeypatch.setattr(checkhybridimage, 'EFI_PATH', grubenv_efi.strpath)
-    monkeypatch.setattr(reporting, 'create_report', create_report_mocked())
-    monkeypatch.setattr(
-        api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=[BIOS_FIRMWARE, INSTALLED_AGENT])
-    )
-    monkeypatch.setattr(api, "produce", produce_mocked())
+class run_mocked(object):
+    def __init__(self, hypervisor='', raise_err=False):
+        self.hypervisor = hypervisor
+        self.called = 0
+        self.args = []
+        self.raise_err = raise_err
+
+    def __call__(self, *args):
+        self.called += 1
+        self.args.append(args)
+
+        if self.raise_err:
+            raise_call_error(args)
+
+        if args[0] == 'systemd-detect-virt':
+            return {'stdout': self.hypervisor}
+
+
+@pytest.mark.parametrize('hypervisor, expected', [('none', False), ('microsoft', True)])
+def test_is_running_on_azure_hypervisor(monkeypatch, hypervisor, expected):
+    monkeypatch.setattr(checkhybridimage, 'run', run_mocked(hypervisor))
+
+    assert checkhybridimage.is_running_on_azure_hypervisor() == expected
+
+
+def test_is_running_on_azure_hypervisor_error(monkeypatch):
+    monkeypatch.setattr(checkhybridimage, 'run', run_mocked('microsoft', raise_err=True))
+    monkeypatch.setattr(api, 'current_logger', logger_mocked())
+
+    result = checkhybridimage.is_running_on_azure_hypervisor()
+
+    assert result is False
+    assert any('Unable to detect' in msg for msg in api.current_logger.warnmsg)
+
+
+@pytest.mark.parametrize('is_bios', [True, False])
+@pytest.mark.parametrize('has_efi_partition', [True, False])
+@pytest.mark.parametrize('agent_installed', [True, False])
+@pytest.mark.parametrize('is_microsoft', [True, False])
+def test_hybrid_image(monkeypatch, tmpdir, is_bios, has_efi_partition, agent_installed, is_microsoft):
+    should_produce = (is_microsoft and is_bios and has_efi_partition) or (agent_installed and is_bios)
 
-    checkhybridimage.check_hybrid_image()
-    assert reporting.create_report.called == 1
-    assert 'hybrid' in reporting.create_report.report_fields['title']
-    assert api.produce.called == 1
-
-
-@pytest.mark.parametrize('is_symlink, realpath_match, is_bios, agent_installed', [
-    (False, True, True, True),
-    (True, False, True, True),
-    (True, True, False, True),
-    (True, True, True, False),
-])
-def test_no_hybrid_image(monkeypatch, is_symlink, realpath_match, is_bios, agent_installed, tmpdir):
-    grubenv_efi = tmpdir.join('grubenv_efi')
-    grubenv_efi.write('grubenv')
-    grubenv_efi_false = tmpdir.join('grubenv_efi_false')
-    grubenv_efi.write('nope')
-    grubenv_boot = tmpdir.join('grubenv_boot')
-
-    grubenv_target = grubenv_efi if realpath_match else grubenv_efi_false
-
-    if is_symlink:
-        grubenv_boot.mksymlinkto(grubenv_target)
-
-    firmw = BIOS_FIRMWARE if is_bios else EFI_FIRMWARE
-    inst_rpms = INSTALLED_AGENT if agent_installed else NOT_INSTALLED_AGENT
-
-    monkeypatch.setattr(checkhybridimage, 'BIOS_PATH', grubenv_boot.strpath)
-    monkeypatch.setattr(checkhybridimage, 'EFI_PATH', grubenv_efi.strpath)
     monkeypatch.setattr(reporting, 'create_report', create_report_mocked())
-    monkeypatch.setattr(
-        api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=[firmw, inst_rpms])
-    )
-    monkeypatch.setattr(api, "produce", produce_mocked())
+    msgs = [
+        BIOS_FIRMWARE if is_bios else EFI_FIRMWARE,
+        INSTALLED_AGENT if agent_installed else NOT_INSTALLED_AGENT
+    ]
+    monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=msgs))
+    monkeypatch.setattr(api, 'produce', produce_mocked())
+    monkeypatch.setattr(checkhybridimage, 'has_efi_partition', lambda: has_efi_partition)
+    monkeypatch.setattr(checkhybridimage, 'is_running_on_azure_hypervisor', lambda: is_microsoft)
 
     checkhybridimage.check_hybrid_image()
-    assert not reporting.create_report.called
-    assert not api.produce.called
+
+    if should_produce:
+        assert api.produce.called == 1
+        assert HybridImage() in api.produce.model_instances
+    else:
+        assert not api.produce.called
diff --git a/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/actor.py b/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/actor.py
new file mode 100644
index 0000000000..68ef54bb9a
--- /dev/null
+++ b/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/actor.py
@@ -0,0 +1,21 @@
+from leapp.actors import Actor
+from leapp.libraries.actor import convertgrubenvtofile
+from leapp.models import ConvertGrubenvTask
+from leapp.tags import FinalizationPhaseTag, IPUWorkflowTag
+
+
+class ConvertGrubenvToFile(Actor):
+    """
+    Convert "grubenv" symlink to a regular file on Azure hybrid images using BIOS.
+
+    For more information see CheckGrubenvToFile actor.
+
+    """
+
+    name = 'convert_grubenv_to_file'
+    consumes = (ConvertGrubenvTask,)
+    produces = ()
+    tags = (FinalizationPhaseTag, IPUWorkflowTag)
+
+    def process(self):
+        convertgrubenvtofile.process()
diff --git a/repos/system_upgrade/common/actors/cloud/grubenvtofile/libraries/grubenvtofile.py b/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/libraries/convertgrubenvtofile.py
similarity index 79%
rename from repos/system_upgrade/common/actors/cloud/grubenvtofile/libraries/grubenvtofile.py
rename to repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/libraries/convertgrubenvtofile.py
index 4d699ec3ff..1803c6c714 100644
--- a/repos/system_upgrade/common/actors/cloud/grubenvtofile/libraries/grubenvtofile.py
+++ b/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/libraries/convertgrubenvtofile.py
@@ -1,9 +1,17 @@
 from leapp.libraries.stdlib import api, CalledProcessError, run
+from leapp.models import ConvertGrubenvTask
 
 BIOS_PATH = '/boot/grub2/grubenv'
 EFI_PATH = '/boot/efi/EFI/redhat/grubenv'
 
 
+def process():
+    convert_grubenv_task = next(api.consume(ConvertGrubenvTask), None)
+
+    if convert_grubenv_task:
+        grubenv_to_file()
+
+
 def grubenv_to_file():
     try:
         run(['unlink', BIOS_PATH])
diff --git a/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/tests/test_convertgrubenvtofile.py b/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/tests/test_convertgrubenvtofile.py
new file mode 100644
index 0000000000..c4534bd62e
--- /dev/null
+++ b/repos/system_upgrade/common/actors/cloud/convertgrubenvtofile/tests/test_convertgrubenvtofile.py
@@ -0,0 +1,51 @@
+import pytest
+
+from leapp.libraries.actor import convertgrubenvtofile
+from leapp.libraries.common.testutils import CurrentActorMocked, logger_mocked
+from leapp.libraries.stdlib import api, CalledProcessError
+from leapp.models import ConvertGrubenvTask
+
+
+def raise_call_error(args=None):
+    raise CalledProcessError(
+        message='A Leapp Command Error occurred.',
+        command=args,
+        result={'signal': None, 'exit_code': 1, 'pid': 0, 'stdout': 'fake', 'stderr': 'fake'}
+    )
+
+
+class run_mocked(object):
+    def __init__(self, raise_err=False):
+        self.called = 0
+        self.args = []
+        self.raise_err = raise_err
+
+    def __call__(self, *args):
+        self.called += 1
+        self.args.append(args)
+        if self.raise_err:
+            raise_call_error(args)
+
+
+def test_grubenv_to_file(monkeypatch):
+    monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=[ConvertGrubenvTask()]))
+    monkeypatch.setattr(convertgrubenvtofile, 'run', run_mocked(raise_err=False))
+    convertgrubenvtofile.process()
+    assert convertgrubenvtofile.run.called == 2
+
+
+def test_no_grubenv_to_file(monkeypatch):
+    monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=[]))
+    monkeypatch.setattr(convertgrubenvtofile, 'run', run_mocked(raise_err=False))
+    convertgrubenvtofile.process()
+    assert convertgrubenvtofile.run.called == 0
+
+
+def test_fail_grubenv_to_file(monkeypatch):
+    monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=[ConvertGrubenvTask()]))
+    monkeypatch.setattr(convertgrubenvtofile, 'run', run_mocked(raise_err=True))
+    monkeypatch.setattr(api, 'current_logger', logger_mocked())
+    convertgrubenvtofile.grubenv_to_file()
+
+    assert convertgrubenvtofile.run.called == 1
+    assert api.current_logger.warnmsg[0].startswith('Could not unlink')
diff --git a/repos/system_upgrade/common/actors/cloud/grubenvtofile/actor.py b/repos/system_upgrade/common/actors/cloud/grubenvtofile/actor.py
deleted file mode 100644
index fc94219c8e..0000000000
--- a/repos/system_upgrade/common/actors/cloud/grubenvtofile/actor.py
+++ /dev/null
@@ -1,28 +0,0 @@
-from leapp.actors import Actor
-from leapp.libraries.actor.grubenvtofile import grubenv_to_file
-from leapp.models import HybridImage
-from leapp.tags import FinalizationPhaseTag, IPUWorkflowTag
-
-
-class GrubenvToFile(Actor):
-    """
-    Convert "grubenv" symlink to a regular file on Azure hybrid images using BIOS.
-
-    Azure images provided by Red Hat aim for hybrid (BIOS/EFI) functionality,
-    however, currently GRUB is not able to see the "grubenv" file if it is a symlink
-    to a different partition (default on EFI with grub2-efi pkg installed) and
-    fails on BIOS systems. This actor converts the symlink to the normal file
-    with the content of grubenv on the EFI partition in case the system is using BIOS
-    and running on the Azure cloud. This action is reported in the preupgrade phase.
-    """
-
-    name = 'grubenvtofile'
-    consumes = (HybridImage,)
-    produces = ()
-    tags = (FinalizationPhaseTag, IPUWorkflowTag)
-
-    def process(self):
-        grubenv_msg = next(self.consume(HybridImage), None)
-
-        if grubenv_msg and grubenv_msg.detected:
-            grubenv_to_file()
diff --git a/repos/system_upgrade/common/actors/cloud/grubenvtofile/tests/test_grubenvtofile.py b/repos/system_upgrade/common/actors/cloud/grubenvtofile/tests/test_grubenvtofile.py
deleted file mode 100644
index 807f5efa37..0000000000
--- a/repos/system_upgrade/common/actors/cloud/grubenvtofile/tests/test_grubenvtofile.py
+++ /dev/null
@@ -1,43 +0,0 @@
-import pytest
-
-from leapp.libraries.actor import grubenvtofile
-from leapp.libraries.common.testutils import logger_mocked
-from leapp.libraries.stdlib import api, CalledProcessError
-from leapp.models import HybridImage
-
-
-def raise_call_error(args=None):
-    raise CalledProcessError(
-        message='A Leapp Command Error occurred.',
-        command=args,
-        result={'signal': None, 'exit_code': 1, 'pid': 0, 'stdout': 'fake', 'stderr': 'fake'}
-    )
-
-
-class run_mocked(object):
-    def __init__(self, raise_err=False):
-        self.called = 0
-        self.args = []
-        self.raise_err = raise_err
-
-    def __call__(self, *args):
-        self.called += 1
-        self.args.append(args)
-        if self.raise_err:
-            raise_call_error(args)
-
-
-def test_grubenv_to_file(monkeypatch):
-    monkeypatch.setattr(api, 'consume', lambda x: iter([HybridImage()]))
-    monkeypatch.setattr(grubenvtofile, 'run', run_mocked())
-    grubenvtofile.grubenv_to_file()
-    assert grubenvtofile.run.called == 2
-
-
-def test_fail_grubenv_to_file(monkeypatch):
-    monkeypatch.setattr(api, 'consume', lambda x: iter([HybridImage()]))
-    monkeypatch.setattr(grubenvtofile, 'run', run_mocked(raise_err=True))
-    monkeypatch.setattr(api, 'current_logger', logger_mocked())
-    grubenvtofile.grubenv_to_file()
-    assert grubenvtofile.run.called == 1
-    assert api.current_logger.warnmsg[0].startswith('Could not unlink')
diff --git a/repos/system_upgrade/common/models/grubenv.py b/repos/system_upgrade/common/models/grubenv.py
index be541131e8..c7f339f101 100644
--- a/repos/system_upgrade/common/models/grubenv.py
+++ b/repos/system_upgrade/common/models/grubenv.py
@@ -1,12 +1,11 @@
-from leapp.models import fields, Model
+from leapp.models import Model
 from leapp.topics import SystemFactsTopic
 
 
-class HybridImage(Model):
+class ConvertGrubenvTask(Model):
     """
-    Model used for instructing Leapp to convert "grubenv" symlink
-    into a regular file in case of hybrid (BIOS/EFI) images using BIOS
-    on Azure.
+    Model used for instructing Leapp to convert "grubenv" symlink into a
+    regular file.
     """
+
     topic = SystemFactsTopic
-    detected = fields.Boolean(default=False)
diff --git a/repos/system_upgrade/common/models/hybridimage.py b/repos/system_upgrade/common/models/hybridimage.py
new file mode 100644
index 0000000000..d00bc44966
--- /dev/null
+++ b/repos/system_upgrade/common/models/hybridimage.py
@@ -0,0 +1,11 @@
+from leapp.models import Model
+from leapp.topics import SystemFactsTopic
+
+
+class HybridImage(Model):
+    """
+    Model used to signify that the system is using a hybrid (BIOS/EFI) images
+    using BIOS on Azure.
+    """
+
+    topic = SystemFactsTopic
diff --git a/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/actor.py b/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/actor.py
new file mode 100644
index 0000000000..00406581f5
--- /dev/null
+++ b/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/actor.py
@@ -0,0 +1,32 @@
+from leapp.actors import Actor
+from leapp.libraries.actor import checkvalidgrubcfghybrid
+from leapp.models import FirmwareFacts, HybridImage
+from leapp.reporting import Report
+from leapp.tags import ChecksPhaseTag, IPUWorkflowTag
+
+
+class CheckValidGrubConfigHybrid(Actor):
+    """
+    Check potential for boot failures in Azure Gen1 VMs due to invalid grubcfg
+
+    This actor addresses the issue where the `/boot/grub2/grub.cfg` file is
+    overwritten during the upgrade process by an old RHEL7 configuration
+    leftover on the system, causing the system to fail to boot.
+
+    The problem occurs on hybrid Azure images, which support both UEFI and
+    Legacy systems. The issue is caused by one of the scriplets in `grub-efi`
+    which overwrites during the upgrade current configuration in
+    `/boot/grub2/grub.cfg` by an old configuration from
+    `/boot/efi/EFI/redhat/grub.cfg`.
+
+    The issue is detected specifically to Azure hybrid cloud systems.
+
+    """
+
+    name = 'check_valid_grubcfg_hybrid'
+    consumes = (FirmwareFacts, HybridImage,)
+    produces = (Report,)
+    tags = (ChecksPhaseTag, IPUWorkflowTag)
+
+    def process(self):
+        checkvalidgrubcfghybrid.process()
diff --git a/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/libraries/checkvalidgrubcfghybrid.py b/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/libraries/checkvalidgrubcfghybrid.py
new file mode 100644
index 0000000000..796c608fae
--- /dev/null
+++ b/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/libraries/checkvalidgrubcfghybrid.py
@@ -0,0 +1,27 @@
+from leapp import reporting
+from leapp.libraries.stdlib import api
+from leapp.models import HybridImage
+
+
+def process():
+    hybrid_image = next(api.consume(HybridImage), None)
+
+    if hybrid_image:
+        reporting.create_report([
+            reporting.Title(
+                'Azure hybrid (BIOS/EFI) image detected. The GRUB configuration might be regenerated.'
+            ),
+            reporting.Summary(
+                'Leapp detected the system is running on Azure cloud, booted using BIOS In case '
+                'of such a hybrid image scenario and upgrading from older systems (i.e. RHEL 7) '
+                'it is possible that the system might end up with invalid GRUB configuration, '
+                'as `/boot/grub2/grub.cfg` might be overwritten by an old configuration from '
+                '`/boot/efi/EFI/redhat/grub.cfg`, which might cause the system to fail to boot. '
+
+                'Please ensure that the system is able to boot with both of these '
+                'configurations. If an invalid configuration is detected during upgrade, '
+                'it will be regenerated automatically using `grub2-mkconfig.`'
+            ),
+            reporting.Severity(reporting.Severity.HIGH),
+            reporting.Groups([reporting.Groups.PUBLIC_CLOUD]),
+        ])
diff --git a/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/tests/test_checkvalidgrubcfghybrid.py b/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/tests/test_checkvalidgrubcfghybrid.py
new file mode 100644
index 0000000000..f98d9be89d
--- /dev/null
+++ b/repos/system_upgrade/el8toel9/actors/cloud/checkvalidgrubcfghybrid/tests/test_checkvalidgrubcfghybrid.py
@@ -0,0 +1,25 @@
+import pytest
+
+from leapp import reporting
+from leapp.libraries.actor import checkvalidgrubcfghybrid
+from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked, produce_mocked
+from leapp.libraries.stdlib import api
+from leapp.models import HybridImage
+
+
+@pytest.mark.parametrize('is_hybrid', [True, False])
+def test_check_invalid_grubcfg_hybrid(monkeypatch, is_hybrid):
+
+    monkeypatch.setattr(reporting, 'create_report', create_report_mocked())
+
+    msgs = [HybridImage()] if is_hybrid else []
+    monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch='x86_64', msgs=msgs))
+    monkeypatch.setattr(api, "produce", produce_mocked())
+
+    checkvalidgrubcfghybrid.process()
+
+    if is_hybrid:
+        assert reporting.create_report.called == 1
+        assert 'regenerated' in reporting.create_report.report_fields['title']
+    else:
+        assert reporting.create_report.called == 0
diff --git a/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/actor.py b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/actor.py
index 68de0433fc..8115198fcd 100644
--- a/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/actor.py
+++ b/repos/system_upgrade/el8toel9/actors/cloud/ensurevalidgrubcfghybrid/actor.py
@@ -8,21 +8,17 @@ class EnsureValidGrubcfgHybrid(Actor):
     """
     Resolve boot failures in Azure Gen1 VMs during upgrades from RHEL 7 to RHEL 8 to RHEL 9.
 
-    This actor addresses the issue where the `/boot/grub2/grub.cfg` file is
-    overwritten during the upgrade process by an old RHEL7 configuration
-    leftover on the system, causing the system to fail to boot.
-
-    The problem occurs on hybrid Azure images, which support both UEFI and
-    Legacy systems and have both `grub-pc` and `grub-efi` packages installed.
-    It is caused by one of the scriplets in `grub-efi` which overwrites the old
-    configuration.
-
     If old configuration is detected, this actor regenerates the grub
     configuration using `grub2-mkconfig -o /boot/grub2/grub.cfg` after
     installing rpms to ensure the correct boot configuration is in place.
 
+    Old configuration is detected by looking for a menuentry corresponding to a
+    kernel from RHEL 7 which should not be present on RHEL 8 systems.
+
     The fix is applied specifically to Azure hybrid cloud systems.
 
+    See also CheckValidGrubConfigHybrid actor.
+
     """
 
     name = 'ensure_valid_grubcfg_hybrid'