sailesh1993 / rpms / cloud-init

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