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/addupgradebootentry/actor.py b/repos/system_upgrade/common/actors/addupgradebootentry/actor.py index 2e28971e79..f400ebf82f 100644 --- a/repos/system_upgrade/common/actors/addupgradebootentry/actor.py +++ b/repos/system_upgrade/common/actors/addupgradebootentry/actor.py @@ -3,7 +3,17 @@ from leapp.actors import Actor from leapp.exceptions import StopActorExecutionError from leapp.libraries.actor.addupgradebootentry import add_boot_entry, fix_grub_config_error -from leapp.models import BootContent, FirmwareFacts, GrubConfigError, TargetKernelCmdlineArgTasks, TransactionDryRun +from leapp.models import ( + BootContent, + FirmwareFacts, + GrubConfigError, + KernelCmdline, + LiveImagePreparationInfo, + LiveModeArtifacts, + LiveModeConfig, + TargetKernelCmdlineArgTasks, + TransactionDryRun +) from leapp.tags import InterimPreparationPhaseTag, IPUWorkflowTag @@ -15,7 +25,16 @@ class AddUpgradeBootEntry(Actor): """ name = 'add_upgrade_boot_entry' - consumes = (BootContent, GrubConfigError, FirmwareFacts, TransactionDryRun) + consumes = ( + BootContent, + GrubConfigError, + FirmwareFacts, + LiveImagePreparationInfo, + LiveModeArtifacts, + LiveModeConfig, + KernelCmdline, + TransactionDryRun + ) produces = (TargetKernelCmdlineArgTasks,) tags = (IPUWorkflowTag, InterimPreparationPhaseTag) diff --git a/repos/system_upgrade/common/actors/addupgradebootentry/libraries/addupgradebootentry.py b/repos/system_upgrade/common/actors/addupgradebootentry/libraries/addupgradebootentry.py index 4e1c420440..553ffc3547 100644 --- a/repos/system_upgrade/common/actors/addupgradebootentry/libraries/addupgradebootentry.py +++ b/repos/system_upgrade/common/actors/addupgradebootentry/libraries/addupgradebootentry.py @@ -1,33 +1,144 @@ +import itertools import os import re from leapp.exceptions import StopActorExecutionError -from leapp.libraries.common.config import architecture +from leapp.libraries.common.config import architecture, get_env from leapp.libraries.stdlib import api, CalledProcessError, run -from leapp.models import BootContent, KernelCmdlineArg, TargetKernelCmdlineArgTasks +from leapp.models import ( + BootContent, + KernelCmdline, + KernelCmdlineArg, + LiveImagePreparationInfo, + LiveModeArtifacts, + LiveModeConfig, + TargetKernelCmdlineArgTasks +) + + +def collect_boot_args(livemode_enabled): + args = { + 'enforcing': '0', + 'rd.plymouth': '0', + 'plymouth.enable': '0' + } + + if get_env('LEAPP_DEBUG', '0') == '1': + args['debug'] = None + + if get_env('LEAPP_DEVEL_INITRAM_NETWORK') in ('network-manager', 'scripts'): + args['ip'] = 'dhcp' + args['rd.neednet'] = '1' + + if livemode_enabled: + livemode_args = construct_cmdline_args_for_livemode() + args.update(livemode_args) + + return args + + +def collect_undesired_args(livemode_enabled): + args = {} + if livemode_enabled: + args = dict(zip(('ro', 'rhgb', 'quiet'), itertools.repeat(None))) + args['rd.lvm.lv'] = _get_rdlvm_arg_values() + + return args + + +def format_grubby_args_from_args_dict(args_dict): + """ Format the given args dictionary in a form required by grubby's --args. """ + + def fmt_single_arg(arg_pair): + key, value = arg_pair + if not value: + return str(key) + return '{key}={value}'.format(key=key, value=value) + + def flatten_arguments(arg_pair): + """ Expand multi-valued values into an iterable (key, value1), (key, value2) """ + key, value = arg_pair + if isinstance(value, (tuple, list)): + # value is multi-valued (a tuple of values) + for value_elem in value: # yield from is not available in python2.7 + yield (key, value_elem) + else: + yield (key, value) # Just a single (key, value) pair + + arg_sequence = itertools.chain(*(flatten_arguments(arg_pair) for arg_pair in args_dict.items())) + + # Sorting should be fine as only values can be None, but we cannot have a (key, None) and (key, value) in + # the dictionary at the same time. + cmdline_pieces = (fmt_single_arg(arg_pair) for arg_pair in sorted(arg_sequence)) + cmdline = ' '.join(cmdline_pieces) + + return cmdline + + +def figure_out_commands_needed_to_add_entry(kernel_path, initramfs_path, args_to_add, args_to_remove): + boot_entry_modification_commands = [] + + args_to_add_str = format_grubby_args_from_args_dict(args_to_add) + + create_entry_cmd = [ + '/usr/sbin/grubby', + '--add-kernel', '{0}'.format(kernel_path), + '--initrd', '{0}'.format(initramfs_path), + '--title', 'RHEL-Upgrade-Initramfs', + '--copy-default', + '--make-default', + '--args', args_to_add_str + ] + boot_entry_modification_commands.append(create_entry_cmd) + + # We need to update root= param separately, since we cannot do it during --add-kernel with --copy-default. + # This is likely a bug in grubby. + root_param_value = args_to_add.get('root', None) + if root_param_value: + enforce_root_param_for_the_entry_cmd = [ + '/usr/sbin/grubby', + '--update-kernel', kernel_path, + '--args', 'root={0}'.format(root_param_value) + ] + boot_entry_modification_commands.append(enforce_root_param_for_the_entry_cmd) + + if args_to_remove: + args_to_remove_str = format_grubby_args_from_args_dict(args_to_remove) + remove_undesired_args_cmd = [ + '/usr/sbin/grubby', + '--update-kernel', kernel_path, + '--remove-args', args_to_remove_str + ] + boot_entry_modification_commands.append(remove_undesired_args_cmd) + return boot_entry_modification_commands def add_boot_entry(configs=None): - debug = 'debug' if os.getenv('LEAPP_DEBUG', '0') == '1' else '' - enable_network = os.getenv('LEAPP_DEVEL_INITRAM_NETWORK') in ('network-manager', 'scripts') - ip_arg = ' ip=dhcp rd.neednet=1' if enable_network else '' kernel_dst_path, initram_dst_path = get_boot_file_paths() _remove_old_upgrade_boot_entry(kernel_dst_path, configs=configs) + + livemode_enabled = next(api.consume(LiveImagePreparationInfo), None) is not None + + cmdline_args = collect_boot_args(livemode_enabled) + undesired_cmdline_args = collect_undesired_args(livemode_enabled) + + commands_to_run = figure_out_commands_needed_to_add_entry(kernel_dst_path, + initram_dst_path, + args_to_add=cmdline_args, + args_to_remove=undesired_cmdline_args) + + def run_commands_adding_entry(extra_command_suffix=None): + if not extra_command_suffix: + extra_command_suffix = [] + for command in commands_to_run: + run(command + extra_command_suffix) + try: - cmd = [ - '/usr/sbin/grubby', - '--add-kernel', '{0}'.format(kernel_dst_path), - '--initrd', '{0}'.format(initram_dst_path), - '--title', 'RHEL-Upgrade-Initramfs', - '--copy-default', - '--make-default', - '--args', '{DEBUG}{NET} enforcing=0 rd.plymouth=0 plymouth.enable=0'.format(DEBUG=debug, NET=ip_arg) - ] if configs: for config in configs: - run(cmd + ['-c', config]) + run_commands_adding_entry(extra_command_suffix=['-c', config]) else: - run(cmd) + run_commands_adding_entry(extra_command_suffix=None) if architecture.matches_architecture(architecture.ARCH_S390X): # on s390x we need to call zipl explicitly because of issue in grubby, @@ -35,7 +146,7 @@ def add_boot_entry(configs=None): # See https://bugzilla.redhat.com/show_bug.cgi?id=1764306 run(['/usr/sbin/zipl']) - if debug: + if 'debug' in cmdline_args: # The kernelopts for target kernel are generated based on the cmdline used in the upgrade initramfs, # therefore, if we enabled debug above, and the original system did not have the debug kernelopt, we # need to explicitly remove it from the target os boot entry. @@ -114,3 +225,101 @@ def fix_grub_config_error(conf_file, error_type): elif error_type == 'missing newline': write_to_file(conf_file, config + '\n') + + +def local_os_stat(path): + """ Local wrapper around os.stat so we can safely mock it in tests. """ + return os.stat(path) + + +def _get_device_uuid(path): + """ + Find the UUID of a device in which the given path is located. + """ + while not os.path.ismount(path): + path = os.path.dirname(path) + + needle_dev_id = local_os_stat(path).st_dev + + for uuid in os.listdir('/dev/disk/by-uuid'): + uuid_fullpath = os.path.join('/dev/disk/by-uuid/', uuid) + dev_path = os.readlink(uuid_fullpath) + + # The link target is likely relative to the UUID_fullpath, e.g., ../../dm-1. + # Joining it will '/dev/disk/by-uuid' will resolve the relative path. + # If dev_path is absolute it returns dev_path. + dev_path = os.path.join('/dev/disk/by-uuid', dev_path) + dev_path = os.path.abspath(dev_path) + + dev_id = local_os_stat(dev_path).st_rdev + if dev_id == needle_dev_id: + return uuid + + return None + + +def _get_rdlvm_arg_values(): + # should we not check args returned by grubby instead? + cmdline_msg = next(api.consume(KernelCmdline), None) + + if not cmdline_msg: + raise StopActorExecutionError('Did not receive any KernelCmdline arguments.') + + rd_lvm_values = sorted(arg.value for arg in cmdline_msg.parameters if arg.key == 'rd.lvm.lv') + api.current_logger().debug('Collected the following rd.lvm.lv args that are undesired for the squashfs: %s', + rd_lvm_values) + + return rd_lvm_values + + +def construct_cmdline_args_for_livemode(): + """ + Prepare cmdline parameters for the live mode + """ + # boot locally by default + + livemode_config = next(api.consume(LiveModeConfig), None) + if not livemode_config: + raise StopActorExecutionError('Did not receive any livemode configuration message although it is enabled.') + + livemode_artifacts = next(api.consume(LiveModeArtifacts), None) + if not livemode_artifacts: + raise StopActorExecutionError('Did not receive any livemode artifacts message although it is enabled.') + + liveimg_filename = os.path.basename(livemode_artifacts.squashfs_path) + dir_path_containing_liveimg = os.path.dirname(livemode_artifacts.squashfs_path) + + args = {'rw': None} + + # if an URL is defined, boot over the network (http, nfs, ftp, ...) + if livemode_config.url_to_load_squashfs_from: + args['root'] = 'live:{}'.format(livemode_config.url_to_load_squashfs_from) + else: + args['root'] = 'live:UUID={}'.format(_get_device_uuid(dir_path_containing_liveimg)) + args['rd.live.dir'] = dir_path_containing_liveimg + args['rd.live.squashimg'] = liveimg_filename + + if livemode_config.dracut_network: + network_fragments = livemode_config.dracut_network.split('=', 1) + + # @Todo(mhecko): verify this during config scan + if len(network_fragments) == 1 or network_fragments[0] != 'ip': + msg = ('The livemode dracut_network configuration value is incorrect - it does not ' + 'have the form of a key=value cmdline arg: `{0}`.') + msg = msg.format(livemode_config.dracut_network) + + api.current_logger().error(msg) + raise StopActorExecutionError('Livemode is not configured correctly.', details={'details': msg}) + + args['ip'] = network_fragments[1] + args['rd.needsnet'] = '1' + + autostart_state = '1' if livemode_config.autostart_upgrade_after_reboot else '0' + args['upgrade.autostart'] = autostart_state + + if livemode_config.capture_upgrade_strace_into: + args['upgrade.strace'] = livemode_config.capture_upgrade_strace_into + + api.current_logger().info('The use of live mode image implies the following cmdline args: %s', args) + + return args diff --git a/repos/system_upgrade/common/actors/addupgradebootentry/tests/unit_test_addupgradebootentry.py b/repos/system_upgrade/common/actors/addupgradebootentry/tests/unit_test_addupgradebootentry.py index ddc37e5223..c4f5232bf8 100644 --- a/repos/system_upgrade/common/actors/addupgradebootentry/tests/unit_test_addupgradebootentry.py +++ b/repos/system_upgrade/common/actors/addupgradebootentry/tests/unit_test_addupgradebootentry.py @@ -8,7 +8,14 @@ from leapp.libraries.common.config.architecture import ARCH_S390X, ARCH_X86_64 from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked from leapp.libraries.stdlib import api -from leapp.models import BootContent, KernelCmdlineArg, TargetKernelCmdlineArgTasks +from leapp.models import ( + BootContent, + KernelCmdline, + KernelCmdlineArg, + LiveModeArtifacts, + LiveModeConfig, + TargetKernelCmdlineArgTasks +) CUR_DIR = os.path.dirname(os.path.abspath(__file__)) @@ -46,7 +53,7 @@ def __call__(self, filename, content): '--copy-default', '--make-default', '--args', - 'debug enforcing=0 rd.plymouth=0 plymouth.enable=0' + 'debug enforcing=0 plymouth.enable=0 rd.plymouth=0' ] run_args_zipl = ['/usr/sbin/zipl'] @@ -66,9 +73,8 @@ def get_boot_file_paths_mocked(): monkeypatch.setattr(addupgradebootentry, 'get_boot_file_paths', get_boot_file_paths_mocked) monkeypatch.setattr(api, 'produce', produce_mocked()) - monkeypatch.setenv('LEAPP_DEBUG', '1') monkeypatch.setattr(addupgradebootentry, 'run', run_mocked()) - monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch)) + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(arch, envars={'LEAPP_DEBUG': '1'})) addupgradebootentry.add_boot_entry() @@ -91,10 +97,10 @@ def get_boot_file_paths_mocked(): monkeypatch.setattr(addupgradebootentry, 'get_boot_file_paths', get_boot_file_paths_mocked) monkeypatch.setattr(api, 'produce', produce_mocked()) - monkeypatch.setenv('LEAPP_DEBUG', '1' if is_leapp_invoked_with_debug else '0') monkeypatch.setattr(addupgradebootentry, 'run', run_mocked()) - monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) + monkeypatch.setattr(api, 'current_actor', + CurrentActorMocked(envars={'LEAPP_DEBUG': str(int(is_leapp_invoked_with_debug))})) addupgradebootentry.add_boot_entry() @@ -114,9 +120,8 @@ def get_boot_file_paths_mocked(): return '/abc', '/def' monkeypatch.setattr(addupgradebootentry, 'get_boot_file_paths', get_boot_file_paths_mocked) - monkeypatch.setenv('LEAPP_DEBUG', '1') monkeypatch.setattr(addupgradebootentry, 'run', run_mocked()) - monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(envars={'LEAPP_DEBUG': '1'})) monkeypatch.setattr(api, 'produce', produce_mocked()) addupgradebootentry.add_boot_entry(CONFIGS) @@ -128,7 +133,7 @@ def get_boot_file_paths_mocked(): assert addupgradebootentry.run.args[3] == run_args_add + ['-c', CONFIGS[1]] assert api.produce.model_instances == [ TargetKernelCmdlineArgTasks(to_remove=[KernelCmdlineArg(key='debug')]), - TargetKernelCmdlineArgTasks(to_remove=[KernelCmdlineArg(key='enforcing', value='0')]) + TargetKernelCmdlineArgTasks(to_remove=[KernelCmdlineArg(key='enforcing', value='0')]), ] @@ -167,3 +172,164 @@ def test_fix_grub_config_error(monkeypatch, error_type, test_file_name): with open(os.path.join(CUR_DIR, 'files/{}.fixed'.format(test_file_name))) as f: assert addupgradebootentry.write_to_file.content == f.read() + + +@pytest.mark.parametrize( + ('is_debug_enabled', 'network_enablement_type'), + ( + (True, 'network-manager'), + (True, 'scripts'), + (True, False), + (False, False), + ) +) +def test_collect_boot_args(monkeypatch, is_debug_enabled, network_enablement_type): + env_vars = {'LEAPP_DEBUG': str(int(is_debug_enabled))} + if network_enablement_type: + env_vars['LEAPP_DEVEL_INITRAM_NETWORK'] = network_enablement_type + + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(envars=env_vars)) + monkeypatch.setattr(addupgradebootentry, 'construct_cmdline_args_for_livemode', + lambda *args: {'livemodearg': 'value'}) + + args = addupgradebootentry.collect_boot_args(livemode_enabled=True) + + assert args['enforcing'] == '0' + assert args['rd.plymouth'] == '0' + assert args['plymouth.enable'] == '0' + assert args['livemodearg'] == 'value' + + if is_debug_enabled: + assert args['debug'] is None + + if network_enablement_type: + assert args['ip'] == 'dhcp' + assert args['rd.neednet'] == '1' + + +@pytest.mark.parametrize( + 'livemode_config', + ( + LiveModeConfig(is_enabled=True, + squashfs_fullpath='/dir/squashfs.img', + url_to_load_squashfs_from='my-url'), + LiveModeConfig(is_enabled=True, + squashfs_fullpath='/dir/squashfs.img', + dracut_network="ip=192.168.122.146::192.168.122.1:255.255.255.0:foo::none"), + LiveModeConfig(is_enabled=True, + squashfs_fullpath='/dir/squashfs.img', + autostart_upgrade_after_reboot=False), + LiveModeConfig(is_enabled=True, + squashfs_fullpath='/dir/squashfs.img', + autostart_upgrade_after_reboot=True), + LiveModeConfig(is_enabled=True, + squashfs_fullpath='/dir/squashfs.img', + capture_upgrade_strace_into='/var/strace.out'), + ), +) +def test_construct_cmdline_for_livemode(monkeypatch, livemode_config): + artifacts = LiveModeArtifacts(squashfs_path=livemode_config.squashfs_fullpath) + messages = [livemode_config, artifacts] + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=messages)) + + monkeypatch.setattr(addupgradebootentry, '_get_device_uuid', lambda *args: 'MY_UUID') + + args = addupgradebootentry.construct_cmdline_args_for_livemode() + + assert 'rw' in args + + if livemode_config.url_to_load_squashfs_from: + assert args['root'] == 'live:my-url' + else: + assert args['root'] == 'live:UUID=MY_UUID' + assert args['rd.live.dir'] == '/dir' + assert args['rd.live.squashimg'] == 'squashfs.img' + + if livemode_config.dracut_network: + assert args['ip'] == '192.168.122.146::192.168.122.1:255.255.255.0:foo::none' + assert args['rd.needsnet'] == '1' + + assert args['upgrade.autostart'] == str(int(livemode_config.autostart_upgrade_after_reboot)) + + if livemode_config.capture_upgrade_strace_into: + assert args['upgrade.strace'] == livemode_config.capture_upgrade_strace_into + + +def test_get_rdlvm_arg_values(monkeypatch): + cmdline = [ + KernelCmdlineArg(key='debug', value=None), + KernelCmdlineArg(key='rd.lvm.lv', value='A'), + KernelCmdlineArg(key='other', value='A'), + KernelCmdlineArg(key='rd.lvm.lv', value='B') + ] + messages = [KernelCmdline(parameters=cmdline)] + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked(msgs=messages)) + + args = addupgradebootentry._get_rdlvm_arg_values() + + assert args == ['A', 'B'] + + +def test_get_device_uuid(monkeypatch): + """ + The file in question is /var/lib/file + Underlying partition /var is a device /dev/sda1 (dev_id=10) linked to from /dev/disk/by-uuid/MY_UUID1 + """ + + execution_stats = { + 'is_mount_call_count': 0 + } + + def is_mount_mock(path): + execution_stats['is_mount_call_count'] += 1 + assert execution_stats['is_mount_call_count'] <= 3 + return path == '/var' + + monkeypatch.setattr(os.path, 'ismount', is_mount_mock) + + StatResult = namedtuple('StatResult', ('st_dev', 'st_rdev')) + + def stat_mock(path): + known_paths_table = { + '/var': StatResult(st_dev=1, st_rdev=None), + '/dev/sda1': StatResult(st_dev=0, st_rdev=1), + '/dev/sda2': StatResult(st_dev=0, st_rdev=2), + '/dev/vda0': StatResult(st_dev=0, st_rdev=3), + } + return known_paths_table[path] + + monkeypatch.setattr(addupgradebootentry, 'local_os_stat', stat_mock) + + def listdir_mock(path): + assert path == '/dev/disk/by-uuid' + return ['MY_UUID0', 'MY_UUID1', 'MY_UUID2'] + + monkeypatch.setattr(os, 'listdir', listdir_mock) + + def readlink_mock(path): + known_links = { + '/dev/disk/by-uuid/MY_UUID0': '/dev/vda0', + '/dev/disk/by-uuid/MY_UUID1': '../../sda1', + '/dev/disk/by-uuid/MY_UUID2': '../../sda2', + } + return known_links[path] + + monkeypatch.setattr(os, 'readlink', readlink_mock) + + path = '/var/lib/file' + uuid = addupgradebootentry._get_device_uuid(path) + + assert uuid == 'MY_UUID1' + + +@pytest.mark.parametrize( + ('args', 'expected_result'), + ( + ([('argA', 'val'), ('argB', 'valB'), ('argC', None), ], 'argA=val argB=valB argC'), + ([('argA', ('val1', 'val2'))], 'argA=val1 argA=val2') + ) +) +def test_format_grubby_args_from_args_dict(args, expected_result): + actual_result = addupgradebootentry.format_grubby_args_from_args_dict(dict(args)) + + assert actual_result == expected_result diff --git a/repos/system_upgrade/common/actors/commonleappdracutmodules/actor.py b/repos/system_upgrade/common/actors/commonleappdracutmodules/actor.py index aae42bbb21..6be0657f6d 100644 --- a/repos/system_upgrade/common/actors/commonleappdracutmodules/actor.py +++ b/repos/system_upgrade/common/actors/commonleappdracutmodules/actor.py @@ -1,9 +1,10 @@ from leapp.actors import Actor from leapp.libraries.actor import modscan -from leapp.tags import FactsPhaseTag, IPUWorkflowTag +from leapp.tags import InterimPreparationPhaseTag, IPUWorkflowTag from leapp.utils.deprecation import suppress_deprecation from leapp.models import ( # isort:skip + LiveModeConfig, RequiredUpgradeInitramPackages, # deprecated UpgradeDracutModule, # deprecated TargetUserSpaceUpgradeTasks, @@ -23,14 +24,14 @@ class CommonLeappDracutModules(Actor): """ name = 'common_leapp_dracut_modules' - consumes = () + consumes = (LiveModeConfig,) produces = ( RequiredUpgradeInitramPackages, # deprecated TargetUserSpaceUpgradeTasks, UpgradeDracutModule, # deprecated UpgradeInitramfsTasks, ) - tags = (IPUWorkflowTag, FactsPhaseTag) + tags = (IPUWorkflowTag, InterimPreparationPhaseTag) def process(self): modscan.process() diff --git a/repos/system_upgrade/common/actors/commonleappdracutmodules/libraries/modscan.py b/repos/system_upgrade/common/actors/commonleappdracutmodules/libraries/modscan.py index 15150a5038..43cf60e66f 100644 --- a/repos/system_upgrade/common/actors/commonleappdracutmodules/libraries/modscan.py +++ b/repos/system_upgrade/common/actors/commonleappdracutmodules/libraries/modscan.py @@ -7,6 +7,7 @@ from leapp.models import ( # isort:skip CopyFile, + LiveModeConfig, RequiredUpgradeInitramPackages, # deprecated UpgradeDracutModule, # deprecated DracutModule, @@ -61,6 +62,14 @@ def _create_initram_networking_tasks(): # @suppress_deprecation(UpgradeDracutModule) def _create_dracut_modules(): dracut_base_path = api.get_actor_folder_path('dracut') + + livemode_config = next(api.consume(LiveModeConfig), None) + if livemode_config and livemode_config.is_enabled: + msg = ('Skipping requesting leapp\'s dracut modules to be included into ' + 'upgrade initramfs due to livemode being enabled.') + api.current_logger().debug(msg) + return + if dracut_base_path: dracut_base_path = os.path.abspath(dracut_base_path) for module in os.listdir(dracut_base_path): diff --git a/repos/system_upgrade/common/actors/commonleappdracutmodules/tests/test_modscan_commonleappdracutmodules.py b/repos/system_upgrade/common/actors/commonleappdracutmodules/tests/test_modscan_commonleappdracutmodules.py index 9c52b51f3e..781d0b4845 100644 --- a/repos/system_upgrade/common/actors/commonleappdracutmodules/tests/test_modscan_commonleappdracutmodules.py +++ b/repos/system_upgrade/common/actors/commonleappdracutmodules/tests/test_modscan_commonleappdracutmodules.py @@ -26,6 +26,8 @@ def _files_get_folder_path(name): @suppress_deprecation(UpgradeDracutModule) def test_created_modules(monkeypatch): + monkeypatch.setattr(api, 'current_actor', CurrentActorMocked()) + monkeypatch.setattr(api, 'get_actor_folder_path', _files_get_folder_path) path = os.path.abspath(api.get_actor_folder_path('dracut')) required_modules = {'sys-upgrade', 'sys-upgrade-redhat'} diff --git a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py index 2c52e817a0..d99bab4823 100644 --- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py +++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/actor.py @@ -5,6 +5,7 @@ from leapp.models import ( BootContent, FIPSInfo, + LiveModeConfig, TargetOSInstallationImage, TargetUserSpaceInfo, TargetUserSpaceUpgradeTasks, @@ -29,6 +30,7 @@ class UpgradeInitramfsGenerator(Actor): name = 'upgrade_initramfs_generator' consumes = ( FIPSInfo, + LiveModeConfig, RequiredUpgradeInitramPackages, # deprecated TargetOSInstallationImage, TargetUserSpaceInfo, diff --git a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py index 04fa061b00..02c3fd9d4a 100644 --- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py +++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/libraries/upgradeinitramfsgenerator.py @@ -1,5 +1,7 @@ +import itertools import os import shutil +from collections import namedtuple from leapp.exceptions import StopActorExecutionError from leapp.libraries.common import dnfplugin, mounting @@ -9,6 +11,7 @@ from leapp.models import UpgradeDracutModule # deprecated from leapp.models import ( BootContent, + LiveModeConfig, TargetOSInstallationImage, TargetUserSpaceInfo, TargetUserSpaceUpgradeTasks, @@ -314,6 +317,30 @@ def _check_free_space(context): ) +InitramfsIncludes = namedtuple('InitramfsIncludes', ('files', 'dracut_modules', 'kernel_modules')) + + +def collect_initramfs_includes(): + """ + Collect modules and files requested to be included in initramfs. + + :returns: A summary of requested initramfs includes + :rtype: InitramfsIncludes + """ + dracut_modules = _get_dracut_modules() # Use lists as leapp's models are not hashable + kernel_modules = list() + additional_initramfs_files = set() + + for task in api.consume(UpgradeInitramfsTasks): + dracut_modules.extend(task.include_dracut_modules) + kernel_modules.extend(task.include_kernel_modules) + additional_initramfs_files.update(task.include_files) + + return InitramfsIncludes(files=list(additional_initramfs_files), + dracut_modules=list(dracut_modules), + kernel_modules=list(kernel_modules)) + + def generate_initram_disk(context): """ Function to actually execute the init ramdisk creation. @@ -328,18 +355,13 @@ def generate_initram_disk(context): # TODO(pstodulk): Add possibility to add particular drivers # Issue #645 - modules = { - 'dracut': _get_dracut_modules(), # deprecated - 'kernel': [], - } - files = set() - for task in api.consume(UpgradeInitramfsTasks): - modules['dracut'].extend(task.include_dracut_modules) - modules['kernel'].extend(task.include_kernel_modules) - files.update(task.include_files) + initramfs_includes = collect_initramfs_includes() + + copy_dracut_modules(context, initramfs_includes.dracut_modules) + copy_kernel_modules(context, initramfs_includes.kernel_modules) - copy_dracut_modules(context, modules['dracut']) - copy_kernel_modules(context, modules['kernel']) + def fmt_module_list(module_list): + return ','.join(mod.name for mod in module_list) # FIXME: issue #376 context.call([ @@ -349,14 +371,115 @@ def generate_initram_disk(context): 'LEAPP_ADD_KERNEL_MODULES="{kernel_modules}" ' 'LEAPP_DRACUT_INSTALL_FILES="{files}" {cmd}'.format( kernel_version=_get_target_kernel_version(context), - dracut_modules=','.join([mod.name for mod in modules['dracut']]), - kernel_modules=','.join([mod.name for mod in modules['kernel']]), + dracut_modules=fmt_module_list(initramfs_includes.dracut_modules), + kernel_modules=fmt_module_list(initramfs_includes.kernel_modules), arch=api.current_actor().configuration.architecture, - files=' '.join(files), + files=' '.join(initramfs_includes.files), cmd=os.path.join('/', INITRAM_GEN_SCRIPT_NAME)) ], env=env) - copy_boot_files(context) + boot_files_info = copy_boot_files(context) + return boot_files_info + + +def get_boot_artifact_names(): + """ + Get the name of leapp's initramfs and upgrade kernel. + + :returns: A tuple (kernel_name, initramfs_name). + :rtype: Tuple[str, str] + """ + + arch = api.current_actor().configuration.architecture + + kernel = 'vmlinuz-upgrade.{}'.format(arch) + initramfs = 'initramfs-upgrade.{}.img'.format(arch) + + return (kernel, initramfs) + + +def copy_target_kernel_from_userspace_into_boot(context, target_kernel_ver, kernel_artifact_name): + userspace_kernel_installation_path = '/lib/modules/{}/vmlinuz'.format(target_kernel_ver) + api.current_logger().info( + 'Copying target kernel ({0}) into host system\'s /boot'.format(userspace_kernel_installation_path) + ) + host_kernel_dest = os.path.join('/boot', kernel_artifact_name) + context.copy_from(userspace_kernel_installation_path, host_kernel_dest) + + +def _generate_livemode_initramfs(context, userspace_initramfs_dest, target_kernel_ver): + """ + Generate livemode initramfs + + Collects modifications requested by received messages, synthesize and executed corresponding + dracut command. The created initramfs is placed at USERSPACE_ARTIFACTS_PATH/ + in the userspace. + + :param userspace_initramfs_dest str: The path at which the generated initramfs will be placed. + :param target_kernel_ver str: Kernel version installed into the userspace that will be used by the live image. + :returns: None + """ + env = {} + if get_target_major_version() == '9': + env = {'SYSTEMD_SECCOMP': '0'} + + initramfs_includes = collect_initramfs_includes() + + copy_dracut_modules(context, initramfs_includes.dracut_modules) + copy_kernel_modules(context, initramfs_includes.kernel_modules) + + dracut_modules = ['livenet', 'dmsquash-live'] + [mod.name for mod in initramfs_includes.dracut_modules] + + cmd = ['dracut', '--verbose', '--compress', 'xz', + '--no-hostonly', '--no-hostonly-default-device', + '-o', 'plymouth dash resume ifcfg earlykdump', + '--lvmconf', '--mdadmconf', + '--kver', target_kernel_ver, '-f', userspace_initramfs_dest] + + # Add dracut modules + cmd.extend(itertools.chain(*(('--add', module) for module in dracut_modules))) + + # Add kernel modules + cmd.extend(itertools.chain(*(('--add-drivers', module.name) for module in initramfs_includes.kernel_modules))) + + try: + context.call(cmd, env=env) + except CalledProcessError as error: + api.current_logger().error('Failed to generate (live) upgrade image. Error: %s', error) + raise StopActorExecutionError( + 'Cannot generate the initramfs for the live mode.', + details={'Problem': 'the dracut command failed: {}'.format(cmd)}) + + +def prepare_boot_files_for_livemode(context): + """ + Generate the initramfs for the live mode using dracut modules: dracut-live dracut-squash. + Silently replace upgrade boot images. + """ + api.current_logger().info('Building initramfs for the live upgrade image.') + + # @Todo(mhecko): See whether we need to do permission manipulation from dracut_install_modules. + # @Todo(mhecko): We need to handle upgrade kernel HMAC if we ever want to boot with FIPS in livemode + + target_kernel_ver = _get_target_kernel_version(context) + kernel_artifact_name, initramfs_artifact_name = get_boot_artifact_names() + + copy_target_kernel_from_userspace_into_boot(context, target_kernel_ver, kernel_artifact_name) + + USERSPACE_ARTIFACTS_PATH = '/artifacts' + context.makedirs(USERSPACE_ARTIFACTS_PATH, exists_ok=True) + userspace_initramfs_dest = os.path.join(USERSPACE_ARTIFACTS_PATH, initramfs_artifact_name) + + _generate_livemode_initramfs(context, userspace_initramfs_dest, target_kernel_ver) + + api.current_logger().debug('Copying artifacts from userspace into host\'s /boot') + host_initramfs_dest = os.path.join('/boot', initramfs_artifact_name) + host_kernel_dest = os.path.join('/boot', kernel_artifact_name) + context.copy_from(userspace_initramfs_dest, host_initramfs_dest) + + return BootContent(kernel_path=host_kernel_dest, + initram_path=host_initramfs_dest, + kernel_hmac_path='') def create_upgrade_hmac_from_target_hmac(original_hmac_path, upgrade_hmac_path, upgrade_kernel): @@ -379,8 +502,10 @@ def create_upgrade_hmac_from_target_hmac(original_hmac_path, upgrade_hmac_path, def copy_boot_files(context): """ - Function to copy the generated initram and corresponding kernel to /boot - Additionally produces a BootContent - message with their location. + Function to copy the generated initram and corresponding kernel to /boot + + + :returns: BootContent message containing the information about where the artifacts can be found. """ curr_arch = api.current_actor().configuration.architecture kernel = 'vmlinuz-upgrade.{}'.format(curr_arch) @@ -401,13 +526,19 @@ def copy_boot_files(context): kernel_hmac_path = context.full_path(os.path.join('/artifacts', kernel_hmac)) create_upgrade_hmac_from_target_hmac(kernel_hmac_path, content.kernel_hmac_path, kernel) - api.produce(content) + return content def process(): userspace_info = next(api.consume(TargetUserSpaceInfo), None) target_iso = next(api.consume(TargetOSInstallationImage), None) + livemode_config = next(api.consume(LiveModeConfig), None) + with mounting.NspawnActions(base_dir=userspace_info.path) as context: with mounting.mount_upgrade_iso_to_root_dir(userspace_info.path, target_iso): prepare_userspace_for_initram(context) - generate_initram_disk(context) + if livemode_config and livemode_config.is_enabled: + boot_file_info = prepare_boot_files_for_livemode(context) + else: + boot_file_info = generate_initram_disk(context) + api.produce(boot_file_info) diff --git a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py index 7397b82b93..8408233e0d 100644 --- a/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py +++ b/repos/system_upgrade/common/actors/initramfs/upgradeinitramfsgenerator/tests/unit_test_upgradeinitramfsgenerator.py @@ -166,13 +166,12 @@ def create_upgrade_hmac_from_target_hmac_mock(original_hmac_path, upgrade_hmac_p 'create_upgrade_hmac_from_target_hmac', create_upgrade_hmac_from_target_hmac_mock) - upgradeinitramfsgenerator.copy_boot_files(context) + actual_boot_content = upgradeinitramfsgenerator.copy_boot_files(context) assert len(context.called_copy_from) == 2 assert (os.path.join('/artifacts', kernel), bootc.kernel_path) in context.called_copy_from assert (os.path.join('/artifacts', initram), bootc.initram_path) in context.called_copy_from - assert upgradeinitramfsgenerator.api.produce.called == 1 - assert upgradeinitramfsgenerator.api.produce.model_instances[0] == bootc + assert actual_boot_content == bootc class MockedCopyArgs(object): 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() diff --git a/repos/system_upgrade/common/actors/targetuserspacecreator/actor.py b/repos/system_upgrade/common/actors/targetuserspacecreator/actor.py index b1225230ef..ddfa7ada0c 100644 --- a/repos/system_upgrade/common/actors/targetuserspacecreator/actor.py +++ b/repos/system_upgrade/common/actors/targetuserspacecreator/actor.py @@ -5,6 +5,7 @@ from leapp.models import TMPTargetRepositoriesFacts # deprecated from leapp.models import ( CustomTargetRepositoryFile, + LiveModeConfig, PkgManagerInfo, Report, RepositoriesFacts, @@ -37,6 +38,7 @@ class TargetUserspaceCreator(Actor): name = 'target_userspace_creator' consumes = ( CustomTargetRepositoryFile, + LiveModeConfig, RHSMInfo, RHUIInfo, RepositoriesFacts, diff --git a/repos/system_upgrade/common/models/livemode.py b/repos/system_upgrade/common/models/livemode.py new file mode 100644 index 0000000000..80db0fbfe9 --- /dev/null +++ b/repos/system_upgrade/common/models/livemode.py @@ -0,0 +1,98 @@ +from leapp.models import fields, Model +from leapp.topics import BootPrepTopic, SystemInfoTopic, TransactionTopic + + +class LiveModeConfig(Model): + topic = SystemInfoTopic + + is_enabled = fields.Boolean() + """ True if the live mode is enabled """ + + setup_passwordless_root = fields.Boolean(default=False) + """ Setup passwordless root for the live image used during the upgrade. """ + + url_to_load_squashfs_from = fields.Nullable(fields.String()) + """ + Url pointing to the squashfs image. + + if not set, the upgrade will boot locally + example: "http://192.168.122.1/live-upgrade.img" + """ + + squashfs_fullpath = fields.String() + """ Path to where the squashfs image should be stored. """ + + dracut_network = fields.Nullable(fields.String()) + """ + Dracut network arguments. + + Required if the url_to_lead_squashfs_from is set + + example1: "ip=dhcp" + example2: "ip=192.168.122.146::192.168.122.1:255.255.255.0:foo::none" + """ + + setup_network_manager = fields.Boolean(default=False) + """ Enable the NetworkManager """ + + additional_packages = fields.List(fields.String(), default=[]) + """ List of extra packages to include in the target userspace """ + + autostart_upgrade_after_reboot = fields.Boolean(default=True) + """ Autostart the upgrade upon reboot """ + + setup_opensshd_with_auth_keys = fields.Nullable(fields.String()) + """ + Setup SSHD using the authorized keys file. + + If empty, SSHD will not be enabled. + + example: "/root/.ssh/authorized_keys" + """ + + capture_upgrade_strace_into = fields.Nullable(fields.String()) + """ + File into which leapp upgrade service's strace output will be written. + + If empty, leapp will not be run under strace. + + example: "/var/lib/leapp/upgrade.strace" + """ + + +class LiveModeRequirementsTasks(Model): + topic = TransactionTopic + + packages = fields.List(fields.String()) + """ + packages to be installed in the target userspace + """ + + +class LiveImagePreparationInfo(Model): + """ + Information about how the upgrade live image is set up. + """ + topic = BootPrepTopic + + has_passwordless_root = fields.Boolean(default=False) + has_sshd = fields.Boolean(default=False) + has_network_set_up = fields.Boolean(default=False) + + +class PrepareLiveImagePostTasks(Model): + topic = BootPrepTopic + + +class LiveBootEntryTasks(Model): + topic = BootPrepTopic + grubby = fields.Boolean() + + +class LiveModeArtifacts(Model): + topic = BootPrepTopic + + """ + Artifacts created for the Live Mode + """ + squashfs_path = fields.String()