From aa0d6604a7c6e2a25e88679ec64855723e6cabbf Mon Sep 17 00:00:00 2001 From: Alberto Planas Date: Mon, 5 Oct 2020 16:32:44 +0200 Subject: [PATCH] Support transactional systems (MicroOS) (#271) * Add rebootmgr module * Add transactional_update module * chroot: add chroot detector * systemd: add offline mode detector * transactional_update: add pending_transaction detector * extra: add EFI and transactional grains * transactional_update: add call, apply_, sls & highstate * transactional_update: add documentation * transactional_update: add executor * Add changelog entry 58519.added Closes #58519 * transactional_update: update the cleanups family * transactional_update: add activate_transaction param * transactional_update: skip tests on Windows --- changelog/58519.added | 1 + doc/ref/executors/all/index.rst | 1 + .../salt.executors.transactional_update.rst | 6 + doc/ref/modules/all/index.rst | 2 + .../modules/all/salt.modules.rebootmgr.rst | 5 + .../all/salt.modules.transactional_update.rst | 5 + salt/executors/transactional_update.py | 126 ++ salt/grains/extra.py | 42 +- salt/modules/chroot.py | 46 +- salt/modules/rebootmgr.py | 357 +++++ salt/modules/systemd_service.py | 21 +- salt/modules/transactional_update.py | 1270 +++++++++++++++++ salt/utils/systemd.py | 27 +- tests/unit/modules/test_chroot.py | 18 +- tests/unit/modules/test_rebootmgr.py | 302 ++++ .../unit/modules/test_transactional_update.py | 681 +++++++++ 16 files changed, 2880 insertions(+), 30 deletions(-) create mode 100644 changelog/58519.added create mode 100644 doc/ref/executors/all/salt.executors.transactional_update.rst create mode 100644 doc/ref/modules/all/salt.modules.rebootmgr.rst create mode 100644 doc/ref/modules/all/salt.modules.transactional_update.rst create mode 100644 salt/executors/transactional_update.py create mode 100644 salt/modules/rebootmgr.py create mode 100644 salt/modules/transactional_update.py create mode 100644 tests/unit/modules/test_rebootmgr.py create mode 100644 tests/unit/modules/test_transactional_update.py diff --git a/changelog/58519.added b/changelog/58519.added new file mode 100644 index 0000000000..1cc8d7dc74 --- /dev/null +++ b/changelog/58519.added @@ -0,0 +1 @@ +Add support for transactional systems, like openSUSE MicroOS \ No newline at end of file diff --git a/doc/ref/executors/all/index.rst b/doc/ref/executors/all/index.rst index 1f26a86fc3..4cd430d8e3 100644 --- a/doc/ref/executors/all/index.rst +++ b/doc/ref/executors/all/index.rst @@ -14,3 +14,4 @@ executors modules docker splay sudo + transactional_update diff --git a/doc/ref/executors/all/salt.executors.transactional_update.rst b/doc/ref/executors/all/salt.executors.transactional_update.rst new file mode 100644 index 0000000000..17f00b2d27 --- /dev/null +++ b/doc/ref/executors/all/salt.executors.transactional_update.rst @@ -0,0 +1,6 @@ +salt.executors.transactional_update module +========================================== + +.. automodule:: salt.executors.transactional_update + :members: + diff --git a/doc/ref/modules/all/index.rst b/doc/ref/modules/all/index.rst index 9fea7af07f..f6780e1694 100644 --- a/doc/ref/modules/all/index.rst +++ b/doc/ref/modules/all/index.rst @@ -394,6 +394,7 @@ execution modules rbac_solaris rbenv rdp + rebootmgr redismod reg rest_pkg @@ -480,6 +481,7 @@ execution modules tls tomcat trafficserver + transactional_update travisci tuned twilio_notify diff --git a/doc/ref/modules/all/salt.modules.rebootmgr.rst b/doc/ref/modules/all/salt.modules.rebootmgr.rst new file mode 100644 index 0000000000..22240080b0 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.rebootmgr.rst @@ -0,0 +1,5 @@ +salt.modules.rebootmgr module +============================= + +.. automodule:: salt.modules.rebootmgr + :members: diff --git a/doc/ref/modules/all/salt.modules.transactional_update.rst b/doc/ref/modules/all/salt.modules.transactional_update.rst new file mode 100644 index 0000000000..2f15b95ad4 --- /dev/null +++ b/doc/ref/modules/all/salt.modules.transactional_update.rst @@ -0,0 +1,5 @@ +salt.modules.transactional_update module +======================================== + +.. automodule:: salt.modules.transactional_update + :members: diff --git a/salt/executors/transactional_update.py b/salt/executors/transactional_update.py new file mode 100644 index 0000000000..ef7d92bc05 --- /dev/null +++ b/salt/executors/transactional_update.py @@ -0,0 +1,126 @@ +""" +Transactional executor module + +.. versionadded:: TBD + +""" + +import salt.utils.path + +# Functions that are mapped into an equivalent one in +# transactional_update module +DELEGATION_MAP = { + "state.single": "transactional_update.single", + "state.sls": "transactional_update.sls", + "state.apply": "transactional_update.apply", + "state.highstate": "transactional_update.highstate", +} + +# By default, all modules and functions are executed outside the +# transaction. The next two sets will enumerate the exceptions that +# will be routed to transactional_update.call() +DEFAULT_DELEGATED_MODULES = [ + "ansible", + "cabal", + "chef", + "cmd", + "composer", + "cp", + "cpan", + "cyg", + "file", + "freeze", + "nix", + "npm", + "pip", + "pkg", + "puppet", + "pyenv", + "rbenv", + "scp", +] +DEFAULT_DELEGATED_FUNCTIONS = [] + + +def __virtual__(): + if salt.utils.path.which("transactional-update"): + return True + else: + return (False, "transactional_update executor requires a transactional system") + + +def execute(opts, data, func, args, kwargs): + """Delegate into transactional_update module + + The ``transactional_update`` module support the execution of + functions inside a transaction, as support apply a state (via + ``apply``, ``sls``, ``single`` or ``highstate``). + + This execution module can be used to route some Salt modules and + functions to be executed inside the transaction snapshot. + + Add this executor in the minion configuration file: + + .. code-block:: yaml + + module_executors: + - transactional_update + - direct_call + + Or use the command line parameter: + + .. code-block:: bash + + salt-call --module-executors='[transactional_update, direct_call]' test.version + + You can also schedule a reboot if needed: + + .. code-block:: bash + + salt-call --module-executors='[transactional_update]' state.sls stuff activate_transaction=True + + There are some configuration parameters supported: + + .. code-block:: yaml + + # Replace the list of default modules that all the functions + # are delegated to `transactional_update.call()` + delegated_modules: [cmd, pkg] + + # Replace the list of default functions that are delegated to + # `transactional_update.call()` + delegated_functions: [pip.install] + + # Expand the default list of modules + add_delegated_modules: [ansible] + + # Expand the default list of functions + add_delegated_functions: [file.copy] + + """ + fun = data["fun"] + module, _ = fun.split(".") + + delegated_modules = set(opts.get("delegated_modules", DEFAULT_DELEGATED_MODULES)) + delegated_functions = set( + opts.get("delegated_functions", DEFAULT_DELEGATED_FUNCTIONS) + ) + if "executor_opts" in data: + delegated_modules |= set(data["executor_opts"].get("add_delegated_modules", [])) + delegated_functions |= set( + data["executor_opts"].get("add_delegated_functions", []) + ) + else: + delegated_modules |= set(opts.get("add_delegated_modules", [])) + delegated_functions |= set(opts.get("add_delegated_functions", [])) + + if fun in DELEGATION_MAP: + result = __executors__["direct_call.execute"]( + opts, data, __salt__[DELEGATION_MAP[fun]], args, kwargs + ) + elif module in delegated_modules or fun in delegated_functions: + result = __salt__["transactional_update.call"](fun, *args, **kwargs) + else: + result = __executors__["direct_call.execute"](opts, data, func, args, kwargs) + + return result diff --git a/salt/grains/extra.py b/salt/grains/extra.py index 0eec27e628..d25faac3b7 100644 --- a/salt/grains/extra.py +++ b/salt/grains/extra.py @@ -1,16 +1,11 @@ -# -*- coding: utf-8 -*- - -from __future__ import absolute_import, print_function, unicode_literals - -# Import third party libs +import glob import logging - -# Import python libs import os -# Import salt libs +import salt.utils import salt.utils.data import salt.utils.files +import salt.utils.path import salt.utils.platform import salt.utils.yaml @@ -70,7 +65,32 @@ def config(): def suse_backported_capabilities(): return { - '__suse_reserved_pkg_all_versions_support': True, - '__suse_reserved_pkg_patches_support': True, - '__suse_reserved_saltutil_states_support': True + "__suse_reserved_pkg_all_versions_support": True, + "__suse_reserved_pkg_patches_support": True, + "__suse_reserved_saltutil_states_support": True, } + + +def __secure_boot(): + """Detect if secure-boot is enabled.""" + enabled = False + sboot = glob.glob("/sys/firmware/efi/vars/SecureBoot-*/data") + if len(sboot) == 1: + with salt.utils.files.fopen(sboot[0], "rb") as fd: + enabled = fd.read()[-1:] == b"\x01" + return enabled + + +def uefi(): + """Populate UEFI grains.""" + grains = { + "efi": os.path.exists("/sys/firmware/efi/systab"), + "efi-secure-boot": __secure_boot(), + } + + return grains + + +def transactional(): + """Determine if the system in transactional.""" + return {"transactional": bool(salt.utils.path.which("transactional-update"))} diff --git a/salt/modules/chroot.py b/salt/modules/chroot.py index 6512a70f88..1e2948607e 100644 --- a/salt/modules/chroot.py +++ b/salt/modules/chroot.py @@ -1,12 +1,9 @@ -# -*- coding: utf-8 -*- - """ :maintainer: Alberto Planas :maturity: new :depends: None :platform: Linux """ -from __future__ import absolute_import, print_function, unicode_literals import copy import logging @@ -21,6 +18,7 @@ import salt.defaults.exitcodes import salt.exceptions import salt.ext.six as six import salt.utils.args +import salt.utils.files __func_alias__ = {"apply_": "apply"} @@ -79,6 +77,38 @@ def create(root): return True +def in_chroot(): + """ + Return True if the process is inside a chroot jail + + .. versionadded:: TBD + + CLI Example: + + .. code-block:: bash + + salt myminion chroot.in_chroot + + """ + result = False + + try: + # We cannot assume that we are "root", so we cannot read + # '/proc/1/root', that is required for the usual way of + # detecting that we are in a chroot jail. We use the debian + # ischroot method. + with salt.utils.files.fopen( + "/proc/1/mountinfo" + ) as root_fd, salt.utils.files.fopen("/proc/self/mountinfo") as self_fd: + root_mountinfo = root_fd.read() + self_mountinfo = self_fd.read() + result = root_mountinfo != self_mountinfo + except OSError: + pass + + return result + + def call(root, function, *args, **kwargs): """ Executes a Salt function inside a chroot environment. @@ -116,7 +146,7 @@ def call(root, function, *args, **kwargs): so_mods=__salt__["config.option"]("thin_so_mods", ""), ) # Some bug in Salt is preventing us to use `archive.tar` here. A - # AsyncZeroMQReqChannel is not closed at the end os the salt-call, + # AsyncZeroMQReqChannel is not closed at the end of the salt-call, # and makes the client never exit. # # stdout = __salt__['archive.tar']('xzf', thin_path, dest=thin_dest_path) @@ -194,7 +224,7 @@ def apply_(root, mods=None, **kwargs): def _create_and_execute_salt_state(root, chunks, file_refs, test, hash_type): """ - Create the salt_stage tarball, and execute in the chroot + Create the salt_state tarball, and execute in the chroot """ # Create the tar containing the state pkg and relevant files. salt.client.ssh.wrapper.state._cleanup_slsmod_low_data(chunks) @@ -206,7 +236,7 @@ def _create_and_execute_salt_state(root, chunks, file_refs, test, hash_type): ret = None # Create a temporary directory inside the chroot where we can move - # the salt_stage.tgz + # the salt_state.tgz salt_state_path = tempfile.mkdtemp(dir=root) salt_state_path = os.path.join(salt_state_path, "salt_state.tgz") salt_state_path_in_chroot = salt_state_path.replace(root, "", 1) @@ -270,12 +300,12 @@ def sls(root, mods, saltenv="base", test=None, exclude=None, **kwargs): opts, pillar, __salt__, salt.fileclient.get_file_client(__opts__) ) - if isinstance(mods, six.string_types): + if isinstance(mods, str): mods = mods.split(",") high_data, errors = st_.render_highstate({saltenv: mods}) if exclude: - if isinstance(exclude, six.string_types): + if isinstance(exclude, str): exclude = exclude.split(",") if "__exclude__" in high_data: high_data["__exclude__"].extend(exclude) diff --git a/salt/modules/rebootmgr.py b/salt/modules/rebootmgr.py new file mode 100644 index 0000000000..96133c754b --- /dev/null +++ b/salt/modules/rebootmgr.py @@ -0,0 +1,357 @@ +""" +:maintainer: Alberto Planas +:maturity: new +:depends: None +:platform: Linux +""" + +import logging +import re + +import salt.exceptions + +log = logging.getLogger(__name__) + + +def __virtual__(): + """rebootmgrctl command is required.""" + if __utils__["path.which"]("rebootmgrctl") is not None: + return True + else: + return (False, "Module rebootmgt requires the command rebootmgrctl") + + +def _cmd(cmd, retcode=False): + """Utility function to run commands.""" + result = __salt__["cmd.run_all"](cmd) + if retcode: + return result["retcode"] + + if result["retcode"]: + raise salt.exceptions.CommandExecutionError(result["stderr"]) + + return result["stdout"] + + +def version(): + """Return the version of rebootmgrd + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr version + + """ + cmd = ["rebootmgrctl", "--version"] + + return _cmd(cmd).split()[-1] + + +def is_active(): + """Check if the rebootmgrd is running and active or not. + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr is_active + + """ + cmd = ["rebootmgrctl", "is_active", "--quiet"] + + return _cmd(cmd, retcode=True) == 0 + + +def reboot(order=None): + """Tells rebootmgr to schedule a reboot. + + With the [now] option, a forced reboot is done, no lock from etcd + is requested and a set maintenance window is ignored. With the + [fast] option, a lock from etcd is requested if needed, but a + defined maintenance window is ignored. + + order + If specified, can be "now" or "fast" + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr reboot + salt microos rebootmgt reboot order=now + + """ + if order and order not in ("now", "fast"): + raise salt.exceptions.CommandExecutionError( + "Order parameter, if specified, must be 'now' or 'fast'" + ) + + cmd = ["rebootmgrctl", "reboot"] + if order: + cmd.append(order) + + return _cmd(cmd) + + +def cancel(): + """Cancels an already running reboot. + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr cancel + + """ + cmd = ["rebootmgrctl", "cancel"] + + return _cmd(cmd) + + +def status(): + """Returns the current status of rebootmgrd. + + Valid returned values are: + 0 - No reboot requested + 1 - Reboot requested + 2 - Reboot requested, waiting for maintenance window + 3 - Reboot requested, waiting for etcd lock. + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr status + + """ + cmd = ["rebootmgrctl", "status", "--quiet"] + + return _cmd(cmd, retcode=True) + + +def set_strategy(strategy=None): + """A new strategy to reboot the machine is set and written into + /etc/rebootmgr.conf. + + strategy + If specified, must be one of those options: + + best-effort - This is the default strategy. If etcd is + running, etcd-lock is used. If no etcd is running, but a + maintenance window is specified, the strategy will be + maint-window. If no maintenance window is specified, the + machine is immediately rebooted (instantly). + + etcd-lock - A lock at etcd for the specified lock-group will + be acquired before reboot. If a maintenance window is + specified, the lock is only acquired during this window. + + maint-window - Reboot does happen only during a specified + maintenance window. If no window is specified, the + instantly strategy is followed. + + instantly - Other services will be informed that a reboot will + happen. Reboot will be done without getting any locks or + waiting for a maintenance window. + + off - Reboot requests are temporary + ignored. /etc/rebootmgr.conf is not modified. + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr set_strategy stragegy=off + + """ + if strategy and strategy not in ( + "best-effort", + "etcd-lock", + "maint-window", + "instantly", + "off", + ): + raise salt.exceptions.CommandExecutionError("Strategy parameter not valid") + + cmd = ["rebootmgrctl", "set-strategy"] + if strategy: + cmd.append(strategy) + + return _cmd(cmd) + + +def get_strategy(): + """The currently used reboot strategy of rebootmgrd will be printed. + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr get_strategy + + """ + cmd = ["rebootmgrctl", "get-strategy"] + + return _cmd(cmd).split(":")[-1].strip() + + +def set_window(time, duration): + """Set's the maintenance window. + + time + The format of time is the same as described in + systemd.time(7). + + duration + The format of duration is "[XXh][YYm]". + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr set_window time="Thu,Fri 2020-*-1,5 11:12:13" duration=1h + + """ + cmd = ["rebootmgrctl", "set-window", time, duration] + + return _cmd(cmd) + + +def get_window(): + """The currently set maintenance window will be printed. + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr get_window + + """ + cmd = ["rebootmgrctl", "get-window"] + window = _cmd(cmd) + + return dict( + zip( + ("time", "duration"), + re.search( + r"Maintenance window is set to (.*), lasting (.*).", window + ).groups(), + ) + ) + + +def set_group(group): + """Set the group, to which this machine belongs to get a reboot lock + from etcd. + + group + Group name + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr set_group group=group_1 + + """ + cmd = ["rebootmgrctl", "set-group", group] + + return _cmd(cmd) + + +def get_group(): + """The currently set lock group for etcd. + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr get_group + + """ + cmd = ["rebootmgrctl", "get-group"] + group = _cmd(cmd) + + return re.search(r"Etcd lock group is set to (.*)", group).groups()[0] + + +def set_max(max_locks, group=None): + """Set the maximal number of hosts in a group, which are allowed to + reboot at the same time. + + number + Maximal number of hosts in a group + + group + Group name + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr set_max 4 + + """ + cmd = ["rebootmgrctl", "set-max"] + if group: + cmd.extend(["--group", group]) + cmd.append(max_locks) + + return _cmd(cmd) + + +def lock(machine_id=None, group=None): + """Lock a machine. If no group is specified, the local default group + will be used. If no machine-id is specified, the local machine + will be locked. + + machine_id + The machine-id is a network wide, unique ID. Per default the + ID from /etc/machine-id is used. + + group + Group name + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr lock group=group1 + + """ + cmd = ["rebootmgrctl", "lock"] + if group: + cmd.extend(["--group", group]) + if machine_id: + cmd.append(machine_id) + + return _cmd(cmd) + + +def unlock(machine_id=None, group=None): + """Unlock a machine. If no group is specified, the local default group + will be used. If no machine-id is specified, the local machine + will be locked. + + machine_id + The machine-id is a network wide, unique ID. Per default the + ID from /etc/machine-id is used. + + group + Group name + + CLI Example: + + .. code-block:: bash + + salt microos rebootmgr unlock group=group1 + + """ + cmd = ["rebootmgrctl", "unlock"] + if group: + cmd.extend(["--group", group]) + if machine_id: + cmd.append(machine_id) + + return _cmd(cmd) diff --git a/salt/modules/systemd_service.py b/salt/modules/systemd_service.py index 03e7268cd4..49e5bd813f 100644 --- a/salt/modules/systemd_service.py +++ b/salt/modules/systemd_service.py @@ -64,7 +64,10 @@ def __virtual__(): """ Only work on systems that have been booted with systemd """ - if __grains__.get("kernel") == "Linux" and salt.utils.systemd.booted(__context__): + is_linux = __grains__.get("kernel") == "Linux" + is_booted = salt.utils.systemd.booted(__context__) + is_offline = salt.utils.systemd.offline(__context__) + if is_linux and (is_booted or is_offline): return __virtualname__ return ( False, @@ -1447,3 +1450,19 @@ def firstboot( raise CommandExecutionError("systemd-firstboot error: {}".format(out["stderr"])) return True + + +def offline(): + """ + .. versionadded:: TBD + + Check if systemd is working in offline mode, where is not possible + to talk with PID 1. + + CLI Example: + + salt '*' service.offline + + """ + + return salt.utils.systemd.offline(__context__) diff --git a/salt/modules/transactional_update.py b/salt/modules/transactional_update.py new file mode 100644 index 0000000000..9b14557e07 --- /dev/null +++ b/salt/modules/transactional_update.py @@ -0,0 +1,1270 @@ +"""Transactional update +==================== + +.. versionadded: TBD + +A transactional system, like `MicroOS`_, can present some challenges +when the user decided to manage it via Salt. + +MicroOS provide a read-only rootfs and a tool, +``transactional-update``, that takes care of the management of the +system (updating, upgrading, installation or reboot, among others) in +an atomic way. + +Atomicity is the main feature of MicroOS, and to guarantee this +property, this model leverages ``snapper``, ``zypper``, ``btrfs`` and +``overlayfs`` to create snapshots that will be updated independently +of the currently running system, and that are activated after the +reboot. This implies, for example, that some changes made on the +system are not visible until the next reboot, as those changes are +living in a different snapshot of the file system. + +This model present a lot of problems with the traditional Salt model, +where the inspections (like 'is this package installed?') are executed +in order to determine if a subsequent action is required (like +'install this package'). + +Lets consider this use case, to see how it works on a traditional +system, and in a transactional system: + +1) Check if ``apache`` is installed + +2) If it is not installed, install it + +3) Check that a ``vhost`` is configured for ``apache`` + +4) Make sure that ``apache2.service`` is enabled + +5) If the configuration changes, restart ``apache2.service`` + +In the traditional system everything will work as expected. The +system can see if the package is present or not, install it if it +isn't, and a re-check will shows that is already present. The same +will happen to the configuration file in ``/etc/apache2``, that will +be available as soon the package gets installed. Salt can inspect the +current form of this file, and add the missing bits if required. Salt +can annotate that a change is present, and restart the service. + +In a transactional system we will have multiple issues. The first one +is that Salt can only see the content of the snapshot where the system +booted from. Later snapshots may contain different content, including +the presence of ``apache``. If Salt decides to install ``apache`` +calling ``zypper``, it will fail, as this will try to write into the +read-only rootfs. Even if Salt would call ``transactional-update pkg +install``, the package would only be present in the new transaction +(snapshot), and will not be found in the currently running system when +later Salt tries to validate the presence of the package in the +current one. + +Any change in ``/etc`` alone will have also problems, as the changes +will be alive in a different overlay, only visible after the reboot. +And, finally, the service can only be enabled and restarted if the +service file is already present in the current ``/etc``. + + +General strategy +---------------- + +``transactional-update`` is the reference tool used for the +administration of transactional systems. Newer versions of this tool +support the execution of random commands in the new transaction, the +continuation of a transaction, the automatic detection of changes in +new transactions and the merge of ``/etc`` overlays. + +Continue a transaction +...................... + +One prerequisite already present is the support for branching from a +different snapshot than the current one in snapper. + +With this feature we can represent in ``transactional-update`` the +action of creating a transaction snapshot based on one that is planned +to be the active one after the reboot. This feature removes a lot of +user complains (like, for example, loosing changes that are stored in +a transaction not yet activated), but also provide a more simple model +to work with. + +So, for example, if the user have this scenario:: + + +-----+ *=====* +--V--+ + --| T.1 |--| T.2 |--| T.3 | + +-----+ *=====* +--A--+ + +where T.2 is the current active one, and T.3 is an snapshot generated +from T.2 with a new package (``apache2``), and is marked to be the +active after the reboot. + +Previously, if the user (that is still on T.2) created a new +transaction, maybe for adding a new package (``tomcat``, for example), +the new T.4 will be based on the content of T.2 again, and not T.3, so +the new T.4 will have lost the changes of T.3 (i.e. `apache2` will not +be present in T.4). + +With the ``--continue`` parameter, ``transactional-update`` will +create T.4 based on T.3, and nothing will be lost. + +Command execution inside a new transaction +.......................................... + +With ``transactional-update run`` we will create a new transaction +based on the current one (T.2), where we can send interactive commands +that can modify the new transaction, and as commented, with +``transactional-update --continue run``, we will create a new +transaction based on the last created (T.3) + +The ``run`` command can execute any application inside the new +transaction namespace. This module uses this feature to execute the +different Salt execution modules, via ``call()``. Or even the full +``salt-thin`` or ``salt-call`` via ``sls()``, ``apply()``, +``single()`` or ``highstate``. + +``transactional-update`` will drop empty snapshots +.................................................. + +The option ``--drop-if-no-change`` is used to detect whether there is +any change in the file system on the read-only subvolume of the new +transaction will be added. If a change is present, the new +transaction will remain, if not it will be discarded. + +For example:: + + transactional-update --continute --drop-if-no-change run zypper in apache2" + +If we are in the scenario described before, ``apache2`` is already +present in T.3. In this case a new transaction, T.4, will be created +based on T.3, ``zypper`` will detect that the package is already +present and no change will be produced on T.4. At the end of the +execution, ``transactional-update`` will validate that T.3 and T.4 are +equivalent and T.4 will be discarded. + +If the command is:: + + transactional-update --continue --drop-if-no-change run zypper in tomcat + +the new T.4 will be indeed different from T.3, and will remain after +the transaction is closed. + +With this feature, every time that we call any function of this +execution module, we will minimize the amount of transaction, while +maintaining the idempotence so some operations. + +Report for pending transaction +.............................. + +A change in the system will create a new transaction, that needs to be +activated via a reboot. With ``pending_transaction()`` we can check +if a reboot is needed. We can execute the reboot using the +``reboot()`` function, that will follow the plan established by the +functions of the ``rebootmgr`` execution module. + +``/etc`` overlay merge when no new transaction is created +......................................................... + +In a transactional model, ``/etc`` is an overlay file system. Changes +done during the update are only present in the new transaction, and so +will only be available after the reboot. Or worse, if the transaction +gets dropped, because there is no change in the ``rootfs``, the +changes in ``/etc`` will be dropped too!. This is designed like that +in order to make the configuration files for the new package available +only when new package is also available to the user. So, after the +reboot. + +This makes sense for the case when, for example, ``apache2`` is not +present in the current transaction, but we installed it. The new +snapshot contains the ``apache2`` service, and the configuration files +in ``/etc`` will be accessible only after the reboot. + +But this model presents an issue. If we use ``transactional-update +--continue --drop-if-no-change run ``, where ```` +does not make any change in the read-only subvolume, but only in +``/etc`` (which is also read-write in the running system), the new +overlay with the changes in ``/etc`` will be dropped together with the +transaction. + +To fix this, ``transactional-update`` will detect that when no change +has been made on the read-only subvolume, but done in the overlay, the +transaction will be dropped and the changes in the overlay will be +merged back into ``/etc`` overlay of the current transaction. + + +Using the execution module +-------------------------- + +With this module we can create states that leverage Salt into this +kind of systems:: + + # Install apache (low-level API) + salt-call transactional_update.pkg_install apache2 + + # We can call any execution module + salt-call transactional_update.call pkg.install apache2 + + # Or via a state + salt-call transactional_update.single pkg.installed name=apache2 + + # We can also execute a zypper directly + salt-call transactional_update run "zypper in apache2" snapshot="continue" + + # We can reuse SLS states + salt-call transactional_update.apply install_and_configure_apache + + # Or apply the full highstate + salt-call transactional_update.highstate + + # Is there any change done in the system? + salt-call transactional_update pending_transaction + + # If so, reboot via rebootmgr + salt-call transactional_update reboot + + # We can enable the service + salt-call service.enable apache2 + + # If apache2 is available, this will work too + salt-call service.restart apache2 + + +Fixing some expectations +------------------------ + +This module alone is an improvement over the current state, but is +easy to see some limitations and problems: + +Is not a fully transparent approach +................................... + +The user needs to know if the system is transactional or not, as not +everything can be expressed inside a transaction (for example, +restarting a service inside transaction is not allowed). + +Two step for service restart +............................ + +In the ``apache2` example from the beginning we can observe the +biggest drawback. If the package ``apache2`` is missing, the new +module will create a new transaction, will execute ``pkg.install`` +inside the transaction (creating the salt-thin, moving it inside and +delegating the execution to `transactional-update` CLI as part of the +full state). Inside the transaction we can do too the required +changes in ``/etc`` for adding the new ``vhost``, and we can enable the +service via systemctl inside the same transaction. + +At this point we will not merge the ``/etc`` overlay into the current +one, and we expect from the user call the ``reboot`` function inside +this module, in order to activate the new transaction and start the +``apache2`` service. + +In the case that the package is already there, but the configuration +for the ``vhost`` is required, the new transaction will be dropped and +the ``/etc`` overlay will be visible in the live system. Then from +outside the transaction, via a different call to Salt, we can command +a restart of the ``apache2`` service. + +We can see that in both cases we break the user expectation, where a +change on the configuration will trigger automatically the restart of +the associated service. In a transactional scenario we need two +different steps: or a reboot, or a restart from outside of the +transaction. + +.. _MicroOS: https://microos.opensuse.org/ + +:maintainer: Alberto Planas +:maturity: new +:depends: None +:platform: Linux + +""" + +import copy +import logging +import os +import sys +import tempfile + +import salt.client.ssh.state +import salt.client.ssh.wrapper.state +import salt.exceptions +import salt.utils.args + +__func_alias__ = {"apply_": "apply"} + +log = logging.getLogger(__name__) + + +def __virtual__(): + """ + transactional-update command is required. + """ + if __utils__["path.which"]("transactional-update"): + return True + else: + return (False, "Module transactional_update requires a transactional system") + + +def _global_params(self_update, snapshot=None, quiet=False): + """Utility function to prepare common global parameters.""" + params = ["--non-interactive", "--drop-if-no-change"] + if self_update is False: + params.append("--no-selfupdate") + if snapshot and snapshot != "continue": + params.extend(["--continue", snapshot]) + elif snapshot: + params.append("--continue") + if quiet: + params.append("--quiet") + return params + + +def _pkg_params(pkg, pkgs, args): + """Utility function to prepare common package parameters.""" + params = [] + + if not pkg and not pkgs: + raise salt.exceptions.CommandExecutionError("Provide pkg or pkgs parameters") + + if args and isinstance(args, str): + params.extend(args.split()) + elif args and isinstance(args, list): + params.extend(args) + + if pkg: + params.append(pkg) + + if pkgs and isinstance(pkgs, str): + params.extend(pkgs.split()) + elif pkgs and isinstance(pkgs, list): + params.extend(pkgs) + + return params + + +def _cmd(cmd, retcode=False): + """Utility function to run commands.""" + result = __salt__["cmd.run_all"](cmd) + if retcode: + return result["retcode"] + + if result["retcode"]: + raise salt.exceptions.CommandExecutionError(result["stderr"]) + + return result["stdout"] + + +def transactional(): + """Check if the system is a transactional system + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update transactional + + """ + return bool(__utils__["path.which"]("transactional-update")) + + +def in_transaction(): + """Check if Salt is executing while in a transaction + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update in_transaction + + """ + return transactional() and __salt__["chroot.in_chroot"]() + + +def cleanup(self_update=False): + """Run both cleanup-snapshots and cleanup-overlays. + + Identical to calling both cleanup-snapshots and cleanup-overlays. + + self_update + Check for newer transactional-update versions. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update cleanup + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update)) + cmd.append("cleanup") + return _cmd(cmd) + + +def cleanup_snapshots(self_update=False): + """Mark unused snapshots for snapper removal. + + If the current root filesystem is identical to the active root + filesystem (means after a reboot, before transactional-update + creates a new snapshot with updates), all old snapshots without a + cleanup algorithm get a cleanup algorithm set. This is to make + sure, that old snapshots will be deleted by snapper. See the + section about cleanup algorithms in snapper(8). + + self_update + Check for newer transactional-update versions. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update cleanup_snapshots + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update)) + cmd.append("cleanup-snapshots") + return _cmd(cmd) + + +def cleanup_overlays(self_update=False): + """Remove unused overlay layers. + + Removes all unreferenced (and thus unused) /etc overlay + directories in /var/lib/overlay. + + self_update + Check for newer transactional-update versions. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update cleanup_overlays + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update)) + cmd.append("cleanup-overlays") + return _cmd(cmd) + + +def grub_cfg(self_update=False, snapshot=None): + """Regenerate grub.cfg + + grub2-mkconfig(8) is called to create a new /boot/grub2/grub.cfg + configuration file for the bootloader. + + self_update + Check for newer transactional-update versions. + + snapshot + Use the given snapshot or, if no number is given, the current + default snapshot as a base for the next snapshot. Use + "continue" to indicate the last snapshot done. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update grub_cfg snapshot="continue" + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update, snapshot=snapshot)) + cmd.append("grub.cfg") + return _cmd(cmd) + + +def bootloader(self_update=False, snapshot=None): + """Reinstall the bootloader + + Same as grub.cfg, but will also rewrite the bootloader itself. + + self_update + Check for newer transactional-update versions. + + snapshot + Use the given snapshot or, if no number is given, the current + default snapshot as a base for the next snapshot. Use + "continue" to indicate the last snapshot done. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update bootloader snapshot="continue" + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update, snapshot=snapshot)) + cmd.append("bootloader") + return _cmd(cmd) + + +def initrd(self_update=False, snapshot=None): + """Regenerate initrd + + A new initrd is created in a snapshot. + + self_update + Check for newer transactional-update versions. + + snapshot + Use the given snapshot or, if no number is given, the current + default snapshot as a base for the next snapshot. Use + "continue" to indicate the last snapshot done. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update initrd snapshot="continue" + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update, snapshot=snapshot)) + cmd.append("initrd") + return _cmd(cmd) + + +def kdump(self_update=False, snapshot=None): + """Regenerate kdump initrd + + A new initrd for kdump is created in a snapshot. + + self_update + Check for newer transactional-update versions. + + snapshot + Use the given snapshot or, if no number is given, the current + default snapshot as a base for the next snapshot. Use + "continue" to indicate the last snapshot done. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update kdump snapshot="continue" + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update, snapshot=snapshot)) + cmd.append("kdump") + return _cmd(cmd) + + +def run(command, self_update=False, snapshot=None): + """Run a command in a new snapshot + + Execute the command inside a new snapshot. By default this snaphot + will remain, but if --drop-if-no-chage is set, the new snapshot + will be dropped if there is no change in the file system. + + command + Command with parameters that will be executed (as string or + array) + + self_update + Check for newer transactional-update versions. + + snapshot + Use the given snapshot or, if no number is given, the current + default snapshot as a base for the next snapshot. Use + "continue" to indicate the last snapshot done. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update run "mkdir /tmp/dir" snapshot="continue" + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update, snapshot=snapshot, quiet=True)) + cmd.append("run") + if isinstance(command, str): + cmd.extend(command.split()) + elif isinstance(command, list): + cmd.extend(command) + else: + raise salt.exceptions.CommandExecutionError("Command parameter not recognized") + return _cmd(cmd) + + +def reboot(self_update=False): + """Reboot after update + + Trigger a reboot after updating the system. + + Several different reboot methods are supported, configurable via + the REBOOT_METHOD configuration option in + transactional-update.conf(5). By default rebootmgrd(8) will be + used to reboot the system according to the configured policies if + the service is running, otherwise systemctl reboot will be called. + + self_update + Check for newer transactional-update versions. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update reboot + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update)) + cmd.append("reboot") + return _cmd(cmd) + + +def dup(self_update=False, snapshot=None): + """Call 'zypper dup' + + If new updates are available, a new snapshot is created and zypper + dup --no-allow-vendor-change is used to update the + snapshot. Afterwards, the snapshot is activated and will be used + as the new root filesystem during next boot. + + self_update + Check for newer transactional-update versions. + + snapshot + Use the given snapshot or, if no number is given, the current + default snapshot as a base for the next snapshot. Use + "continue" to indicate the last snapshot done. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update dup snapshot="continue" + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update, snapshot=snapshot)) + cmd.append("dup") + return _cmd(cmd) + + +def up(self_update=False, snapshot=None): + """Call 'zypper up' + + If new updates are available, a new snapshot is created and zypper + up is used to update the snapshot. Afterwards, the snapshot is + activated and will be used as the new root filesystem during next + boot. + + self_update + Check for newer transactional-update versions. + + snapshot + Use the given snapshot or, if no number is given, the current + default snapshot as a base for the next snapshot. Use + "continue" to indicate the last snapshot done. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update up snapshot="continue" + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update, snapshot=snapshot)) + cmd.append("up") + return _cmd(cmd) + + +def patch(self_update=False, snapshot=None): + """Call 'zypper patch' + + If new updates are available, a new snapshot is created and zypper + patch is used to update the snapshot. Afterwards, the snapshot is + activated and will be used as the new root filesystem during next + boot. + + self_update + Check for newer transactional-update versions. + + snapshot + Use the given snapshot or, if no number is given, the current + default snapshot as a base for the next snapshot. Use + "continue" to indicate the last snapshot done. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update patch snapshot="continue" + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update, snapshot=snapshot)) + cmd.append("patch") + return _cmd(cmd) + + +def migration(self_update=False, snapshot=None): + """Updates systems registered via SCC / SMT + + On systems which are registered against the SUSE Customer Center + (SCC) or SMT, a migration to a new version of the installed + products can be made with this option. + + self_update + Check for newer transactional-update versions. + + snapshot + Use the given snapshot or, if no number is given, the current + default snapshot as a base for the next snapshot. Use + "continue" to indicate the last snapshot done. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update migration snapshot="continue" + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update, snapshot=snapshot)) + cmd.append("migration") + return _cmd(cmd) + + +def pkg_install(pkg=None, pkgs=None, args=None, self_update=False, snapshot=None): + """Install individual packages + + Installs additional software. See the install description in the + "Package Management Commands" section of zypper's man page for all + available arguments. + + pkg + Package name to install + + pkgs + List of packages names to install + + args + String or list of extra parameters for zypper + + self_update + Check for newer transactional-update versions. + + snapshot + Use the given snapshot or, if no number is given, the current + default snapshot as a base for the next snapshot. Use + "continue" to indicate the last snapshot done. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update pkg_install pkg=emacs snapshot="continue" + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update, snapshot=snapshot)) + cmd.extend(["pkg", "install"]) + cmd.extend(_pkg_params(pkg, pkgs, args)) + return _cmd(cmd) + + +def pkg_remove(pkg=None, pkgs=None, args=None, self_update=False, snapshot=None): + """Remove individual packages + + Removes installed software. See the remove description in the + "Package Management Commands" section of zypper's man page for all + available arguments. + + pkg + Package name to install + + pkgs + List of packages names to install + + args + String or list of extra parameters for zypper + + self_update + Check for newer transactional-update versions. + + snapshot + Use the given snapshot or, if no number is given, the current + default snapshot as a base for the next snapshot. Use + "continue" to indicate the last snapshot done. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update pkg_remove pkg=vim snapshot="continue" + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update, snapshot=snapshot)) + cmd.extend(["pkg", "remove"]) + cmd.extend(_pkg_params(pkg, pkgs, args)) + return _cmd(cmd) + + +def pkg_update(pkg=None, pkgs=None, args=None, self_update=False, snapshot=None): + """Updates individual packages + + Update selected software. See the update description in the + "Update Management Commands" section of zypper's man page for all + available arguments. + + pkg + Package name to install + + pkgs + List of packages names to install + + args + String or list of extra parameters for zypper + + self_update + Check for newer transactional-update versions. + + snapshot + Use the given snapshot or, if no number is given, the current + default snapshot as a base for the next snapshot. Use + "continue" to indicate the last snapshot done. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update pkg_update pkg=emacs snapshot="continue" + + """ + cmd = ["transactional-update"] + cmd.extend(_global_params(self_update=self_update, snapshot=snapshot)) + cmd.extend(["pkg", "update"]) + cmd.extend(_pkg_params(pkg, pkgs, args)) + return _cmd(cmd) + + +def rollback(snapshot=None): + """Set the current, given or last working snapshot as default snapshot + + Sets the default root file system. On a read-only system the root + file system is set directly using btrfs. On read-write systems + snapper(8) rollback is called. + + If no snapshot number is given, the current root file system is + set as the new default root file system. Otherwise number can + either be a snapshot number (as displayed by snapper list) or the + word last. last will try to reset to the latest working snapshot. + + snapshot + Use the given snapshot or, if no number is given, the current + default snapshot as a base for the next snapshot. Use + "last" to indicate the last working snapshot done. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update rollback + + """ + if ( + snapshot + and isinstance(snapshot, str) + and snapshot != "last" + and not snapshot.isnumeric() + ): + raise salt.exceptions.CommandExecutionError( + "snapshot should be a number or 'last'" + ) + cmd = ["transactional-update"] + cmd.append("rollback") + if snapshot: + cmd.append(snapshot) + return _cmd(cmd) + + +def pending_transaction(): + """Check if there is a pending transaction + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update pending_transaction + + """ + # If we are running inside a transaction, we do not have a good + # way yet to detect a pending transaction + if in_transaction(): + raise salt.exceptions.CommandExecutionError( + "pending_transaction cannot be executed inside a transaction" + ) + + cmd = ["snapper", "--no-dbus", "list", "--columns", "number"] + snapshots = _cmd(cmd) + + return any(snapshot.endswith("+") for snapshot in snapshots) + + +def call(function, *args, **kwargs): + """Executes a Salt function inside a transaction. + + The chroot does not need to have Salt installed, but Python is + required. + + function + Salt execution module function + + activate_transaction + If at the end of the transaction there is a pending activation + (i.e there is a new snaphot in the system), a new reboot will + be scheduled (default False) + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update.call test.ping + salt microos transactional_update.call ssh.set_auth_key user key=mykey + salt microos transactional_update.call pkg.install emacs activate_transaction=True + + """ + + if not function: + raise salt.exceptions.CommandExecutionError("Missing function parameter") + + activate_transaction = kwargs.pop("activate_transaction", False) + + # Generate the salt-thin and create a temporary directory in a + # place that the new transaction will have access to, and where we + # can untar salt-thin + thin_path = __utils__["thin.gen_thin"]( + __opts__["cachedir"], + extra_mods=__salt__["config.option"]("thin_extra_mods", ""), + so_mods=__salt__["config.option"]("thin_so_mods", ""), + ) + thin_dest_path = tempfile.mkdtemp(dir=__opts__["cachedir"]) + # Some bug in Salt is preventing us to use `archive.tar` here. A + # AsyncZeroMQReqChannel is not closed at the end of the salt-call, + # and makes the client never exit. + # + # stdout = __salt__['archive.tar']('xzf', thin_path, dest=thin_dest_path) + # + stdout = __salt__["cmd.run"](["tar", "xzf", thin_path, "-C", thin_dest_path]) + if stdout: + __utils__["files.rm_rf"](thin_dest_path) + return {"result": False, "comment": stdout} + + try: + safe_kwargs = salt.utils.args.clean_kwargs(**kwargs) + salt_argv = ( + [ + "python{}".format(sys.version_info[0]), + os.path.join(thin_dest_path, "salt-call"), + "--metadata", + "--local", + "--log-file", + os.path.join(thin_dest_path, "log"), + "--cachedir", + os.path.join(thin_dest_path, "cache"), + "--out", + "json", + "-l", + "quiet", + "--", + function, + ] + + list(args) + + ["{}={}".format(k, v) for (k, v) in safe_kwargs.items()] + ) + try: + ret_stdout = run([str(x) for x in salt_argv], snapshot="continue") + except salt.exceptions.CommandExecutionError as e: + ret_stdout = e.message + + # Process "real" result in stdout + try: + data = __utils__["json.find_json"](ret_stdout) + local = data.get("local", data) + if isinstance(local, dict) and "retcode" in local: + __context__["retcode"] = local["retcode"] + return local.get("return", data) + except (KeyError, ValueError): + return {"result": False, "comment": ret_stdout} + finally: + __utils__["files.rm_rf"](thin_dest_path) + + # Check if reboot is needed + if activate_transaction and pending_transaction(): + reboot() + + +def apply_(mods=None, **kwargs): + """Apply an state inside a transaction. + + This function will call `transactional_update.highstate` or + `transactional_update.sls` based on the arguments passed to this + function. It exists as a more intuitive way of applying states. + + For a formal description of the possible parameters accepted in + this function, check `state.apply_` documentation. + + activate_transaction + If at the end of the transaction there is a pending activation + (i.e there is a new snaphot in the system), a new reboot will + be scheduled (default False) + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update.apply + salt microos transactional_update.apply stuff + salt microos transactional_update.apply stuff pillar='{"foo": "bar"}' + salt microos transactional_update.apply stuff activate_transaction=True + + """ + if mods: + return sls(mods, **kwargs) + return highstate(**kwargs) + + +def _create_and_execute_salt_state( + chunks, file_refs, test, hash_type, activate_transaction +): + """Create the salt_state tarball, and execute it in a transaction""" + + # Create the tar containing the state pkg and relevant files. + salt.client.ssh.wrapper.state._cleanup_slsmod_low_data(chunks) + trans_tar = salt.client.ssh.state.prep_trans_tar( + salt.fileclient.get_file_client(__opts__), chunks, file_refs, __pillar__ + ) + trans_tar_sum = salt.utils.hashutils.get_hash(trans_tar, hash_type) + + ret = None + + # Create a temporary directory accesible later by the transaction + # where we can move the salt_state.tgz + salt_state_path = tempfile.mkdtemp(dir=__opts__["cachedir"]) + salt_state_path = os.path.join(salt_state_path, "salt_state.tgz") + try: + salt.utils.files.copyfile(trans_tar, salt_state_path) + ret = call( + "state.pkg", + salt_state_path, + test=test, + pkg_sum=trans_tar_sum, + hash_type=hash_type, + activate_transaction=activate_transaction, + ) + finally: + __utils__["files.rm_rf"](salt_state_path) + + return ret + + +def sls( + mods, saltenv="base", test=None, exclude=None, activate_transaction=False, **kwargs +): + """Execute the states in one or more SLS files inside a transaction. + + saltenv + Specify a salt fileserver environment to be used when applying + states + + mods + List of states to execute + + test + Run states in test-only (dry-run) mode + + exclude + Exclude specific states from execution. Accepts a list of sls + names, a comma-separated string of sls names, or a list of + dictionaries containing ``sls`` or ``id`` keys. Glob-patterns + may be used to match multiple states. + + activate_transaction + If at the end of the transaction there is a pending activation + (i.e there is a new snaphot in the system), a new reboot will + be scheduled (default False) + + For a formal description of the possible parameters accepted in + this function, check `state.sls` documentation. + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update.sls stuff pillar='{"foo": "bar"}' + salt microos transactional_update.sls stuff activate_transaction=True + + """ + # Get a copy of the pillar data, to avoid overwriting the current + # pillar, instead the one delegated + pillar = copy.deepcopy(__pillar__) + pillar.update(kwargs.get("pillar", {})) + + # Clone the options data and apply some default values. May not be + # needed, as this module just delegate + opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) + st_ = salt.client.ssh.state.SSHHighState( + opts, pillar, __salt__, salt.fileclient.get_file_client(__opts__) + ) + + if isinstance(mods, str): + mods = mods.split(",") + + high_data, errors = st_.render_highstate({saltenv: mods}) + if exclude: + if isinstance(exclude, str): + exclude = exclude.split(",") + if "__exclude__" in high_data: + high_data["__exclude__"].extend(exclude) + else: + high_data["__exclude__"] = exclude + + high_data, ext_errors = st_.state.reconcile_extend(high_data) + errors += ext_errors + errors += st_.state.verify_high(high_data) + if errors: + return errors + + high_data, req_in_errors = st_.state.requisite_in(high_data) + errors += req_in_errors + if errors: + return errors + + high_data = st_.state.apply_exclude(high_data) + + # Compile and verify the raw chunks + chunks = st_.state.compile_high_data(high_data) + file_refs = salt.client.ssh.state.lowstate_file_refs( + chunks, + salt.client.ssh.wrapper.state._merge_extra_filerefs( + kwargs.get("extra_filerefs", ""), opts.get("extra_filerefs", "") + ), + ) + + hash_type = opts["hash_type"] + return _create_and_execute_salt_state( + chunks, file_refs, test, hash_type, activate_transaction + ) + + +def highstate(activate_transaction=False, **kwargs): + """Retrieve the state data from the salt master for this minion and + execute it inside a transaction. + + For a formal description of the possible parameters accepted in + this function, check `state.highstate` documentation. + + activate_transaction + If at the end of the transaction there is a pending activation + (i.e there is a new snaphot in the system), a new reboot will + be scheduled (default False) + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update.highstate + salt microos transactional_update.highstate pillar='{"foo": "bar"}' + salt microos transactional_update.highstate activate_transaction=True + + """ + # Get a copy of the pillar data, to avoid overwriting the current + # pillar, instead the one delegated + pillar = copy.deepcopy(__pillar__) + pillar.update(kwargs.get("pillar", {})) + + # Clone the options data and apply some default values. May not be + # needed, as this module just delegate + opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) + st_ = salt.client.ssh.state.SSHHighState( + opts, pillar, __salt__, salt.fileclient.get_file_client(__opts__) + ) + + # Compile and verify the raw chunks + chunks = st_.compile_low_chunks() + file_refs = salt.client.ssh.state.lowstate_file_refs( + chunks, + salt.client.ssh.wrapper.state._merge_extra_filerefs( + kwargs.get("extra_filerefs", ""), opts.get("extra_filerefs", "") + ), + ) + # Check for errors + for chunk in chunks: + if not isinstance(chunk, dict): + __context__["retcode"] = 1 + return chunks + + test = kwargs.pop("test", False) + hash_type = opts["hash_type"] + return _create_and_execute_salt_state( + chunks, file_refs, test, hash_type, activate_transaction + ) + + +def single(fun, name, test=None, activate_transaction=False, **kwargs): + """Execute a single state function with the named kwargs, returns + False if insufficient data is sent to the command + + By default, the values of the kwargs will be parsed as YAML. So, + you can specify lists values, or lists of single entry key-value + maps, as you would in a YAML salt file. Alternatively, JSON format + of keyword values is also supported. + + activate_transaction + If at the end of the transaction there is a pending activation + (i.e there is a new snaphot in the system), a new reboot will + be scheduled (default False) + + CLI Example: + + .. code-block:: bash + + salt microos transactional_update.single pkg.installed name=emacs + salt microos transactional_update.single pkg.installed name=emacs activate_transaction=True + + """ + # Get a copy of the pillar data, to avoid overwriting the current + # pillar, instead the one delegated + pillar = copy.deepcopy(__pillar__) + pillar.update(kwargs.get("pillar", {})) + + # Clone the options data and apply some default values. May not be + # needed, as this module just delegate + opts = salt.utils.state.get_sls_opts(__opts__, **kwargs) + st_ = salt.client.ssh.state.SSHState(opts, pillar) + + # state.fun -> [state, fun] + comps = fun.split(".") + if len(comps) < 2: + __context__["retcode"] = 1 + return "Invalid function passed" + + # Create the low chunk, using kwargs as a base + kwargs.update({"state": comps[0], "fun": comps[1], "__id__": name, "name": name}) + + # Verify the low chunk + err = st_.verify_data(kwargs) + if err: + __context__["retcode"] = 1 + return err + + # Must be a list of low-chunks + chunks = [kwargs] + + # Retrieve file refs for the state run, so we can copy relevant + # files down to the minion before executing the state + file_refs = salt.client.ssh.state.lowstate_file_refs( + chunks, + salt.client.ssh.wrapper.state._merge_extra_filerefs( + kwargs.get("extra_filerefs", ""), opts.get("extra_filerefs", "") + ), + ) + + hash_type = opts["hash_type"] + return _create_and_execute_salt_state( + chunks, file_refs, test, hash_type, activate_transaction + ) diff --git a/salt/utils/systemd.py b/salt/utils/systemd.py index e830d36ed4..4d902bc920 100644 --- a/salt/utils/systemd.py +++ b/salt/utils/systemd.py @@ -1,18 +1,14 @@ -# -*- coding: utf-8 -*- """ Contains systemd related help files """ -# import python libs -from __future__ import absolute_import, print_function, unicode_literals import logging import os import re import subprocess +import salt.utils.path import salt.utils.stringutils - -# Import Salt libs from salt.exceptions import SaltInvocationError log = logging.getLogger(__name__) @@ -49,6 +45,27 @@ def booted(context=None): return ret +def offline(context=None): + """Return True is systemd is in offline mode""" + contextkey = "salt.utils.systemd.offline" + if isinstance(context, dict): + if contextkey in context: + return context[contextkey] + elif context is not None: + raise SaltInvocationError("context must be a dictionary if passed") + + # Note that there is a difference from SYSTEMD_OFFLINE=1. Here we + # assume that there is no PID 1 to talk with. + ret = not booted(context) and salt.utils.path.which("systemctl") + + try: + context[contextkey] = ret + except TypeError: + pass + + return ret + + def version(context=None): """ Attempts to run systemctl --version. Returns None if unable to determine diff --git a/tests/unit/modules/test_chroot.py b/tests/unit/modules/test_chroot.py index 76811df46e..196e3ad27f 100644 --- a/tests/unit/modules/test_chroot.py +++ b/tests/unit/modules/test_chroot.py @@ -1,4 +1,3 @@ -# -*- coding: utf-8 -*- # # Author: Alberto Planas # @@ -26,16 +25,13 @@ :platform: Linux """ -# Import Python Libs -from __future__ import absolute_import, print_function, unicode_literals import sys +import salt.modules.chroot as chroot import salt.modules.chroot as chroot import salt.utils.platform from salt.exceptions import CommandExecutionError - -# Import Salt Testing Libs from tests.support.mixins import LoaderModuleMockMixin from tests.support.mock import MagicMock, patch from tests.support.unit import TestCase, skipIf @@ -75,6 +71,18 @@ class ChrootTestCase(TestCase, LoaderModuleMockMixin): self.assertTrue(chroot.create("/chroot")) makedirs.assert_called() + @patch("salt.modules.chroot.exist") + @patch("salt.utils.files.fopen") + def test_in_chroot(self, fopen): + """ + Test the detection of chroot environment. + """ + matrix = (("a", "b", True), ("a", "a", False)) + for root_mountinfo, self_mountinfo, result in matrix: + fopen.return_value.__enter__.return_value = fopen + fopen.read = MagicMock(side_effect=(root_mountinfo, self_mountinfo)) + self.assertEqual(chroot.in_chroot(), result) + @patch("salt.modules.chroot.exist") def test_call_fails_input_validation(self, exist): """ diff --git a/tests/unit/modules/test_rebootmgr.py b/tests/unit/modules/test_rebootmgr.py new file mode 100644 index 0000000000..a84dec2c1c --- /dev/null +++ b/tests/unit/modules/test_rebootmgr.py @@ -0,0 +1,302 @@ +import pytest +import salt.modules.rebootmgr as rebootmgr +from salt.exceptions import CommandExecutionError +from tests.support.mixins import LoaderModuleMockMixin +from tests.support.mock import MagicMock, patch +from tests.support.unit import TestCase + + +class RebootMgrTestCase(TestCase, LoaderModuleMockMixin): + """ + Test cases for salt.modules.rebootmgr + """ + + def setup_loader_modules(self): + return {rebootmgr: {"__salt__": {}, "__utils__": {}}} + + def test_version(self): + """ + Test rebootmgr.version without parameters + """ + version = "rebootmgrctl (rebootmgr) 1.3" + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": version, "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.version() == "1.3" + salt_mock["cmd.run_all"].assert_called_with(["rebootmgrctl", "--version"]) + + def test_is_active(self): + """ + Test rebootmgr.is_active without parameters + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": None, "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.is_active() + salt_mock["cmd.run_all"].assert_called_with( + ["rebootmgrctl", "is_active", "--quiet"] + ) + + def test_reboot(self): + """ + Test rebootmgr.reboot without parameters + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.reboot() == "output" + salt_mock["cmd.run_all"].assert_called_with(["rebootmgrctl", "reboot"]) + + def test_reboot_order(self): + """ + Test rebootmgr.reboot with order parameter + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.reboot("now") == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["rebootmgrctl", "reboot", "now"] + ) + + def test_reboot_invalid(self): + """ + Test rebootmgr.reboot with invalid parameter + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + with pytest.raises(CommandExecutionError): + rebootmgr.reboot("invalid") + + def test_cancel(self): + """ + Test rebootmgr.cancel without parameters + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.cancel() == "output" + salt_mock["cmd.run_all"].assert_called_with(["rebootmgrctl", "cancel"]) + + def test_status(self): + """ + Test rebootmgr.status without parameters + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + # 0 - No reboot requested + assert rebootmgr.status() == 0 + salt_mock["cmd.run_all"].assert_called_with( + ["rebootmgrctl", "status", "--quiet"] + ) + + def test_set_strategy_default(self): + """ + Test rebootmgr.set_strategy without parameters + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.set_strategy() == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["rebootmgrctl", "set-strategy"] + ) + + def test_set_strategy(self): + """ + Test rebootmgr.set_strategy with strategy parameter + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.set_strategy("best-effort") == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["rebootmgrctl", "set-strategy", "best-effort"] + ) + + def test_set_strategy_invalid(self): + """ + Test rebootmgr.strategy with invalid parameter + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + with pytest.raises(CommandExecutionError): + rebootmgr.set_strategy("invalid") + + def test_get_strategy(self): + """ + Test rebootmgr.get_strategy without parameters + """ + strategy = "Reboot strategy: best-effort" + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": strategy, "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.get_strategy() == "best-effort" + salt_mock["cmd.run_all"].assert_called_with( + ["rebootmgrctl", "get-strategy"] + ) + + def test_set_window(self): + """ + Test rebootmgr.set_window with parameters + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.set_window("Thu,Fri 2020-*-1,5 11:12:13", "1h") == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["rebootmgrctl", "set-window", "Thu,Fri 2020-*-1,5 11:12:13", "1h"] + ) + + def test_get_window(self): + """ + Test rebootmgr.get_window without parameters + """ + window = "Maintenance window is set to *-*-* 03:30:00, lasting 01h30m." + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": window, "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.get_window() == { + "time": "*-*-* 03:30:00", + "duration": "01h30m", + } + salt_mock["cmd.run_all"].assert_called_with(["rebootmgrctl", "get-window"]) + + def test_set_group(self): + """ + Test rebootmgr.set_group with parameters + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.set_group("group1") == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["rebootmgrctl", "set-group", "group1"] + ) + + def test_get_group(self): + """ + Test rebootmgr.get_group without parameters + """ + group = "Etcd lock group is set to group1" + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": group, "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.get_group() == "group1" + salt_mock["cmd.run_all"].assert_called_with(["rebootmgrctl", "get-group"]) + + def test_set_max(self): + """ + Test rebootmgr.set_max with default parameters + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.set_max(10) == "output" + salt_mock["cmd.run_all"].assert_called_with(["rebootmgrctl", "set-max", 10]) + + def test_set_max_group(self): + """ + Test rebootmgr.set_max with group parameter + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.set_max(10, "group1") == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["rebootmgrctl", "set-max", "--group", "group1", 10] + ) + + def test_lock(self): + """ + Test rebootmgr.lock without parameters + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.lock() == "output" + salt_mock["cmd.run_all"].assert_called_with(["rebootmgrctl", "lock"]) + + def test_lock_machine_id(self): + """ + Test rebootmgr.lock with machine_id parameter + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.lock("machine-id") == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["rebootmgrctl", "lock", "machine-id"] + ) + + def test_lock_machine_id_group(self): + """ + Test rebootmgr.lock with machine_id and group parameters + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.lock("machine-id", "group1") == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["rebootmgrctl", "lock", "--group", "group1", "machine-id"] + ) + + def test_unlock(self): + """ + Test rebootmgr.unlock without parameters + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.unlock() == "output" + salt_mock["cmd.run_all"].assert_called_with(["rebootmgrctl", "unlock"]) + + def test_unlock_machine_id(self): + """ + Test rebootmgr.unlock with machine_id parameter + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.unlock("machine-id") == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["rebootmgrctl", "unlock", "machine-id"] + ) + + def test_unlock_machine_id_group(self): + """ + Test rebootmgr.unlock with machine_id and group parameters + """ + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(rebootmgr.__salt__, salt_mock): + assert rebootmgr.unlock("machine-id", "group1") == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["rebootmgrctl", "unlock", "--group", "group1", "machine-id"] + ) diff --git a/tests/unit/modules/test_transactional_update.py b/tests/unit/modules/test_transactional_update.py new file mode 100644 index 0000000000..08a704c212 --- /dev/null +++ b/tests/unit/modules/test_transactional_update.py @@ -0,0 +1,681 @@ +import sys + +import pytest +import salt.modules.transactional_update as tu +import salt.utils.platform +from salt.exceptions import CommandExecutionError +from tests.support.mixins import LoaderModuleMockMixin +from tests.support.mock import MagicMock, patch +from tests.support.unit import TestCase, skipIf + + +@skipIf(salt.utils.platform.is_windows(), "Do not run these tests on Windows") +class TransactionalUpdateTestCase(TestCase, LoaderModuleMockMixin): + """ + Test cases for salt.modules.transactional_update + """ + + def setup_loader_modules(self): + return {tu: {"__salt__": {}, "__utils__": {}}} + + def test__global_params_no_self_update(self): + """Test transactional_update._global_params without self_update""" + assert tu._global_params(self_update=False) == [ + "--non-interactive", + "--drop-if-no-change", + "--no-selfupdate", + ] + + def test__global_params_self_update(self): + """Test transactional_update._global_params with self_update""" + assert tu._global_params(self_update=True) == [ + "--non-interactive", + "--drop-if-no-change", + ] + + def test__global_params_no_self_update_snapshot(self): + """Test transactional_update._global_params without self_update and + snapshot + + """ + assert tu._global_params(self_update=False, snapshot=10) == [ + "--non-interactive", + "--drop-if-no-change", + "--no-selfupdate", + "--continue", + 10, + ] + + def test__global_params_no_self_update_continue(self): + """Test transactional_update._global_params without self_update and + snapshot conitue + + """ + assert tu._global_params(self_update=False, snapshot="continue") == [ + "--non-interactive", + "--drop-if-no-change", + "--no-selfupdate", + "--continue", + ] + + def test__pkg_params_no_packages(self): + """Test transactional_update._pkg_params without packages""" + with pytest.raises(CommandExecutionError): + tu._pkg_params(pkg=None, pkgs=None, args=None) + + def test__pkg_params_pkg(self): + """Test transactional_update._pkg_params with single package""" + assert tu._pkg_params(pkg="pkg1", pkgs=None, args=None) == ["pkg1"] + + def test__pkg_params_pkgs(self): + """Test transactional_update._pkg_params with packages""" + assert tu._pkg_params(pkg=None, pkgs="pkg1", args=None) == ["pkg1"] + assert tu._pkg_params(pkg=None, pkgs="pkg1 pkg2 ", args=None) == [ + "pkg1", + "pkg2", + ] + assert tu._pkg_params(pkg=None, pkgs=["pkg1", "pkg2"], args=None) == [ + "pkg1", + "pkg2", + ] + + def test__pkg_params_pkg_pkgs(self): + """Test transactional_update._pkg_params with packages""" + assert tu._pkg_params(pkg="pkg1", pkgs="pkg2", args=None) == [ + "pkg1", + "pkg2", + ] + + def test__pkg_params_args(self): + """Test transactional_update._pkg_params with argumens""" + assert tu._pkg_params(pkg="pkg1", pkgs=None, args="--arg1") == [ + "--arg1", + "pkg1", + ] + assert tu._pkg_params(pkg="pkg1", pkgs=None, args="--arg1 --arg2") == [ + "--arg1", + "--arg2", + "pkg1", + ] + assert tu._pkg_params(pkg="pkg1", pkgs=None, args=["--arg1", "--arg2"]) == [ + "--arg1", + "--arg2", + "pkg1", + ] + + def test_transactional_transactional(self): + """Test transactional_update.transactional""" + matrix = (("/usr/sbin/transactional-update", True), ("", False)) + + for path_which, result in matrix: + utils_mock = {"path.which": MagicMock(return_value=path_which)} + + with patch.dict(tu.__utils__, utils_mock): + assert tu.transactional() is result + utils_mock["path.which"].assert_called_with("transactional-update") + + def test_in_transaction(self): + """Test transactional_update.in_transaction""" + matrix = ( + ("/usr/sbin/transactional-update", True, True), + ("/usr/sbin/transactional-update", False, False), + ("", True, False), + ("", False, False), + ) + + for path_which, in_chroot, result in matrix: + utils_mock = {"path.which": MagicMock(return_value=path_which)} + salt_mock = {"chroot.in_chroot": MagicMock(return_value=in_chroot)} + + with patch.dict(tu.__utils__, utils_mock): + with patch.dict(tu.__salt__, salt_mock): + assert tu.in_transaction() is result + + def test_commands_with_global_params(self): + """Test commands that only accept global params""" + for cmd in [ + "cleanup", + "cleanup_snapshots", + "cleanup_overlays", + "grub_cfg", + "bootloader", + "initrd", + "kdump", + "reboot", + "dup", + "up", + "patch", + "migration", + ]: + salt_mock = { + "cmd.run_all": MagicMock( + return_value={"stdout": "output", "retcode": 0} + ) + } + with patch.dict(tu.__salt__, salt_mock): + assert getattr(tu, cmd)() == "output" + salt_mock["cmd.run_all"].assert_called_with( + [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "--no-selfupdate", + cmd.replace("_", ".") + if cmd.startswith("grub") + else cmd.replace("_", "-"), + ] + ) + + def test_run_error(self): + """Test transactional_update.run with missing command""" + with pytest.raises(CommandExecutionError): + tu.run(None) + + def test_run_string(self): + """Test transactional_update.run with command as string""" + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(tu.__salt__, salt_mock): + assert tu.run("cmd --flag p1 p2") == "output" + salt_mock["cmd.run_all"].assert_called_with( + [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "--no-selfupdate", + "--quiet", + "run", + "cmd", + "--flag", + "p1", + "p2", + ] + ) + + def test_run_array(self): + """Test transactional_update.run with command as array""" + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(tu.__salt__, salt_mock): + assert tu.run(["cmd", "--flag", "p1", "p2"]) == "output" + salt_mock["cmd.run_all"].assert_called_with( + [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "--no-selfupdate", + "--quiet", + "run", + "cmd", + "--flag", + "p1", + "p2", + ] + ) + + def test_pkg_commands(self): + """Test transactional_update.pkg_* commands""" + for cmd in ["pkg_install", "pkg_remove", "pkg_update"]: + salt_mock = { + "cmd.run_all": MagicMock( + return_value={"stdout": "output", "retcode": 0} + ) + } + with patch.dict(tu.__salt__, salt_mock): + assert getattr(tu, cmd)("pkg1", "pkg2 pkg3", "--arg") == "output" + salt_mock["cmd.run_all"].assert_called_with( + [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "--no-selfupdate", + "pkg", + cmd.replace("pkg_", ""), + "--arg", + "pkg1", + "pkg2", + "pkg3", + ] + ) + + def test_rollback_error(self): + """Test transactional_update.rollback with wrong snapshot""" + with pytest.raises(CommandExecutionError): + tu.rollback("error") + + def test_rollback_default(self): + """Test transactional_update.rollback with default snapshot""" + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(tu.__salt__, salt_mock): + assert tu.rollback() == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["transactional-update", "rollback"] + ) + + def test_rollback_snapshot_number(self): + """Test transactional_update.rollback with numeric snapshot""" + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(tu.__salt__, salt_mock): + assert tu.rollback(10) == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["transactional-update", "rollback", 10] + ) + + def test_rollback_snapshot_str(self): + """Test transactional_update.rollback with string snapshot""" + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(tu.__salt__, salt_mock): + assert tu.rollback("10") == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["transactional-update", "rollback", "10"] + ) + + def test_rollback_last(self): + """Test transactional_update.rollback with last snapshot""" + salt_mock = { + "cmd.run_all": MagicMock(return_value={"stdout": "output", "retcode": 0}) + } + with patch.dict(tu.__salt__, salt_mock): + assert tu.rollback("last") == "output" + salt_mock["cmd.run_all"].assert_called_with( + ["transactional-update", "rollback", "last"] + ) + + def test_pending_transaction(self): + """Test transactional_update.pending_transaction""" + matrix = ( + (False, ["1", "2+", "3-"], True), + (False, ["1", "2-", "3+"], True), + (False, ["1", "2", "3*"], False), + ) + + for in_transaction, snapshots, result in matrix: + salt_mock = { + "cmd.run_all": MagicMock( + return_value={"stdout": snapshots, "retcode": 0} + ) + } + + tu_in_transaction = "salt.modules.transactional_update.in_transaction" + with patch(tu_in_transaction) as in_transaction_mock: + in_transaction_mock.return_value = in_transaction + with patch.dict(tu.__salt__, salt_mock): + assert tu.pending_transaction() is result + salt_mock["cmd.run_all"].assert_called_with( + ["snapper", "--no-dbus", "list", "--columns", "number"] + ) + + def test_pending_transaction_in_transaction(self): + """Test transactional_update.pending_transaction when in transaction""" + tu_in_transaction = "salt.modules.transactional_update.in_transaction" + with patch(tu_in_transaction) as in_transaction_mock: + in_transaction_mock.return_value = True + with pytest.raises(CommandExecutionError): + tu.pending_transaction() + + def test_call_fails_input_validation(self): + """Test transactional_update.call missing function name""" + with pytest.raises(CommandExecutionError): + tu.call("") + + @patch("tempfile.mkdtemp") + def test_call_fails_untar(self, mkdtemp): + """Test transactional_update.call when tar fails""" + mkdtemp.return_value = "/var/cache/salt/minion/tmp01" + utils_mock = { + "thin.gen_thin": MagicMock(return_value="/salt-thin.tgz"), + "files.rm_rf": MagicMock(), + } + opts_mock = {"cachedir": "/var/cache/salt/minion"} + salt_mock = { + "cmd.run": MagicMock(return_value="Error"), + "config.option": MagicMock(), + } + with patch.dict(tu.__utils__, utils_mock), patch.dict( + tu.__opts__, opts_mock + ), patch.dict(tu.__salt__, salt_mock): + assert tu.call("/chroot", "test.ping") == { + "result": False, + "comment": "Error", + } + + utils_mock["thin.gen_thin"].assert_called_once() + salt_mock["config.option"].assert_called() + salt_mock["cmd.run"].assert_called_once() + utils_mock["files.rm_rf"].assert_called_once() + + @patch("tempfile.mkdtemp") + def test_call_fails_salt_thin(self, mkdtemp): + """Test transactional_update.chroot when fails salt_thin""" + mkdtemp.return_value = "/var/cache/salt/minion/tmp01" + utils_mock = { + "thin.gen_thin": MagicMock(return_value="/salt-thin.tgz"), + "files.rm_rf": MagicMock(), + "json.find_json": MagicMock(side_effect=ValueError()), + } + opts_mock = {"cachedir": "/var/cache/salt/minion"} + salt_mock = { + "cmd.run": MagicMock(return_value=""), + "config.option": MagicMock(), + "cmd.run_all": MagicMock(return_value={"retcode": 1, "stderr": "Error"}), + } + with patch.dict(tu.__utils__, utils_mock), patch.dict( + tu.__opts__, opts_mock + ), patch.dict(tu.__salt__, salt_mock): + assert tu.call("test.ping") == {"result": False, "comment": "Error"} + + utils_mock["thin.gen_thin"].assert_called_once() + salt_mock["config.option"].assert_called() + salt_mock["cmd.run"].assert_called_once() + salt_mock["cmd.run_all"].assert_called_with( + [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "--no-selfupdate", + "--continue", + "--quiet", + "run", + "python{}".format(sys.version_info[0]), + "/var/cache/salt/minion/tmp01/salt-call", + "--metadata", + "--local", + "--log-file", + "/var/cache/salt/minion/tmp01/log", + "--cachedir", + "/var/cache/salt/minion/tmp01/cache", + "--out", + "json", + "-l", + "quiet", + "--", + "test.ping", + ] + ) + utils_mock["files.rm_rf"].assert_called_once() + + @patch("tempfile.mkdtemp") + def test_call_fails_function(self, mkdtemp): + """Test transactional_update.chroot when fails the function""" + mkdtemp.return_value = "/var/cache/salt/minion/tmp01" + utils_mock = { + "thin.gen_thin": MagicMock(return_value="/salt-thin.tgz"), + "files.rm_rf": MagicMock(), + "json.find_json": MagicMock(side_effect=ValueError()), + } + opts_mock = {"cachedir": "/var/cache/salt/minion"} + salt_mock = { + "cmd.run": MagicMock(return_value=""), + "config.option": MagicMock(), + "cmd.run_all": MagicMock( + return_value={"retcode": 0, "stdout": "Not found", "stderr": ""} + ), + } + with patch.dict(tu.__utils__, utils_mock), patch.dict( + tu.__opts__, opts_mock + ), patch.dict(tu.__salt__, salt_mock): + assert tu.call("test.ping") == {"result": False, "comment": "Not found"} + + utils_mock["thin.gen_thin"].assert_called_once() + salt_mock["config.option"].assert_called() + salt_mock["cmd.run"].assert_called_once() + salt_mock["cmd.run_all"].assert_called_with( + [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "--no-selfupdate", + "--continue", + "--quiet", + "run", + "python{}".format(sys.version_info[0]), + "/var/cache/salt/minion/tmp01/salt-call", + "--metadata", + "--local", + "--log-file", + "/var/cache/salt/minion/tmp01/log", + "--cachedir", + "/var/cache/salt/minion/tmp01/cache", + "--out", + "json", + "-l", + "quiet", + "--", + "test.ping", + ] + ) + utils_mock["files.rm_rf"].assert_called_once() + + @patch("tempfile.mkdtemp") + def test_call_success_no_reboot(self, mkdtemp): + """Test transactional_update.chroot when succeed""" + mkdtemp.return_value = "/var/cache/salt/minion/tmp01" + utils_mock = { + "thin.gen_thin": MagicMock(return_value="/salt-thin.tgz"), + "files.rm_rf": MagicMock(), + "json.find_json": MagicMock(return_value={"return": "result"}), + } + opts_mock = {"cachedir": "/var/cache/salt/minion"} + salt_mock = { + "cmd.run": MagicMock(return_value=""), + "config.option": MagicMock(), + "cmd.run_all": MagicMock(return_value={"retcode": 0, "stdout": ""}), + } + with patch.dict(tu.__utils__, utils_mock), patch.dict( + tu.__opts__, opts_mock + ), patch.dict(tu.__salt__, salt_mock): + assert tu.call("test.ping") == "result" + + utils_mock["thin.gen_thin"].assert_called_once() + salt_mock["config.option"].assert_called() + salt_mock["cmd.run"].assert_called_once() + salt_mock["cmd.run_all"].assert_called_with( + [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "--no-selfupdate", + "--continue", + "--quiet", + "run", + "python{}".format(sys.version_info[0]), + "/var/cache/salt/minion/tmp01/salt-call", + "--metadata", + "--local", + "--log-file", + "/var/cache/salt/minion/tmp01/log", + "--cachedir", + "/var/cache/salt/minion/tmp01/cache", + "--out", + "json", + "-l", + "quiet", + "--", + "test.ping", + ] + ) + utils_mock["files.rm_rf"].assert_called_once() + + @patch("salt.modules.transactional_update.reboot") + @patch("salt.modules.transactional_update.pending_transaction") + @patch("tempfile.mkdtemp") + def test_call_success_reboot(self, mkdtemp, pending_transaction, reboot): + """Test transactional_update.chroot when succeed and reboot""" + mkdtemp.return_value = "/var/cache/salt/minion/tmp01" + pending_transaction.return_value = True + utils_mock = { + "thin.gen_thin": MagicMock(return_value="/salt-thin.tgz"), + "files.rm_rf": MagicMock(), + "json.find_json": MagicMock(return_value={"return": "result"}), + } + opts_mock = {"cachedir": "/var/cache/salt/minion"} + salt_mock = { + "cmd.run": MagicMock(return_value=""), + "config.option": MagicMock(), + "cmd.run_all": MagicMock(return_value={"retcode": 0, "stdout": ""}), + } + with patch.dict(tu.__utils__, utils_mock), patch.dict( + tu.__opts__, opts_mock + ), patch.dict(tu.__salt__, salt_mock): + assert ( + tu.call("transactional_update.dup", activate_transaction=True) + == "result" + ) + + utils_mock["thin.gen_thin"].assert_called_once() + salt_mock["config.option"].assert_called() + salt_mock["cmd.run"].assert_called_once() + salt_mock["cmd.run_all"].assert_called_with( + [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "--no-selfupdate", + "--continue", + "--quiet", + "run", + "python{}".format(sys.version_info[0]), + "/var/cache/salt/minion/tmp01/salt-call", + "--metadata", + "--local", + "--log-file", + "/var/cache/salt/minion/tmp01/log", + "--cachedir", + "/var/cache/salt/minion/tmp01/cache", + "--out", + "json", + "-l", + "quiet", + "--", + "transactional_update.dup", + ] + ) + utils_mock["files.rm_rf"].assert_called_once() + pending_transaction.assert_called_once() + reboot.assert_called_once() + + @patch("tempfile.mkdtemp") + def test_call_success_parameters(self, mkdtemp): + """Test transactional_update.chroot when succeed with parameters""" + mkdtemp.return_value = "/var/cache/salt/minion/tmp01" + utils_mock = { + "thin.gen_thin": MagicMock(return_value="/salt-thin.tgz"), + "files.rm_rf": MagicMock(), + "json.find_json": MagicMock(return_value={"return": "result"}), + } + opts_mock = {"cachedir": "/var/cache/salt/minion"} + salt_mock = { + "cmd.run": MagicMock(return_value=""), + "config.option": MagicMock(), + "cmd.run_all": MagicMock(return_value={"retcode": 0, "stdout": ""}), + } + with patch.dict(tu.__utils__, utils_mock), patch.dict( + tu.__opts__, opts_mock + ), patch.dict(tu.__salt__, salt_mock): + assert tu.call("module.function", key="value") == "result" + + utils_mock["thin.gen_thin"].assert_called_once() + salt_mock["config.option"].assert_called() + salt_mock["cmd.run"].assert_called_once() + salt_mock["cmd.run_all"].assert_called_with( + [ + "transactional-update", + "--non-interactive", + "--drop-if-no-change", + "--no-selfupdate", + "--continue", + "--quiet", + "run", + "python{}".format(sys.version_info[0]), + "/var/cache/salt/minion/tmp01/salt-call", + "--metadata", + "--local", + "--log-file", + "/var/cache/salt/minion/tmp01/log", + "--cachedir", + "/var/cache/salt/minion/tmp01/cache", + "--out", + "json", + "-l", + "quiet", + "--", + "module.function", + "key=value", + ] + ) + utils_mock["files.rm_rf"].assert_called_once() + + @patch("salt.modules.transactional_update._create_and_execute_salt_state") + @patch("salt.client.ssh.state.SSHHighState") + @patch("salt.fileclient.get_file_client") + @patch("salt.utils.state.get_sls_opts") + def test_sls( + self, + get_sls_opts, + get_file_client, + SSHHighState, + _create_and_execute_salt_state, + ): + """Test transactional_update.sls""" + SSHHighState.return_value = SSHHighState + SSHHighState.render_highstate.return_value = (None, []) + SSHHighState.state.reconcile_extend.return_value = (None, []) + SSHHighState.state.requisite_in.return_value = (None, []) + SSHHighState.state.verify_high.return_value = [] + + _create_and_execute_salt_state.return_value = "result" + opts_mock = { + "hash_type": "md5", + } + get_sls_opts.return_value = opts_mock + with patch.dict(tu.__opts__, opts_mock): + assert tu.sls("module") == "result" + _create_and_execute_salt_state.assert_called_once() + + @patch("salt.modules.transactional_update._create_and_execute_salt_state") + @patch("salt.client.ssh.state.SSHHighState") + @patch("salt.fileclient.get_file_client") + @patch("salt.utils.state.get_sls_opts") + def test_highstate( + self, + get_sls_opts, + get_file_client, + SSHHighState, + _create_and_execute_salt_state, + ): + """Test transactional_update.highstage""" + SSHHighState.return_value = SSHHighState + + _create_and_execute_salt_state.return_value = "result" + opts_mock = { + "hash_type": "md5", + } + get_sls_opts.return_value = opts_mock + with patch.dict(tu.__opts__, opts_mock): + assert tu.highstate() == "result" + _create_and_execute_salt_state.assert_called_once() + + @patch("salt.modules.transactional_update._create_and_execute_salt_state") + @patch("salt.client.ssh.state.SSHState") + @patch("salt.utils.state.get_sls_opts") + def test_single(self, get_sls_opts, SSHState, _create_and_execute_salt_state): + """Test transactional_update.single""" + SSHState.return_value = SSHState + SSHState.verify_data.return_value = None + + _create_and_execute_salt_state.return_value = "result" + opts_mock = { + "hash_type": "md5", + } + get_sls_opts.return_value = opts_mock + with patch.dict(tu.__opts__, opts_mock): + assert tu.single("pkg.installed", name="emacs") == "result" + _create_and_execute_salt_state.assert_called_once() -- 2.29.2