diff --git a/ci-Allow-growpart-to-resize-encrypted-partitions-1316.patch b/ci-Allow-growpart-to-resize-encrypted-partitions-1316.patch new file mode 100644 index 0000000..93d922f --- /dev/null +++ b/ci-Allow-growpart-to-resize-encrypted-partitions-1316.patch @@ -0,0 +1,516 @@ +From 1176a788c23697099093b4d8a9a21f10f71ebb12 Mon Sep 17 00:00:00 2001 +From: Vitaly Kuznetsov +Date: Wed, 1 Feb 2023 10:47:07 +0100 +Subject: [PATCH] Allow growpart to resize encrypted partitions (#1316) + +Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=2166245 + +commit d95a331d1035d52443c470e0c00765a2c2b271cc +Author: James Falcon +Date: Tue Apr 26 19:03:13 2022 -0500 + + Allow growpart to resize encrypted partitions (#1316) + + Adds the ability for growpart to resize a LUKS formatted partition. + This involves resizing the underlying partition as well as the + filesystem. 'cryptsetup' is used for resizing. + + This relies on a file present at /cc_growpart_keydata containing + json formatted 'key' and 'slot' keys, with the key being + base64 encoded. After resize, cloud-init will destroy + the luks slot used for resizing and remove the key file. + +Conflicts: + cloudinit/config/cc_growpart.py (includes only) + +Signed-off-by: Vitaly Kuznetsov +--- + cloudinit/config/cc_growpart.py | 171 +++++++++++++++- + test-requirements.txt | 1 + + tests/unittests/config/test_cc_growpart.py | 228 +++++++++++++++++++++ + tox.ini | 1 + + 4 files changed, 400 insertions(+), 1 deletion(-) + +diff --git a/cloudinit/config/cc_growpart.py b/cloudinit/config/cc_growpart.py +index 43334caa..bdf17aba 100644 +--- a/cloudinit/config/cc_growpart.py ++++ b/cloudinit/config/cc_growpart.py +@@ -64,10 +64,16 @@ growpart is:: + ignore_growroot_disabled: + """ + ++import base64 ++import copy ++import json + import os + import os.path + import re + import stat ++from contextlib import suppress ++from pathlib import Path ++from typing import Tuple + + from cloudinit import log as logging + from cloudinit import subp, temp_utils, util +@@ -81,6 +87,8 @@ DEFAULT_CONFIG = { + "ignore_growroot_disabled": False, + } + ++KEYDATA_PATH = Path("/cc_growpart_keydata") ++ + + class RESIZE(object): + SKIPPED = "SKIPPED" +@@ -289,10 +297,128 @@ def devent2dev(devent): + return dev + + ++def get_mapped_device(blockdev): ++ """Returns underlying block device for a mapped device. ++ ++ If it is mapped, blockdev will usually take the form of ++ /dev/mapper/some_name ++ ++ If blockdev is a symlink pointing to a /dev/dm-* device, return ++ the device pointed to. Otherwise, return None. ++ """ ++ realpath = os.path.realpath(blockdev) ++ if realpath.startswith("/dev/dm-"): ++ LOG.debug("%s is a mapped device pointing to %s", blockdev, realpath) ++ return realpath ++ return None ++ ++ ++def is_encrypted(blockdev, partition) -> bool: ++ """ ++ Check if a device is an encrypted device. blockdev should have ++ a /dev/dm-* path whereas partition is something like /dev/sda1. ++ """ ++ if not subp.which("cryptsetup"): ++ LOG.debug("cryptsetup not found. Assuming no encrypted partitions") ++ return False ++ try: ++ subp.subp(["cryptsetup", "status", blockdev]) ++ except subp.ProcessExecutionError as e: ++ if e.exit_code == 4: ++ LOG.debug("Determined that %s is not encrypted", blockdev) ++ else: ++ LOG.warning( ++ "Received unexpected exit code %s from " ++ "cryptsetup status. Assuming no encrypted partitions.", ++ e.exit_code, ++ ) ++ return False ++ with suppress(subp.ProcessExecutionError): ++ subp.subp(["cryptsetup", "isLuks", partition]) ++ LOG.debug("Determined that %s is encrypted", blockdev) ++ return True ++ return False ++ ++ ++def get_underlying_partition(blockdev): ++ command = ["dmsetup", "deps", "--options=devname", blockdev] ++ dep: str = subp.subp(command)[0] # type: ignore ++ # Returned result should look something like: ++ # 1 dependencies : (vdb1) ++ if not dep.startswith("1 depend"): ++ raise RuntimeError( ++ f"Expecting '1 dependencies' from 'dmsetup'. Received: {dep}" ++ ) ++ try: ++ return f'/dev/{dep.split(": (")[1].split(")")[0]}' ++ except IndexError as e: ++ raise RuntimeError( ++ f"Ran `{command}`, but received unexpected stdout: `{dep}`" ++ ) from e ++ ++ ++def resize_encrypted(blockdev, partition) -> Tuple[str, str]: ++ """Use 'cryptsetup resize' to resize LUKS volume. ++ ++ The loaded keyfile is json formatted with 'key' and 'slot' keys. ++ key is base64 encoded. Example: ++ {"key":"XFmCwX2FHIQp0LBWaLEMiHIyfxt1SGm16VvUAVledlY=","slot":5} ++ """ ++ if not KEYDATA_PATH.exists(): ++ return (RESIZE.SKIPPED, "No encryption keyfile found") ++ try: ++ with KEYDATA_PATH.open() as f: ++ keydata = json.load(f) ++ key = keydata["key"] ++ decoded_key = base64.b64decode(key) ++ slot = keydata["slot"] ++ except Exception as e: ++ raise RuntimeError( ++ "Could not load encryption key. This is expected if " ++ "the volume has been previously resized." ++ ) from e ++ ++ try: ++ subp.subp( ++ ["cryptsetup", "--key-file", "-", "resize", blockdev], ++ data=decoded_key, ++ ) ++ finally: ++ try: ++ subp.subp( ++ [ ++ "cryptsetup", ++ "luksKillSlot", ++ "--batch-mode", ++ partition, ++ str(slot), ++ ] ++ ) ++ except subp.ProcessExecutionError as e: ++ LOG.warning( ++ "Failed to kill luks slot after resizing encrypted volume: %s", ++ e, ++ ) ++ try: ++ KEYDATA_PATH.unlink() ++ except Exception: ++ util.logexc( ++ LOG, "Failed to remove keyfile after resizing encrypted volume" ++ ) ++ ++ return ( ++ RESIZE.CHANGED, ++ f"Successfully resized encrypted volume '{blockdev}'", ++ ) ++ ++ + def resize_devices(resizer, devices): + # returns a tuple of tuples containing (entry-in-devices, action, message) ++ devices = copy.copy(devices) + info = [] +- for devent in devices: ++ ++ while devices: ++ devent = devices.pop(0) + try: + blockdev = devent2dev(devent) + except ValueError as e: +@@ -329,6 +455,49 @@ def resize_devices(resizer, devices): + ) + continue + ++ underlying_blockdev = get_mapped_device(blockdev) ++ if underlying_blockdev: ++ try: ++ # We need to resize the underlying partition first ++ partition = get_underlying_partition(blockdev) ++ if is_encrypted(underlying_blockdev, partition): ++ if partition not in [x[0] for x in info]: ++ # We shouldn't attempt to resize this mapped partition ++ # until the underlying partition is resized, so re-add ++ # our device to the beginning of the list we're ++ # iterating over, then add our underlying partition ++ # so it can get processed first ++ devices.insert(0, devent) ++ devices.insert(0, partition) ++ continue ++ status, message = resize_encrypted(blockdev, partition) ++ info.append( ++ ( ++ devent, ++ status, ++ message, ++ ) ++ ) ++ else: ++ info.append( ++ ( ++ devent, ++ RESIZE.SKIPPED, ++ f"Resizing mapped device ({blockdev}) skipped " ++ "as it is not encrypted.", ++ ) ++ ) ++ except Exception as e: ++ info.append( ++ ( ++ devent, ++ RESIZE.FAILED, ++ f"Resizing encrypted device ({blockdev}) failed: {e}", ++ ) ++ ) ++ # At this point, we WON'T resize a non-encrypted mapped device ++ # though we should probably grow the ability to ++ continue + try: + (disk, ptnum) = device_part_info(blockdev) + except (TypeError, ValueError) as e: +diff --git a/test-requirements.txt b/test-requirements.txt +index 06dfbbec..7160416a 100644 +--- a/test-requirements.txt ++++ b/test-requirements.txt +@@ -2,6 +2,7 @@ + httpretty>=0.7.1 + pytest + pytest-cov ++pytest-mock + + # Only really needed on older versions of python + setuptools +diff --git a/tests/unittests/config/test_cc_growpart.py b/tests/unittests/config/test_cc_growpart.py +index ba66f136..7d4e2629 100644 +--- a/tests/unittests/config/test_cc_growpart.py ++++ b/tests/unittests/config/test_cc_growpart.py +@@ -8,6 +8,7 @@ import shutil + import stat + import unittest + from contextlib import ExitStack ++from itertools import chain + from unittest import mock + + from cloudinit import cloud, subp, temp_utils +@@ -342,6 +343,233 @@ class TestResize(unittest.TestCase): + os.stat = real_stat + + ++class TestEncrypted: ++ """Attempt end-to-end scenarios using encrypted devices. ++ ++ Things are mocked such that: ++ - "/fake_encrypted" is mounted onto "/dev/mapper/fake" ++ - "/dev/mapper/fake" is a LUKS device and symlinked to /dev/dm-1 ++ - The partition backing "/dev/mapper/fake" is "/dev/vdx1" ++ - "/" is not encrypted and mounted onto "/dev/vdz1" ++ ++ Note that we don't (yet) support non-encrypted mapped drives, such ++ as LVM volumes. If our mount point is /dev/mapper/*, then we will ++ not resize it if it is not encrypted. ++ """ ++ ++ def _subp_side_effect(self, value, good=True, **kwargs): ++ if value[0] == "dmsetup": ++ return ("1 dependencies : (vdx1)",) ++ return mock.Mock() ++ ++ def _device_part_info_side_effect(self, value): ++ if value.startswith("/dev/mapper/"): ++ raise TypeError(f"{value} not a partition") ++ return (1024, 1024) ++ ++ def _devent2dev_side_effect(self, value): ++ if value == "/fake_encrypted": ++ return "/dev/mapper/fake" ++ elif value == "/": ++ return "/dev/vdz" ++ elif value.startswith("/dev"): ++ return value ++ raise Exception(f"unexpected value {value}") ++ ++ def _realpath_side_effect(self, value): ++ return "/dev/dm-1" if value.startswith("/dev/mapper") else value ++ ++ def assert_resize_and_cleanup(self): ++ all_subp_args = list( ++ chain(*[args[0][0] for args in self.m_subp.call_args_list]) ++ ) ++ assert "resize" in all_subp_args ++ assert "luksKillSlot" in all_subp_args ++ self.m_unlink.assert_called_once() ++ ++ def assert_no_resize_or_cleanup(self): ++ all_subp_args = list( ++ chain(*[args[0][0] for args in self.m_subp.call_args_list]) ++ ) ++ assert "resize" not in all_subp_args ++ assert "luksKillSlot" not in all_subp_args ++ self.m_unlink.assert_not_called() ++ ++ @pytest.fixture ++ def common_mocks(self, mocker): ++ # These are all "happy path" mocks which will get overridden ++ # when needed ++ mocker.patch( ++ "cloudinit.config.cc_growpart.device_part_info", ++ side_effect=self._device_part_info_side_effect, ++ ) ++ mocker.patch("os.stat") ++ mocker.patch("stat.S_ISBLK") ++ mocker.patch("stat.S_ISCHR") ++ mocker.patch( ++ "cloudinit.config.cc_growpart.devent2dev", ++ side_effect=self._devent2dev_side_effect, ++ ) ++ mocker.patch( ++ "os.path.realpath", side_effect=self._realpath_side_effect ++ ) ++ # Only place subp.which is used in cc_growpart is for cryptsetup ++ mocker.patch( ++ "cloudinit.config.cc_growpart.subp.which", ++ return_value="/usr/sbin/cryptsetup", ++ ) ++ self.m_subp = mocker.patch( ++ "cloudinit.config.cc_growpart.subp.subp", ++ side_effect=self._subp_side_effect, ++ ) ++ mocker.patch( ++ "pathlib.Path.open", ++ new_callable=mock.mock_open, ++ read_data=( ++ '{"key":"XFmCwX2FHIQp0LBWaLEMiHIyfxt1SGm16VvUAVledlY=",' ++ '"slot":5}' ++ ), ++ ) ++ mocker.patch("pathlib.Path.exists", return_value=True) ++ self.m_unlink = mocker.patch("pathlib.Path.unlink", autospec=True) ++ ++ self.resizer = mock.Mock() ++ self.resizer.resize = mock.Mock(return_value=(1024, 1024)) ++ ++ def test_resize_when_encrypted(self, common_mocks, caplog): ++ info = cc_growpart.resize_devices(self.resizer, ["/fake_encrypted"]) ++ assert len(info) == 2 ++ assert info[0][0] == "/dev/vdx1" ++ assert info[0][2].startswith("no change necessary") ++ assert info[1][0] == "/fake_encrypted" ++ assert ( ++ info[1][2] ++ == "Successfully resized encrypted volume '/dev/mapper/fake'" ++ ) ++ assert ( ++ "/dev/mapper/fake is a mapped device pointing to /dev/dm-1" ++ in caplog.text ++ ) ++ assert "Determined that /dev/dm-1 is encrypted" in caplog.text ++ ++ self.assert_resize_and_cleanup() ++ ++ def test_resize_when_unencrypted(self, common_mocks): ++ info = cc_growpart.resize_devices(self.resizer, ["/"]) ++ assert len(info) == 1 ++ assert info[0][0] == "/" ++ assert "encrypted" not in info[0][2] ++ self.assert_no_resize_or_cleanup() ++ ++ def test_encrypted_but_cryptsetup_not_found( ++ self, common_mocks, mocker, caplog ++ ): ++ mocker.patch( ++ "cloudinit.config.cc_growpart.subp.which", ++ return_value=None, ++ ) ++ info = cc_growpart.resize_devices(self.resizer, ["/fake_encrypted"]) ++ ++ assert len(info) == 1 ++ assert "skipped as it is not encrypted" in info[0][2] ++ assert "cryptsetup not found" in caplog.text ++ self.assert_no_resize_or_cleanup() ++ ++ def test_dmsetup_not_found(self, common_mocks, mocker, caplog): ++ def _subp_side_effect(value, **kwargs): ++ if value[0] == "dmsetup": ++ raise subp.ProcessExecutionError() ++ ++ mocker.patch( ++ "cloudinit.config.cc_growpart.subp.subp", ++ side_effect=_subp_side_effect, ++ ) ++ info = cc_growpart.resize_devices(self.resizer, ["/fake_encrypted"]) ++ assert len(info) == 1 ++ assert info[0][0] == "/fake_encrypted" ++ assert info[0][1] == "FAILED" ++ assert ( ++ "Resizing encrypted device (/dev/mapper/fake) failed" in info[0][2] ++ ) ++ self.assert_no_resize_or_cleanup() ++ ++ def test_unparsable_dmsetup(self, common_mocks, mocker, caplog): ++ def _subp_side_effect(value, **kwargs): ++ if value[0] == "dmsetup": ++ return ("2 dependencies",) ++ return mock.Mock() ++ ++ mocker.patch( ++ "cloudinit.config.cc_growpart.subp.subp", ++ side_effect=_subp_side_effect, ++ ) ++ info = cc_growpart.resize_devices(self.resizer, ["/fake_encrypted"]) ++ assert len(info) == 1 ++ assert info[0][0] == "/fake_encrypted" ++ assert info[0][1] == "FAILED" ++ assert ( ++ "Resizing encrypted device (/dev/mapper/fake) failed" in info[0][2] ++ ) ++ self.assert_no_resize_or_cleanup() ++ ++ def test_missing_keydata(self, common_mocks, mocker, caplog): ++ # Note that this will be standard behavior after first boot ++ # on a system with an encrypted root partition ++ mocker.patch("pathlib.Path.open", side_effect=FileNotFoundError()) ++ info = cc_growpart.resize_devices(self.resizer, ["/fake_encrypted"]) ++ assert len(info) == 2 ++ assert info[0][0] == "/dev/vdx1" ++ assert info[0][2].startswith("no change necessary") ++ assert info[1][0] == "/fake_encrypted" ++ assert info[1][1] == "FAILED" ++ assert ( ++ info[1][2] ++ == "Resizing encrypted device (/dev/mapper/fake) failed: Could " ++ "not load encryption key. This is expected if the volume has " ++ "been previously resized." ++ ) ++ self.assert_no_resize_or_cleanup() ++ ++ def test_resize_failed(self, common_mocks, mocker, caplog): ++ def _subp_side_effect(value, **kwargs): ++ if value[0] == "dmsetup": ++ return ("1 dependencies : (vdx1)",) ++ elif value[0] == "cryptsetup" and "resize" in value: ++ raise subp.ProcessExecutionError() ++ return mock.Mock() ++ ++ self.m_subp = mocker.patch( ++ "cloudinit.config.cc_growpart.subp.subp", ++ side_effect=_subp_side_effect, ++ ) ++ ++ info = cc_growpart.resize_devices(self.resizer, ["/fake_encrypted"]) ++ assert len(info) == 2 ++ assert info[0][0] == "/dev/vdx1" ++ assert info[0][2].startswith("no change necessary") ++ assert info[1][0] == "/fake_encrypted" ++ assert info[1][1] == "FAILED" ++ assert ( ++ "Resizing encrypted device (/dev/mapper/fake) failed" in info[1][2] ++ ) ++ # Assert we still cleanup ++ all_subp_args = list( ++ chain(*[args[0][0] for args in self.m_subp.call_args_list]) ++ ) ++ assert "luksKillSlot" in all_subp_args ++ self.m_unlink.assert_called_once() ++ ++ def test_resize_skipped(self, common_mocks, mocker, caplog): ++ mocker.patch("pathlib.Path.exists", return_value=False) ++ info = cc_growpart.resize_devices(self.resizer, ["/fake_encrypted"]) ++ assert len(info) == 2 ++ assert info[1] == ( ++ "/fake_encrypted", ++ "SKIPPED", ++ "No encryption keyfile found", ++ ) ++ ++ + def simple_device_part_info(devpath): + # simple stupid return (/dev/vda, 1) for /dev/vda + ret = re.search("([^0-9]*)([0-9]*)$", devpath) +diff --git a/tox.ini b/tox.ini +index c494cb94..04a206f2 100644 +--- a/tox.ini ++++ b/tox.ini +@@ -108,6 +108,7 @@ deps = + # test-requirements + pytest==3.3.2 + pytest-cov==2.5.1 ++ pytest-mock==1.7.1 + # Needed by pytest and default causes failures + attrs==17.4.0 + +-- +2.39.1 + diff --git a/cloud-init.spec b/cloud-init.spec index 94aa683..3078090 100644 --- a/cloud-init.spec +++ b/cloud-init.spec @@ -1,6 +1,6 @@ Name: cloud-init Version: 22.1 -Release: 8%{?dist} +Release: 9%{?dist} Summary: Cloud instance init scripts License: ASL 2.0 or GPLv3 URL: http://launchpad.net/cloud-init @@ -52,6 +52,8 @@ Patch19: ci-cloud.cfg.tmpl-make-sure-centos-settings-are-identic.patch Patch20: ci-Ensure-network-ready-before-cloud-init-service-runs-.patch # For bz#2140893 - systemd[1]: Failed to start Initial cloud-init job after reboot system via sysrq 'b' Patch21: ci-cc_set_hostname-ignore-var-lib-cloud-data-set-hostna.patch +# For bz#2166245 - Add support for resizing encrypted root volume +Patch22: ci-Allow-growpart-to-resize-encrypted-partitions-1316.patch # Source-git patches @@ -242,6 +244,11 @@ fi %config(noreplace) %{_sysconfdir}/rsyslog.d/21-cloudinit.conf %changelog +* Wed Feb 08 2023 Camilla Conte - 22.1-9 +- ci-Allow-growpart-to-resize-encrypted-partitions-1316.patch [bz#2166245] +- Resolves: bz#2166245 + (Add support for resizing encrypted root volume) + * Fri Jan 27 2023 Camilla Conte - 22.1-8 - ci-cc_set_hostname-ignore-var-lib-cloud-data-set-hostna.patch [bz#2140893] - Resolves: bz#2140893