diff --git a/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py b/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py index fdf873e119..62e1b7f8a3 100644 --- a/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py +++ b/repos/system_upgrade/common/actors/targetuserspacecreator/libraries/userspacegen.py @@ -328,7 +328,54 @@ def _get_files_owned_by_rpms(context, dirpath, pkgs=None): return files_owned_by_rpms -def _copy_certificates(context, target_userspace): +def _copy_decouple(srcdir, dstdir, skip_broken_symlinks=True): + for root, _, files in os.walk(srcdir): + for filename in files: + relpath = os.path.relpath(root, srcdir) + source_filepath = os.path.join(root, filename) + target_filepath = os.path.join(dstdir, relpath, filename) + + # Skip broken and report broken symlinks (this follows any possible + # chain of symlinks) + if skip_broken_symlinks and not os.path.exists(source_filepath): + api.current_logger().warning('File {} is a broken symlink!'.format(source_filepath)) + continue + + # Copy symlinks to the target userspace + pointee = None + if os.path.islink(source_filepath): + pointee = os.readlink(source_filepath) + + # If source file is a symlink within `src` then preserve it, + # otherwise resolve and copy it as a file it points to + if pointee is not None and not pointee.startswith(srcdir): + # Follow the path until we hit a file or get back to /etc/pki + while not pointee.startswith(srcdir) and os.path.islink(pointee): + pointee = os.readlink(pointee) + + # Pointee points to a file outside /etc/pki so we copy it instead + if not pointee.startswith(srcdir) and not os.path.islink(pointee): + source_filepath = pointee + else: + # pointee points back to /etc/pki + pass + + # Ensure parent directory exists + parent_dir = os.path.dirname(target_filepath) + if not os.path.exists(parent_dir): + os.makedirs(parent_dir, exist_ok=True) + + if os.path.islink(source_filepath): + # Translate pointee to target /etc/pki + target_pointee = os.path.join(dstdir, os.path.relpath(pointee, root)) + # TODO(dkubek): Preserve the owner and permissions of the original symlink + run(['ln', '-s', target_pointee, target_filepath]) + continue + + run(['cp', '-a', source_filepath, target_filepath]) + + +def _copy_certificates(context, target_userspace, skip_broken_symlinks=True): """ Copy the needed cetificates into the container, but preserve original ones @@ -340,19 +387,56 @@ def _copy_certificates(context, target_userspace): target_pki = os.path.join(target_userspace, 'etc', 'pki') backup_pki = os.path.join(target_userspace, 'etc', 'pki.backup') - # FIXME(pstodulk): search for all files owned by RPMs inside the container - # before the mv, and all such files restore - # - this is requirement to not break IPU with RHUI when making the copy - # of certificates unconditional + with mounting.NspawnActions(base_dir=target_userspace) as target_context: + files_owned_by_rpms = _get_files_owned_by_rpms(target_context, '/etc/pki', recursive=True) + api.current_logger().debug('Files owned by rpms: {}'.format(' '.join(files_owned_by_rpms))) + + # Backup container /etc/pki run(['mv', target_pki, backup_pki]) - context.copytree_from('/etc/pki', target_pki) - # TODO(pstodulk): restore the files owned by rpms instead of the code below - for fname in os.listdir(os.path.join(backup_pki, 'rpm-gpg')): - src_path = os.path.join(backup_pki, 'rpm-gpg', fname) - dst_path = os.path.join(target_pki, 'rpm-gpg', fname) + # Copy source /etc/pki to the container + _copy_decouple('/etc/pki', target_pki, skip_broken_symlinks=skip_broken_symlinks) + + # Assertion: If skip_broken_symlinks is True + # => no broken symlinks exist in /etc/pki + # So any new broken symlinks created will be by the installed packages. + + # Recover installed packages as they always get precedence + for filepath in files_owned_by_rpms: + src_path = os.path.join(backup_pki, filepath) + dst_path = os.path.join(target_pki, filepath) + + # Resolve and skip any broken symlinks + # TODO(dkubek): Skip broken symlinks optionally + is_broken_symlink = False + pointee = None + if os.path.islink(src_path): + pointee = os.path.join(target_userspace, os.readlink(src_path)[1:]) + + while os.path.islink(pointee): + # The symlink points to a path relative to the target userspace so + # we need to readjust it + pointee = os.path.join(target_userspace, os.readlink(src_path)[1:]) + if not os.path.exists(pointee): + is_broken_symlink = True + + # The path original path of the broken symlink in the container + report_path = os.path.join(target_pki, os.path.relpath(src_path, backup_pki)) + api.current_logger().warning('File {} is a broken symlink!'.format(report_path)) + break + + if is_broken_symlink and skip_broken_symlinks: + continue + + # Cleanup conflicting files run(['rm', '-rf', dst_path]) - run(['cp', '-a', src_path, dst_path]) + + # Ensure destination exists + parent_dir = os.path.dirname(dst_path) + run(['mkdir', '-p', parent_dir]) + + # Copy the new file + run(['cp', '-R', '--preserve=all', src_path, dst_path]) def _prep_repository_access(context, target_userspace): @@ -362,6 +446,10 @@ def _prep_repository_access(context, target_userspace): target_etc = os.path.join(target_userspace, 'etc') target_yum_repos_d = os.path.join(target_etc, 'yum.repos.d') backup_yum_repos_d = os.path.join(target_etc, 'yum.repos.d.backup') + + _copy_certificates(context, target_userspace) + context.call(['update-ca-trust', 'extract']) + if not rhsm.skip_rhsm(): # TODO: make the _copy_certificates unconditional. keeping it conditional # due to issues causing on RHUI diff --git a/repos/system_upgrade/common/actors/targetuserspacecreator/tests/unit_test_targetuserspacecreator.py b/repos/system_upgrade/common/actors/targetuserspacecreator/tests/unit_test_targetuserspacecreator.py index a519275e46..245b210f8d 100644 --- a/repos/system_upgrade/common/actors/targetuserspacecreator/tests/unit_test_targetuserspacecreator.py +++ b/repos/system_upgrade/common/actors/targetuserspacecreator/tests/unit_test_targetuserspacecreator.py @@ -2,6 +2,8 @@ from collections import namedtuple import pytest +from pathlib import Path +import subprocess from leapp import models, reporting from leapp.exceptions import StopActorExecution, StopActorExecutionError @@ -48,6 +50,82 @@ def __exit__(self, exception_type, exception_value, traceback): pass +def traverse_structure(structure, root=Path('/')): + for filename, links_to in structure.items(): + filepath = root / filename + + if isinstance(links_to, dict): + yield from traverse_structure(links_to, filepath) + else: + yield (filepath, links_to) + + +def assert_directory_structure_matches(root, initial, expected): + for filepath, links_to in traverse_structure(expected, root=root / 'expected'): + assert filepath.exists() + + if links_to is None: + assert filepath.is_file() + continue + + assert filepath.is_symlink() + assert os.readlink(filepath) == str(root / 'expected' / links_to.lstrip('/')) + + +@pytest.fixture +def temp_directory_layout(tmp_path, initial_structure): + for filepath, links_to in traverse_structure(initial_structure, root=tmp_path / 'initial'): + file_path = tmp_path / filepath + file_path.parent.mkdir(parents=True, exist_ok=True) + + if links_to is None: + file_path.touch() + continue + + file_path.symlink_to(tmp_path / 'initial' / links_to.lstrip('/')) + + (tmp_path / 'expected').mkdir() + assert (tmp_path / 'expected').exists() + + return tmp_path + + +# The semantics of initial_structure and expected_structure are as follows: +# +# 1. The outermost dictionary encodes the root of a directory structure +# +# 2. Depending on the value for a key in a dict, each key in the dictionary +# denotes the name of either a: +# a) directory -- if value is dict +# b) regular file -- if value is None +# c) symlink -- if a value is str +# +# 3. The value of a symlink entry is a absolut path to a file in the context of +# the structure. +# +@pytest.mark.parametrize('initial_structure,expected_structure', [ + ({'dir': {'fileA': None}}, {'dir': {'fileA': None}}), + ({'dir': {'fileA': 'nonexistent'}}, {'dir': {}}), + ({'dir': {'fileA': '/dir/fileB', 'fileB': None}}, {'dir': {'fileA': '/dir/fileB', 'fileB': None}}), + ({'dir': {'fileA': '/dir/fileB', 'fileB': 'nonexistent'}}, + {'dir': {}}), + ({'dir': {'fileA': '/dir/fileB', 'fileB': '/dir/fileC', 'fileC': None}}, + {'dir': {'fileA': '/dir/fileB', 'fileB': '/dir/fileC', 'fileC': None}}), + ({'dir': {'fileA': '/dir/fileB', 'fileB': '/dir/fileC', 'fileC': '/outside/fileOut', 'fileE': None}, + 'outside': {'fileOut': '/outside/fileD', 'fileD': '/dir/fileE'}}, + {'dir': {'fileA': '/dir/fileB', 'fileB': '/dir/fileC', 'fileC': '/dir/fileE'}}), + ] +) +def test_copy_decouple(monkeypatch, temp_directory_layout, initial_structure, expected_structure): + monkeypatch.setattr(userspacegen, 'run', subprocess.run) + userspacegen._copy_decouple( + str(temp_directory_layout / 'initial' / 'dir'), + str(temp_directory_layout / 'expected' / 'dir'), + ) + + assert_directory_structure_matches(temp_directory_layout, initial_structure, expected_structure) + + @pytest.mark.parametrize('result,dst_ver,arch,prod_type', [ (os.path.join(_CERTS_PATH, '8.1', '479.pem'), '8.1', architecture.ARCH_X86_64, 'ga'), (os.path.join(_CERTS_PATH, '8.1', '419.pem'), '8.1', architecture.ARCH_ARM64, 'ga'),