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..016f6c040c --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/tests/test_config_scanner.py @@ -0,0 +1,125 @@ +import sys +from collections import namedtuple +from enum import Enum + +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 + +try: + import configparser +except ImportError: + import ConfigParser as configparser + + +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' + + if sys.version[0] == '2': + config_content = config_content.decode('utf-8') # python2 compat + + 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..58046b6173 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/tests/test_livemode_userspace_modifications.py @@ -0,0 +1,488 @@ +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(object): + 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(object): + 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(object): + 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(object): + 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' + return [] # unreachable, but pylint does not know that + + 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()