Blob Blame History Raw
From 1176a788c23697099093b4d8a9a21f10f71ebb12 Mon Sep 17 00:00:00 2001
From: Vitaly Kuznetsov <vkuznets@redhat.com>
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 <james.falcon@canonical.com>
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 <vkuznets@redhat.com>
---
 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: <true/false>
 """
 
+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