From f6de26092b750db0934f017f2f70fc8a81053d6a Mon Sep 17 00:00:00 2001 From: Michal Hecko Date: Thu, 15 Aug 2024 16:07:34 +0200 Subject: [PATCH] live_mode: add new actors implementing the live mode functionality Add actors that scan the new configuration file devel-livemode.ini, informing the rest of the actor collective about the configuration. Based on this configuration, additional packages are requested to be installed into the target userspace. The target userspace is also modified to contain services that execute leapp based on kernel cmdline. For a full list of modifications, see models/livemode.py added in a previous commit. The feature can be enabled by setting LEAPP_UNSUPPORTED=1 together with LEAPP_DEVEL_ENABLE_LIVE_MODE=1. Note, that the squashfs-tools package must be installed (otherwise an error will be raised). The live mode feature is currently tested only for x86_64, and, therefore, attempting to use this feature on a different architecture will be prohibited by the implementation. Jira ref: RHEL-45280 --- etc/leapp/files/devel-livemode.ini | 9 + .../actor.py | 20 + .../emit_livemode_userspace_requirements.py | 38 ++ .../tests/test_emit_livemode_requirements.py | 37 ++ .../livemode/liveimagegenerator/actor.py | 29 + .../libraries/liveimagegenerator.py | 72 +++ .../tests/test_image_generation.py | 96 ++++ .../livemode/livemode_config_scanner/actor.py | 18 + .../libraries/scan_livemode_config.py | 125 +++++ .../tests/test_config_scanner.py | 122 +++++ .../actors/livemode/livemodereporter/actor.py | 19 + .../libraries/report_livemode.py | 24 + .../tests/test_report_livemode.py | 30 + .../modify_userspace_for_livemode/actor.py | 40 ++ .../files/console.service | 19 + .../files/do-upgrade.sh | 372 +++++++++++++ .../files/upgrade-strace.service | 14 + .../files/upgrade.service | 14 + .../libraries/prepareliveimage.py | 514 ++++++++++++++++++ .../test_livemode_userspace_modifications.py | 487 +++++++++++++++++ .../actors/livemode/removeliveimage/actor.py | 18 + .../libraries/remove_live_image.py | 25 + .../tests/test_remove_live_image.py | 44 ++ 23 files changed, 2186 insertions(+) create mode 100644 etc/leapp/files/devel-livemode.ini create mode 100644 repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/actor.py create mode 100644 repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/libraries/emit_livemode_userspace_requirements.py create mode 100644 repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/tests/test_emit_livemode_requirements.py create mode 100644 repos/system_upgrade/common/actors/livemode/liveimagegenerator/actor.py create mode 100644 repos/system_upgrade/common/actors/livemode/liveimagegenerator/libraries/liveimagegenerator.py create mode 100644 repos/system_upgrade/common/actors/livemode/liveimagegenerator/tests/test_image_generation.py create mode 100644 repos/system_upgrade/common/actors/livemode/livemode_config_scanner/actor.py create mode 100644 repos/system_upgrade/common/actors/livemode/livemode_config_scanner/libraries/scan_livemode_config.py create mode 100644 repos/system_upgrade/common/actors/livemode/livemode_config_scanner/tests/test_config_scanner.py create mode 100644 repos/system_upgrade/common/actors/livemode/livemodereporter/actor.py create mode 100644 repos/system_upgrade/common/actors/livemode/livemodereporter/libraries/report_livemode.py create mode 100644 repos/system_upgrade/common/actors/livemode/livemodereporter/tests/test_report_livemode.py create mode 100644 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/actor.py create mode 100644 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/console.service create mode 100755 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/do-upgrade.sh create mode 100644 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/upgrade-strace.service create mode 100644 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/upgrade.service create mode 100644 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/libraries/prepareliveimage.py create mode 100644 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/tests/test_livemode_userspace_modifications.py create mode 100644 repos/system_upgrade/common/actors/livemode/removeliveimage/actor.py create mode 100644 repos/system_upgrade/common/actors/livemode/removeliveimage/libraries/remove_live_image.py create mode 100644 repos/system_upgrade/common/actors/livemode/removeliveimage/tests/test_remove_live_image.py diff --git a/etc/leapp/files/devel-livemode.ini b/etc/leapp/files/devel-livemode.ini new file mode 100644 index 0000000000..b79ed4df85 --- /dev/null +++ b/etc/leapp/files/devel-livemode.ini @@ -0,0 +1,9 @@ +# Configuration for the *experimental* livemode feature +# It is likely that this entire configuration file will be replaced by some +# other mechanism/file in the future. For the full list of configuration options, +# see models/livemode.py +[livemode] +squashfs_fullpath=/var/lib/leapp/live-upgrade.img +setup_network_manager=no +autostart_upgrade_after_reboot=yes +setup_passwordless_root=no diff --git a/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/actor.py b/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/actor.py new file mode 100644 index 0000000000..a8aa7112db --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/actor.py @@ -0,0 +1,20 @@ +from leapp.actors import Actor +from leapp.libraries.actor import emit_livemode_userspace_requirements as emit_livemode_userspace_requirements_lib +from leapp.models import LiveModeConfig, TargetUserSpaceUpgradeTasks +from leapp.tags import ExperimentalTag, InterimPreparationPhaseTag, IPUWorkflowTag + + +class EmitLiveModeRequirements(Actor): + """ + Request addiontal packages to be installed into target userspace. + + Additional packages can be requested using LiveModeConfig.additional_packages + """ + + name = 'emit_livemode_requirements' + consumes = (LiveModeConfig,) + produces = (TargetUserSpaceUpgradeTasks,) + tags = (ExperimentalTag, InterimPreparationPhaseTag, IPUWorkflowTag,) + + def process(self): + emit_livemode_userspace_requirements_lib.emit_livemode_userspace_requirements() diff --git a/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/libraries/emit_livemode_userspace_requirements.py b/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/libraries/emit_livemode_userspace_requirements.py new file mode 100644 index 0000000000..4ecf682b74 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/libraries/emit_livemode_userspace_requirements.py @@ -0,0 +1,38 @@ +from leapp.libraries.stdlib import api +from leapp.models import LiveModeConfig, TargetUserSpaceUpgradeTasks + +# NOTE: would also need +# _REQUIRED_PACKAGES from actors/commonleappdracutmodules/libraries/modscan.py + +_REQUIRED_PACKAGES_FOR_LIVE_MODE = [ + 'systemd-container', + 'dbus-daemon', + 'NetworkManager', + 'util-linux', + 'dracut-live', + 'dracut-squash', + 'dmidecode', + 'pciutils', + 'lsscsi', + 'passwd', + 'kexec-tools', + 'vi', + 'less', + 'openssh-clients', + 'strace', + 'tcpdump', +] + + +def emit_livemode_userspace_requirements(): + livemode_config = next(api.consume(LiveModeConfig), None) + if not livemode_config or not livemode_config.is_enabled: + return + + packages = _REQUIRED_PACKAGES_FOR_LIVE_MODE + livemode_config.additional_packages + if livemode_config.setup_opensshd_with_auth_keys: + packages += ['openssh-server', 'crypto-policies'] + + packages = sorted(set(packages)) + + api.produce(TargetUserSpaceUpgradeTasks(install_rpms=packages)) diff --git a/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/tests/test_emit_livemode_requirements.py b/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/tests/test_emit_livemode_requirements.py new file mode 100644 index 0000000000..c376f03e2a --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/tests/test_emit_livemode_requirements.py @@ -0,0 +1,37 @@ +import pytest + +from leapp.libraries.actor import emit_livemode_userspace_requirements as emit_livemode_userspace_requirements_lib +from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked +from leapp.libraries.stdlib import api +from leapp.models import LiveModeConfig, TargetUserSpaceUpgradeTasks + + +@pytest.mark.parametrize('livemode_config', (None, LiveModeConfig(squashfs_fullpath='', is_enabled=False))) +def test_no_emit_if_livemode_disabled(monkeypatch, livemode_config): + messages = [livemode_config] if livemode_config else [] + actor_mock = CurrentActorMocked(msgs=messages) + monkeypatch.setattr(api, 'current_actor', actor_mock) + monkeypatch.setattr(api, 'produce', produce_mocked()) + + emit_livemode_userspace_requirements_lib.emit_livemode_userspace_requirements() + + assert not api.produce.called + + +def test_emit(monkeypatch): + config = LiveModeConfig(squashfs_fullpath='', is_enabled=True, additional_packages=['EXTRA_PKG']) + actor_mock = CurrentActorMocked(msgs=[config]) + monkeypatch.setattr(api, 'current_actor', actor_mock) + monkeypatch.setattr(api, 'produce', produce_mocked()) + + emit_livemode_userspace_requirements_lib.emit_livemode_userspace_requirements() + + assert api.produce.called + assert len(api.produce.model_instances) == 1 + + required_pkgs = api.produce.model_instances[0] + assert isinstance(required_pkgs, TargetUserSpaceUpgradeTasks) + + assert 'dracut-live' in required_pkgs.install_rpms + assert 'dracut-squash' in required_pkgs.install_rpms + assert 'EXTRA_PKG' in required_pkgs.install_rpms diff --git a/repos/system_upgrade/common/actors/livemode/liveimagegenerator/actor.py b/repos/system_upgrade/common/actors/livemode/liveimagegenerator/actor.py new file mode 100644 index 0000000000..85a59a3efd --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/liveimagegenerator/actor.py @@ -0,0 +1,29 @@ +from leapp.actors import Actor +from leapp.libraries.actor.liveimagegenerator import generate_live_image_if_enabled +from leapp.models import ( + LiveImagePreparationInfo, + LiveModeArtifacts, + LiveModeConfig, + LiveModeRequirementsTasks, + PrepareLiveImagePostTasks, + TargetUserSpaceInfo +) +from leapp.tags import ExperimentalTag, InterimPreparationPhaseTag, IPUWorkflowTag + + +class LiveImageGenerator(Actor): + """ + Generates the squashfs image for the livemode upgrade + """ + + name = 'live_image_generator' + consumes = (LiveModeConfig, + LiveModeRequirementsTasks, + LiveImagePreparationInfo, + PrepareLiveImagePostTasks, + TargetUserSpaceInfo,) + produces = (LiveModeArtifacts,) + tags = (ExperimentalTag, InterimPreparationPhaseTag, IPUWorkflowTag,) + + def process(self): + generate_live_image_if_enabled() diff --git a/repos/system_upgrade/common/actors/livemode/liveimagegenerator/libraries/liveimagegenerator.py b/repos/system_upgrade/common/actors/livemode/liveimagegenerator/libraries/liveimagegenerator.py new file mode 100644 index 0000000000..af8981d862 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/liveimagegenerator/libraries/liveimagegenerator.py @@ -0,0 +1,72 @@ +import os +import os.path +import shutil + +from leapp.exceptions import StopActorExecutionError +from leapp.libraries.common import mounting +from leapp.libraries.stdlib import api, CalledProcessError, run +from leapp.models import LiveModeArtifacts, LiveModeConfig, TargetUserSpaceInfo + + +def lighten_target_userpace(context): + """ + Remove unneeded files from the target userspace. + """ + + userspace_trees_to_prune = ['artifacts', 'boot'] + + for tree_to_prune in userspace_trees_to_prune: + tree_full_path = os.path.join(context.base_dir, tree_to_prune) + try: + shutil.rmtree(tree_full_path) + except OSError as error: + api.current_logger().warning('Failed to remove /%s directory from the live image. Full error: %s', + tree_to_prune, error) + + +def build_squashfs(livemode_config, userspace_info): + """ + Generate the live rootfs image based on the target userspace + + :param livemode LiveModeConfig: Livemode configuration message + :param userspace_info TargetUserspaceInfo: Information about how target userspace is set up + """ + target_userspace_fullpath = userspace_info.path + squashfs_fullpath = livemode_config.squashfs_fullpath + + api.current_logger().info('Building the squashfs image %s from target userspace located at %s', + squashfs_fullpath, target_userspace_fullpath) + + try: + if os.path.exists(squashfs_fullpath): + os.unlink(squashfs_fullpath) + except OSError as error: + api.current_logger().warning('Failed to remove already existing %s. Full error: %s', + squashfs_fullpath, error) + + try: + run(['mksquashfs', target_userspace_fullpath, squashfs_fullpath]) + except CalledProcessError as error: + raise StopActorExecutionError( + 'Cannot pack the target userspace into a squash image. ', + details={'details': 'The following error occurred while building the squashfs image: {0}.'.format(error)} + ) + + return squashfs_fullpath + + +def generate_live_image_if_enabled(): + """ + Main function to generate the additional artifacts needed to run in live mode. + """ + + livemode_config = next(api.consume(LiveModeConfig), None) + if not livemode_config or not livemode_config.is_enabled: + return + + userspace_info = next(api.consume(TargetUserSpaceInfo), None) + + with mounting.NspawnActions(base_dir=userspace_info.path) as context: + lighten_target_userpace(context) + squashfs_path = build_squashfs(livemode_config, userspace_info) + api.produce(LiveModeArtifacts(squashfs_path=squashfs_path)) diff --git a/repos/system_upgrade/common/actors/livemode/liveimagegenerator/tests/test_image_generation.py b/repos/system_upgrade/common/actors/livemode/liveimagegenerator/tests/test_image_generation.py new file mode 100644 index 0000000000..5c434a6bb3 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/liveimagegenerator/tests/test_image_generation.py @@ -0,0 +1,96 @@ +import collections +import os +import shutil + +import pytest + +from leapp.libraries.actor import liveimagegenerator as live_image_generator_lib +from leapp.libraries.common import mounting +from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked +from leapp.libraries.stdlib import api +from leapp.models import LiveModeArtifacts, LiveModeConfig, TargetUserSpaceInfo + + +def test_squafs_creation(monkeypatch): + userspace_info = TargetUserSpaceInfo(path='/USERSPACE', scratch='/SCRATCH', mounts='/MOUNTS') + livemode_config = LiveModeConfig(is_enabled=True, squashfs_fullpath='/var/lib/leapp/squashfs.img') + + def exists_mock(path): + assert path == '/var/lib/leapp/squashfs.img' + return True + + monkeypatch.setattr(os.path, 'exists', exists_mock) + + def unlink_mock(path): + assert path == '/var/lib/leapp/squashfs.img' + + monkeypatch.setattr(os, 'unlink', unlink_mock) + + commands_executed = [] + + def run_mock(command): + commands_executed.append(command[0]) + + monkeypatch.setattr(live_image_generator_lib, 'run', run_mock) + + live_image_generator_lib.build_squashfs(livemode_config, userspace_info) + assert commands_executed == ['mksquashfs'] + + +def test_userspace_lightening(monkeypatch): + + removed_trees = [] + + def rmtree_mock(path): + removed_trees.append(path) + + monkeypatch.setattr(shutil, 'rmtree', rmtree_mock) + + _ContextMock = collections.namedtuple('ContextMock', ('base_dir')) + context_mock = _ContextMock(base_dir='/USERSPACE') + + live_image_generator_lib.lighten_target_userpace(context_mock) + + assert removed_trees == ['/USERSPACE/artifacts', '/USERSPACE/boot'] + + +@pytest.mark.parametrize( + ('livemode_config', 'should_produce'), + ( + (LiveModeConfig(is_enabled=True, squashfs_fullpath='/squashfs'), True), + (LiveModeConfig(is_enabled=False, squashfs_fullpath='/squashfs'), False), + (None, False), + ) +) +def test_generate_live_image_if_enabled(monkeypatch, livemode_config, should_produce): + userspace_info = TargetUserSpaceInfo(path='/USERSPACE', scratch='/SCRATCH', mounts='/MOUNTS') + messages = [livemode_config, userspace_info] if livemode_config else [userspace_info] + actor_mock = CurrentActorMocked(msgs=messages) + monkeypatch.setattr(api, 'current_actor', actor_mock) + + class NspawnMock(object): + def __init__(self, *args, **kwargs): + pass + + def __enter__(self, *args, **kwargs): + pass + + def __exit__(self, *args, **kwargs): + pass + + monkeypatch.setattr(mounting, 'NspawnActions', NspawnMock) + monkeypatch.setattr(live_image_generator_lib, 'lighten_target_userpace', lambda context: None) + monkeypatch.setattr(live_image_generator_lib, 'build_squashfs', + lambda livemode_config, userspace_info: '/squashfs') + monkeypatch.setattr(api, 'produce', produce_mocked()) + + live_image_generator_lib.generate_live_image_if_enabled() + + if should_produce: + assert api.produce.called + assert len(api.produce.model_instances) == 1 + artifacts = api.produce.model_instances[0] + assert isinstance(artifacts, LiveModeArtifacts) + assert artifacts.squashfs_path == '/squashfs' + else: + assert not api.produce.called diff --git a/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/actor.py b/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/actor.py new file mode 100644 index 0000000000..dc79ecff07 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/actor.py @@ -0,0 +1,18 @@ +from leapp.actors import Actor +from leapp.libraries.actor import scan_livemode_config as scan_livemode_config_lib +from leapp.models import InstalledRPM, LiveModeConfig +from leapp.tags import ExperimentalTag, FactsPhaseTag, IPUWorkflowTag + + +class LiveModeConfigScanner(Actor): + """ + Read livemode configuration located at /etc/leapp/files/devel-livemode.ini + """ + + name = 'live_mode_config_scanner' + consumes = (InstalledRPM,) + produces = (LiveModeConfig,) + tags = (ExperimentalTag, FactsPhaseTag, IPUWorkflowTag,) + + def process(self): + scan_livemode_config_lib.scan_config_and_emit_message() diff --git a/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/libraries/scan_livemode_config.py b/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/libraries/scan_livemode_config.py new file mode 100644 index 0000000000..b2f0af7f26 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/libraries/scan_livemode_config.py @@ -0,0 +1,125 @@ +try: + import configparser +except ImportError: + import ConfigParser as configparser + +from leapp.exceptions import StopActorExecutionError +from leapp.libraries.common.config import architecture, get_env +from leapp.libraries.common.rpms import has_package +from leapp.libraries.stdlib import api +from leapp.models import InstalledRPM, LiveModeConfig +from leapp.models.fields import ModelViolationError + +LIVEMODE_CONFIG_LOCATION = '/etc/leapp/files/devel-livemode.ini' +DEFAULT_SQUASHFS_PATH = '/var/lib/leapp/live-upgrade.img' + + +def should_scan_config(): + is_unsupported = get_env('LEAPP_UNSUPPORTED', '0') == '1' + is_livemode_enabled = get_env('LEAPP_DEVEL_ENABLE_LIVE_MODE', '0') == '1' + + if not is_unsupported: + api.current_logger().debug('Will not scan livemode config - the upgrade is not unsupported.') + return False + + if not is_livemode_enabled: + api.current_logger().debug('Will not scan livemode config - the live mode is not enabled.') + return False + + if not architecture.matches_architecture(architecture.ARCH_X86_64): + api.current_logger().debug('Will not scan livemode config - livemode is currently limited to x86_64.') + details = 'Live upgrades are currently limited to x86_64 only.' + raise StopActorExecutionError( + 'CPU architecture does not meet requirements for live upgrades', + details={'Problem': details} + ) + + if not has_package(InstalledRPM, 'squashfs-tools'): + # This feature is not to be used by standard users, so stopping the upgrade and providing + # the developer a speedy feedback is OK. + raise StopActorExecutionError( + 'The \'squashfs-tools\' is not installed', + details={'Problem': 'The \'squashfs-tools\' is required for the live mode.'} + ) + + return True + + +def scan_config_and_emit_message(): + if not should_scan_config(): + return + + api.current_logger().info('Loading livemode config from %s', LIVEMODE_CONFIG_LOCATION) + parser = configparser.ConfigParser() + + try: + parser.read((LIVEMODE_CONFIG_LOCATION, )) + except configparser.ParsingError as error: + api.current_logger().error('Failed to parse live mode configuration due to the following error: %s', error) + + details = 'Failed to read livemode configuration due to the following error: {0}.' + raise StopActorExecutionError( + 'Failed to read livemode configuration', + details={'Problem': details.format(error)} + ) + + livemode_section = 'livemode' + if not parser.has_section(livemode_section): + details = 'The configuration is missing the \'[{0}]\' section'.format(livemode_section) + raise StopActorExecutionError( + 'Live mode configuration does not have the required structure', + details={'Problem': details} + ) + + config_kwargs = { + 'is_enabled': True, + 'url_to_load_squashfs_from': None, + 'squashfs_fullpath': DEFAULT_SQUASHFS_PATH, + 'dracut_network': None, + 'setup_network_manager': False, + 'additional_packages': [], + 'autostart_upgrade_after_reboot': True, + 'setup_opensshd_with_auth_keys': None, + 'setup_passwordless_root': False, + 'capture_upgrade_strace_into': None + } + + config_str_options = ( + 'url_to_load_squashfs_from', + 'squashfs_fullpath', + 'dracut_network', + 'setup_opensshd_with_auth_keys', + 'capture_upgrade_strace_into' + ) + + config_list_options = ( + 'additional_packages', + ) + + config_bool_options = ( + 'setup_network_manager', + 'setup_passwordless_root', + 'autostart_upgrade_after_reboot', + ) + + for config_option in config_str_options: + if parser.has_option(livemode_section, config_option): + config_kwargs[config_option] = parser.get(livemode_section, config_option) + + for config_option in config_bool_options: + if parser.has_option(livemode_section, config_option): + config_kwargs[config_option] = parser.getboolean(livemode_section, config_option) + + for config_option in config_list_options: + if parser.has_option(livemode_section, config_option): + option_val = parser.get(livemode_section, config_option) + option_list = (opt_val.strip() for opt_val in option_val.split(',')) + option_list = [opt for opt in option_list if opt] + config_kwargs[config_option] = option_list + + try: + config = LiveModeConfig(**config_kwargs) + except ModelViolationError as error: + raise StopActorExecutionError('Failed to parse livemode configuration.', details={'Problem': str(error)}) + + api.produce(config) diff --git a/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/tests/test_config_scanner.py b/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/tests/test_config_scanner.py new file mode 100644 index 0000000000..f6eea3fa76 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/tests/test_config_scanner.py @@ -0,0 +1,122 @@ +from collections import namedtuple +from enum import Enum +from functools import partial + +try: + import configparser +except ImportError: + import ConfigParser as configparser + +import pytest + +import leapp.libraries.actor.scan_livemode_config as scan_livemode_config_lib +from leapp.exceptions import StopActorExecutionError +from leapp.libraries.common.config import architecture +from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked +from leapp.libraries.stdlib import api +from leapp.models import LiveModeConfig + + +class EnablementResult(Enum): + DO_NOTHING = 0 + RAISE = 1 + SCAN_CONFIG = 2 + + +EnablementTestCase = namedtuple('EnablementTestCase', ('env_vars', 'arch', 'pkgs', 'result')) + + +@pytest.mark.parametrize( + 'case_descr', + ( + EnablementTestCase(env_vars={'LEAPP_UNSUPPORTED': '1', 'LEAPP_DEVEL_ENABLE_LIVE_MODE': '1'}, + arch=architecture.ARCH_X86_64, pkgs=('squashfs-tools', ), + result=EnablementResult.SCAN_CONFIG), + EnablementTestCase(env_vars={'LEAPP_UNSUPPORTED': '0', 'LEAPP_DEVEL_ENABLE_LIVE_MODE': '1'}, + arch=architecture.ARCH_X86_64, pkgs=('squashfs-tools', ), + result=EnablementResult.DO_NOTHING), + EnablementTestCase(env_vars={'LEAPP_UNSUPPORTED': '1', 'LEAPP_DEVEL_ENABLE_LIVE_MODE': '0'}, + arch=architecture.ARCH_X86_64, pkgs=('squashfs-tools', ), + result=EnablementResult.DO_NOTHING), + EnablementTestCase(env_vars={'LEAPP_UNSUPPORTED': '1', 'LEAPP_DEVEL_ENABLE_LIVE_MODE': '1'}, + arch=architecture.ARCH_ARM64, pkgs=('squashfs-tools', ), + result=EnablementResult.RAISE), + EnablementTestCase(env_vars={'LEAPP_UNSUPPORTED': '1', 'LEAPP_DEVEL_ENABLE_LIVE_MODE': '1'}, + arch=architecture.ARCH_ARM64, pkgs=tuple(), + result=EnablementResult.RAISE), + ) +) +def test_enablement_conditions(monkeypatch, case_descr): + """ + Check whether scanning is performed only when enablement and system conditions are met. + + Enablement conditions: + - LEAPP_UNSUPPORTED=1 + - LEAPP_DEVEL_ENABLE_LIVE_MODE=1 + + Not meeting enablement conditions should prevent config message from being produced. + + System requirements: + - architecture = x86_64 + - 'squashfs-tools' package is installed + + Not meeting system requirements should raise StopActorExecutionError. + """ + + def has_package_mock(message_class, pkg_name): + return pkg_name in case_descr.pkgs + + monkeypatch.setattr(scan_livemode_config_lib, 'has_package', has_package_mock) + + mocked_actor = CurrentActorMocked(envars=case_descr.env_vars, arch=case_descr.arch) + monkeypatch.setattr(api, 'current_actor', mocked_actor) + + if case_descr.result == EnablementResult.RAISE: + with pytest.raises(StopActorExecutionError): + scan_livemode_config_lib.should_scan_config() + else: + should_scan = scan_livemode_config_lib.should_scan_config() + + if case_descr.result == EnablementResult.DO_NOTHING: + assert not should_scan + elif case_descr.result == EnablementResult.SCAN_CONFIG: + assert should_scan + + +def test_config_scanning(monkeypatch): + """ Test whether scanning a valid config is properly transcribed into a config message. """ + + config_lines = [ + '[livemode]', + 'squashfs_fullpath=IMG', + 'setup_network_manager=yes', + 'autostart_upgrade_after_reboot=no', + 'setup_opensshd_with_auth_keys=/root/.ssh/authorized_keys', + 'setup_passwordless_root=no', + 'additional_packages=pkgA,pkgB' + ] + config_content = '\n'.join(config_lines) + '\n' + + class ConfigParserMock(configparser.ConfigParser): # pylint: disable=too-many-ancestors + def read(self, file_paths, *args, **kwargs): + self.read_string(config_content) + return file_paths + + monkeypatch.setattr(configparser, 'ConfigParser', ConfigParserMock) + + monkeypatch.setattr(scan_livemode_config_lib, 'should_scan_config', lambda: True) + + monkeypatch.setattr(api, 'produce', produce_mocked()) + + scan_livemode_config_lib.scan_config_and_emit_message() + + assert api.produce.called + assert len(api.produce.model_instances) == 1 + + produced_message = api.produce.model_instances[0] + assert isinstance(produced_message, LiveModeConfig) + + assert produced_message.additional_packages == ['pkgA', 'pkgB'] + assert produced_message.squashfs_fullpath == 'IMG' + assert produced_message.setup_opensshd_with_auth_keys == '/root/.ssh/authorized_keys' + assert produced_message.setup_network_manager diff --git a/repos/system_upgrade/common/actors/livemode/livemodereporter/actor.py b/repos/system_upgrade/common/actors/livemode/livemodereporter/actor.py new file mode 100644 index 0000000000..6de79260a5 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/livemodereporter/actor.py @@ -0,0 +1,19 @@ +from leapp.actors import Actor +from leapp.libraries.actor import report_livemode as report_livemode_lib +from leapp.models import LiveModeConfig +from leapp.reporting import Report +from leapp.tags import ExperimentalTag, FactsPhaseTag, IPUWorkflowTag + + +class LiveModeReporter(Actor): + """ + Warn the user about the required space and memory to use the live mode if live mode is enabled. + """ + + name = 'live_mode_reporter' + consumes = (LiveModeConfig,) + produces = (Report,) + tags = (ExperimentalTag, IPUWorkflowTag, FactsPhaseTag) + + def process(self): + report_livemode_lib.report_live_mode_if_enabled() diff --git a/repos/system_upgrade/common/actors/livemode/livemodereporter/libraries/report_livemode.py b/repos/system_upgrade/common/actors/livemode/livemodereporter/libraries/report_livemode.py new file mode 100644 index 0000000000..d3de142b04 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/livemodereporter/libraries/report_livemode.py @@ -0,0 +1,24 @@ +from leapp import reporting +from leapp.libraries.stdlib import api +from leapp.models import LiveModeConfig + + +def report_live_mode_if_enabled(): + livemode = next(api.consume(LiveModeConfig), None) + if not livemode or not livemode.is_enabled: + return + + summary = ( + 'The Live Upgrade Mode requires at least 2 GB of additional space ' + 'in the partition that hosts /var/lib/leapp in order to create ' + 'the squashfs image. During the "reboot phase", the image will ' + 'need more space into memory, in particular for booting over the ' + 'network. The recommended memory for this mode is at least 4 GB.' + ) + reporting.create_report([ + reporting.Title('Live Upgrade Mode enabled'), + reporting.Summary(summary), + reporting.Severity(reporting.Severity.HIGH), + reporting.Groups([reporting.Groups.BOOT]), + reporting.RelatedResource('file', '/etc/leapp/files/devel-livemode.ini') + ]) diff --git a/repos/system_upgrade/common/actors/livemode/livemodereporter/tests/test_report_livemode.py b/repos/system_upgrade/common/actors/livemode/livemodereporter/tests/test_report_livemode.py new file mode 100644 index 0000000000..0ad75e2b82 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/livemodereporter/tests/test_report_livemode.py @@ -0,0 +1,30 @@ +import pytest + +from leapp import reporting +from leapp.libraries.actor import report_livemode as report_livemode_lib +from leapp.libraries.common.testutils import create_report_mocked, CurrentActorMocked +from leapp.libraries.stdlib import api +from leapp.models import LiveModeConfig + + +@pytest.mark.parametrize( + ('livemode_config', 'should_report'), + ( + (LiveModeConfig(is_enabled=True, squashfs_fullpath='path'), True), + (LiveModeConfig(is_enabled=False, squashfs_fullpath='path'), False), + (None, False), + ) +) +def test_report_livemode(monkeypatch, livemode_config, should_report): + messages = [livemode_config] if livemode_config else [] + mocked_actor = CurrentActorMocked(msgs=messages) + monkeypatch.setattr(api, 'current_actor', mocked_actor) + + monkeypatch.setattr(reporting, 'create_report', create_report_mocked()) + + report_livemode_lib.report_live_mode_if_enabled() + + if should_report: + assert reporting.create_report.called == 1 + else: + assert reporting.create_report.called == 0 diff --git a/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/actor.py b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/actor.py new file mode 100644 index 0000000000..de1e9023b4 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/actor.py @@ -0,0 +1,40 @@ +from leapp.actors import Actor +from leapp.libraries.actor.prepareliveimage import modify_userspace_as_configured +from leapp.libraries.stdlib import api +from leapp.models import ( + BootContent, + LiveImagePreparationInfo, + LiveModeConfig, + LiveModeRequirementsTasks, + StorageInfo, + TargetUserSpaceInfo +) +from leapp.tags import ExperimentalTag, InterimPreparationPhaseTag, IPUWorkflowTag + + +class ModifyUserspaceForLiveMode(Actor): + """ + Perform modifications of the userspace according to LiveModeConfig. + + Actor depends on BootContent to require that the upgrade initramfs has already + been generated since during installation of initramfs dependencies systemd units + might be modified, overwriting changes that might have been done by this actor. + """ + + name = 'prepare_live_image' + consumes = ( + LiveModeConfig, + LiveModeRequirementsTasks, + StorageInfo, + TargetUserSpaceInfo, + BootContent, + ) + produces = (LiveImagePreparationInfo,) + tags = (ExperimentalTag, InterimPreparationPhaseTag, IPUWorkflowTag,) + + def process(self): + livemode_config = next(api.consume(LiveModeConfig), None) + userspace_info = next(api.consume(TargetUserSpaceInfo), None) + storage = next(api.consume(StorageInfo), None) + + modify_userspace_as_configured(userspace_info, storage, livemode_config) diff --git a/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/console.service b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/console.service new file mode 100644 index 0000000000..0ca465ade2 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/console.service @@ -0,0 +1,19 @@ +[Unit] +Description=Leapp Upgrade Console service +After=basic.target +ConditionKernelCommandLine=!upgrade.autostart=0 + +[Service] +Type=simple +ExecStart=/usr/bin/tail -f /sysroot/var/log/leapp/leapp-upgrade.log +StandardOutput=tty +StandardError=tty +StandardInput=tty-force +TTYPath=/dev/tty1 +TTYReset=yes +Restart=always +RestartSec=5s +KillMode=process + +[Install] +WantedBy=multi-user.target diff --git a/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/do-upgrade.sh b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/do-upgrade.sh new file mode 100755 index 0000000000..4b2f9a1f48 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/do-upgrade.sh @@ -0,0 +1,372 @@ +#!/bin/bash +# actually perform the upgrade, using UPGRADEBIN (set in /etc/conf.d) + +warn() { + echo "$@" +} + +get_rhel_major_release() { + local os_version + os_version=$(grep -o '^VERSION="[0-9][0-9]*\.' /etc/os-release | grep -o '[0-9]*') + [ -z "$os_version" ] && { + # This should not happen as /etc/initrd-release is supposed to have API + # stability, but check is better than broken system. + warn "Cannot determine the major RHEL version." + warn "The upgrade environment cannot be setup reliably." + echo "Content of the /etc/initrd-release:" + cat /etc/os-release + exit 1 + } + + echo "$os_version" +} + +RHEL_OS_MAJOR_RELEASE=$(get_rhel_major_release) +export RHEL_OS_MAJOR_RELEASE +export LEAPPBIN=/usr/bin/leapp +export LEAPPHOME=/root/tmp_leapp_py3 +export LEAPP3_BIN=$LEAPPHOME/leapp3 + +# this was initially a dracut script, hence $NEWROOT. +# the rootfs is mounted on /run/initramfs/live when booted with dmsquash-live +export NEWROOT=/run/initramfs/live + +NSPAWN_OPTS="--capability=all --bind=/dev --bind=/dev/pts --bind=/proc --bind=/run/udev --bind=/run/lock" +[ -d /dev/mapper ] && NSPAWN_OPTS="$NSPAWN_OPTS --bind=/dev/mapper" +if [ "$RHEL_OS_MAJOR_RELEASE" == "8" ]; then + # IPU 7 -> 8 + NSPAWN_OPTS="$NSPAWN_OPTS --bind=/sys --bind=/run/systemd" +else + # IPU 8 -> 9 + # TODO(pstodulk, mreznik): Why --console=pipe? Is it ok? Discovered a weird + # issue on IPU 8 -> 9 without that in our VMs + NSPAWN_OPTS="$NSPAWN_OPTS --bind=/sys:/hostsys --console=pipe" + # workaround to have the real host's root parameter in /proc/cmdline + NSPAWN_OPTS="$NSPAWN_OPTS --bind-ro=/var/lib/leapp/.fakecmdline:/proc/cmdline" + [ -e /sys/firmware/efi/efivars ] && NSPAWN_OPTS="$NSPAWN_OPTS --bind=/sys/firmware/efi/efivars" +fi +export NSPAWN_OPTS="$NSPAWN_OPTS --keep-unit --register=no --timezone=off --resolv-conf=off" + + +export LEAPP_FAILED_FLAG_FILE="/root/tmp_leapp_py3/.leapp_upgrade_failed" + +# +# Temp for collecting and preparing tarball +# +LEAPP_DEBUG_TMP="/tmp/leapp-debug-root" + +# +# Number of times to emit all chunks +# +# To avoid spammy parts of console log, second and later emissions +# take longer delay in-between. For example, with N being 3, +# first emission is done immediately, second after 10s, and the +# third one after 20s. +# +IBDMP_ITER=3 + +# +# Size of one payload chunk +# +# IOW, amount of characters in a single chunk of the base64-encoded +# payload. (By base64 standard, these characters are inherently ASCII, +# so ie. they correspond to bytes.) +# +IBDMP_CHUNKSIZE=40 + +collect_and_dump_debug_data() { + # + # Collect various debug files and dump tarball using ibdmp + # + local tmp=$LEAPP_DEBUG_TMP + local data=$tmp/data + mkdir -p "$data" || { echo >&2 "fatal: cannot create leapp dump data dir: $data"; exit 4; } + journalctl -amo verbose >"$data/journalctl.log" + mkdir -p "$data/var/lib/leapp" + mkdir -p "$data/var/log" + cp -vr "$NEWROOT/var/lib/leapp/leapp.db" \ + "$data/var/lib/leapp" + cp -vr "$NEWROOT/var/log/leapp" \ + "$data/var/log" + tar -cJf "$tmp/data.tar.xz" "$data" + ibdmp "$tmp/data.tar.xz" + rm -r "$tmp" +} + +want_inband_dump() { + # + # True if dump collection is needed given leapp exit status $1 and kernel option + # + local leapp_es=$1 + local mode + local kopt + kopt=$(getarg 'rd.upgrade.inband') + case $kopt in + always|never|onerror) mode="$kopt" ;; + "") mode="never" ;; + *) warn "ignoring unknown value of rd.upgrade.inband (dump will be disabled): '$kopt'" + return 2 ;; + esac + case $mode:$leapp_es in + always:*) return 0 ;; + never:*) return 1 ;; + onerror:0) return 1 ;; + onerror:*) return 0 ;; + esac +} + +ibdmp() { + # + # Dump tarball $1 in base64 to stdout + # + # Tarball is encoded in a way that: + # + # * final data can be printed to plain text terminal, + # * tarball can be restored by scanning the saved + # terminal output, + # * corruptions caused by extra terminal noise + # (extra lines, extra characters within lines, + # line splits..) can be corrected. + # + # That is, + # + # 1. encode tarball using base64 + # + # 2. prepend line `chunks=CHUNKS,md5=MD5` where + # MD5 is the MD5 digest of original tarball and + # CHUNKS is number of upcoming Base64 chunks + # + # 3. decorate each chunk with prefix `N:` where + # N is number of given chunk. + # + # 4. Finally print all lines (prepended "header" + # line and all chunks) several times, where + # every iteration should be prefixed by + # `_ibdmp:I/TTL|` and suffixed by `|`. + # where `I` is iteration number and `TTL` is + # total iteration numbers. + # + # Decoder should look for strings like this: + # + # _ibdmp:I/J|CN:PAYLOAD| + # + # where I, J and CN are integers and PAYLOAD is a slice of a + # base64 string. + # + # Here, I represents number of iteration, J total of iterations + # ($IBDMP_ITER), and CN is number of given chunk within this + # iteration. CN goes from 1 up to number of chunks (CHUNKS) + # predicted by header. + # + # Each set corresponds to one dump of the tarball and error + # correction is achieved by merging sets using these rules: + # + # 1. each set has to contain header (`chunks=CHUNKS, + # md5=MD5`) prevalent header wins. + # + # 2. each set has to contain number of chunks + # as per header + # + # 3. chunks are numbered so they can be compared across + # sets; prevalent chunk wins. + # + # Finally the merged set of chunks is decoded as base64. + # Resulting data has to match md5 sum or we're hosed. + # + local tarball=$1 + local tmp=$LEAPP_DEBUG_TMP/ibdmp + local md5 + local i + mkdir -p "$tmp" + base64 -w "$IBDMP_CHUNKSIZE" "$tarball" > "$tmp/b64" + md5=$(md5sum "$tarball" | sed 's/ .*//') + chunks=$(wc -l <"$tmp/b64") + ( + set +x + echo "chunks=$chunks,md5=$md5" + cnum=1 + while read -r chunk; do + echo "$cnum:$chunk" + ((cnum++)) + done <"$tmp/b64" + ) >"$tmp/report" + i=0 + while test "$i" -lt "$IBDMP_ITER"; do + sleep "$((i * 10))" + ((i++)) + sed "s%^%_ibdmp:$i/$IBDMP_ITER|%; s%$%|%; " <"$tmp/report" + done +} + +do_upgrade() { + local args="" rv=0 + + # Force selinux into permissive mode unless booted with 'enforcing=1'. + # FIXME: THIS IS A BIG STUPID HAMMER AND WE SHOULD ACTUALLY SOLVE THE ROOT + # PROBLEMS RATHER THAN JUST PAPERING OVER THE WHOLE THING. But this is what + # Anaconda did, and upgrades don't seem to work otherwise, so... + if [ -f /sys/fs/selinux/enforce ]; then + enforce=$(< /sys/fs/selinux/enforce) + ## FIXME: check enforcing bool in /proc/cmdline + echo 0 > /sys/fs/selinux/enforce + fi + + # and off we go... + # NOTE: in case we would need to run leapp before pivot, we would need to + # specify where the root is, e.g. --root=/sysroot + # TODO: update: systemd-nspawn + + # NOTE: We disable shell-check since we want to word-break NSPAWN_OPTS + # shellcheck disable=SC2086 + /usr/bin/systemd-nspawn $NSPAWN_OPTS -D "$NEWROOT" /usr/bin/bash -c "mount -a; $LEAPPBIN upgrade --resume $args" + rv=$? + + # NOTE: flush the cached content to disk to ensure everything is written + sync + + ## TODO: implement "Break after LEAPP upgrade stop" + + if [ "$rv" -eq 0 ]; then + # run leapp to proceed phases after the upgrade with Python3 + #PY_LEAPP_PATH=/usr/lib/python2.7/site-packages/leapp/ + #$NEWROOT/bin/systemd-nspawn $NSPAWN_OPTS -D $NEWROOT -E PYTHONPATH="${PYTHONPATH}:${PY_LEAPP_PATH}" /usr/bin/python3 $LEAPPBIN upgrade --resume $args + + # on aarch64 systems during el8 to el9 upgrades the swap is broken due to change in page size (64K to 4k) + # adjust the page size before booting into the new system, as it is possible the swap is necessary for to boot + # `arch` command is not available in the dracut shell, using uname -m instead + # FIXME: check with LiveMode + [ "$(uname -m)" = "aarch64" ] && [ "$RHEL_OS_MAJOR_RELEASE" = "9" ] && { + cp -aS ".leapp_bp" $NEWROOT/etc/fstab /etc/fstab + # swapon internally uses mkswap and both swapon and mkswap aren't available in dracut shell + # as a workaround we can use the one from $NEWROOT in $NEWROOT/usr/sbin + # for swapon to find mkswap we must temporarily adjust the PATH + # NOTE: we want to continue the upgrade even when the swapon command fails as users can fix it + # manually later. It's not a major blocker. + PATH="$PATH:${NEWROOT}/usr/sbin/" swapon -af || echo >&2 "Error: Failed fixing the swap page size. Manual action is required after the upgrade." + mv /etc/fstab.leapp_bp /etc/fstab + } + + # NOTE: + # mount everything from FSTAB before run of the leapp as mount inside + # the container is not persistent and we need to have mounted /boot + # all FSTAB partitions. As mount was working before, hopefully will + # work now as well. Later this should be probably modified as we will + # need to handle more stuff around storage at all. + + # NOTE: We disable shell-check since we want to word-break NSPAWN_OPTS + # shellcheck disable=SC2086 + /usr/bin/systemd-nspawn $NSPAWN_OPTS -D "$NEWROOT" /usr/bin/bash -c "mount -a; /usr/bin/python3 -B $LEAPP3_BIN upgrade --resume $args" + rv=$? + fi + + if [ "$rv" -ne 0 ]; then + # set the upgrade failed flag to prevent the upgrade from running again + # when the emergency shell exits and the upgrade.target is restarted + local dirname + dirname="$("$NEWROOT/bin/dirname" "$NEWROOT$LEAPP_FAILED_FLAG_FILE")" + [ -d "$dirname" ] || mkdir "$dirname" + "$NEWROOT/bin/touch" "$NEWROOT$LEAPP_FAILED_FLAG_FILE" + fi + + # Dump debug data in case something went wrong + ##if want_inband_dump "$rv"; then + ## collect_and_dump_debug_data + ##fi + + # NOTE: THIS SHOULD BE AGAIN PART OF LEAPP IDEALLY + ## backup old product id certificates + #chroot $NEWROOT /bin/sh -c 'mkdir /etc/pki/product_old; mv -f /etc/pki/product/*.pem /etc/pki/product_old/' + + ## install new product id certificates + #chroot $NEWROOT /bin/sh -c 'mv -f /system-upgrade/*.pem /etc/pki/product/' + + # restore things twiddled by workarounds above. TODO: remove! + if [ -f /sys/fs/selinux/enforce ]; then + echo "$enforce" > /sys/fs/selinux/enforce + fi + return $rv +} + +save_journal() { + # Q: would it be possible that journal will not be flushed completely yet? + echo "writing logs to disk and rebooting" + + local logfile="/sysroot/tmp-leapp-upgrade.log" + + # Create logfile if it doesn't exist + [ -n "$logfile" ] && true > $logfile + + # If file exists save the journal + if [ -e $logfile ]; then + # Add a separator + echo "### LEAPP reboot ###" > $logfile + + # write out the logfile + journalctl -a -m >> $logfile + + # We need to run the actual saving of leapp-upgrade.log in a container and mount everything before, to be + # sure /var/log is mounted in case it is on a separate partition. + local store_cmd="mount -a" + local store_cmd="$store_cmd; cat /tmp-leapp-upgrade.log >> /var/log/leapp/leapp-upgrade.log" + + # NOTE: We disable shell-check since we want to word-break NSPAWN_OPTS + # shellcheck disable=SC2086 + /usr/bin/systemd-nspawn $NSPAWN_OPTS -D "$NEWROOT" /usr/bin/bash -c "$store_cmd" + + rm -f $logfile + fi +} + + +############################### MAIN ######################################### + +# workaround to replace the live root arg by the host's real root in +# /proc/cmdline that is read by /usr/lib/kernel/50-dracut.install +# during the kernel-core rpm postscript. +# the result is ro-bind-mounted over /proc/cmdline inside the container. +awk '{print $1}' /proc/cmdline \ + | xargs -I@ echo @ "$(cat "${NEWROOT}"/var/lib/leapp/.fakerootfs)" \ + > ${NEWROOT}/var/lib/leapp/.fakecmdline + +##### do the upgrade ####### +( + # check if leapp previously failed in the initramfs, if it did return to the emergency shell + [ -f "$NEWROOT$LEAPP_FAILED_FLAG_FILE" ] && { + echo >&2 "Found file $NEWROOT$LEAPP_FAILED_FLAG_FILE" + echo >&2 "Error: Leapp previously failed and cannot continue, returning back to emergency shell" + echo >&2 "Please file a support case with $NEWROOT/var/log/leapp/leapp-upgrade.log attached" + echo >&2 "To rerun the upgrade upon exiting the dracut shell remove the $NEWROOT$LEAPP_FAILED_FLAG_FILE file" + exit 1 + } + + [ ! -x "$NEWROOT$LEAPPBIN" ] && { + warn "upgrade binary '$LEAPPBIN' missing!" + exit 1 + } + + do_upgrade || exit $? +) +result=$? + +##### safe the data ##### +save_journal + +# NOTE: flush the cached content to disk to ensure everything is written +sync + +# cleanup workarounds +/bin/rm -f ${NEWROOT}/var/lib/leapp/.fake{rootfs,cmdline} || true + +# we cannot rely on reboot_system() from leapp.utils, since shutdown commands +# won't work within a container: +#""" +#System has not been booted with systemd as init system (PID 1). Can't operate. +#Failed to connect to bus: Host is down +#Failed to talk to init daemon: Host is down +#""" +if [ "$result" == "0" ]; then + [ -f "${NEWROOT}/.noreboot" ] || reboot +else + echo >&2 "The upgrade container returned a non-zero exit code." + exit $result +fi diff --git a/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/upgrade-strace.service b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/upgrade-strace.service new file mode 100644 index 0000000000..25498e1ab9 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/upgrade-strace.service @@ -0,0 +1,14 @@ +[Unit] +Description=Leapp strace upgrade service +After=basic.target +ConditionKernelCommandLine=!upgrade.autostart=0 +ConditionKernelCommandLine=upgrade.strace + +[Service] +Type=oneshot +ExecStart=/bin/bash -c "/usr/bin/strace -fTttyyvs 256 -o $(tr ' ' '\n' < /proc/cmdline | awk -F= '/^upgrade.strace=/ {print $2}') /usr/bin/upgrade" +StandardOutput=journal +KillMode=process + +[Install] +WantedBy=multi-user.target diff --git a/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/upgrade.service b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/upgrade.service new file mode 100644 index 0000000000..1da6d04699 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/upgrade.service @@ -0,0 +1,14 @@ +[Unit] +Description=Leapp Upgrade service +After=basic.target +ConditionKernelCommandLine=!upgrade.autostart=0 +ConditionKernelCommandLine=!upgrade.strace + +[Service] +Type=oneshot +ExecStart=/usr/bin/upgrade +StandardOutput=journal +KillMode=process + +[Install] +WantedBy=multi-user.target diff --git a/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/libraries/prepareliveimage.py b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/libraries/prepareliveimage.py new file mode 100644 index 0000000000..c573c84a43 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/libraries/prepareliveimage.py @@ -0,0 +1,514 @@ +import grp +import os +import os.path + +from leapp.exceptions import StopActorExecutionError +from leapp.libraries.common import mounting +from leapp.libraries.common.config.version import get_target_major_version +from leapp.libraries.stdlib import api, CalledProcessError +from leapp.models import LiveImagePreparationInfo + +LEAPP_UPGRADE_SERVICE_FILE = 'upgrade.service' +""" Service that executes the actual upgrade (/usr/bin/upgrade). """ + +LEAPP_CONSOLE_SERVICE_FILE = 'console.service' +""" Service that tails (tail -f) leapp logs and outputs them on tty1. """ + +LEAPP_STRACE_SERVICE_FILE = 'upgrade-strace.service' +""" Service that executes the upgrade while strace-ing the corresponding Leapp's process tree. """ + +SOURCE_ROOT_MOUNT_LOCATION = '/run/initramfs/live' +""" Controls where the source system's root will be mounted inside the upgrade image. """ + + +def create_fstab_mounting_current_root_elsewhere(context, host_fstab): + """ + Create a new /etc/fstab file that mounts source system filesystem to relative other mountpoint than /. + + The location of the source system's / will be mounted at SOURCE_ROO_MOUNT_LOCATION, and all other + mountpoints present in source system's fstab will be made relative to SOURCE_ROOT_MOUNT_LOCATION. + + :returns: None + :raises StopActorExecutionError: The upgrade is stopped if the new fstab could not be created. + """ + + live_fstab_lines = list() + + for fstab_entry in host_fstab: + relative_root = SOURCE_ROOT_MOUNT_LOCATION + + if fstab_entry.fs_vfstype == 'swap': + relative_root = '/' + elif fstab_entry.fs_vfstype not in ('xfs', 'ext4', 'ext3', 'vfat'): + msg = 'The following fstab entry is skipped and it will not be present in upgrade image\'s fstab entry: %s' + api.current_logger().debug(msg, fstab_entry.fs_file) + continue + + new_mountpoint = os.path.join(relative_root, fstab_entry.fs_file.lstrip('/')) + + entry = ' '.join([fstab_entry.fs_spec, new_mountpoint, fstab_entry.fs_vfstype, fstab_entry.fs_mntops, + fstab_entry.fs_freq, fstab_entry.fs_passno]) + + live_fstab_lines.append(entry) + + live_fstab_content = '\n'.join(live_fstab_lines) + '\n' + + try: + with context.open('/etc/fstab', 'w+') as upgrade_img_fstab: + upgrade_img_fstab.write(live_fstab_content) + except OSError as error: + api.current_logger().error('Failed to create upgrade image\'s /etc/fstab. Error: %s', error) + + details = {'Problem': 'write to /etc/fstab failed.'} + raise StopActorExecutionError('Cannot create upgrade image\'s /etc/fstab', details) + + +def create_symlink_from_sysroot_to_source_root_mountpoint(context): + """ + Create a symlink from /sysroot to SOURCE_ROOT_MOUNT_LOCATION. + + The root (/) of the source system will be mounted at SOURCE_ROOT_MOUNT_LOCATION in the upgrade image, + however, upgrade scripts expect the source system's root to be at /sysroot. + + :raises StopActorExecutionError: Failing to create a symlink leads to stopping the upgrade process as it + is a critical step. + """ + try: + os.symlink(SOURCE_ROOT_MOUNT_LOCATION, context.full_path('/sysroot')) + except OSError as err: + api.current_logger().warning('Failed to create the /sysroot symlink. Full error: %s', err) + raise StopActorExecutionError('Failed to create mountpoint symlink') + + +def setup_console(context): + """ + Setup the console - upgrade logs on tty1 and tty2-tty4 will be standard terms (agetty). + """ + api.current_logger().debug('Configuring the console') + + service_file = api.get_actor_file_path(LEAPP_CONSOLE_SERVICE_FILE) + console_service_dest = os.path.join('/usr/lib/systemd/system/', LEAPP_CONSOLE_SERVICE_FILE) + + try: + context.copy_to(service_file, console_service_dest) + except OSError as error: + api.current_logger().error( + 'Failed to copy the leapp\'s console service into the target userspace. Error: %s', error + ) + details = { + 'Problem': 'Failed to copy leapp\'s console service into the upgrade image.' + } + raise StopActorExecutionError('Failed to set up upgrade image\'s console service', details=details) + + # Enable automatic spawning of virtual terminals to create automatically. When switching to a previously unused + # VT, "autovt" services are created automatically (linked to getty by default). See man 5 logind.conf + try: + with context.open('/etc/systemd/logind.conf', 'a') as logind_conf: + logind_conf.write('NAutoVTs=1\n') + except OSError as error: + msg = 'Failed to modify logind.conf to change the number of VTs created automatically. Full error: %s' + api.current_logger().error(msg, error) + + problem_desc = ( + 'Failed to modify upgrade image\'s logind.conf to specify the number of VTs created automatically' + ) + details = {'Problem': problem_desc} + raise StopActorExecutionError('Failed to setup console for the upgrade image.', details=details) + + tty_service_path_template = '/etc/systemd/system/getty.target.wants/getty@tty{tty_num}.service' + console_enablement_link = os.path.join('/etc/systemd/system/multi-user.target.wants/', LEAPP_CONSOLE_SERVICE_FILE) + + try: + # tty1 will be populated with leapp's logs + tty1_service_symlink = tty_service_path_template.format(tty_num='1') + if os.path.exists(tty1_service_symlink): + context.remove(tty1_service_symlink) # Will be used to output leapp there + + for i in range(2, 5): + ttyi_service_path = context.full_path(tty_service_path_template.format(tty_num=i)) + os.symlink('/usr/lib/systemd/system/getty@.service', ttyi_service_path) + + os.symlink(console_service_dest, context.full_path(console_enablement_link)) + except OSError as error: + api.current_logger().error('Failed to change how tty services are set up in the upgrade image. Error: %s', + error) + details = {'Problem': 'Failed to modify tty services in the upgrade image'} + raise StopActorExecutionError('Failed to setup console for the upgrade image.', details=details) + + +def setup_upgrade_service(context): + """ + Setup the systemd service that starts upgrade after reboot. + + The performed setup consists of: + - install leapp_upgrade.sh as /usr/bin/upgrade + - systemd symlink + - install upgrade-strace.service + + :returns: None + :raises StopActorExecutionError: Setting up upgrade service(s) is critical - failure in copying the required files + or creating symlinks activating the services stops the upgrade. + """ + api.current_logger().info('Configuring the upgrade.service') + + upgrade_service_path = api.get_actor_file_path(LEAPP_UPGRADE_SERVICE_FILE) + do_upgrade_shellscript_path = api.get_actor_file_path('do-upgrade.sh') + upgrade_strace_service_path = api.get_actor_file_path(LEAPP_STRACE_SERVICE_FILE) + + upgrade_service_dst_path = os.path.join('/usr/lib/systemd/system/', LEAPP_UPGRADE_SERVICE_FILE) + strace_service_dst_path = os.path.join('/usr/lib/systemd/system/', LEAPP_STRACE_SERVICE_FILE) + + try: + context.copy_to(upgrade_service_path, upgrade_service_dst_path) + context.copy_to(upgrade_strace_service_path, strace_service_dst_path) + context.copy_to(do_upgrade_shellscript_path, '/usr/bin/upgrade') + except OSError as err: + details = { + 'Problem': 'copying leapp_upgrade.service and leapp_upgrade.sh to the target userspace failed.', + 'err': str(err) + } + raise StopActorExecutionError('Cannot copy the leapp_upgrade service files', details=details) + + # Enable Leapp's services by adding them as dependency to multi-user.target.wants + + services_to_enable = [upgrade_service_dst_path, strace_service_dst_path] + symlink_dest_dir = '/etc/systemd/system/multi-user.target.wants/' + + for service_path in services_to_enable: + service_file = os.path.basename(service_path) + symlink_dst = os.path.join(symlink_dest_dir, service_file) + try: + os.symlink(service_path, context.full_path(symlink_dst)) + except OSError as error: + api.current_logger().error('Failed to create a symlink enabling leapp\'s upgrade service (%s). Error %s', + service_path, error) + + details = {'Problem': 'Failed to enable leapp\'s upgrade service (upgrade.service)'} + raise StopActorExecutionError('Cannot enable the upgrade.service', details=details) + + +def make_root_account_passwordless(context): + """ + Make root account passwordless. + + Modify /etc/passwd found in the upgrade image, removing root's password. + + :returns: Noting. + :raises StopActorExecutionError: The upgrade is stopped if the user requests upgrade root account to be + passwordless, however, the corresponding modifications could not be performed. + """ + target_userspace_passwd_path = context.full_path('/etc/passwd') + + if not os.path.exists(target_userspace_passwd_path): + api.current_logger().warning('Target userspace is lacking /etc/passwd; cannot setup passwordless root.') + return + + try: + with context.open('/etc/passwd') as f: + passwd = f.readlines() + except OSError: + msg = 'Failed to open target userspace /etc/passwd for reading; passwordless root will not be set up.' + api.current_logger().error(msg) + details = {'Problem': 'Failed to open target userspace /etc/passwd for reading'} + raise StopActorExecutionError( + 'Could not set up passwordless root login for the upgrade environment.', + details=details + ) + + new_passwd_lines = [] + found_root_entry = False + for entry in passwd: + if entry.startswith('root:'): + found_root_entry = True + + root_fields = entry.split(':') + root_fields[1] = '' + entry = ':'.join(root_fields) + + new_passwd_lines.append(entry) + + if not found_root_entry: + msg = 'Failed to set up a passwordless root login in the target userspace - there is no root user entry.' + api.current_logger().warning(msg) + details = {'Problem': 'There is no root user entry in target userspace\'s /etc/passwd'} + raise StopActorExecutionError( + 'Could not set up passwordless root login for the upgrade environment.', + details=details + ) + + try: + with context.open('/etc/passwd', 'w+') as passwd_file: + passwd_contents = ''.join(new_passwd_lines) + passwd_file.write(passwd_contents) + except OSError: + api.current_logger().warning('Failed to write new contents into target userspace /etc/passwd.') + raise StopActorExecutionError( + 'Could not set up passwordless root login for the upgrade environment.', + details={'Problem': 'Filed to write /etc/passwd for the upgrade environment'} + ) + + +def enable_dbus(context): + """ + Enable dbus-daemon into the target userspace + Looks like it's not enabled by default when installing into a container. + """ + api.current_logger().info('Configuring the dbus services') + + links = ['/etc/systemd/system/multi-user.target.wants/dbus-daemon.service', + '/etc/systemd/system/dbus.service', + '/etc/systemd/system/messagebus.service'] + + for link in links: + try: + os.symlink('/usr/lib/systemd/system/dbus-daemon.service', context.full_path(link)) + except OSError as err: + details = {'Problem': 'An error occurred while creating the systemd symlink', 'source_error': str(err)} + raise StopActorExecutionError('Cannot enable the dbus services', details=details) + + +def setup_network(context): + """ + Setup network for the livemode image. + + Copy ifcfg files and NetworkManager's system connections into the live image. + :returns: None + :raises StopActorExecutionError: The exception is raised when failing to copy the configuration + files into the livemode image. + """ + # TODO(mhecko): implementation here is incomplete + # ideally we'd need to run 'nmcli con migrate' for the live mode. + if get_target_major_version() < "9": + return # 8>9 only + + network_scripts_path = '/etc/sysconfig/network-scripts' + ifcfgs = [ifcfg for ifcfg in os.listdir(network_scripts_path) if ifcfg.startswith('ifcfg-')] + + network_manager_conns_path = '/etc/NetworkManager/system-connections' + conns = os.listdir(network_manager_conns_path) + + try: + if ifcfgs: + context.makedirs(network_scripts_path, exists_ok=True) + for ifcfg in ifcfgs: + ifcfg_fullpath = os.path.join(network_scripts_path, ifcfg) + context.copy_to(ifcfg_fullpath, ifcfg_fullpath) + + if conns: + context.makedirs(network_manager_conns_path, exists_ok=True) + for nm_conn in conns: + nm_conn_fullpath = os.path.join(network_manager_conns_path, nm_conn) + context.copy_to(nm_conn_fullpath, nm_conn_fullpath) + except OSError as error: + api.current_logger().error('Failed to setup network connections for the upgrade live image. Error: %s', error) + details = {'Problem': str(error)} + raise StopActorExecutionError('Failed to setup network connections for the upgrade live image.', + details=details) + + +def setup_sshd(context, authorized_keys): + """ + Setup a temporary ssh server with /root/.ssh/authorized_keys + + :param NspawnActions context: Context (target userspace) abstracting the root structure which will + become the upgrade image. + :param authorized_keys: Path to a file containing a list of (public) ssh keys that can authenticate + to the upgrade sshd server. + :returns: None + :raises StopActorExecutionError: The exception is raised when the sshd could not be set up. + """ + api.current_logger().warning('Preparing temporary sshd for live mode') + + system_ssh_config_dir = '/etc/ssh' + try: + sshd_config = os.listdir(system_ssh_config_dir) + hostkeys = [key for key in sshd_config if key.startswith('ssh_key_')] + public = [key for key in hostkeys if key.endswith('.pub')] + for key in hostkeys: + key_path = os.path.join(system_ssh_config_dir, key) + + if key in public: + access_rights = 0o644 + group = 'root' + else: + access_rights = 0o640 + group = 'ssh_keys' + + context.copy_to(key_path, system_ssh_config_dir) + + key_guest_path = context.full_path(key_path) + os.chmod(key_guest_path, access_rights) + os.chown(key_guest_path, uid=0, gid=grp.getgrnam(group).gr_gid) + except OSError as error: + api.current_logger().error('Failed to set up SSH keys from system\'s default location. Error: %s', error) + raise StopActorExecutionError( + 'Failed to set up SSH keys from system\'s default location for the upgrade image.' + ) + + root_authorized_keys_path = '/root/.ssh/authorized_keys' + try: + context.makedirs(os.path.dirname(root_authorized_keys_path)) + context.copy_to(authorized_keys, root_authorized_keys_path) + os.chmod(context.full_path(root_authorized_keys_path), 0o600) + os.chmod(context.full_path('/root/.ssh'), 0o700) + except OSError as error: + api.current_logger().error('Failed to set up /root/.ssh/authorized_keys. Error: %s', error) + details = {'Problem': 'Failed to set up /root/.ssh/authorized_keys. Error: {0}'.format(error)} + raise StopActorExecutionError('Failed to set up SSH access for the upgrade image.', details=details) + + sshd_service_activation_link_dst = context.full_path('/etc/systemd/system/multi-user.target.wants/sshd.service') + if not os.path.exists(sshd_service_activation_link_dst): + try: + os.symlink('/usr/lib/systemd/system/sshd.service', sshd_service_activation_link_dst) + except OSError as error: + api.current_logger().error( + 'Failed to enable the sshd service in the upgrade image (failed to create a symlink). Full error: %s', + error + ) + + # @Todo(mhecko): This is hazardous. I guess we are setting this so that we can use weaker SSH keys from RHEL7, + # # but this way we change crypto settings system-wise (could be a problem for FIPS). Instead, we + # # should check whether the keys will be OK on RHEL8, and inform the user otherwise. + if get_target_major_version() == '8': # set to LEGACY for 7>8 only + try: + with context.open('/etc/crypto-policies/config', 'w+') as f: + f.write('LEGACY\n') + except OSError as error: + api.current_logger().warning('Cannot set crypto policy to LEGACY') + details = {'details': 'Failed to set crypto-policies to LEGACY due to the error: {0}'.format(error)} + raise StopActorExecutionError('Failed to set up livemode SSHD', details=details) + + +# stolen from upgradeinitramfsgenerator.py +def _get_target_kernel_version(context): + """ + Get the version of the most recent kernel version within the container. + """ + try: + results = context.call(['rpm', '-qa', 'kernel-core'], split=True)['stdout'] + + except CalledProcessError as error: + problem = 'Could not query the target userspace kernel version through rpm. Full error: {0}'.format(error) + raise StopActorExecutionError( + 'Cannot get the version of the installed kernel.', + details={'Problem': problem}) + + if len(results) > 1: + raise StopActorExecutionError( + 'Cannot detect the version of the target userspace kernel.', + details={'Problem': 'Detected unexpectedly multiple kernels inside target userspace container.'}) + if not results: + raise StopActorExecutionError( + 'Cannot detect the version of the target userspace kernel.', + details={'Problem': 'An rpm query for the available kernels did not produce any results.'}) + + kernel_version = '-'.join(results[0].rsplit("-", 2)[-2:]) + api.current_logger().debug('Detected kernel version inside container: {}.'.format(kernel_version)) + + return kernel_version + + +def fakerootfs(): + """ + Create the FAKEROOTFS_FILE with source system's kernel cmdline. + + The list of parameters is used after the reboot to replace live system's `root=` parameter with the original one + so that kernel-core RPM installs properly. + + :returns: None + :raises StopActorExecutionError: The error is raised when the FAKEROOTFS_FILE cannot be created. + """ + FAKEROOTFS_FILE = '/var/lib/leapp/.fakerootfs' + with open('/proc/cmdline') as f: + all_args = f.read().split(' ') + + args_to_write = (arg.strip() for arg in all_args if not arg.startswith('BOOT_IMAGE')) + + try: + with open(FAKEROOTFS_FILE, 'w+') as f: + f.write(' '.join(args_to_write)) + except OSError as error: + api.current_logger().error('Failed to create the FAKEROOTFS_FILE. Full error: %s', error) + raise StopActorExecutionError( + 'Cannot prepare the kernel cmdline workaround.', + details={'Problem': 'Cannot write {0}'.format(FAKEROOTFS_FILE)} + ) + + +def create_etc_issue(context, stop_upgrade_on_failure=False): + """ + Create /etc/issue warning the user about upgrade being in-progress. + """ + try: + msg = ('\n\n\n' + '============================================================\n' + ' LEAPP LIVE UPGRADE MODE - *UNSUPPORTED*\n' + '============================================================\n' + ' DO NOT REBOOT until the upgrade is finished.\n' + ' Upgrade logs are sent on tty1 (Ctrl+Alt+F1)\n' + '============================================================\n' + ' It will automatically reboot unless you touch this file:\n' + ' # touch /sysroot/.noreboot\n' + '\n' + ' If upgrade.autostart=0 is set, run an upgrade manually:\n' + ' # upgrade |& tee /sysroot/var/log/leapp/leapp-upgrade.log\n' + '\n' + ' Log in as root, without password.\n' + '\n\n') + + with context.open('/etc/issue', 'w+') as f: + f.write(msg) + with context.open('/etc/motd', 'w+') as f: + f.write(msg) + except OSError as error: + api.current_logger().warning('Cannot write /etc/issue. Full error: %s', error) + if stop_upgrade_on_failure: + raise StopActorExecutionError('Failed to set up /etc/issue informing the user about pending upgrade.') + + +def modify_userspace_as_configured(userspace_info, storage, livemode_config): + """ + Prepare the (minimal) target RHEL userspace to be squashed into an image. + + The following preparation steps are performed: + - upgrade services and scripts are copied into the image and enabled + - console is set up to display leapp's logs on tty0 + - sshd is set up (if configured) + - kernel and initramfs is generated + - a new /etc/fstab is generating, mounting / of the source system somewhere else + - /etc/issue is created, informing user about the ongoing upgrade + - network manager is set up + """ + if not livemode_config or not livemode_config.is_enabled: + return + + setup_info = LiveImagePreparationInfo() + with mounting.NspawnActions(base_dir=userspace_info.path) as context: + # Perform all mounts that are required to make the userspace functional, and then + # create an Nspawn context inside the userspace + + # Non-configurable modifications: + setup_upgrade_service(context) + setup_console(context) + create_fstab_mounting_current_root_elsewhere(context, storage.fstab) + create_symlink_from_sysroot_to_source_root_mountpoint(context) + create_etc_issue(context) + enable_dbus(context) + + # Configurable modifications: + + if livemode_config.setup_opensshd_with_auth_keys: + setup_sshd(context, livemode_config.setup_opensshd_with_auth_keys) + setup_info.has_sshd = True + + if livemode_config.setup_passwordless_root: + make_root_account_passwordless(context) + setup_info.has_passwordless_root = True + + if livemode_config.setup_network_manager: + setup_network(context) + setup_info.has_network_set_up = True + + fakerootfs() # Workaround to hide the squashfs root arg in /proc/cmdline + + api.produce(setup_info) diff --git a/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/tests/test_livemode_userspace_modifications.py b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/tests/test_livemode_userspace_modifications.py new file mode 100644 index 0000000000..f652ae6aae --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/tests/test_livemode_userspace_modifications.py @@ -0,0 +1,487 @@ +import enum +import functools +import grp +import os +from collections import namedtuple + +import pytest + +from leapp.libraries.actor import prepareliveimage as modify_userspace_for_livemode_lib +from leapp.libraries.common import mounting +from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked +from leapp.libraries.stdlib import api +from leapp.models import FstabEntry, LiveModeConfig, StorageInfo, TargetUserSpaceInfo + +_LiveModeConfig = functools.partial(LiveModeConfig, squashfs_fullpath='') + + +@pytest.mark.parametrize( + ('livemode_config', 'should_modify'), + ( + (_LiveModeConfig(is_enabled=True), True,), + (_LiveModeConfig(is_enabled=False), False,), + (None, False) + ) +) +def test_modifications_require_livemode_enabled(monkeypatch, livemode_config, should_modify): + monkeypatch.setattr(api, 'produce', produce_mocked()) + + class NspawnActionsMock: + def __init__(self, *arg, **kwargs): + pass + + def __enter__(self): + pass + + def __exit__(self, *args): + pass + + monkeypatch.setattr(mounting, 'NspawnActions', NspawnActionsMock) + + modification_fns = [ + 'setup_upgrade_service', + 'setup_console', + 'setup_sshd', + 'create_fstab_mounting_current_root_elsewhere', + 'create_symlink_from_sysroot_to_source_root_mountpoint', + 'make_root_account_passwordless', + 'create_etc_issue', + 'enable_dbus', + 'setup_network', + 'fakerootfs' + ] + + def do_nothing(call_list, called_fn, *args, **kwargs): + call_list.append(called_fn) + + call_list = [] + for modification_fn in modification_fns: + monkeypatch.setattr(modify_userspace_for_livemode_lib, modification_fn, + functools.partial(do_nothing, call_list, modification_fn)) + + userspace = TargetUserSpaceInfo(path='', scratch='', mounts='') + storage = StorageInfo() + + modify_userspace_for_livemode_lib.modify_userspace_as_configured(userspace, storage, livemode_config) + + if should_modify: + assert 'setup_upgrade_service' in call_list + else: + assert not call_list + + +Action = namedtuple('Action', ('type_', 'args')) + + +class ActionType(enum.Enum): + COPY = 0 + SYMLINK = 1 + OPEN = 2 + WRITE = 3 + CHOWN = 4 + CHMOD = 5 + + +class WriterMock: + def __init__(self, action_log): + self.action_log = action_log + + def write(self, content): + action = Action(type_=ActionType.WRITE, args=(content,)) + self.action_log.append(action) + + +class FileHandleMock: + def __init__(self, action_log): + self.action_log = action_log + + def __enter__(self): + return WriterMock(action_log=self.action_log) + + def __exit__(self, *args): + pass + + +class NspawnActionsMock: + def __init__(self, base_dir, action_log): + self.base_dir = base_dir + self.action_log = action_log + + def copy_to(self, host_path, guest_path): + self.action_log.append(Action(type_=ActionType.COPY, args=(host_path, self.full_path(guest_path)))) + + def full_path(self, guest_path): + abs_guest_path = os.path.abspath(guest_path) + return os.path.join(self.base_dir, abs_guest_path.lstrip('/')) + + def open(self, guest_path, mode): + host_path = self.full_path(guest_path) + self.action_log.append(Action(type_=ActionType.OPEN, args=(host_path,))) + return FileHandleMock(action_log=self.action_log) + + def makedirs(self, *args, **kwargs): + pass + + +def assert_execution_trace_subsumes_other(actual_trace, expected_trace): + expected_action_log_idx = 0 + for actual_action in actual_trace: + if expected_trace[expected_action_log_idx] == actual_action: + expected_action_log_idx += 1 + + if expected_action_log_idx >= len(expected_trace): + break + + if expected_action_log_idx < len(expected_trace): + error_msg = 'Failed to find action {0} in actual action log'.format( + expected_trace[expected_action_log_idx] + ) + return error_msg + return None + + +def test_setup_upgrade_service(monkeypatch): + """ + Test whether setup_upgrade_service is being set up. + + The upgrade service is set up if: + 1) a service file is copied /usr/lib/systemd/system + 2) a shellscript /usr/bin/upgrade is copied into the userspace + 3) a symlink from /usr/lib/systemd/system/ to /etc/.../multi-user.target.wants/ is created + """ + + mocked_actor = CurrentActorMocked() + monkeypatch.setattr(api, 'current_actor', mocked_actor) + + actual_trace = [] + context_mock = NspawnActionsMock('/USERSPACE', action_log=actual_trace) + + def symlink_mock(link_target, symlink_location): + actual_trace.append(Action(type_=ActionType.SYMLINK, args=(link_target, symlink_location))) + + monkeypatch.setattr(os, 'symlink', symlink_mock) + monkeypatch.setattr(os.path, 'exists', lambda path: False) + + modify_userspace_for_livemode_lib.setup_upgrade_service(context_mock) + + service_filename = modify_userspace_for_livemode_lib.LEAPP_UPGRADE_SERVICE_FILE + expected_action_log = [ + Action(type_=ActionType.COPY, + args=(os.path.join('files', service_filename), + os.path.join('/USERSPACE/usr/lib/systemd/system', service_filename))), + Action(type_=ActionType.COPY, + args=(os.path.join('files', 'do-upgrade.sh'), '/USERSPACE/usr/bin/upgrade')), + Action(type_=ActionType.SYMLINK, + args=(os.path.join('/usr/lib/systemd/system', service_filename), + os.path.join('/USERSPACE/etc/systemd/system/multi-user.target.wants', service_filename))) + ] + + error = assert_execution_trace_subsumes_other(actual_trace, expected_action_log) + assert not error, error + + +def test_setup_console(monkeypatch): + """ + Test whether the console is being set up. + + The upgrade service is set up if: + 1) a consoele service file is copied /usr/lib/systemd/system + 2) /etc/systemd/login.d is modified + 3) old '/etc/systemd/system/getty.target.wants/getty@tty{tty_num}.service' is removed if it exists + 4) tty{2..5} are added to getty.target.wants + 5) leapp's console service is enabled + """ + mocked_actor = CurrentActorMocked() + monkeypatch.setattr(api, 'current_actor', mocked_actor) + + service_filename = modify_userspace_for_livemode_lib.LEAPP_CONSOLE_SERVICE_FILE + expected_trace = [ + Action(type_=ActionType.COPY, + args=(os.path.join('files', service_filename), + os.path.join('/USERSPACE/usr/lib/systemd/system', service_filename))), + Action(type_=ActionType.OPEN, args=('/USERSPACE/etc/systemd/logind.conf',)), + Action(type_=ActionType.WRITE, args=('NAutoVTs=1\n',)), + Action(type_=ActionType.SYMLINK, + args=('/usr/lib/systemd/system/getty@.service', + '/USERSPACE/etc/systemd/system/getty.target.wants/getty@tty2.service')), + Action(type_=ActionType.SYMLINK, + args=('/usr/lib/systemd/system/getty@.service', + '/USERSPACE/etc/systemd/system/getty.target.wants/getty@tty3.service')), + Action(type_=ActionType.SYMLINK, + args=('/usr/lib/systemd/system/getty@.service', + '/USERSPACE/etc/systemd/system/getty.target.wants/getty@tty4.service')), + Action(type_=ActionType.SYMLINK, + args=(os.path.join('/usr/lib/systemd/system/', service_filename), + os.path.join('/USERSPACE/etc/systemd/system/multi-user.target.wants/', service_filename))), + ] + + actual_trace = [] + + def symlink_mock(link_target, symlink_location): + actual_trace.append(Action(type_=ActionType.SYMLINK, args=(link_target, symlink_location))) + + monkeypatch.setattr(os, 'symlink', symlink_mock) + monkeypatch.setattr(os.path, 'exists', lambda path: False) + + context_mock = NspawnActionsMock(base_dir='/USERSPACE', action_log=actual_trace) + modify_userspace_for_livemode_lib.setup_console(context_mock) + + error_str = assert_execution_trace_subsumes_other(actual_trace, expected_trace) + assert not error_str, error_str + + +def test_setup_sshd(monkeypatch): + """ + + Test whether the sshd is set up correctly. + + SSHD setup should include: + 1) copying of any ssh_key_* and *.pub keys with correct rights, uid, gid into /etc/ssh + 2) copying the given config.setup_opensshd_with_auth_keys into /root/.ssh/authorized_keys with correct rights + 3) sshd is enabled + """ + mocked_actor = CurrentActorMocked() + monkeypatch.setattr(api, 'current_actor', mocked_actor) + + actual_trace = [] + + def chmod_mock(path, rights): + actual_trace.append(Action(type_=ActionType.CHMOD, args=(path, rights, ))) + + def chown_mock(path, uid=None, gid=None): + assert uid is not None + assert gid is not None + actual_trace.append(Action(type_=ActionType.CHOWN, args=(path, uid, gid))) + + def listdir_mock(path): + assert path == '/etc/ssh' + return [ + 'ssh_key_A', + 'ssh_key_B', + 'ssh_key_A.pub' + ] + + def symlink_mock(link_target, symlink_location): + actual_trace.append(Action(type_=ActionType.SYMLINK, args=(link_target, symlink_location))) + + _GroupInfo = namedtuple('GroupInfo', ('gr_gid')) + user_groups_table = { + 'root': _GroupInfo(0), + 'ssh_keys': _GroupInfo(1) + } + + monkeypatch.setattr(os.path, 'exists', lambda *args, **kwargs: False) + monkeypatch.setattr(os, 'chmod', chmod_mock) + monkeypatch.setattr(os, 'chown', chown_mock) + monkeypatch.setattr(os, 'symlink', symlink_mock) + monkeypatch.setattr(os, 'listdir', listdir_mock) + monkeypatch.setattr(grp, 'getgrnam', user_groups_table.get) + + context_mock = NspawnActionsMock(base_dir='/USERSPACE', action_log=actual_trace) + + modify_userspace_for_livemode_lib.setup_sshd(context_mock, 'AUTHORIZED_KEYS') + + expected_trace = [ + Action(type_=ActionType.COPY, args=('/etc/ssh/ssh_key_A', '/USERSPACE/etc/ssh')), + Action(type_=ActionType.CHMOD, args=('/USERSPACE/etc/ssh/ssh_key_A', 0o640)), + Action(type_=ActionType.CHOWN, args=('/USERSPACE/etc/ssh/ssh_key_A', 0, 1)), + Action(type_=ActionType.COPY, args=('/etc/ssh/ssh_key_B', '/USERSPACE/etc/ssh')), + Action(type_=ActionType.CHMOD, args=('/USERSPACE/etc/ssh/ssh_key_B', 0o640)), + Action(type_=ActionType.CHOWN, args=('/USERSPACE/etc/ssh/ssh_key_B', 0, 1)), + Action(type_=ActionType.COPY, args=('/etc/ssh/ssh_key_A.pub', '/USERSPACE/etc/ssh')), + Action(type_=ActionType.CHMOD, args=('/USERSPACE/etc/ssh/ssh_key_A.pub', 0o644)), + Action(type_=ActionType.CHOWN, args=('/USERSPACE/etc/ssh/ssh_key_A.pub', 0, 0)), + Action(type_=ActionType.CHMOD, args=('/USERSPACE/root/.ssh/authorized_keys', 0o600)), + Action(type_=ActionType.CHMOD, args=('/USERSPACE/root/.ssh', 0o700)), + Action(type_=ActionType.SYMLINK, + args=('/usr/lib/systemd/system/sshd.service', + '/USERSPACE/etc/systemd/system/multi-user.target.wants/sshd.service')), + Action(type_=ActionType.OPEN, args=('/USERSPACE/etc/crypto-policies/config',)), + Action(type_=ActionType.WRITE, args=('LEGACY\n',)), + ] + + error = assert_execution_trace_subsumes_other(actual_trace, expected_trace) + assert not error, error + + +def test_create_alternative_fstab(monkeypatch): + """ + Check whether alternative fstab is created soundly. + + Given host's fstab, an alternative userspace fstab that mounts + everything into a relative root should be created. + """ + + host_fstab = [ + FstabEntry(fs_spec='/A1', fs_file='/A2', fs_vfstype='ext4', + fs_mntops='optsA', fs_freq='freqA', fs_passno='passnoA'), + FstabEntry(fs_spec='/B1', fs_file='/B2', fs_vfstype='xfs', + fs_mntops='optsB', fs_freq='freqB', fs_passno='passnoB'), + FstabEntry(fs_spec='/swap-dev', fs_file='/swap', fs_vfstype='swap', + fs_mntops='opts-swap', fs_freq='freq-swap', fs_passno='passno-swap') + ] + + actual_trace = [] + context_mock = NspawnActionsMock('/USERSPACE', action_log=actual_trace) + + monkeypatch.setattr(modify_userspace_for_livemode_lib, 'SOURCE_ROOT_MOUNT_LOCATION', '/REL') + + modify_userspace_for_livemode_lib.create_fstab_mounting_current_root_elsewhere(context_mock, host_fstab) + + expected_fstab_contents = ( + '/A1 /REL/A2 ext4 optsA freqA passnoA\n' + '/B1 /REL/B2 xfs optsB freqB passnoB\n' + '/swap-dev /swap swap opts-swap freq-swap passno-swap\n' + ) + + expected_trace = [ + Action(type_=ActionType.OPEN, args=('/USERSPACE/etc/fstab',)), + Action(type_=ActionType.WRITE, args=(expected_fstab_contents,)), + ] + + error = assert_execution_trace_subsumes_other(actual_trace, expected_trace) + assert not error, error + + +def test_alternative_root_symlink_creation(monkeypatch): + actual_trace = [] + context_mock = NspawnActionsMock('/USERSPACE', action_log=actual_trace) + + def symlink_mock(link_target, symlink_location): + actual_trace.append(Action(type_=ActionType.SYMLINK, args=(link_target, symlink_location))) + + monkeypatch.setattr(os, 'symlink', symlink_mock) + monkeypatch.setattr(modify_userspace_for_livemode_lib, 'SOURCE_ROOT_MOUNT_LOCATION', '/NEW-ROOT') + + modify_userspace_for_livemode_lib.create_symlink_from_sysroot_to_source_root_mountpoint(context_mock) + + expected_trace = [ + Action(type_=ActionType.SYMLINK, args=('/NEW-ROOT', '/USERSPACE/sysroot')), + ] + + error = assert_execution_trace_subsumes_other(actual_trace, expected_trace) + assert not error, error + + +def test_enable_dbus(monkeypatch): + """ Test whether dbus-daemon is activated in the userspace. """ + + actual_trace = [] + context_mock = NspawnActionsMock('/USERSPACE', action_log=actual_trace) + + def symlink_mock(link_target, symlink_location): + actual_trace.append(Action(type_=ActionType.SYMLINK, args=(link_target, symlink_location))) + + monkeypatch.setattr(os, 'symlink', symlink_mock) + + modify_userspace_for_livemode_lib.enable_dbus(context_mock) + + expected_trace = [ + Action(type_=ActionType.SYMLINK, + args=('/usr/lib/systemd/system/dbus-daemon.service', + '/USERSPACE/etc/systemd/system/multi-user.target.wants/dbus-daemon.service')), + Action(type_=ActionType.SYMLINK, + args=('/usr/lib/systemd/system/dbus-daemon.service', + '/USERSPACE/etc/systemd/system/dbus.service')), + Action(type_=ActionType.SYMLINK, + args=('/usr/lib/systemd/system/dbus-daemon.service', + '/USERSPACE/etc/systemd/system/messagebus.service')), + ] + + error = assert_execution_trace_subsumes_other(actual_trace, expected_trace) + assert not error, error + + +def test_setup_network(monkeypatch): + """ Test whether the network is being set up correctly. """ + + def listdir_mock(path): + if path == '/etc/sysconfig/network-scripts': + return ['ifcfg-A', 'ifcfg-B'] + if path == '/etc/NetworkManager/system-connections': + return ['conn1', 'conn2'] + assert False, 'listing unexpected path' + + monkeypatch.setattr(os, 'listdir', listdir_mock) + + mocked_actor = CurrentActorMocked(dst_ver='9.4') + monkeypatch.setattr(api, 'current_actor', mocked_actor) + + actual_trace = [] + context_mock = NspawnActionsMock(base_dir='/USERSPACE', action_log=actual_trace) + + expected_trace = [ + Action(type_=ActionType.COPY, + args=('/etc/sysconfig/network-scripts/ifcfg-A', + '/USERSPACE/etc/sysconfig/network-scripts/ifcfg-A')), + Action(type_=ActionType.COPY, + args=('/etc/sysconfig/network-scripts/ifcfg-B', + '/USERSPACE/etc/sysconfig/network-scripts/ifcfg-B')), + Action(type_=ActionType.COPY, + args=('/etc/NetworkManager/system-connections/conn1', + '/USERSPACE/etc/NetworkManager/system-connections/conn1')), + Action(type_=ActionType.COPY, + args=('/etc/NetworkManager/system-connections/conn2', + '/USERSPACE/etc/NetworkManager/system-connections/conn2')), + ] + + modify_userspace_for_livemode_lib.setup_network(context_mock) + + error = assert_execution_trace_subsumes_other(actual_trace, expected_trace) + assert not error, error + + +@pytest.mark.parametrize( + 'livemode_config', + ( + _LiveModeConfig(is_enabled=True, setup_passwordless_root=True), + _LiveModeConfig(is_enabled=True, setup_opensshd_with_auth_keys='auth-keys-path'), + _LiveModeConfig(is_enabled=True, setup_network_manager=True), + _LiveModeConfig(is_enabled=True), + ) +) +def test_individual_modifications_are_performed_only_when_configured(monkeypatch, livemode_config): + mocked_actor = CurrentActorMocked(dst_ver='9.4') + monkeypatch.setattr(api, 'current_actor', mocked_actor) + monkeypatch.setattr(api, 'produce', produce_mocked()) + + mandatory_modifications = { + 'setup_upgrade_service', + 'setup_console', + 'create_fstab_mounting_current_root_elsewhere', + 'create_symlink_from_sysroot_to_source_root_mountpoint', + 'create_etc_issue', + 'enable_dbus', + 'fakerootfs', + } + + optional_modifications = { + 'setup_network', + 'make_root_account_passwordless', + 'setup_sshd', + } + + actual_modifications = set() + + def modification_mock(modif_name, *args, **kwargs): + actual_modifications.add(modif_name) + + for mandatory_modification in mandatory_modifications.union(optional_modifications): + monkeypatch.setattr(modify_userspace_for_livemode_lib, mandatory_modification, + functools.partial(modification_mock, mandatory_modification)) + + expected_modifications = set() + if livemode_config.setup_opensshd_with_auth_keys: + expected_modifications.add('setup_sshd') + if livemode_config.setup_passwordless_root: + expected_modifications.add('make_root_account_passwordless') + if livemode_config.setup_network_manager: + expected_modifications.add('setup_network') + expected_modifications = expected_modifications | mandatory_modifications + + userspace_info = TargetUserSpaceInfo(path='', scratch='', mounts='') + storage_info = StorageInfo() + + modify_userspace_for_livemode_lib.modify_userspace_as_configured(userspace_info, storage_info, livemode_config) + + assert actual_modifications == expected_modifications diff --git a/repos/system_upgrade/common/actors/livemode/removeliveimage/actor.py b/repos/system_upgrade/common/actors/livemode/removeliveimage/actor.py new file mode 100644 index 0000000000..1fa3312b6a --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/removeliveimage/actor.py @@ -0,0 +1,18 @@ +from leapp.actors import Actor +from leapp.libraries.actor import remove_live_image as remove_live_image_lib +from leapp.models import LiveModeArtifacts, LiveModeConfig +from leapp.tags import ExperimentalTag, FirstBootPhaseTag, IPUWorkflowTag + + +class RemoveLiveImage(Actor): + """ + Remove live mode artifacts + """ + + name = 'remove_live_image' + consumes = (LiveModeConfig, LiveModeArtifacts,) + produces = () + tags = (ExperimentalTag, FirstBootPhaseTag, IPUWorkflowTag) + + def process(self): + remove_live_image_lib.remove_live_image() diff --git a/repos/system_upgrade/common/actors/livemode/removeliveimage/libraries/remove_live_image.py b/repos/system_upgrade/common/actors/livemode/removeliveimage/libraries/remove_live_image.py new file mode 100644 index 0000000000..5bb7e40f7a --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/removeliveimage/libraries/remove_live_image.py @@ -0,0 +1,25 @@ +import os + +from leapp.libraries.stdlib import api +from leapp.models import LiveModeArtifacts, LiveModeConfig + + +def remove_live_image(): + livemode = next(api.consume(LiveModeConfig), None) + if not livemode or not livemode.is_enabled: + return + + artifacts = next(api.consume(LiveModeArtifacts), None) + + if not artifacts: + # Livemode is enabled, but we have received no artifacts - this should not happen. + # Anyway, it is futile to sabotage the upgrade this late (after the upgrade transaction) + error_descr = ('Livemode is enabled, but there is no LiveModeArtifacts message. ' + 'Cannot delete squashfs image (location is unknown)') + api.current_logger().error(error_descr) + return + + try: + os.unlink(artifacts.squashfs_path) + except OSError as error: + api.current_logger().warning('Failed to remove %s with error: %s', artifacts.squashfs, error) diff --git a/repos/system_upgrade/common/actors/livemode/removeliveimage/tests/test_remove_live_image.py b/repos/system_upgrade/common/actors/livemode/removeliveimage/tests/test_remove_live_image.py new file mode 100644 index 0000000000..4d6aa821d1 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/removeliveimage/tests/test_remove_live_image.py @@ -0,0 +1,44 @@ +import functools +import os + +import pytest + +from leapp.libraries.actor import remove_live_image as remove_live_image_lib +from leapp.libraries.common.testutils import CurrentActorMocked +from leapp.libraries.stdlib import api +from leapp.models import LiveModeArtifacts, LiveModeConfig + +_LiveModeConfig = functools.partial(LiveModeConfig, squashfs_fullpath='configured_path') + + +@pytest.mark.parametrize( + ('livemode_config', 'squashfs_path', 'should_unlink_be_called'), + ( + (_LiveModeConfig(is_enabled=True), '/squashfs', True), + (_LiveModeConfig(is_enabled=True), '/var/lib/leapp/upgrade.img', True), + (_LiveModeConfig(is_enabled=False), '/var/lib/leapp/upgrade.img', False), + (None, '/var/lib/leapp/upgrade.img', False), + (_LiveModeConfig(is_enabled=True), None, False), + ) +) +def test_remove_live_image(monkeypatch, livemode_config, squashfs_path, should_unlink_be_called): + """ Test whether live-mode image (as found in LiveModeArtifacts) is removed. """ + + messages = [] + if livemode_config: + messages.append(livemode_config) + if squashfs_path: + messages.append(LiveModeArtifacts(squashfs_path=squashfs_path)) + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=messages)) + + def unlink_mock(path): + if should_unlink_be_called: + assert path == squashfs_path + return + assert False # If we should not call unlink and we call it then fail the test + monkeypatch.setattr(os, 'unlink', unlink_mock) + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=messages)) + + remove_live_image_lib.remove_live_image()