forked from rpms/cloud-init
import cloud-init-22.1-9.el9
This commit is contained in:
parent
9e60c5cffd
commit
5231c595b0
@ -0,0 +1,516 @@
|
|||||||
|
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
|
||||||
|
|
@ -0,0 +1,36 @@
|
|||||||
|
From 14d1952c17637b80923d1bfaf3b6b5f8cf032147 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Emanuele Giuseppe Esposito <eesposit@redhat.com>
|
||||||
|
Date: Wed, 14 Dec 2022 09:31:51 +0100
|
||||||
|
Subject: [PATCH] Ensure network ready before cloud-init service runs on RHEL
|
||||||
|
(#1893)
|
||||||
|
|
||||||
|
Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=2152100
|
||||||
|
|
||||||
|
commit 6e725f36647407d201af0603d7db11fc96a93d4d
|
||||||
|
Author: James Falcon <james.falcon@canonical.com>
|
||||||
|
Date: Tue Dec 13 10:55:23 2022 -0600
|
||||||
|
|
||||||
|
Ensure network ready before cloud-init service runs on RHEL (#1893)
|
||||||
|
|
||||||
|
LP: #1998655
|
||||||
|
|
||||||
|
Signed-off-by: Emanuele Giuseppe Esposito <eesposit@redhat.com>
|
||||||
|
---
|
||||||
|
systemd/cloud-init.service.tmpl | 1 +
|
||||||
|
1 file changed, 1 insertion(+)
|
||||||
|
|
||||||
|
diff --git a/systemd/cloud-init.service.tmpl b/systemd/cloud-init.service.tmpl
|
||||||
|
index c170aef7..fc984d5c 100644
|
||||||
|
--- a/systemd/cloud-init.service.tmpl
|
||||||
|
+++ b/systemd/cloud-init.service.tmpl
|
||||||
|
@@ -16,6 +16,7 @@ After=networking.service
|
||||||
|
"miraclelinux", "openEuler", "rhel", "rocky", "virtuozzo"] %}
|
||||||
|
After=network.service
|
||||||
|
After=NetworkManager.service
|
||||||
|
+After=NetworkManager-wait-online.service
|
||||||
|
{% endif %}
|
||||||
|
{% if variant in ["suse"] %}
|
||||||
|
After=wicked.service
|
||||||
|
--
|
||||||
|
2.38.1
|
||||||
|
|
@ -0,0 +1,77 @@
|
|||||||
|
From d51546dee17c9abbb9d44fb33cf81be085a46dae Mon Sep 17 00:00:00 2001
|
||||||
|
From: Emanuele Giuseppe Esposito <eesposit@redhat.com>
|
||||||
|
Date: Thu, 19 Jan 2023 09:40:10 +0100
|
||||||
|
Subject: [PATCH 22/22] cc_set_hostname: ignore
|
||||||
|
/var/lib/cloud/data/set-hostname if it's empty (#1967)
|
||||||
|
|
||||||
|
Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=2140893
|
||||||
|
|
||||||
|
commit 9c7502a801763520639c66125eb373123d1e4f44
|
||||||
|
Author: Emanuele Giuseppe Esposito <eesposit@redhat.com>
|
||||||
|
Date: Wed Jan 18 17:55:16 2023 +0100
|
||||||
|
|
||||||
|
cc_set_hostname: ignore /var/lib/cloud/data/set-hostname if it's empty (#1967)
|
||||||
|
|
||||||
|
If the file exists but is empty, do nothing.
|
||||||
|
Otherwise cloud-init will crash because it does not handle the empty file.
|
||||||
|
|
||||||
|
RHBZ: 2140893
|
||||||
|
|
||||||
|
Signed-off-by: Emanuele Giuseppe Esposito <eesposit@redhat.com>
|
||||||
|
|
||||||
|
Signed-off-by: Emanuele Giuseppe Esposito <eesposit@redhat.com>
|
||||||
|
---
|
||||||
|
cloudinit/config/cc_set_hostname.py | 2 +-
|
||||||
|
tests/unittests/config/test_cc_set_hostname.py | 17 +++++++++++++++++
|
||||||
|
2 files changed, 18 insertions(+), 1 deletion(-)
|
||||||
|
|
||||||
|
diff --git a/cloudinit/config/cc_set_hostname.py b/cloudinit/config/cc_set_hostname.py
|
||||||
|
index 2674fa20..7e3d5b74 100644
|
||||||
|
--- a/cloudinit/config/cc_set_hostname.py
|
||||||
|
+++ b/cloudinit/config/cc_set_hostname.py
|
||||||
|
@@ -86,7 +86,7 @@ def handle(name, cfg, cloud, log, _args):
|
||||||
|
# distro._read_hostname implementation so we only validate one artifact.
|
||||||
|
prev_fn = os.path.join(cloud.get_cpath("data"), "set-hostname")
|
||||||
|
prev_hostname = {}
|
||||||
|
- if os.path.exists(prev_fn):
|
||||||
|
+ if os.path.exists(prev_fn) and os.stat(prev_fn).st_size > 0:
|
||||||
|
prev_hostname = util.load_json(util.load_file(prev_fn))
|
||||||
|
hostname_changed = hostname != prev_hostname.get(
|
||||||
|
"hostname"
|
||||||
|
diff --git a/tests/unittests/config/test_cc_set_hostname.py b/tests/unittests/config/test_cc_set_hostname.py
|
||||||
|
index 3d1d86ee..2c92949f 100644
|
||||||
|
--- a/tests/unittests/config/test_cc_set_hostname.py
|
||||||
|
+++ b/tests/unittests/config/test_cc_set_hostname.py
|
||||||
|
@@ -5,6 +5,7 @@ import os
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from io import BytesIO
|
||||||
|
+from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from configobj import ConfigObj
|
||||||
|
@@ -242,5 +243,21 @@ class TestHostname(t_help.FilesystemMockingTestCase):
|
||||||
|
str(ctx_mgr.exception),
|
||||||
|
)
|
||||||
|
|
||||||
|
+ def test_ignore_empty_previous_artifact_file(self):
|
||||||
|
+ cfg = {
|
||||||
|
+ "hostname": "blah",
|
||||||
|
+ "fqdn": "blah.blah.blah.yahoo.com",
|
||||||
|
+ }
|
||||||
|
+ distro = self._fetch_distro("debian")
|
||||||
|
+ paths = helpers.Paths({"cloud_dir": self.tmp})
|
||||||
|
+ ds = None
|
||||||
|
+ cc = cloud.Cloud(ds, paths, {}, distro, None)
|
||||||
|
+ self.patchUtils(self.tmp)
|
||||||
|
+ prev_fn = Path(cc.get_cpath("data")) / "set-hostname"
|
||||||
|
+ prev_fn.touch()
|
||||||
|
+ cc_set_hostname.handle("cc_set_hostname", cfg, cc, LOG, [])
|
||||||
|
+ contents = util.load_file("/etc/hostname")
|
||||||
|
+ self.assertEqual("blah", contents.strip())
|
||||||
|
+
|
||||||
|
|
||||||
|
# vi: ts=4 expandtab
|
||||||
|
--
|
||||||
|
2.39.1
|
||||||
|
|
@ -0,0 +1,139 @@
|
|||||||
|
From dd5ae3081491a2a98bd74e1655b22c9354707630 Mon Sep 17 00:00:00 2001
|
||||||
|
From: Emanuele Giuseppe Esposito <eesposit@redhat.com>
|
||||||
|
Date: Thu, 8 Sep 2022 17:46:45 +0200
|
||||||
|
Subject: [PATCH] cloud.cfg.tmpl: make sure "centos" settings are identical to
|
||||||
|
"rhel" (#1639)
|
||||||
|
|
||||||
|
Bugzilla: https://bugzilla.redhat.com/show_bug.cgi?id=2115576
|
||||||
|
|
||||||
|
commit 7593243a1abe2ccaf4698579720999380a4da73b
|
||||||
|
Author: Emanuele Giuseppe Esposito <eesposit@redhat.com>
|
||||||
|
Date: Wed Sep 7 14:53:26 2022 +0200
|
||||||
|
|
||||||
|
cloud.cfg.tmpl: make sure "centos" settings are identical to "rhel" (#1639)
|
||||||
|
|
||||||
|
We have a couple of bugs where centos does not have the default user as rhel.
|
||||||
|
This PR makes sure the configuration is exactly the same.
|
||||||
|
|
||||||
|
Signed-off-by: Emanuele Giuseppe Esposito <eesposit@redhat.com>
|
||||||
|
|
||||||
|
RHBZ: 2115565
|
||||||
|
RHBZ: 2115576
|
||||||
|
Conflicts:
|
||||||
|
config/cloud.cfg.tmpl: "openmandriva" distro added in the options
|
||||||
|
|
||||||
|
Signed-off-by: Emanuele Giuseppe Esposito <eesposit@redhat.com>
|
||||||
|
---
|
||||||
|
config/cloud.cfg.tmpl | 27 +++++++++++++------------
|
||||||
|
tests/unittests/test_render_cloudcfg.py | 1 +
|
||||||
|
2 files changed, 15 insertions(+), 13 deletions(-)
|
||||||
|
|
||||||
|
diff --git a/config/cloud.cfg.tmpl b/config/cloud.cfg.tmpl
|
||||||
|
index 80ab4f96..08b6efbc 100644
|
||||||
|
--- a/config/cloud.cfg.tmpl
|
||||||
|
+++ b/config/cloud.cfg.tmpl
|
||||||
|
@@ -2,6 +2,7 @@
|
||||||
|
# The top level settings are used as module
|
||||||
|
# and system configuration.
|
||||||
|
{% set is_bsd = variant in ["dragonfly", "freebsd", "netbsd", "openbsd"] %}
|
||||||
|
+{% set is_rhel = variant in ["rhel", "centos"] %}
|
||||||
|
{% if is_bsd %}
|
||||||
|
syslog_fix_perms: root:wheel
|
||||||
|
{% elif variant in ["suse"] %}
|
||||||
|
@@ -32,9 +33,9 @@ disable_root: false
|
||||||
|
disable_root: true
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
-{% if variant in ["almalinux", "alpine", "amazon", "centos", "cloudlinux", "eurolinux",
|
||||||
|
- "fedora", "miraclelinux", "openEuler", "rhel", "rocky", "virtuozzo"] %}
|
||||||
|
-{% if variant == "rhel" %}
|
||||||
|
+{% if variant in ["almalinux", "alpine", "amazon", "cloudlinux", "eurolinux",
|
||||||
|
+ "fedora", "miraclelinux", "openEuler", "openmandriva", "rocky", "virtuozzo"] or is_rhel %}
|
||||||
|
+{% if is_rhel %}
|
||||||
|
mount_default_fields: [~, ~, 'auto', 'defaults,nofail,x-systemd.requires=cloud-init.service,_netdev', '0', '2']
|
||||||
|
{% else %}
|
||||||
|
mount_default_fields: [~, ~, 'auto', 'defaults,nofail', '0', '2']
|
||||||
|
@@ -70,7 +71,7 @@ network:
|
||||||
|
config: disabled
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
-{% if variant == "rhel" %}
|
||||||
|
+{% if is_rhel %}
|
||||||
|
# Default redhat settings:
|
||||||
|
ssh_deletekeys: true
|
||||||
|
ssh_genkeytypes: ['rsa', 'ecdsa', 'ed25519']
|
||||||
|
@@ -119,16 +120,16 @@ cloud_config_modules:
|
||||||
|
{% endif %}
|
||||||
|
{% if variant not in ["photon"] %}
|
||||||
|
- ssh-import-id
|
||||||
|
-{% if variant not in ["rhel"] %}
|
||||||
|
+{% if not is_rhel %}
|
||||||
|
- keyboard
|
||||||
|
{% endif %}
|
||||||
|
- locale
|
||||||
|
{% endif %}
|
||||||
|
- set-passwords
|
||||||
|
-{% if variant in ["rhel"] %}
|
||||||
|
+{% if is_rhel %}
|
||||||
|
- rh_subscription
|
||||||
|
{% endif %}
|
||||||
|
-{% if variant in ["rhel", "fedora", "photon"] %}
|
||||||
|
+{% if variant in ["fedora", "openmandriva", "photon"] or is_rhel %}
|
||||||
|
{% if variant not in ["photon"] %}
|
||||||
|
- spacewalk
|
||||||
|
{% endif %}
|
||||||
|
@@ -193,9 +194,9 @@ cloud_final_modules:
|
||||||
|
# (not accessible to handlers/transforms)
|
||||||
|
system_info:
|
||||||
|
# This will affect which distro class gets used
|
||||||
|
-{% if variant in ["almalinux", "alpine", "amazon", "arch", "centos", "cloudlinux", "debian",
|
||||||
|
+{% if variant in ["almalinux", "alpine", "amazon", "arch", "cloudlinux", "debian",
|
||||||
|
"eurolinux", "fedora", "freebsd", "gentoo", "netbsd", "miraclelinux", "openbsd", "openEuler",
|
||||||
|
- "photon", "rhel", "rocky", "suse", "ubuntu", "virtuozzo"] %}
|
||||||
|
+ "openmandriva", "photon", "rocky", "suse", "ubuntu", "virtuozzo"] or is_rhel %}
|
||||||
|
distro: {{ variant }}
|
||||||
|
{% elif variant in ["dragonfly"] %}
|
||||||
|
distro: dragonflybsd
|
||||||
|
@@ -248,15 +249,15 @@ system_info:
|
||||||
|
primary: http://ports.ubuntu.com/ubuntu-ports
|
||||||
|
security: http://ports.ubuntu.com/ubuntu-ports
|
||||||
|
ssh_svcname: ssh
|
||||||
|
-{% elif variant in ["almalinux", "alpine", "amazon", "arch", "centos", "cloudlinux", "eurolinux",
|
||||||
|
- "fedora", "gentoo", "miraclelinux", "openEuler", "rhel", "rocky", "suse", "virtuozzo"] %}
|
||||||
|
+{% elif variant in ["almalinux", "alpine", "amazon", "arch", "cloudlinux", "eurolinux",
|
||||||
|
+ "fedora", "gentoo", "miraclelinux", "openEuler", "openmandriva", "rocky", "suse", "virtuozzo"] or is_rhel %}
|
||||||
|
# Default user name + that default users groups (if added/used)
|
||||||
|
default_user:
|
||||||
|
{% if variant == "amazon" %}
|
||||||
|
name: ec2-user
|
||||||
|
lock_passwd: True
|
||||||
|
gecos: EC2 Default User
|
||||||
|
-{% elif variant == "rhel" %}
|
||||||
|
+{% elif is_rhel %}
|
||||||
|
name: cloud-user
|
||||||
|
lock_passwd: true
|
||||||
|
gecos: Cloud User
|
||||||
|
@@ -275,7 +276,7 @@ system_info:
|
||||||
|
groups: [adm, sudo]
|
||||||
|
{% elif variant == "arch" %}
|
||||||
|
groups: [wheel, users]
|
||||||
|
-{% elif variant == "rhel" %}
|
||||||
|
+{% elif is_rhel %}
|
||||||
|
groups: [adm, systemd-journal]
|
||||||
|
{% else %}
|
||||||
|
groups: [wheel, adm, systemd-journal]
|
||||||
|
diff --git a/tests/unittests/test_render_cloudcfg.py b/tests/unittests/test_render_cloudcfg.py
|
||||||
|
index 9f95d448..1a6e2715 100644
|
||||||
|
--- a/tests/unittests/test_render_cloudcfg.py
|
||||||
|
+++ b/tests/unittests/test_render_cloudcfg.py
|
||||||
|
@@ -69,6 +69,7 @@ class TestRenderCloudCfg:
|
||||||
|
"amazon": "ec2-user",
|
||||||
|
"debian": "ubuntu",
|
||||||
|
"rhel": "cloud-user",
|
||||||
|
+ "centos": "cloud-user",
|
||||||
|
"unknown": "ubuntu",
|
||||||
|
}
|
||||||
|
default_user = system_cfg["system_info"]["default_user"]["name"]
|
||||||
|
--
|
||||||
|
2.37.3
|
||||||
|
|
@ -1,6 +1,6 @@
|
|||||||
Name: cloud-init
|
Name: cloud-init
|
||||||
Version: 22.1
|
Version: 22.1
|
||||||
Release: 5%{?dist}
|
Release: 9%{?dist}
|
||||||
Summary: Cloud instance init scripts
|
Summary: Cloud instance init scripts
|
||||||
License: ASL 2.0 or GPLv3
|
License: ASL 2.0 or GPLv3
|
||||||
URL: http://launchpad.net/cloud-init
|
URL: http://launchpad.net/cloud-init
|
||||||
@ -46,6 +46,14 @@ Patch17: ci-Revert-Use-Network-Manager-and-Netplan-as-default-re.patch
|
|||||||
# For bz#2117532 - [RHEL9.1] Revert patch of configuring networking by NM keyfiles
|
# For bz#2117532 - [RHEL9.1] Revert patch of configuring networking by NM keyfiles
|
||||||
# For bz#2098501 - [RHEL-9.1] IPv6 not workable when cloud-init configure network using NM keyfiles
|
# For bz#2098501 - [RHEL-9.1] IPv6 not workable when cloud-init configure network using NM keyfiles
|
||||||
Patch18: ci-Revert-Revert-Setting-highest-autoconnect-priority-f.patch
|
Patch18: ci-Revert-Revert-Setting-highest-autoconnect-priority-f.patch
|
||||||
|
# For bz#2115565 - cloud-init configures user "centos" or "rhel" instead of "cloud-user" with cloud-init-22.1
|
||||||
|
Patch19: ci-cloud.cfg.tmpl-make-sure-centos-settings-are-identic.patch
|
||||||
|
# For bz#2152100 - [RHEL-9] Ensure network ready before cloud-init service runs on RHEL
|
||||||
|
Patch20: ci-Ensure-network-ready-before-cloud-init-service-runs-.patch
|
||||||
|
# For bz#2140893 - systemd[1]: Failed to start Initial cloud-init job after reboot system via sysrq 'b'
|
||||||
|
Patch21: ci-cc_set_hostname-ignore-var-lib-cloud-data-set-hostna.patch
|
||||||
|
# For bz#2166245 - Add support for resizing encrypted root volume
|
||||||
|
Patch22: ci-Allow-growpart-to-resize-encrypted-partitions-1316.patch
|
||||||
|
|
||||||
# Source-git patches
|
# Source-git patches
|
||||||
|
|
||||||
@ -236,6 +244,26 @@ fi
|
|||||||
%config(noreplace) %{_sysconfdir}/rsyslog.d/21-cloudinit.conf
|
%config(noreplace) %{_sysconfdir}/rsyslog.d/21-cloudinit.conf
|
||||||
|
|
||||||
%changelog
|
%changelog
|
||||||
|
* Wed Feb 08 2023 Camilla Conte <cconte@redhat.com> - 22.1-9
|
||||||
|
- ci-Allow-growpart-to-resize-encrypted-partitions-1316.patch [bz#2166245]
|
||||||
|
- Resolves: bz#2166245
|
||||||
|
(Add support for resizing encrypted root volume)
|
||||||
|
|
||||||
|
* Fri Jan 27 2023 Camilla Conte <cconte@redhat.com> - 22.1-8
|
||||||
|
- ci-cc_set_hostname-ignore-var-lib-cloud-data-set-hostna.patch [bz#2140893]
|
||||||
|
- Resolves: bz#2140893
|
||||||
|
(systemd[1]: Failed to start Initial cloud-init job after reboot system via sysrq 'b')
|
||||||
|
|
||||||
|
* Wed Dec 21 2022 Camilla Conte <cconte@redhat.com> - 22.1-7
|
||||||
|
- ci-Ensure-network-ready-before-cloud-init-service-runs-.patch [bz#2152100]
|
||||||
|
- Resolves: bz#2152100
|
||||||
|
([RHEL-9] Ensure network ready before cloud-init service runs on RHEL)
|
||||||
|
|
||||||
|
* Tue Sep 27 2022 Camilla Conte <cconte@redhat.com> - 22.1-6
|
||||||
|
- ci-cloud.cfg.tmpl-make-sure-centos-settings-are-identic.patch [bz#2115565]
|
||||||
|
- Resolves: bz#2115565
|
||||||
|
(cloud-init configures user "centos" or "rhel" instead of "cloud-user" with cloud-init-22.1)
|
||||||
|
|
||||||
* Wed Aug 17 2022 Miroslav Rezanina <mrezanin@redhat.com> - 22.1-5
|
* Wed Aug 17 2022 Miroslav Rezanina <mrezanin@redhat.com> - 22.1-5
|
||||||
- ci-Revert-Add-native-NetworkManager-support-1224.patch [bz#2107463 bz#2104389 bz#2117532 bz#2098501]
|
- ci-Revert-Add-native-NetworkManager-support-1224.patch [bz#2107463 bz#2104389 bz#2117532 bz#2098501]
|
||||||
- ci-Revert-Use-Network-Manager-and-Netplan-as-default-re.patch [bz#2107463 bz#2104389 bz#2117532 bz#2098501]
|
- ci-Revert-Use-Network-Manager-and-Netplan-as-default-re.patch [bz#2107463 bz#2104389 bz#2117532 bz#2098501]
|
||||||
|
Loading…
Reference in New Issue
Block a user