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