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