From 8dc4b5db96f008a92a39cebd355fd2c72b753137 Mon Sep 17 00:00:00 2001 From: Michal Hecko Date: Thu, 15 Aug 2024 15:54:34 +0200 Subject: [PATCH 1/3] live_mode: introduce new models required by the feature The experimental "live mode" feature that allows booting into a squashfs image of the target userspace and running leapp via a service builds on several models added by this commit. The most important role has the LiveModeConfig model, storing user-defined configuration of the feature. Jira ref: RHEL-45280 --- .../system_upgrade/common/models/livemode.py | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 repos/system_upgrade/common/models/livemode.py 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() From dae6188f0502cf3d6b095c7626ae922c1e0f1da8 Mon Sep 17 00:00:00 2001 From: Michal Hecko Date: Thu, 15 Aug 2024 15:59:11 +0200 Subject: [PATCH 2/3] live_mode: add support in the already-existing core actors Modify core actors to support upgrades with "live mode". Whereas live mode implies a new, separate, code path for generating the live image initramfs, the changes introduced in add_upgrade_boot_entry actor interfere deeply with the old implementation. Kernel cmdline arguments for the created boot entry are now manipulated uniformly, avoiding ad- hoc string formatting. It is also possible to remove kernel cmdline args from the entry. Addition of arguments precedes removal, i.e., if arg=value should be added and also removed, it will be removed. The root cmdline parameter is modified separately, due to a bug in grubby. Jira ref: RHEL-45280 --- .../actors/addupgradebootentry/actor.py | 23 +- .../libraries/addupgradebootentry.py | 243 ++++++++++++++++-- .../tests/unit_test_addupgradebootentry.py | 184 ++++++++++++- .../actors/commonleappdracutmodules/actor.py | 7 +- .../libraries/modscan.py | 9 + .../test_modscan_commonleappdracutmodules.py | 2 + .../upgradeinitramfsgenerator/actor.py | 2 + .../libraries/upgradeinitramfsgenerator.py | 169 ++++++++++-- .../unit_test_upgradeinitramfsgenerator.py | 5 +- .../actors/targetuserspacecreator/actor.py | 2 + 10 files changed, 593 insertions(+), 53 deletions(-) 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/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, From 04e9062f614ba2acdbe4fe64cee8a454d91763b1 Mon Sep 17 00:00:00 2001 From: Michal Hecko Date: Thu, 15 Aug 2024 16:07:34 +0200 Subject: [PATCH 3/3] live_mode: add new actors implementing the live mode functionality Add actors that scan the new configuration file devel-livemode.ini, informing the rest of the actor collective about the configuration. Based on this configuration, additional packages are requested to be installed into the target userspace. The target userspace is also modified to contain services that execute leapp based on kernel cmdline. For a full list of modifications, see models/livemode.py added in a previous commit. The feature can be enabled by setting LEAPP_UNSUPPORTED=1 together with LEAPP_DEVEL_ENABLE_LIVE_MODE=1. Note, that the squashfs-tools package must be installed (otherwise an error will be raised). The live mode feature is currently tested only for x86_64, and, therefore, attempting to use this feature on a different architecture will be prohibited by the implementation. Jira ref: RHEL-45280 --- etc/leapp/files/devel-livemode.ini | 9 + .../actor.py | 20 + .../emit_livemode_userspace_requirements.py | 38 ++ .../tests/test_emit_livemode_requirements.py | 37 ++ .../livemode/liveimagegenerator/actor.py | 29 + .../libraries/liveimagegenerator.py | 72 +++ .../tests/test_image_generation.py | 96 ++++ .../livemode/livemode_config_scanner/actor.py | 18 + .../libraries/scan_livemode_config.py | 125 +++++ .../tests/test_config_scanner.py | 125 +++++ .../actors/livemode/livemodereporter/actor.py | 19 + .../libraries/report_livemode.py | 24 + .../tests/test_report_livemode.py | 30 + .../modify_userspace_for_livemode/actor.py | 40 ++ .../files/console.service | 19 + .../files/do-upgrade.sh | 372 +++++++++++++ .../files/upgrade-strace.service | 14 + .../files/upgrade.service | 14 + .../libraries/prepareliveimage.py | 514 ++++++++++++++++++ .../test_livemode_userspace_modifications.py | 488 +++++++++++++++++ .../actors/livemode/removeliveimage/actor.py | 18 + .../libraries/remove_live_image.py | 25 + .../tests/test_remove_live_image.py | 44 ++ 23 files changed, 2190 insertions(+) create mode 100644 etc/leapp/files/devel-livemode.ini create mode 100644 repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/actor.py create mode 100644 repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/libraries/emit_livemode_userspace_requirements.py create mode 100644 repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/tests/test_emit_livemode_requirements.py create mode 100644 repos/system_upgrade/common/actors/livemode/liveimagegenerator/actor.py create mode 100644 repos/system_upgrade/common/actors/livemode/liveimagegenerator/libraries/liveimagegenerator.py create mode 100644 repos/system_upgrade/common/actors/livemode/liveimagegenerator/tests/test_image_generation.py create mode 100644 repos/system_upgrade/common/actors/livemode/livemode_config_scanner/actor.py create mode 100644 repos/system_upgrade/common/actors/livemode/livemode_config_scanner/libraries/scan_livemode_config.py create mode 100644 repos/system_upgrade/common/actors/livemode/livemode_config_scanner/tests/test_config_scanner.py create mode 100644 repos/system_upgrade/common/actors/livemode/livemodereporter/actor.py create mode 100644 repos/system_upgrade/common/actors/livemode/livemodereporter/libraries/report_livemode.py create mode 100644 repos/system_upgrade/common/actors/livemode/livemodereporter/tests/test_report_livemode.py create mode 100644 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/actor.py create mode 100644 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/console.service create mode 100755 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/do-upgrade.sh create mode 100644 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/upgrade-strace.service create mode 100644 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/files/upgrade.service create mode 100644 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/libraries/prepareliveimage.py create mode 100644 repos/system_upgrade/common/actors/livemode/modify_userspace_for_livemode/tests/test_livemode_userspace_modifications.py create mode 100644 repos/system_upgrade/common/actors/livemode/removeliveimage/actor.py create mode 100644 repos/system_upgrade/common/actors/livemode/removeliveimage/libraries/remove_live_image.py create mode 100644 repos/system_upgrade/common/actors/livemode/removeliveimage/tests/test_remove_live_image.py diff --git a/etc/leapp/files/devel-livemode.ini b/etc/leapp/files/devel-livemode.ini new file mode 100644 index 0000000000..b79ed4df85 --- /dev/null +++ b/etc/leapp/files/devel-livemode.ini @@ -0,0 +1,9 @@ +# Configuration for the *experimental* livemode feature +# It is likely that this entire configuration file will be replaced by some +# other mechanism/file in the future. For the full list of configuration options, +# see models/livemode.py +[livemode] +squashfs_fullpath=/var/lib/leapp/live-upgrade.img +setup_network_manager=no +autostart_upgrade_after_reboot=yes +setup_passwordless_root=no diff --git a/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/actor.py b/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/actor.py new file mode 100644 index 0000000000..a8aa7112db --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/actor.py @@ -0,0 +1,20 @@ +from leapp.actors import Actor +from leapp.libraries.actor import emit_livemode_userspace_requirements as emit_livemode_userspace_requirements_lib +from leapp.models import LiveModeConfig, TargetUserSpaceUpgradeTasks +from leapp.tags import ExperimentalTag, InterimPreparationPhaseTag, IPUWorkflowTag + + +class EmitLiveModeRequirements(Actor): + """ + Request addiontal packages to be installed into target userspace. + + Additional packages can be requested using LiveModeConfig.additional_packages + """ + + name = 'emit_livemode_requirements' + consumes = (LiveModeConfig,) + produces = (TargetUserSpaceUpgradeTasks,) + tags = (ExperimentalTag, InterimPreparationPhaseTag, IPUWorkflowTag,) + + def process(self): + emit_livemode_userspace_requirements_lib.emit_livemode_userspace_requirements() diff --git a/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/libraries/emit_livemode_userspace_requirements.py b/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/libraries/emit_livemode_userspace_requirements.py new file mode 100644 index 0000000000..4ecf682b74 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/libraries/emit_livemode_userspace_requirements.py @@ -0,0 +1,38 @@ +from leapp.libraries.stdlib import api +from leapp.models import LiveModeConfig, TargetUserSpaceUpgradeTasks + +# NOTE: would also need +# _REQUIRED_PACKAGES from actors/commonleappdracutmodules/libraries/modscan.py + +_REQUIRED_PACKAGES_FOR_LIVE_MODE = [ + 'systemd-container', + 'dbus-daemon', + 'NetworkManager', + 'util-linux', + 'dracut-live', + 'dracut-squash', + 'dmidecode', + 'pciutils', + 'lsscsi', + 'passwd', + 'kexec-tools', + 'vi', + 'less', + 'openssh-clients', + 'strace', + 'tcpdump', +] + + +def emit_livemode_userspace_requirements(): + livemode_config = next(api.consume(LiveModeConfig), None) + if not livemode_config or not livemode_config.is_enabled: + return + + packages = _REQUIRED_PACKAGES_FOR_LIVE_MODE + livemode_config.additional_packages + if livemode_config.setup_opensshd_with_auth_keys: + packages += ['openssh-server', 'crypto-policies'] + + packages = sorted(set(packages)) + + api.produce(TargetUserSpaceUpgradeTasks(install_rpms=packages)) diff --git a/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/tests/test_emit_livemode_requirements.py b/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/tests/test_emit_livemode_requirements.py new file mode 100644 index 0000000000..c376f03e2a --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/emit_livemode_userspace_requirements/tests/test_emit_livemode_requirements.py @@ -0,0 +1,37 @@ +import pytest + +from leapp.libraries.actor import emit_livemode_userspace_requirements as emit_livemode_userspace_requirements_lib +from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked +from leapp.libraries.stdlib import api +from leapp.models import LiveModeConfig, TargetUserSpaceUpgradeTasks + + +@pytest.mark.parametrize('livemode_config', (None, LiveModeConfig(squashfs_fullpath='', is_enabled=False))) +def test_no_emit_if_livemode_disabled(monkeypatch, livemode_config): + messages = [livemode_config] if livemode_config else [] + actor_mock = CurrentActorMocked(msgs=messages) + monkeypatch.setattr(api, 'current_actor', actor_mock) + monkeypatch.setattr(api, 'produce', produce_mocked()) + + emit_livemode_userspace_requirements_lib.emit_livemode_userspace_requirements() + + assert not api.produce.called + + +def test_emit(monkeypatch): + config = LiveModeConfig(squashfs_fullpath='', is_enabled=True, additional_packages=['EXTRA_PKG']) + actor_mock = CurrentActorMocked(msgs=[config]) + monkeypatch.setattr(api, 'current_actor', actor_mock) + monkeypatch.setattr(api, 'produce', produce_mocked()) + + emit_livemode_userspace_requirements_lib.emit_livemode_userspace_requirements() + + assert api.produce.called + assert len(api.produce.model_instances) == 1 + + required_pkgs = api.produce.model_instances[0] + assert isinstance(required_pkgs, TargetUserSpaceUpgradeTasks) + + assert 'dracut-live' in required_pkgs.install_rpms + assert 'dracut-squash' in required_pkgs.install_rpms + assert 'EXTRA_PKG' in required_pkgs.install_rpms diff --git a/repos/system_upgrade/common/actors/livemode/liveimagegenerator/actor.py b/repos/system_upgrade/common/actors/livemode/liveimagegenerator/actor.py new file mode 100644 index 0000000000..85a59a3efd --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/liveimagegenerator/actor.py @@ -0,0 +1,29 @@ +from leapp.actors import Actor +from leapp.libraries.actor.liveimagegenerator import generate_live_image_if_enabled +from leapp.models import ( + LiveImagePreparationInfo, + LiveModeArtifacts, + LiveModeConfig, + LiveModeRequirementsTasks, + PrepareLiveImagePostTasks, + TargetUserSpaceInfo +) +from leapp.tags import ExperimentalTag, InterimPreparationPhaseTag, IPUWorkflowTag + + +class LiveImageGenerator(Actor): + """ + Generates the squashfs image for the livemode upgrade + """ + + name = 'live_image_generator' + consumes = (LiveModeConfig, + LiveModeRequirementsTasks, + LiveImagePreparationInfo, + PrepareLiveImagePostTasks, + TargetUserSpaceInfo,) + produces = (LiveModeArtifacts,) + tags = (ExperimentalTag, InterimPreparationPhaseTag, IPUWorkflowTag,) + + def process(self): + generate_live_image_if_enabled() diff --git a/repos/system_upgrade/common/actors/livemode/liveimagegenerator/libraries/liveimagegenerator.py b/repos/system_upgrade/common/actors/livemode/liveimagegenerator/libraries/liveimagegenerator.py new file mode 100644 index 0000000000..af8981d862 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/liveimagegenerator/libraries/liveimagegenerator.py @@ -0,0 +1,72 @@ +import os +import os.path +import shutil + +from leapp.exceptions import StopActorExecutionError +from leapp.libraries.common import mounting +from leapp.libraries.stdlib import api, CalledProcessError, run +from leapp.models import LiveModeArtifacts, LiveModeConfig, TargetUserSpaceInfo + + +def lighten_target_userpace(context): + """ + Remove unneeded files from the target userspace. + """ + + userspace_trees_to_prune = ['artifacts', 'boot'] + + for tree_to_prune in userspace_trees_to_prune: + tree_full_path = os.path.join(context.base_dir, tree_to_prune) + try: + shutil.rmtree(tree_full_path) + except OSError as error: + api.current_logger().warning('Failed to remove /%s directory from the live image. Full error: %s', + tree_to_prune, error) + + +def build_squashfs(livemode_config, userspace_info): + """ + Generate the live rootfs image based on the target userspace + + :param livemode LiveModeConfig: Livemode configuration message + :param userspace_info TargetUserspaceInfo: Information about how target userspace is set up + """ + target_userspace_fullpath = userspace_info.path + squashfs_fullpath = livemode_config.squashfs_fullpath + + api.current_logger().info('Building the squashfs image %s from target userspace located at %s', + squashfs_fullpath, target_userspace_fullpath) + + try: + if os.path.exists(squashfs_fullpath): + os.unlink(squashfs_fullpath) + except OSError as error: + api.current_logger().warning('Failed to remove already existing %s. Full error: %s', + squashfs_fullpath, error) + + try: + run(['mksquashfs', target_userspace_fullpath, squashfs_fullpath]) + except CalledProcessError as error: + raise StopActorExecutionError( + 'Cannot pack the target userspace into a squash image. ', + details={'details': 'The following error occurred while building the squashfs image: {0}.'.format(error)} + ) + + return squashfs_fullpath + + +def generate_live_image_if_enabled(): + """ + Main function to generate the additional artifacts needed to run in live mode. + """ + + livemode_config = next(api.consume(LiveModeConfig), None) + if not livemode_config or not livemode_config.is_enabled: + return + + userspace_info = next(api.consume(TargetUserSpaceInfo), None) + + with mounting.NspawnActions(base_dir=userspace_info.path) as context: + lighten_target_userpace(context) + squashfs_path = build_squashfs(livemode_config, userspace_info) + api.produce(LiveModeArtifacts(squashfs_path=squashfs_path)) diff --git a/repos/system_upgrade/common/actors/livemode/liveimagegenerator/tests/test_image_generation.py b/repos/system_upgrade/common/actors/livemode/liveimagegenerator/tests/test_image_generation.py new file mode 100644 index 0000000000..5c434a6bb3 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/liveimagegenerator/tests/test_image_generation.py @@ -0,0 +1,96 @@ +import collections +import os +import shutil + +import pytest + +from leapp.libraries.actor import liveimagegenerator as live_image_generator_lib +from leapp.libraries.common import mounting +from leapp.libraries.common.testutils import CurrentActorMocked, produce_mocked +from leapp.libraries.stdlib import api +from leapp.models import LiveModeArtifacts, LiveModeConfig, TargetUserSpaceInfo + + +def test_squafs_creation(monkeypatch): + userspace_info = TargetUserSpaceInfo(path='/USERSPACE', scratch='/SCRATCH', mounts='/MOUNTS') + livemode_config = LiveModeConfig(is_enabled=True, squashfs_fullpath='/var/lib/leapp/squashfs.img') + + def exists_mock(path): + assert path == '/var/lib/leapp/squashfs.img' + return True + + monkeypatch.setattr(os.path, 'exists', exists_mock) + + def unlink_mock(path): + assert path == '/var/lib/leapp/squashfs.img' + + monkeypatch.setattr(os, 'unlink', unlink_mock) + + commands_executed = [] + + def run_mock(command): + commands_executed.append(command[0]) + + monkeypatch.setattr(live_image_generator_lib, 'run', run_mock) + + live_image_generator_lib.build_squashfs(livemode_config, userspace_info) + assert commands_executed == ['mksquashfs'] + + +def test_userspace_lightening(monkeypatch): + + removed_trees = [] + + def rmtree_mock(path): + removed_trees.append(path) + + monkeypatch.setattr(shutil, 'rmtree', rmtree_mock) + + _ContextMock = collections.namedtuple('ContextMock', ('base_dir')) + context_mock = _ContextMock(base_dir='/USERSPACE') + + live_image_generator_lib.lighten_target_userpace(context_mock) + + assert removed_trees == ['/USERSPACE/artifacts', '/USERSPACE/boot'] + + +@pytest.mark.parametrize( + ('livemode_config', 'should_produce'), + ( + (LiveModeConfig(is_enabled=True, squashfs_fullpath='/squashfs'), True), + (LiveModeConfig(is_enabled=False, squashfs_fullpath='/squashfs'), False), + (None, False), + ) +) +def test_generate_live_image_if_enabled(monkeypatch, livemode_config, should_produce): + userspace_info = TargetUserSpaceInfo(path='/USERSPACE', scratch='/SCRATCH', mounts='/MOUNTS') + messages = [livemode_config, userspace_info] if livemode_config else [userspace_info] + actor_mock = CurrentActorMocked(msgs=messages) + monkeypatch.setattr(api, 'current_actor', actor_mock) + + class NspawnMock(object): + def __init__(self, *args, **kwargs): + pass + + def __enter__(self, *args, **kwargs): + pass + + def __exit__(self, *args, **kwargs): + pass + + monkeypatch.setattr(mounting, 'NspawnActions', NspawnMock) + monkeypatch.setattr(live_image_generator_lib, 'lighten_target_userpace', lambda context: None) + monkeypatch.setattr(live_image_generator_lib, 'build_squashfs', + lambda livemode_config, userspace_info: '/squashfs') + monkeypatch.setattr(api, 'produce', produce_mocked()) + + live_image_generator_lib.generate_live_image_if_enabled() + + if should_produce: + assert api.produce.called + assert len(api.produce.model_instances) == 1 + artifacts = api.produce.model_instances[0] + assert isinstance(artifacts, LiveModeArtifacts) + assert artifacts.squashfs_path == '/squashfs' + else: + assert not api.produce.called diff --git a/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/actor.py b/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/actor.py new file mode 100644 index 0000000000..dc79ecff07 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/actor.py @@ -0,0 +1,18 @@ +from leapp.actors import Actor +from leapp.libraries.actor import scan_livemode_config as scan_livemode_config_lib +from leapp.models import InstalledRPM, LiveModeConfig +from leapp.tags import ExperimentalTag, FactsPhaseTag, IPUWorkflowTag + + +class LiveModeConfigScanner(Actor): + """ + Read livemode configuration located at /etc/leapp/files/devel-livemode.ini + """ + + name = 'live_mode_config_scanner' + consumes = (InstalledRPM,) + produces = (LiveModeConfig,) + tags = (ExperimentalTag, FactsPhaseTag, IPUWorkflowTag,) + + def process(self): + scan_livemode_config_lib.scan_config_and_emit_message() diff --git a/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/libraries/scan_livemode_config.py b/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/libraries/scan_livemode_config.py new file mode 100644 index 0000000000..b2f0af7f26 --- /dev/null +++ b/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/libraries/scan_livemode_config.py @@ -0,0 +1,125 @@ +try: + import configparser +except ImportError: + import ConfigParser as configparser + +from leapp.exceptions import StopActorExecutionError +from leapp.libraries.common.config import architecture, get_env +from leapp.libraries.common.rpms import has_package +from leapp.libraries.stdlib import api +from leapp.models import InstalledRPM, LiveModeConfig +from leapp.models.fields import ModelViolationError + +LIVEMODE_CONFIG_LOCATION = '/etc/leapp/files/devel-livemode.ini' +DEFAULT_SQUASHFS_PATH = '/var/lib/leapp/live-upgrade.img' + + +def should_scan_config(): + is_unsupported = get_env('LEAPP_UNSUPPORTED', '0') == '1' + is_livemode_enabled = get_env('LEAPP_DEVEL_ENABLE_LIVE_MODE', '0') == '1' + + if not is_unsupported: + api.current_logger().debug('Will not scan livemode config - the upgrade is not unsupported.') + return False + + if not is_livemode_enabled: + api.current_logger().debug('Will not scan livemode config - the live mode is not enabled.') + return False + + if not architecture.matches_architecture(architecture.ARCH_X86_64): + api.current_logger().debug('Will not scan livemode config - livemode is currently limited to x86_64.') + details = 'Live upgrades are currently limited to x86_64 only.' + raise StopActorExecutionError( + 'CPU architecture does not meet requirements for live upgrades', + details={'Problem': details} + ) + + if not has_package(InstalledRPM, 'squashfs-tools'): + # This feature is not to be used by standard users, so stopping the upgrade and providing + # the developer a speedy feedback is OK. + raise StopActorExecutionError( + 'The \'squashfs-tools\' is not installed', + details={'Problem': 'The \'squashfs-tools\' is required for the live mode.'} + ) + + return True + + +def scan_config_and_emit_message(): + if not should_scan_config(): + return + + api.current_logger().info('Loading livemode config from %s', LIVEMODE_CONFIG_LOCATION) + parser = configparser.ConfigParser() + + try: + parser.read((LIVEMODE_CONFIG_LOCATION, )) + except configparser.ParsingError as error: + api.current_logger().error('Failed to parse live mode configuration due to the following error: %s', error) + + details = 'Failed to read livemode configuration due to the following error: {0}.' + raise StopActorExecutionError( + 'Failed to read livemode configuration', + details={'Problem': details.format(error)} + ) + + livemode_section = 'livemode' + if not parser.has_section(livemode_section): + details = 'The configuration is missing the \'[{0}]\' section'.format(livemode_section) + raise StopActorExecutionError( + 'Live mode configuration does not have the required structure', + details={'Problem': details} + ) + + config_kwargs = { + 'is_enabled': True, + 'url_to_load_squashfs_from': None, + 'squashfs_fullpath': DEFAULT_SQUASHFS_PATH, + 'dracut_network': None, + 'setup_network_manager': False, + 'additional_packages': [], + 'autostart_upgrade_after_reboot': True, + 'setup_opensshd_with_auth_keys': None, + 'setup_passwordless_root': False, + 'capture_upgrade_strace_into': None + } + + config_str_options = ( + 'url_to_load_squashfs_from', + 'squashfs_fullpath', + 'dracut_network', + 'setup_opensshd_with_auth_keys', + 'capture_upgrade_strace_into' + ) + + config_list_options = ( + 'additional_packages', + ) + + config_bool_options = ( + 'setup_network_manager', + 'setup_passwordless_root', + 'autostart_upgrade_after_reboot', + ) + + for config_option in config_str_options: + if parser.has_option(livemode_section, config_option): + config_kwargs[config_option] = parser.get(livemode_section, config_option) + + for config_option in config_bool_options: + if parser.has_option(livemode_section, config_option): + config_kwargs[config_option] = parser.getboolean(livemode_section, config_option) + + for config_option in config_list_options: + if parser.has_option(livemode_section, config_option): + option_val = parser.get(livemode_section, config_option) + option_list = (opt_val.strip() for opt_val in option_val.split(',')) + option_list = [opt for opt in option_list if opt] + config_kwargs[config_option] = option_list + + try: + config = LiveModeConfig(**config_kwargs) + except ModelViolationError as error: + raise StopActorExecutionError('Failed to parse livemode configuration.', details={'Problem': str(error)}) + + api.produce(config) diff --git a/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/tests/test_config_scanner.py b/repos/system_upgrade/common/actors/livemode/livemode_config_scanner/tests/test_config_scanner.py new file mode 100644 index 0000000000..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()