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