diff options
author | Robert Schweikert <rjschwei@suse.com> | 2023-02-21 14:13:43 -0500 |
---|---|---|
committer | GitHub <noreply@github.com> | 2023-02-21 13:13:43 -0600 |
commit | 6397d3bc2a5b2e1e56de336482fa65b98d9e4361 (patch) | |
tree | 7909738f6e50268d995e80ab4e8dd3bc466bdfb4 | |
parent | 15a6e0868097ec8a6ef97b9fde59a9486270fc37 (diff) | |
download | cloud-init-git-6397d3bc2a5b2e1e56de336482fa65b98d9e4361.tar.gz |
Support transactional-updates for SUSE based distros (#1997)
openSUSE/SUSE has distros that use read only root and btrfs. To update
a running system in such a setup the transactional-update command
needs to be used. This change implements support for use of the
transactional-update commend when appropriate.
-rw-r--r-- | cloudinit/config/cc_zypper_add_repo.py | 3 | ||||
-rw-r--r-- | cloudinit/distros/opensuse.py | 77 | ||||
-rw-r--r-- | tests/unittests/distros/test_opensuse.py | 333 |
3 files changed, 404 insertions, 9 deletions
diff --git a/cloudinit/config/cc_zypper_add_repo.py b/cloudinit/config/cc_zypper_add_repo.py index b63b87bf..958e4f94 100644 --- a/cloudinit/config/cc_zypper_add_repo.py +++ b/cloudinit/config/cc_zypper_add_repo.py @@ -40,7 +40,8 @@ options will be resolved by the way the zypp.conf INI file is parsed. The ``repos`` key may be used to add repositories to the system. Beyond the required ``id`` and ``baseurl`` attributions, no validation is performed on the ``repos`` entries. It is assumed the user is familiar with the -zypper repository file format. +zypper repository file format. This configuration is also applicable for +systems with transactional-updates. """ meta: MetaSchema = { "id": "cc_zypper_add_repo", diff --git a/cloudinit/distros/opensuse.py b/cloudinit/distros/opensuse.py index 00ed1514..38307c91 100644 --- a/cloudinit/distros/opensuse.py +++ b/cloudinit/distros/opensuse.py @@ -8,11 +8,17 @@ # # This file is part of cloud-init. See LICENSE file for license information. -from cloudinit import distros, helpers, subp, util +import os + +from cloudinit import distros, helpers +from cloudinit import log as logging +from cloudinit import subp, util from cloudinit.distros import rhel_util as rhutil from cloudinit.distros.parsers.hostname import HostnameConf from cloudinit.settings import PER_INSTANCE +LOG = logging.getLogger(__name__) + class Distro(distros.Distro): clock_conf_fn = "/etc/sysconfig/clock" @@ -44,6 +50,8 @@ class Distro(distros.Distro): distros.Distro.__init__(self, name, cfg, paths) self._runner = helpers.Runners(paths) self.osfamily = "suse" + self.update_method = None + self.read_only_root = False cfg["ssh_svcname"] = "sshd" if self.uses_systemd(): self.init_cmd = ["systemctl"] @@ -69,12 +77,44 @@ class Distro(distros.Distro): if pkgs is None: pkgs = [] + self._set_update_method() + if self.read_only_root and not self.update_method == "transactional": + LOG.error( + "Package operation requested but read only root " + "without btrfs and transactional-updata" + ) + return + # No user interaction possible, enable non-interactive mode - cmd = ["zypper", "--non-interactive"] + if self.update_method == "zypper": + cmd = ["zypper", "--non-interactive"] + else: + cmd = [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "pkg", + ] # Command is the operation, such as install if command == "upgrade": command = "update" + if ( + not pkgs + and self.update_method == "transactional" + and command == "update" + ): + command = "up" + cmd = [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + ] + # Repo refresh only modifies data in the read-write path, + # always uses zypper + if command == "refresh": + # Repo refresh is a zypper only option, ignore the t-u setting + cmd = ["zypper", "--non-interactive"] cmd.append(command) # args are the arguments to the command, not global options @@ -89,6 +129,11 @@ class Distro(distros.Distro): # Allow the output of this to flow outwards (ie not be captured) subp.subp(cmd, capture=False) + if self.update_method == "transactional": + LOG.info( + "To use/activate the installed packages reboot the system" + ) + def set_timezone(self, tz): tz_file = self._find_tz_file(tz) if self.uses_systemd(): @@ -147,6 +192,34 @@ class Distro(distros.Distro): host_fn = self.hostname_conf_fn return (host_fn, self._read_hostname(host_fn)) + def _set_update_method(self): + """Decide if we want to use transactional-update or zypper""" + if self.update_method is None: + result = util.get_mount_info("/") + fs_type = "" + if result: + (devpth, fs_type, mount_point) = result + # Check if the file system is read only + mounts = util.load_file("/proc/mounts").split("\n") + for mount in mounts: + if mount.startswith(devpth): + mount_info = mount.split() + if mount_info[1] != mount_point: + continue + self.read_only_root = mount_info[3].startswith("ro") + break + if fs_type.lower() == "btrfs" and os.path.exists( + "/usr/sbin/transactional-update" + ): + self.update_method = "transactional" + else: + self.update_method = "zypper" + else: + LOG.info( + "Could not determine filesystem type of '/' using zypper" + ) + self.update_method = "zypper" + def _write_hostname(self, hostname, filename): if self.uses_systemd() and filename.endswith("/previous-hostname"): util.write_file(filename, hostname) diff --git a/tests/unittests/distros/test_opensuse.py b/tests/unittests/distros/test_opensuse.py index 6b8eea65..3261d629 100644 --- a/tests/unittests/distros/test_opensuse.py +++ b/tests/unittests/distros/test_opensuse.py @@ -1,10 +1,331 @@ # This file is part of cloud-init. See LICENSE file for license information. -from tests.unittests.distros import _get_distro -from tests.unittests.helpers import CiTestCase +from unittest import mock +from cloudinit import distros -class TestopenSUSE(CiTestCase): - def test_get_distro(self): - distro = _get_distro("opensuse") - self.assertEqual(distro.osfamily, "suse") + +@mock.patch("cloudinit.distros.opensuse.subp.subp") +class TestPackageCommands: + distro = distros.fetch("opensuse")("opensuse", {}, None) + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "xfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / xfs rw,bar\n", + ) + @mock.patch( + "cloudinit.distros.opensuse.os.path.exists", return_value=False + ) + def test_upgrade_not_btrfs(self, m_tu_path, m_mounts, m_minfo, m_subp): + # Reset state + self.distro.update_method = None + + self.distro.package_command("upgrade") + expected_cmd = ["zypper", "--non-interactive", "update"] + m_subp.assert_called_with(expected_cmd, capture=False) + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "xfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / xfs rw,bar\n", + ) + @mock.patch( + "cloudinit.distros.opensuse.os.path.exists", return_value=False + ) + def test_upgrade_not_btrfs_pkg(self, m_tu_path, m_mounts, m_minfo, m_subp): + # Reset state + self.distro.update_method = None + + self.distro.package_command("upgrade", None, ["python36", "gzip"]) + expected_cmd = [ + "zypper", + "--non-interactive", + "update", + "python36", + "gzip", + ] + m_subp.assert_called_with(expected_cmd, capture=False) + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "xfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / xfs rw,bar\n", + ) + @mock.patch( + "cloudinit.distros.opensuse.os.path.exists", return_value=False + ) + def test_update_not_btrfs(self, m_tu_path, m_mounts, m_minfo, m_subp): + # Reset state + self.distro.update_method = None + + self.distro.package_command("update") + expected_cmd = ["zypper", "--non-interactive", "update"] + m_subp.assert_called_with(expected_cmd, capture=False) + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "xfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / xfs rw,bar\n", + ) + @mock.patch( + "cloudinit.distros.opensuse.os.path.exists", return_value=False + ) + def test_update_not_btrfs_pkg(self, m_tu_path, m_mounts, m_minfo, m_subp): + # Reset state + self.distro.update_method = None + + self.distro.package_command("update", None, ["python36", "gzip"]) + expected_cmd = [ + "zypper", + "--non-interactive", + "update", + "python36", + "gzip", + ] + m_subp.assert_called_with(expected_cmd, capture=False) + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "xfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / xfs rw,bar\n", + ) + @mock.patch( + "cloudinit.distros.opensuse.os.path.exists", return_value=False + ) + def test_install_not_btrfs_pkg(self, m_tu_path, m_mounts, m_minfo, m_subp): + # Reset state + self.distro.update_method = None + + self.distro.install_packages(["python36", "gzip"]) + expected_cmd = [ + "zypper", + "--non-interactive", + "install", + "--auto-agree-with-licenses", + "python36", + "gzip", + ] + m_subp.assert_called_with(expected_cmd, capture=False) + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "btrfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / btrfs rw,bar\n", + ) + @mock.patch("cloudinit.distros.opensuse.os.path.exists", return_value=True) + def test_upgrade_btrfs(self, m_tu_path, m_mounts, m_minfo, m_subp): + # Reset state + self.distro.update_method = None + + self.distro.package_command("upgrade") + expected_cmd = [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "up", + ] + m_subp.assert_called_with(expected_cmd, capture=False) + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "btrfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / btrfs rw,bar\n", + ) + @mock.patch("cloudinit.distros.opensuse.os.path.exists", return_value=True) + def test_upgrade_btrfs_pkg(self, m_tu_path, m_mounts, m_minfo, m_subp): + # Reset state + self.distro.update_method = None + + self.distro.package_command("upgrade", None, ["python36", "gzip"]) + expected_cmd = [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "pkg", + "update", + "python36", + "gzip", + ] + m_subp.assert_called_with(expected_cmd, capture=False) + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "btrfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / btrf rw,bar\n", + ) + @mock.patch("cloudinit.distros.opensuse.os.path.exists", return_value=True) + def test_update_btrfs(self, m_tu_path, m_mounts, m_minfo, m_subp): + # Reset state + self.distro.update_method = None + + self.distro.package_command("update") + expected_cmd = [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "up", + ] + m_subp.assert_called_with(expected_cmd, capture=False) + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "btrfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / btrfs rw,bar\n", + ) + @mock.patch("cloudinit.distros.opensuse.os.path.exists", return_value=True) + def test_update_btrfs_pkg(self, m_tu_path, m_mounts, m_minfo, m_subp): + # Reset state + self.distro.update_method = None + + self.distro.package_command("update", None, ["python36", "gzip"]) + expected_cmd = [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "pkg", + "update", + "python36", + "gzip", + ] + m_subp.assert_called_with(expected_cmd, capture=False) + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "btrfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / btrfs rw,bar\n", + ) + @mock.patch("cloudinit.distros.opensuse.os.path.exists", return_value=True) + def test_install_btrfs_pkg(self, m_tu_path, m_mounts, m_minfo, m_subp): + # Reset state + self.distro.update_method = None + + self.distro.install_packages(["python36", "gzip"]) + expected_cmd = [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "pkg", + "install", + "--auto-agree-with-licenses", + "python36", + "gzip", + ] + m_subp.assert_called_with(expected_cmd, capture=False) + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "btrfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / btrfs ro,bar\n", + ) + @mock.patch( + "cloudinit.distros.opensuse.os.path.exists", return_value=False + ) + def test_upgrade_no_transact_up_ro_root( + self, m_tu_path, m_mounts, m_minfo, m_subp + ): + # Reset state + self.distro.update_method = None + + result = self.distro.package_command("upgrade") + assert self.distro.read_only_root + assert result is None + assert not m_subp.called + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "btrfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / btrfs rw,bar\n", + ) + @mock.patch( + "cloudinit.distros.opensuse.os.path.exists", return_value=False + ) + def test_upgrade_no_transact_up_rw_root_btrfs( + self, m_tu_path, m_mounts, m_minfo, m_subp + ): + # Reset state + self.distro.update_method = None + + self.distro.package_command("upgrade") + assert self.distro.update_method == "zypper" + assert self.distro.read_only_root is False + expected_cmd = ["zypper", "--non-interactive", "update"] + m_subp.assert_called_with(expected_cmd, capture=False) + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "xfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / xfs ro,bar\n", + ) + @mock.patch("cloudinit.distros.opensuse.os.path.exists", return_value=True) + def test_upgrade_transact_up_ro_root( + self, m_tu_path, m_mounts, m_minfo, m_subp + ): + # Reset state + self.distro.update_method = None + + result = self.distro.package_command("upgrade") + assert self.distro.update_method == "zypper" + assert self.distro.read_only_root + assert result is None + assert not m_subp.called + + @mock.patch( + "cloudinit.distros.opensuse.util.get_mount_info", + return_value=("/dev/sda1", "btrfs", "/"), + ) + @mock.patch( + "cloudinit.distros.opensuse.util.load_file", + return_value="foo\n/dev/sda1 / btrfs ro,bar\n", + ) + @mock.patch("cloudinit.distros.opensuse.os.path.exists", return_value=True) + def test_refresh_transact_up_ro_root_btrfs( + self, m_tu_path, m_mounts, m_minfo, m_subp + ): + # Reset state + self.distro.update_method = None + + self.distro.package_command("refresh") + assert self.distro.update_method == "transactional" + assert self.distro.read_only_root + expected_cmd = ["zypper", "--non-interactive", "refresh"] + m_subp.assert_called_with(expected_cmd, capture=False) |