Script 'mail_helper' called by obssrc Hello community, here is the log from the commit of package salt for openSUSE:Factory checked in at 2022-10-10 18:43:22 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Comparing /work/SRC/openSUSE:Factory/salt (Old) and /work/SRC/openSUSE:Factory/.salt.new.2275 (New) ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Package is "salt" Mon Oct 10 18:43:22 2022 rev:131 rq:1008560 version:3004 Changes: -------- --- /work/SRC/openSUSE:Factory/salt/salt.changes 2022-07-12 11:12:22.719693673 +0200 +++ /work/SRC/openSUSE:Factory/.salt.new.2275/salt.changes 2022-10-10 18:43:27.382725800 +0200 @@ -1,0 +2,40 @@ +Thu Oct 6 10:10:16 UTC 2022 - Pablo Su??rez Hern??ndez <[email protected]> + +- Make pass renderer configurable and fix detected issues +- Workaround fopen line buffering for binary mode (bsc#1203834) +- Handle non-UTF-8 bytes in core grains generation (bsc#1202165) +- Fix Syndic authentication errors (bsc#1199562) + +- Added: + * make-pass-renderer-configurable-other-fixes-532.patch + * ignore-non-utf8-characters-while-reading-files-with-.patch + * fopen-workaround-bad-buffering-for-binary-mode-563.patch + * backport-syndic-auth-fixes.patch + +------------------------------------------------------------------- +Thu Sep 1 12:43:39 UTC 2022 - Victor Zhestkov <[email protected]> + +- Add Amazon EC2 detection for virtual grains (bsc#1195624) +- Fix the regression in schedule module releasded in 3004 (bsc#1202631) +- Fix state.apply in test mode with file state module + on user/group checking (bsc#1202167) +- Change the delimeters to prevent possible tracebacks + on some packages with dpkg_lowpkg +- Make zypperpkg to retry if RPM lock is temporarily unavailable (bsc#1200596) + +- Added: + * fix-the-regression-in-schedule-module-releasded-in-3.patch + * retry-if-rpm-lock-is-temporarily-unavailable-547.patch + * change-the-delimeters-to-prevent-possible-tracebacks.patch + * add-amazon-ec2-detection-for-virtual-grains-bsc-1195.patch + * fix-state.apply-in-test-mode-with-file-state-module-.patch + +------------------------------------------------------------------- +Tue Jul 12 12:37:51 UTC 2022 - Alexander Graul <[email protected]> + +- Fix test_ipc unit test + +- Added: + * fix-test_ipc-unit-tests.patch + +------------------------------------------------------------------- New: ---- add-amazon-ec2-detection-for-virtual-grains-bsc-1195.patch backport-syndic-auth-fixes.patch change-the-delimeters-to-prevent-possible-tracebacks.patch fix-state.apply-in-test-mode-with-file-state-module-.patch fix-test_ipc-unit-tests.patch fix-the-regression-in-schedule-module-releasded-in-3.patch fopen-workaround-bad-buffering-for-binary-mode-563.patch ignore-non-utf8-characters-while-reading-files-with-.patch make-pass-renderer-configurable-other-fixes-532.patch retry-if-rpm-lock-is-temporarily-unavailable-547.patch ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ Other differences: ------------------ ++++++ salt.spec ++++++ --- /var/tmp/diff_new_pack.oKYii9/_old 2022-10-10 18:43:29.286729898 +0200 +++ /var/tmp/diff_new_pack.oKYii9/_new 2022-10-10 18:43:29.294729915 +0200 @@ -330,6 +330,26 @@ Patch91: fix-jinja2-contextfuntion-base-on-version-bsc-119874.patch # PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/pull/62209 Patch92: add-support-for-gpgautoimport-539.patch +# PATCH-FIX_OPENSUSE: https://github.com/openSUSE/salt/commit/2b486d0484c51509e9972e581d97655f4f87852e +Patch93: fix-test_ipc-unit-tests.patch +# PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/pull/62204 +Patch94: retry-if-rpm-lock-is-temporarily-unavailable-547.patch +# PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/pull/62519 +Patch95: change-the-delimeters-to-prevent-possible-tracebacks.patch +# PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/pull/61847 +Patch96: fix-state.apply-in-test-mode-with-file-state-module-.patch +# PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/pull/61423 +Patch97: fix-the-regression-in-schedule-module-releasded-in-3.patch +# PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/pull/62539 +Patch98: add-amazon-ec2-detection-for-virtual-grains-bsc-1195.patch +# PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/commit/643bd4b572ca97466e085ecd1d84da45b1684332 +Patch99: backport-syndic-auth-fixes.patch +# PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/pull/62633 +Patch100: ignore-non-utf8-characters-while-reading-files-with-.patch +# PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/pull/62817 +Patch101: fopen-workaround-bad-buffering-for-binary-mode-563.patch +# PATCH-FIX_UPSTREAM: https://github.com/saltstack/salt/pull/62120 +Patch102: make-pass-renderer-configurable-other-fixes-532.patch BuildRoot: %{_tmppath}/%{name}-%{version}-build BuildRequires: logrotate ++++++ _lastrevision ++++++ --- /var/tmp/diff_new_pack.oKYii9/_old 2022-10-10 18:43:29.358730053 +0200 +++ /var/tmp/diff_new_pack.oKYii9/_new 2022-10-10 18:43:29.362730062 +0200 @@ -1,3 +1,3 @@ -e07459bfeea39239f6b446f40f6502e72dea488f +e04acec89d982e3bd465742afffe6ae5ec82620b (No newline at EOF) ++++++ _service ++++++ --- /var/tmp/diff_new_pack.oKYii9/_old 2022-10-10 18:43:29.378730096 +0200 +++ /var/tmp/diff_new_pack.oKYii9/_new 2022-10-10 18:43:29.382730105 +0200 @@ -3,7 +3,7 @@ <param name="url">https://github.com/openSUSE/salt-packaging.git</param> <param name="subdir">salt</param> <param name="filename">package</param> - <param name="revision">3004</param> + <param name="revision">release/3004</param> <param name="scm">git</param> </service> <service name="extract_file" mode="disabled"> ++++++ add-amazon-ec2-detection-for-virtual-grains-bsc-1195.patch ++++++ >From 77e90c4925a4268c5975cf1ce0bb0e4c457618c1 Mon Sep 17 00:00:00 2001 From: Victor Zhestkov <[email protected]> Date: Thu, 1 Sep 2022 14:46:24 +0300 Subject: [PATCH] Add Amazon EC2 detection for virtual grains (bsc#1195624) * Add ignore_retcode to quiet run functions * Implement Amazon EC2 detection for virtual grains * Add test for virtual grain detection of Amazon EC2 * Also detect the product of Amazon EC2 instance * Add changelog entry --- changelog/62539.added | 1 + salt/grains/core.py | 18 ++++ salt/modules/cmdmod.py | 4 + tests/pytests/unit/grains/test_core.py | 117 +++++++++++++++++++++++++ 4 files changed, 140 insertions(+) create mode 100644 changelog/62539.added diff --git a/changelog/62539.added b/changelog/62539.added new file mode 100644 index 0000000000..5f402d61c2 --- /dev/null +++ b/changelog/62539.added @@ -0,0 +1 @@ +Implementation of Amazon EC2 instance detection and setting `virtual_subtype` grain accordingly including the product if possible to identify. diff --git a/salt/grains/core.py b/salt/grains/core.py index c5d996d1bb..9530a43fc5 100644 --- a/salt/grains/core.py +++ b/salt/grains/core.py @@ -1173,6 +1173,24 @@ def _virtual(osdata): if grains.get("virtual_subtype") and grains["virtual"] == "physical": grains["virtual"] = "virtual" + # Try to detect if the instance is running on Amazon EC2 + if grains["virtual"] in ("qemu", "kvm", "xen"): + dmidecode = salt.utils.path.which("dmidecode") + if dmidecode: + ret = __salt__["cmd.run_all"]( + [dmidecode, "-t", "system"], ignore_retcode=True + ) + output = ret["stdout"] + if "Manufacturer: Amazon EC2" in output: + grains["virtual_subtype"] = "Amazon EC2" + product = re.match( + r".*Product Name: ([^\r\n]*).*", output, flags=re.DOTALL + ) + if product: + grains["virtual_subtype"] = "Amazon EC2 ({})".format(product[1]) + elif re.match(r".*Version: [^\r\n]+\.amazon.*", output, flags=re.DOTALL): + grains["virtual_subtype"] = "Amazon EC2" + for command in failed_commands: log.info( "Although '%s' was found in path, the current user " diff --git a/salt/modules/cmdmod.py b/salt/modules/cmdmod.py index 61b328b13b..cd42e2cda0 100644 --- a/salt/modules/cmdmod.py +++ b/salt/modules/cmdmod.py @@ -907,6 +907,7 @@ def _run_quiet( success_retcodes=None, success_stdout=None, success_stderr=None, + ignore_retcode=None, ): """ Helper for running commands quietly for minion startup @@ -933,6 +934,7 @@ def _run_quiet( success_retcodes=success_retcodes, success_stdout=success_stdout, success_stderr=success_stderr, + ignore_retcode=ignore_retcode, )["stdout"] @@ -955,6 +957,7 @@ def _run_all_quiet( success_retcodes=None, success_stdout=None, success_stderr=None, + ignore_retcode=None, ): """ @@ -987,6 +990,7 @@ def _run_all_quiet( success_retcodes=success_retcodes, success_stdout=success_stdout, success_stderr=success_stderr, + ignore_retcode=ignore_retcode, ) diff --git a/tests/pytests/unit/grains/test_core.py b/tests/pytests/unit/grains/test_core.py index bc3947fa1b..84dd97d62f 100644 --- a/tests/pytests/unit/grains/test_core.py +++ b/tests/pytests/unit/grains/test_core.py @@ -2720,3 +2720,120 @@ def test_get_server_id(): with patch.dict(core.__opts__, {"id": "otherid"}): assert core.get_server_id() != expected + + [email protected]_unless_on_linux +def test_virtual_set_virtual_ec2(): + osdata = {} + + ( + osdata["kernel"], + osdata["nodename"], + osdata["kernelrelease"], + osdata["kernelversion"], + osdata["cpuarch"], + _, + ) = platform.uname() + + which_mock = MagicMock( + side_effect=[ + # Check with virt-what + "/usr/sbin/virt-what", + "/usr/sbin/virt-what", + None, + "/usr/sbin/dmidecode", + # Check with systemd-detect-virt + None, + "/usr/bin/systemd-detect-virt", + None, + "/usr/sbin/dmidecode", + # Check with systemd-detect-virt when no dmidecode available + None, + "/usr/bin/systemd-detect-virt", + None, + None, + ] + ) + cmd_run_all_mock = MagicMock( + side_effect=[ + # Check with virt-what + {"retcode": 0, "stderr": "", "stdout": "xen"}, + { + "retcode": 0, + "stderr": "", + "stdout": "\n".join( + [ + "dmidecode 3.2", + "Getting SMBIOS data from sysfs.", + "SMBIOS 2.7 present.", + "", + "Handle 0x0100, DMI type 1, 27 bytes", + "System Information", + " Manufacturer: Xen", + " Product Name: HVM domU", + " Version: 4.11.amazon", + " Serial Number: 12345678-abcd-4321-dcba-0123456789ab", + " UUID: 01234567-dcba-1234-abcd-abcdef012345", + " Wake-up Type: Power Switch", + " SKU Number: Not Specified", + " Family: Not Specified", + "", + "Handle 0x2000, DMI type 32, 11 bytes", + "System Boot Information", + " Status: No errors detected", + ] + ), + }, + # Check with systemd-detect-virt + {"retcode": 0, "stderr": "", "stdout": "kvm"}, + { + "retcode": 0, + "stderr": "", + "stdout": "\n".join( + [ + "dmidecode 3.2", + "Getting SMBIOS data from sysfs.", + "SMBIOS 2.7 present.", + "", + "Handle 0x0001, DMI type 1, 27 bytes", + "System Information", + " Manufacturer: Amazon EC2", + " Product Name: m5.large", + " Version: Not Specified", + " Serial Number: 01234567-dcba-1234-abcd-abcdef012345", + " UUID: 12345678-abcd-4321-dcba-0123456789ab", + " Wake-up Type: Power Switch", + " SKU Number: Not Specified", + " Family: Not Specified", + ] + ), + }, + # Check with systemd-detect-virt when no dmidecode available + {"retcode": 0, "stderr": "", "stdout": "kvm"}, + ] + ) + + with patch("salt.utils.path.which", which_mock), patch.dict( + core.__salt__, + { + "cmd.run": salt.modules.cmdmod.run, + "cmd.run_all": cmd_run_all_mock, + "cmd.retcode": salt.modules.cmdmod.retcode, + "smbios.get": salt.modules.smbios.get, + }, + ): + + virtual_grains = core._virtual(osdata.copy()) + + assert virtual_grains["virtual"] == "xen" + assert virtual_grains["virtual_subtype"] == "Amazon EC2" + + virtual_grains = core._virtual(osdata.copy()) + + assert virtual_grains["virtual"] == "kvm" + assert virtual_grains["virtual_subtype"] == "Amazon EC2 (m5.large)" + + virtual_grains = core._virtual(osdata.copy()) + + assert virtual_grains["virtual"] == "kvm" + assert "virtual_subtype" not in virtual_grains -- 2.37.2 ++++++ backport-syndic-auth-fixes.patch ++++++ >From 54ab69e74beb83710d0bf6049039d13e260d5517 Mon Sep 17 00:00:00 2001 From: Alexander Graul <[email protected]> Date: Tue, 13 Sep 2022 11:26:21 +0200 Subject: [PATCH] Backport Syndic auth fixes [3004.2] Syndic Fixes (cherry picked from commit 643bd4b572ca97466e085ecd1d84da45b1684332) Co-authored-by: Megan Wilhite <[email protected]> --- changelog/61868.fixed | 1 + salt/transport/mixins/auth.py | 2 +- salt/transport/tcp.py | 2 +- salt/transport/zeromq.py | 2 +- tests/pytests/unit/transport/test_tcp.py | 149 +++++++++++++++++++- tests/pytests/unit/transport/test_zeromq.py | 73 +++++++++- 6 files changed, 224 insertions(+), 5 deletions(-) create mode 100644 changelog/61868.fixed diff --git a/changelog/61868.fixed b/changelog/61868.fixed new file mode 100644 index 0000000000..0169c48e99 --- /dev/null +++ b/changelog/61868.fixed @@ -0,0 +1 @@ +Make sure the correct key is being used when verifying or validating communication, eg. when a Salt syndic is involved use syndic_master.pub and when a Salt minion is involved use minion_master.pub. diff --git a/salt/transport/mixins/auth.py b/salt/transport/mixins/auth.py index 1e2e8e6b7b..e5c6a5345f 100644 --- a/salt/transport/mixins/auth.py +++ b/salt/transport/mixins/auth.py @@ -43,7 +43,7 @@ class AESPubClientMixin: ) # Verify that the signature is valid - master_pubkey_path = os.path.join(self.opts["pki_dir"], "minion_master.pub") + master_pubkey_path = os.path.join(self.opts["pki_dir"], self.auth.mpub) if not salt.crypt.verify_signature( master_pubkey_path, payload["load"], payload.get("sig") ): diff --git a/salt/transport/tcp.py b/salt/transport/tcp.py index f00b3c40eb..2821be82c7 100644 --- a/salt/transport/tcp.py +++ b/salt/transport/tcp.py @@ -295,7 +295,7 @@ class AsyncTCPReqChannel(salt.transport.client.ReqChannel): signed_msg = pcrypt.loads(ret[dictkey]) # Validate the master's signature. - master_pubkey_path = os.path.join(self.opts["pki_dir"], "minion_master.pub") + master_pubkey_path = os.path.join(self.opts["pki_dir"], self.auth.mpub) if not salt.crypt.verify_signature( master_pubkey_path, signed_msg["data"], signed_msg["sig"] ): diff --git a/salt/transport/zeromq.py b/salt/transport/zeromq.py index aa06298ee1..8199378239 100644 --- a/salt/transport/zeromq.py +++ b/salt/transport/zeromq.py @@ -255,7 +255,7 @@ class AsyncZeroMQReqChannel(salt.transport.client.ReqChannel): signed_msg = pcrypt.loads(ret[dictkey]) # Validate the master's signature. - master_pubkey_path = os.path.join(self.opts["pki_dir"], "minion_master.pub") + master_pubkey_path = os.path.join(self.opts["pki_dir"], self.auth.mpub) if not salt.crypt.verify_signature( master_pubkey_path, signed_msg["data"], signed_msg["sig"] ): diff --git a/tests/pytests/unit/transport/test_tcp.py b/tests/pytests/unit/transport/test_tcp.py index 3b6e175472..e41edcc37e 100644 --- a/tests/pytests/unit/transport/test_tcp.py +++ b/tests/pytests/unit/transport/test_tcp.py @@ -1,13 +1,53 @@ import contextlib +import os import socket import attr import pytest import salt.exceptions +import salt.transport.mixins.auth import salt.transport.tcp from salt.ext.tornado import concurrent, gen, ioloop from saltfactories.utils.ports import get_unused_localhost_port -from tests.support.mock import MagicMock, patch +from tests.support.mock import MagicMock, PropertyMock, create_autospec, patch + + [email protected] +def fake_keys(): + with patch("salt.crypt.AsyncAuth.get_keys", autospec=True): + yield + + [email protected] +def fake_crypto(): + with patch("salt.transport.tcp.PKCS1_OAEP", create=True) as fake_crypto: + yield fake_crypto + + [email protected] +def fake_authd(): + @salt.ext.tornado.gen.coroutine + def return_nothing(): + raise salt.ext.tornado.gen.Return() + + with patch( + "salt.crypt.AsyncAuth.authenticated", new_callable=PropertyMock + ) as mock_authed, patch( + "salt.crypt.AsyncAuth.authenticate", + autospec=True, + return_value=return_nothing(), + ), patch( + "salt.crypt.AsyncAuth.gen_token", autospec=True, return_value=42 + ): + mock_authed.return_value = False + yield + + [email protected] +def fake_crypticle(): + with patch("salt.crypt.Crypticle") as fake_crypticle: + fake_crypticle.generate_key_string.return_value = "fakey fake" + yield fake_crypticle @pytest.fixture @@ -405,3 +445,110 @@ def test_client_reconnect_backoff(client_socket): client.io_loop.run_sync(client._connect) finally: client.close() + + +async def test_when_async_req_channel_with_syndic_role_should_use_syndic_master_pub_file_to_verify_master_sig( + fake_keys, fake_crypto, fake_crypticle +): + # Syndics use the minion pki dir, but they also create a syndic_master.pub + # file for comms with the Salt master + expected_pubkey_path = os.path.join("/etc/salt/pki/minion", "syndic_master.pub") + fake_crypto.new.return_value.decrypt.return_value = "decrypted_return_value" + mockloop = MagicMock() + opts = { + "master_uri": "tcp://127.0.0.1:4506", + "interface": "127.0.0.1", + "ret_port": 4506, + "ipv6": False, + "sock_dir": ".", + "pki_dir": "/etc/salt/pki/minion", + "id": "syndic", + "__role": "syndic", + "keysize": 4096, + } + client = salt.transport.tcp.AsyncTCPReqChannel(opts, io_loop=mockloop) + + dictkey = "pillar" + target = "minion" + + # Mock auth and message client. + client.auth._authenticate_future = MagicMock() + client.auth._authenticate_future.done.return_value = True + client.auth._authenticate_future.exception.return_value = None + client.auth._crypticle = MagicMock() + client.message_client = create_autospec(client.message_client) + + @salt.ext.tornado.gen.coroutine + def mocksend(msg, timeout=60, tries=3): + raise salt.ext.tornado.gen.Return({"pillar": "data", "key": "value"}) + + client.message_client.send = mocksend + + # Note the 'ver' value in 'load' does not represent the the 'version' sent + # in the top level of the transport's message. + load = { + "id": target, + "grains": {}, + "saltenv": "base", + "pillarenv": "base", + "pillar_override": True, + "extra_minion_data": {}, + "ver": "2", + "cmd": "_pillar", + } + fake_nonce = 42 + with patch( + "salt.crypt.verify_signature", autospec=True, return_value=True + ) as fake_verify, patch( + "salt.payload.loads", + autospec=True, + return_value={"key": "value", "nonce": fake_nonce, "pillar": "data"}, + ), patch( + "uuid.uuid4", autospec=True + ) as fake_uuid: + fake_uuid.return_value.hex = fake_nonce + ret = await client.crypted_transfer_decode_dictentry( + load, + dictkey="pillar", + ) + + assert fake_verify.mock_calls[0].args[0] == expected_pubkey_path + + +async def test_mixin_should_use_correct_path_when_syndic( + fake_keys, fake_authd, fake_crypticle +): + mockloop = MagicMock() + expected_pubkey_path = os.path.join("/etc/salt/pki/minion", "syndic_master.pub") + opts = { + "master_uri": "tcp://127.0.0.1:4506", + "interface": "127.0.0.1", + "ret_port": 4506, + "ipv6": False, + "sock_dir": ".", + "pki_dir": "/etc/salt/pki/minion", + "id": "syndic", + "__role": "syndic", + "keysize": 4096, + "sign_pub_messages": True, + } + + with patch( + "salt.crypt.verify_signature", autospec=True, return_value=True + ) as fake_verify, patch( + "salt.utils.msgpack.loads", + autospec=True, + return_value={"enc": "aes", "load": "", "sig": "fake_signature"}, + ): + client = salt.transport.tcp.AsyncTCPPubChannel(opts, io_loop=mockloop) + client.message_client = MagicMock() + client.message_client.on_recv.side_effect = lambda x: x(b"some_data") + await client.connect() + client.auth._crypticle = fake_crypticle + + @client.on_recv + def test_recv_function(*args, **kwargs): + ... + + await test_recv_function + assert fake_verify.mock_calls[0].args[0] == expected_pubkey_path diff --git a/tests/pytests/unit/transport/test_zeromq.py b/tests/pytests/unit/transport/test_zeromq.py index 1f0515c91a..c3093f4b19 100644 --- a/tests/pytests/unit/transport/test_zeromq.py +++ b/tests/pytests/unit/transport/test_zeromq.py @@ -23,7 +23,7 @@ import salt.utils.process import salt.utils.stringutils from salt.master import SMaster from salt.transport.zeromq import AsyncReqMessageClientPool -from tests.support.mock import MagicMock, patch +from tests.support.mock import MagicMock, create_autospec, patch try: from M2Crypto import RSA @@ -608,6 +608,7 @@ async def test_req_chan_decode_data_dict_entry_v2(pki_dir): auth = client.auth auth._crypticle = salt.crypt.Crypticle(opts, AES_KEY) client.auth = MagicMock() + client.auth.mpub = auth.mpub client.auth.authenticated = True client.auth.get_keys = auth.get_keys client.auth.crypticle.dumps = auth.crypticle.dumps @@ -672,6 +673,7 @@ async def test_req_chan_decode_data_dict_entry_v2_bad_nonce(pki_dir): auth = client.auth auth._crypticle = salt.crypt.Crypticle(opts, AES_KEY) client.auth = MagicMock() + client.auth.mpub = auth.mpub client.auth.authenticated = True client.auth.get_keys = auth.get_keys client.auth.crypticle.dumps = auth.crypticle.dumps @@ -735,6 +737,7 @@ async def test_req_chan_decode_data_dict_entry_v2_bad_signature(pki_dir): auth = client.auth auth._crypticle = salt.crypt.Crypticle(opts, AES_KEY) client.auth = MagicMock() + client.auth.mpub = auth.mpub client.auth.authenticated = True client.auth.get_keys = auth.get_keys client.auth.crypticle.dumps = auth.crypticle.dumps @@ -814,6 +817,7 @@ async def test_req_chan_decode_data_dict_entry_v2_bad_key(pki_dir): auth = client.auth auth._crypticle = salt.crypt.Crypticle(opts, AES_KEY) client.auth = MagicMock() + client.auth.mpub = auth.mpub client.auth.authenticated = True client.auth.get_keys = auth.get_keys client.auth.crypticle.dumps = auth.crypticle.dumps @@ -1273,3 +1277,70 @@ async def test_req_chan_auth_v2_new_minion_without_master_pub(pki_dir, io_loop): assert "sig" in ret ret = client.auth.handle_signin_response(signin_payload, ret) assert ret == "retry" + + +async def test_when_async_req_channel_with_syndic_role_should_use_syndic_master_pub_file_to_verify_master_sig( + pki_dir, +): + # Syndics use the minion pki dir, but they also create a syndic_master.pub + # file for comms with the Salt master + expected_pubkey_path = str(pki_dir.join("minion").join("syndic_master.pub")) + mockloop = MagicMock() + opts = { + "master_uri": "tcp://127.0.0.1:4506", + "interface": "127.0.0.1", + "ret_port": 4506, + "ipv6": False, + "sock_dir": ".", + "pki_dir": str(pki_dir.join("minion")), + "id": "syndic", + "__role": "syndic", + "keysize": 4096, + } + master_opts = dict(opts, pki_dir=str(pki_dir.join("master"))) + server = salt.transport.zeromq.ZeroMQReqServerChannel(master_opts) + client = salt.transport.zeromq.AsyncZeroMQReqChannel(opts, io_loop=mockloop) + + dictkey = "pillar" + target = "minion" + pillar_data = {"pillar1": "data1"} + + # Mock auth and message client. + client.auth._authenticate_future = MagicMock() + client.auth._authenticate_future.done.return_value = True + client.auth._authenticate_future.exception.return_value = None + client.auth._crypticle = salt.crypt.Crypticle(opts, AES_KEY) + client.message_client = create_autospec(client.message_client) + + @salt.ext.tornado.gen.coroutine + def mocksend(msg, timeout=60, tries=3): + client.message_client.msg = msg + load = client.auth.crypticle.loads(msg["load"]) + ret = server._encrypt_private( + pillar_data, dictkey, target, nonce=load["nonce"], sign_messages=True + ) + raise salt.ext.tornado.gen.Return(ret) + + client.message_client.send = mocksend + + # Note the 'ver' value in 'load' does not represent the the 'version' sent + # in the top level of the transport's message. + load = { + "id": target, + "grains": {}, + "saltenv": "base", + "pillarenv": "base", + "pillar_override": True, + "extra_minion_data": {}, + "ver": "2", + "cmd": "_pillar", + } + with patch( + "salt.crypt.verify_signature", autospec=True, return_value=True + ) as fake_verify: + ret = await client.crypted_transfer_decode_dictentry( + load, + dictkey="pillar", + ) + + assert fake_verify.mock_calls[0].args[0] == expected_pubkey_path -- 2.37.3 ++++++ change-the-delimeters-to-prevent-possible-tracebacks.patch ++++++ >From e28385eb37932809a11ec81c81834a51e094f507 Mon Sep 17 00:00:00 2001 From: Victor Zhestkov <[email protected]> Date: Thu, 1 Sep 2022 14:42:24 +0300 Subject: [PATCH] Change the delimeters to prevent possible tracebacks on some packages with dpkg_lowpkg * Use another separator on query to dpkg-query * Fix the test test_dpkg_lowpkg::test_info --- salt/modules/dpkg_lowpkg.py | 13 ++++++++----- tests/unit/modules/test_dpkg_lowpkg.py | 4 ++-- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/salt/modules/dpkg_lowpkg.py b/salt/modules/dpkg_lowpkg.py index 2c25b1fb2a..fc93d99549 100644 --- a/salt/modules/dpkg_lowpkg.py +++ b/salt/modules/dpkg_lowpkg.py @@ -309,9 +309,8 @@ def _get_pkg_info(*packages, **kwargs): "origin:${Origin}\\n" "homepage:${Homepage}\\n" "status:${db:Status-Abbrev}\\n" - "======\\n" "description:${Description}\\n" - "------\\n'" + "\\n*/~^\\\\*\\n'" ) cmd += " {}".format(" ".join(packages)) cmd = cmd.strip() @@ -325,9 +324,13 @@ def _get_pkg_info(*packages, **kwargs): else: return ret - for pkg_info in [elm for elm in re.split(r"------", call["stdout"]) if elm.strip()]: + for pkg_info in [ + elm + for elm in re.split(r"\r?\n\*/~\^\\\*(\r?\n|)", call["stdout"]) + if elm.strip() + ]: pkg_data = {} - pkg_info, pkg_descr = re.split(r"======", pkg_info) + pkg_info, pkg_descr = pkg_info.split("\ndescription:", 1) for pkg_info_line in [ el.strip() for el in pkg_info.split(os.linesep) if el.strip() ]: @@ -344,7 +347,7 @@ def _get_pkg_info(*packages, **kwargs): if build_date: pkg_data["build_date"] = build_date pkg_data["build_date_time_t"] = build_date_t - pkg_data["description"] = pkg_descr.split(":", 1)[-1] + pkg_data["description"] = pkg_descr ret.append(pkg_data) return ret diff --git a/tests/unit/modules/test_dpkg_lowpkg.py b/tests/unit/modules/test_dpkg_lowpkg.py index d00fc46c66..a97519f489 100644 --- a/tests/unit/modules/test_dpkg_lowpkg.py +++ b/tests/unit/modules/test_dpkg_lowpkg.py @@ -290,7 +290,6 @@ class DpkgTestCase(TestCase, LoaderModuleMockMixin): "origin:", "homepage:http://tiswww.case.edu/php/chet/bash/bashtop.html", "status:ii ", - "======", "description:GNU Bourne Again SHell", " Bash is an sh-compatible command language interpreter that" " executes", @@ -307,7 +306,8 @@ class DpkgTestCase(TestCase, LoaderModuleMockMixin): " The Programmable Completion Code, by Ian Macdonald, is now" " found in", " the bash-completion package.", - "------", + "", + "*/~^\\*", # pylint: disable=W1401 ] ), } -- 2.37.2 ++++++ fix-state.apply-in-test-mode-with-file-state-module-.patch ++++++ >From ed567e5f339f7bf95d4361ac47e67427db71714c Mon Sep 17 00:00:00 2001 From: Victor Zhestkov <[email protected]> Date: Thu, 1 Sep 2022 14:44:26 +0300 Subject: [PATCH] Fix state.apply in test mode with file state module on user/group checking (bsc#1202167) * Do not fail on checking user/group in test mode * fixes saltstack/salt#61846 reporting of errors in test mode Co-authored-by: nicholasmhughes <[email protected]> * Add tests for _check_user usage Co-authored-by: nicholasmhughes <[email protected]> --- changelog/61846.fixed | 1 + salt/states/file.py | 5 ++ tests/pytests/unit/states/file/test_copy.py | 35 ++++++++++++ .../unit/states/file/test_directory.py | 55 +++++++++++++++++++ .../unit/states/file/test_filestate.py | 42 ++++++++++++++ .../pytests/unit/states/file/test_managed.py | 31 +++++++++++ 6 files changed, 169 insertions(+) create mode 100644 changelog/61846.fixed diff --git a/changelog/61846.fixed b/changelog/61846.fixed new file mode 100644 index 0000000000..c4024efe9f --- /dev/null +++ b/changelog/61846.fixed @@ -0,0 +1 @@ +Fix the reporting of errors for file.directory in test mode diff --git a/salt/states/file.py b/salt/states/file.py index a6288025e5..39cf83b78e 100644 --- a/salt/states/file.py +++ b/salt/states/file.py @@ -379,6 +379,11 @@ def _check_user(user, group): gid = __salt__["file.group_to_gid"](group) if gid == "": err += "Group {} is not available".format(group) + if err and __opts__["test"]: + # Write the warning with error message, but prevent failing, + # in case of applying the state in test mode. + log.warning(err) + return "" return err diff --git a/tests/pytests/unit/states/file/test_copy.py b/tests/pytests/unit/states/file/test_copy.py index ce7161f02d..a11adf5ae0 100644 --- a/tests/pytests/unit/states/file/test_copy.py +++ b/tests/pytests/unit/states/file/test_copy.py @@ -205,3 +205,38 @@ def test_copy(tmp_path): ) res = filestate.copy_(name, source, group=group, preserve=False) assert res == ret + + +def test_copy_test_mode_user_group_not_present(): + """ + Test file copy in test mode with no user or group existing + """ + source = "/tmp/src_copy_no_user_group_test_mode" + filename = "/tmp/copy_no_user_group_test_mode" + with patch.dict( + filestate.__salt__, + { + "file.group_to_gid": MagicMock(side_effect=["1234", "", ""]), + "file.user_to_uid": MagicMock(side_effect=["", "4321", ""]), + "file.get_mode": MagicMock(return_value="0644"), + }, + ), patch.dict(filestate.__opts__, {"test": True}), patch.object( + os.path, "exists", return_value=True + ): + ret = filestate.copy_( + source, filename, group="nonexistinggroup", user="nonexistinguser" + ) + assert ret["result"] is not False + assert "is not available" not in ret["comment"] + + ret = filestate.copy_( + source, filename, group="nonexistinggroup", user="nonexistinguser" + ) + assert ret["result"] is not False + assert "is not available" not in ret["comment"] + + ret = filestate.copy_( + source, filename, group="nonexistinggroup", user="nonexistinguser" + ) + assert ret["result"] is not False + assert "is not available" not in ret["comment"] diff --git a/tests/pytests/unit/states/file/test_directory.py b/tests/pytests/unit/states/file/test_directory.py index 0e15e1d3ca..1287609c6a 100644 --- a/tests/pytests/unit/states/file/test_directory.py +++ b/tests/pytests/unit/states/file/test_directory.py @@ -291,3 +291,58 @@ def test_directory(): assert ( filestate.directory(name, user=user, group=group) == ret ) + + +def test_directory_test_mode_user_group_not_present(): + name = "/etc/testdir" + user = "salt" + group = "saltstack" + if salt.utils.platform.is_windows(): + name = name.replace("/", "\\") + + ret = { + "name": name, + "result": None, + "comment": "", + "changes": {name: {"directory": "new"}}, + } + + if salt.utils.platform.is_windows(): + comt = 'The directory "{}" will be changed' "".format(name) + else: + comt = "The following files will be changed:\n{}:" " directory - new\n".format( + name + ) + ret["comment"] = comt + + mock_f = MagicMock(return_value=False) + mock_uid = MagicMock( + side_effect=[ + "", + "U12", + "", + ] + ) + mock_gid = MagicMock( + side_effect=[ + "G12", + "", + "", + ] + ) + mock_error = CommandExecutionError + with patch.dict( + filestate.__salt__, + { + "file.user_to_uid": mock_uid, + "file.group_to_gid": mock_gid, + "file.stats": mock_f, + }, + ), patch("salt.utils.win_dacl.get_sid", mock_error), patch.object( + os.path, "isdir", mock_f + ), patch.dict( + filestate.__opts__, {"test": True} + ): + assert filestate.directory(name, user=user, group=group) == ret + assert filestate.directory(name, user=user, group=group) == ret + assert filestate.directory(name, user=user, group=group) == ret diff --git a/tests/pytests/unit/states/file/test_filestate.py b/tests/pytests/unit/states/file/test_filestate.py index 2f9f369fb2..c373cb3449 100644 --- a/tests/pytests/unit/states/file/test_filestate.py +++ b/tests/pytests/unit/states/file/test_filestate.py @@ -577,3 +577,45 @@ def test_mod_run_check_cmd(): assert filestate.mod_run_check_cmd(cmd, filename) == ret assert filestate.mod_run_check_cmd(cmd, filename) + + +def test_recurse_test_mode_user_group_not_present(): + """ + Test file recurse in test mode with no user or group existing + """ + filename = "/tmp/recurse_no_user_group_test_mode" + source = "salt://tmp/src_recurse_no_user_group_test_mode" + mock_l = MagicMock(return_value=[]) + mock_emt = MagicMock(return_value=["tmp/src_recurse_no_user_group_test_mode"]) + with patch.dict( + filestate.__salt__, + { + "file.group_to_gid": MagicMock(side_effect=["1234", "", ""]), + "file.user_to_uid": MagicMock(side_effect=["", "4321", ""]), + "file.get_mode": MagicMock(return_value="0644"), + "file.source_list": MagicMock(return_value=[source, ""]), + "cp.list_master_dirs": mock_emt, + "cp.list_master": mock_l, + }, + ), patch.dict(filestate.__opts__, {"test": True}), patch.object( + os.path, "exists", return_value=True + ), patch.object( + os.path, "isdir", return_value=True + ): + ret = filestate.recurse( + filename, source, group="nonexistinggroup", user="nonexistinguser" + ) + assert ret["result"] is not False + assert "is not available" not in ret["comment"] + + ret = filestate.recurse( + filename, source, group="nonexistinggroup", user="nonexistinguser" + ) + assert ret["result"] is not False + assert "is not available" not in ret["comment"] + + ret = filestate.recurse( + filename, source, group="nonexistinggroup", user="nonexistinguser" + ) + assert ret["result"] is not False + assert "is not available" not in ret["comment"] diff --git a/tests/pytests/unit/states/file/test_managed.py b/tests/pytests/unit/states/file/test_managed.py index 9d9fb17717..0b341e09a9 100644 --- a/tests/pytests/unit/states/file/test_managed.py +++ b/tests/pytests/unit/states/file/test_managed.py @@ -373,3 +373,34 @@ def test_managed(): filestate.managed(name, user=user, group=group) == ret ) + + +def test_managed_test_mode_user_group_not_present(): + """ + Test file managed in test mode with no user or group existing + """ + filename = "/tmp/managed_no_user_group_test_mode" + with patch.dict( + filestate.__salt__, + { + "file.group_to_gid": MagicMock(side_effect=["1234", "", ""]), + "file.user_to_uid": MagicMock(side_effect=["", "4321", ""]), + }, + ), patch.dict(filestate.__opts__, {"test": True}): + ret = filestate.managed( + filename, group="nonexistinggroup", user="nonexistinguser" + ) + assert ret["result"] is not False + assert "is not available" not in ret["comment"] + + ret = filestate.managed( + filename, group="nonexistinggroup", user="nonexistinguser" + ) + assert ret["result"] is not False + assert "is not available" not in ret["comment"] + + ret = filestate.managed( + filename, group="nonexistinggroup", user="nonexistinguser" + ) + assert ret["result"] is not False + assert "is not available" not in ret["comment"] -- 2.37.2 ++++++ fix-test_ipc-unit-tests.patch ++++++ >From 61d9b5e4ceaa0f5feb7fc364c9089cb624006812 Mon Sep 17 00:00:00 2001 From: Alexander Graul <[email protected]> Date: Tue, 12 Jul 2022 14:02:58 +0200 Subject: [PATCH] Fix test_ipc unit tests --- tests/unit/transport/test_ipc.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/unit/transport/test_ipc.py b/tests/unit/transport/test_ipc.py index 79b49f9406..7177b7f6c4 100644 --- a/tests/unit/transport/test_ipc.py +++ b/tests/unit/transport/test_ipc.py @@ -107,8 +107,8 @@ class IPCMessagePubSubCase(salt.ext.tornado.testing.AsyncTestCase): self.stop() # Now let both waiting data at once - client1.read_async(handler) - client2.read_async(handler) + client1.read_async() + client2.read_async() self.pub_channel.publish("TEST") self.wait() self.assertEqual(len(call_cnt), 2) @@ -150,7 +150,7 @@ class IPCMessagePubSubCase(salt.ext.tornado.testing.AsyncTestCase): pass try: - ret1 = yield client1.read_async(handler) + ret1 = yield client1.read_async() self.wait() except StreamClosedError as ex: assert False, "StreamClosedError was raised inside the Future" -- 2.36.1 ++++++ fix-the-regression-in-schedule-module-releasded-in-3.patch ++++++ ++++ 821 lines (skipped) ++++++ fopen-workaround-bad-buffering-for-binary-mode-563.patch ++++++ >From 6c1c81aba71711632a14b725426077f9183065e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Su=C3=A1rez=20Hern=C3=A1ndez?= <[email protected]> Date: Thu, 6 Oct 2022 10:55:50 +0100 Subject: [PATCH] fopen: Workaround bad buffering for binary mode (#563) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lot of code assumes Python 2.x behavior for buffering, in which 1 is a special value meaning line buffered. Python 3 makes this value unusable, so fallback to the default buffering size, and report these calls to be fixed. Fixes: https://github.com/saltstack/salt/issues/57584 Do not drop buffering from kwargs to avoid errors Add unit test around linebuffering in binary mode Add changelog file Co-authored-by: Pablo Su??rez Hern??ndez <[email protected]> Co-authored-by: Ismael Luceno <[email protected]> --- changelog/62817.fixed | 1 + salt/utils/files.py | 8 ++++++++ tests/pytests/unit/utils/test_files.py | 13 ++++++++++++- 3 files changed, 21 insertions(+), 1 deletion(-) create mode 100644 changelog/62817.fixed diff --git a/changelog/62817.fixed b/changelog/62817.fixed new file mode 100644 index 0000000000..ff335f2916 --- /dev/null +++ b/changelog/62817.fixed @@ -0,0 +1 @@ +Prevent annoying RuntimeWarning message about line buffering (buffering=1) not being supported in binary mode diff --git a/salt/utils/files.py b/salt/utils/files.py index 1cf636a753..3c57cce713 100644 --- a/salt/utils/files.py +++ b/salt/utils/files.py @@ -6,6 +6,7 @@ Functions for working with files import codecs import contextlib import errno +import io import logging import os import re @@ -382,6 +383,13 @@ def fopen(*args, **kwargs): if not binary and not kwargs.get("newline", None): kwargs["newline"] = "" + # Workaround callers with bad buffering setting for binary files + if kwargs.get("buffering") == 1 and "b" in kwargs.get("mode", ""): + log.debug( + "Line buffering (buffering=1) isn't supported in binary mode, the default buffer size will be used" + ) + kwargs["buffering"] = io.DEFAULT_BUFFER_SIZE + f_handle = open(*args, **kwargs) # pylint: disable=resource-leakage if is_fcntl_available(): diff --git a/tests/pytests/unit/utils/test_files.py b/tests/pytests/unit/utils/test_files.py index fd88167b16..bd18bc5750 100644 --- a/tests/pytests/unit/utils/test_files.py +++ b/tests/pytests/unit/utils/test_files.py @@ -4,11 +4,12 @@ Unit Tests for functions located in salt/utils/files.py import copy +import io import os import pytest import salt.utils.files -from tests.support.mock import patch +from tests.support.mock import MagicMock, patch def test_safe_rm(): @@ -74,6 +75,16 @@ def test_fopen_with_disallowed_fds(): ) +def test_fopen_binary_line_buffering(tmp_path): + tmp_file = os.path.join(tmp_path, "foobar") + with patch("builtins.open") as open_mock, patch( + "salt.utils.files.is_fcntl_available", MagicMock(return_value=False) + ): + salt.utils.files.fopen(os.path.join(tmp_path, "foobar"), mode="b", buffering=1) + assert open_mock.called + assert open_mock.call_args[1]["buffering"] == io.DEFAULT_BUFFER_SIZE + + def _create_temp_structure(temp_directory, structure): for folder, files in structure.items(): current_directory = os.path.join(temp_directory, folder) -- 2.37.3 ++++++ ignore-non-utf8-characters-while-reading-files-with-.patch ++++++ >From b4945a0608b3d8996e8b5593dcc458c15b11d6ba Mon Sep 17 00:00:00 2001 From: Victor Zhestkov <[email protected]> Date: Wed, 14 Sep 2022 14:57:29 +0300 Subject: [PATCH] Ignore non utf8 characters while reading files with core grains module (bsc#1202165) * Ignore UnicodeDecodeError on reading files with core grains * Add tests for non utf8 chars in cmdline * Blacken modified lines * Fix the tests * Add changelog entry * Change ignore to surrogateescape for kernelparameters * Turn static test files to dynamic --- changelog/62633.fixed | 1 + salt/grains/core.py | 12 ++- tests/pytests/unit/grains/test_core.py | 118 +++++++++++++++++++++++++ 3 files changed, 128 insertions(+), 3 deletions(-) create mode 100644 changelog/62633.fixed diff --git a/changelog/62633.fixed b/changelog/62633.fixed new file mode 100644 index 0000000000..1ab74f9122 --- /dev/null +++ b/changelog/62633.fixed @@ -0,0 +1 @@ +Prevent possible tracebacks in core grains module by ignoring non utf8 characters in /proc/1/environ, /proc/1/cmdline, /proc/cmdline diff --git a/salt/grains/core.py b/salt/grains/core.py index 9530a43fc5..b543144da2 100644 --- a/salt/grains/core.py +++ b/salt/grains/core.py @@ -1093,7 +1093,9 @@ def _virtual(osdata): if ("virtual_subtype" not in grains) or (grains["virtual_subtype"] != "LXC"): if os.path.isfile("/proc/1/environ"): try: - with salt.utils.files.fopen("/proc/1/environ", "r") as fhr: + with salt.utils.files.fopen( + "/proc/1/environ", "r", errors="ignore" + ) as fhr: fhr_contents = fhr.read() if "container=lxc" in fhr_contents: grains["virtual"] = "container" @@ -1911,7 +1913,9 @@ def os_data(): grains["init"] = "systemd" except OSError: try: - with salt.utils.files.fopen("/proc/1/cmdline") as fhr: + with salt.utils.files.fopen( + "/proc/1/cmdline", "r", errors="ignore" + ) as fhr: init_cmdline = fhr.read().replace("\x00", " ").split() except OSError: pass @@ -3160,7 +3164,9 @@ def kernelparams(): return {} else: try: - with salt.utils.files.fopen("/proc/cmdline", "r") as fhr: + with salt.utils.files.fopen( + "/proc/cmdline", "r", errors="surrogateescape" + ) as fhr: cmdline = fhr.read() grains = {"kernelparams": []} for data in [ diff --git a/tests/pytests/unit/grains/test_core.py b/tests/pytests/unit/grains/test_core.py index 84dd97d62f..e640a07f76 100644 --- a/tests/pytests/unit/grains/test_core.py +++ b/tests/pytests/unit/grains/test_core.py @@ -11,6 +11,7 @@ import os import pathlib import platform import socket +import tempfile import textwrap from collections import namedtuple @@ -2635,6 +2636,38 @@ def test_kernelparams_return_linux(cmdline, expectation): assert core.kernelparams() == expectation [email protected]_unless_on_linux +def test_kernelparams_return_linux_non_utf8(): + _salt_utils_files_fopen = salt.utils.files.fopen + + expected = { + "kernelparams": [ + ("TEST_KEY1", "VAL1"), + ("TEST_KEY2", "VAL2"), + ("BOOTABLE_FLAG", "\udc80"), + ("TEST_KEY_NOVAL", None), + ("TEST_KEY3", "3"), + ] + } + + with tempfile.TemporaryDirectory() as tempdir: + + def _open_mock(file_name, *args, **kwargs): + return _salt_utils_files_fopen( + os.path.join(tempdir, "cmdline"), *args, **kwargs + ) + + with salt.utils.files.fopen( + os.path.join(tempdir, "cmdline"), + "wb", + ) as cmdline_fh, patch("salt.utils.files.fopen", _open_mock): + cmdline_fh.write( + b'TEST_KEY1=VAL1 TEST_KEY2=VAL2 BOOTABLE_FLAG="\x80" TEST_KEY_NOVAL TEST_KEY3=3\n' + ) + cmdline_fh.close() + assert core.kernelparams() == expected + + def test_linux_gpus(): """ Test GPU detection on Linux systems @@ -2837,3 +2870,88 @@ def test_virtual_set_virtual_ec2(): assert virtual_grains["virtual"] == "kvm" assert "virtual_subtype" not in virtual_grains + + [email protected]_on_windows +def test_linux_proc_files_with_non_utf8_chars(): + _salt_utils_files_fopen = salt.utils.files.fopen + + empty_mock = MagicMock(return_value={}) + + with tempfile.TemporaryDirectory() as tempdir: + + def _mock_open(filename, *args, **kwargs): + return _salt_utils_files_fopen( + os.path.join(tempdir, "cmdline-1"), *args, **kwargs + ) + + with salt.utils.files.fopen( + os.path.join(tempdir, "cmdline-1"), + "wb", + ) as cmdline_fh, patch("os.path.isfile", return_value=False), patch( + "salt.utils.files.fopen", _mock_open + ), patch.dict( + core.__salt__, + { + "cmd.retcode": salt.modules.cmdmod.retcode, + "cmd.run": MagicMock(return_value=""), + }, + ), patch.object( + core, "_linux_bin_exists", return_value=False + ), patch.object( + core, "_parse_lsb_release", return_value=empty_mock + ), patch.object( + core, "_parse_os_release", return_value=empty_mock + ), patch.object( + core, "_hw_data", return_value=empty_mock + ), patch.object( + core, "_virtual", return_value=empty_mock + ), patch.object( + core, "_bsd_cpudata", return_value=empty_mock + ), patch.object( + os, "stat", side_effect=OSError() + ): + cmdline_fh.write( + b"/usr/lib/systemd/systemd\x00--switched-root\x00--system\x00--deserialize\x0028\x80\x00" + ) + cmdline_fh.close() + os_grains = core.os_data() + assert os_grains != {} + + [email protected]_on_windows +def test_virtual_linux_proc_files_with_non_utf8_chars(): + _salt_utils_files_fopen = salt.utils.files.fopen + + def _is_file_mock(filename): + if filename == "/proc/1/environ": + return True + return False + + with tempfile.TemporaryDirectory() as tempdir: + + def _mock_open(filename, *args, **kwargs): + return _salt_utils_files_fopen( + os.path.join(tempdir, "environ"), *args, **kwargs + ) + + with salt.utils.files.fopen( + os.path.join(tempdir, "environ"), + "wb", + ) as environ_fh, patch("os.path.isfile", _is_file_mock), patch( + "salt.utils.files.fopen", _mock_open + ), patch.object( + salt.utils.path, "which", MagicMock(return_value=None) + ), patch.dict( + core.__salt__, + { + "cmd.run_all": MagicMock( + return_value={"retcode": 1, "stderr": "", "stdout": ""} + ), + "cmd.run": MagicMock(return_value=""), + }, + ): + environ_fh.write(b"KEY1=VAL1 KEY2=VAL2\x80 KEY2=VAL2") + environ_fh.close() + virt_grains = core._virtual({"kernel": "Linux"}) + assert virt_grains == {"virtual": "physical"} -- 2.37.3 ++++++ make-pass-renderer-configurable-other-fixes-532.patch ++++++ >From 7b4f5007b7e6a35386d197afe53d02c8d7b41d53 Mon Sep 17 00:00:00 2001 From: Daniel Mach <[email protected]> Date: Thu, 6 Oct 2022 11:58:23 +0200 Subject: [PATCH] Make pass renderer configurable & other fixes (#532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * pass: Use a secure way of handling pass arguments The original code would fail on pass paths with spaces, because they would be split into multiple arguments. * pass: Strip only trailing newline characters from the secret * pass: Do not modify $HOME env globally Just set $HOME for calling the pass binary to avoid affecting anything outside the pass renderer. * pass: Use pass executable path from _get_pass_exec() * Make the pass renderer more configurable 1. Allow us to make the pass renderer fail during pillar rendering when a secret corresponding with a pass path cannot be fetched. For this we add a master config variable pass_strict_fetch. 2. Allow to have prefix for variables that should be processed with the pass renderer. For this we add a master config variable pass_variable_prefix. 3. Allow us to configure pass' GNUPGHOME and PASSWORD_STORE_DIR environmental variables. For this we add master config variables pass_gnupghome and pass_dir. * Add tests for the pass renderer * pass: Handle FileNotFoundError when pass binary is not available Co-authored-by: Marcus R??ckert <[email protected]> --- changelog/62120.added | 4 + changelog/62120.fixed | 4 + salt/config/__init__.py | 12 ++ salt/renderers/pass.py | 104 ++++++++++++-- tests/pytests/unit/renderers/test_pass.py | 164 ++++++++++++++++++++++ 5 files changed, 274 insertions(+), 14 deletions(-) create mode 100644 changelog/62120.added create mode 100644 changelog/62120.fixed create mode 100644 tests/pytests/unit/renderers/test_pass.py diff --git a/changelog/62120.added b/changelog/62120.added new file mode 100644 index 0000000000..4303d124f0 --- /dev/null +++ b/changelog/62120.added @@ -0,0 +1,4 @@ +Config option pass_variable_prefix allows to distinguish variables that contain paths to pass secrets. +Config option pass_strict_fetch allows to error out when a secret cannot be fetched from pass. +Config option pass_dir allows setting the PASSWORD_STORE_DIR env for pass. +Config option pass_gnupghome allows setting the $GNUPGHOME env for pass. diff --git a/changelog/62120.fixed b/changelog/62120.fixed new file mode 100644 index 0000000000..22a9711383 --- /dev/null +++ b/changelog/62120.fixed @@ -0,0 +1,4 @@ +Pass executable path from _get_path_exec() is used when calling the program. +The $HOME env is no longer modified globally. +Only trailing newlines are stripped from the fetched secret. +Pass process arguments are handled in a secure way. diff --git a/salt/config/__init__.py b/salt/config/__init__.py index 2c42290598..9e72a5b4b7 100644 --- a/salt/config/__init__.py +++ b/salt/config/__init__.py @@ -960,6 +960,14 @@ VALID_OPTS = immutabletypes.freeze( # Use Adler32 hashing algorithm for server_id (default False until Sodium, "adler32" after) # Possible values are: False, adler32, crc32 "server_id_use_crc": (bool, str), + # pass renderer: Fetch secrets only for the template variables matching the prefix + "pass_variable_prefix": str, + # pass renderer: Whether to error out when unable to fetch a secret + "pass_strict_fetch": bool, + # pass renderer: Set GNUPGHOME env for Pass + "pass_gnupghome": str, + # pass renderer: Set PASSWORD_STORE_DIR env for Pass + "pass_dir": str, } ) @@ -1601,6 +1609,10 @@ DEFAULT_MASTER_OPTS = immutabletypes.freeze( "fips_mode": False, "detect_remote_minions": False, "remote_minions_port": 22, + "pass_variable_prefix": "", + "pass_strict_fetch": False, + "pass_gnupghome": "", + "pass_dir": "", } ) diff --git a/salt/renderers/pass.py b/salt/renderers/pass.py index 71b1021b96..ba0f152c23 100644 --- a/salt/renderers/pass.py +++ b/salt/renderers/pass.py @@ -45,6 +45,34 @@ Install pass binary pass: pkg.installed + +Salt master configuration options + +.. code-block:: yaml + + # If the prefix is *not* set (default behavior), all template variables are + # considered for fetching secrets from Pass. Those that cannot be resolved + # to a secret are passed through. + # + # If the prefix is set, only the template variables with matching prefix are + # considered for fetching the secrets, other variables are passed through. + # + # For ease of use it is recommended to set the following options as well: + # renderer: 'jinja|yaml|pass' + # pass_strict_fetch: true + # + pass_variable_prefix: 'pass:' + + # If set to 'true', error out when unable to fetch a secret for a template variable. + pass_strict_fetch: true + + # Set GNUPGHOME env for Pass. + # Defaults to: ~/.gnupg + pass_gnupghome: <path> + + # Set PASSWORD_STORE_DIR env for Pass. + # Defaults to: ~/.password-store + pass_dir: <path> """ @@ -54,7 +82,7 @@ from os.path import expanduser from subprocess import PIPE, Popen import salt.utils.path -from salt.exceptions import SaltRenderError +from salt.exceptions import SaltConfigurationError, SaltRenderError log = logging.getLogger(__name__) @@ -75,18 +103,71 @@ def _fetch_secret(pass_path): Fetch secret from pass based on pass_path. If there is any error, return back the original pass_path value """ - cmd = "pass show {}".format(pass_path.strip()) - log.debug("Fetching secret: %s", cmd) + pass_exec = _get_pass_exec() + + # Make a backup in case we want to return the original value without stripped whitespaces + original_pass_path = pass_path + + # Remove the optional prefix from pass path + pass_prefix = __opts__["pass_variable_prefix"] + if pass_prefix: + # If we do not see our prefix we do not want to process this variable + # and we return the unmodified pass path + if not pass_path.startswith(pass_prefix): + return pass_path + + # strip the prefix from the start of the string + pass_path = pass_path[len(pass_prefix) :] + + # The pass_strict_fetch option must be used with pass_variable_prefix + pass_strict_fetch = __opts__["pass_strict_fetch"] + if pass_strict_fetch and not pass_prefix: + msg = "The 'pass_strict_fetch' option requires 'pass_variable_prefix' option enabled" + raise SaltConfigurationError(msg) + + # Remove whitespaces from the pass_path + pass_path = pass_path.strip() - proc = Popen(cmd.split(" "), stdout=PIPE, stderr=PIPE) - pass_data, pass_error = proc.communicate() + cmd = [pass_exec, "show", pass_path] + log.debug("Fetching secret: %s", " ".join(cmd)) + + # Make sure environment variable HOME is set, since Pass looks for the + # password-store under ~/.password-store. + env = os.environ.copy() + env["HOME"] = expanduser("~") + + pass_dir = __opts__["pass_dir"] + if pass_dir: + env["PASSWORD_STORE_DIR"] = pass_dir + + pass_gnupghome = __opts__["pass_gnupghome"] + if pass_gnupghome: + env["GNUPGHOME"] = pass_gnupghome + + try: + proc = Popen(cmd, stdout=PIPE, stderr=PIPE, env=env) + pass_data, pass_error = proc.communicate() + pass_returncode = proc.returncode + except OSError as e: + pass_data, pass_error = "", str(e) + pass_returncode = 1 # The version of pass used during development sent output to # stdout instead of stderr even though its returncode was non zero. - if proc.returncode or not pass_data: - log.warning("Could not fetch secret: %s %s", pass_data, pass_error) - pass_data = pass_path - return pass_data.strip() + if pass_returncode or not pass_data: + try: + pass_error = pass_error.decode("utf-8") + except (AttributeError, ValueError): + pass + msg = "Could not fetch secret '{}' from the password store: {}".format( + pass_path, pass_error + ) + if pass_strict_fetch: + raise SaltRenderError(msg) + else: + log.warning(msg) + return original_pass_path + return pass_data.rstrip("\r\n") def _decrypt_object(obj): @@ -108,9 +189,4 @@ def render(pass_info, saltenv="base", sls="", argline="", **kwargs): """ Fetch secret from pass based on pass_path """ - _get_pass_exec() - - # Make sure environment variable HOME is set, since Pass looks for the - # password-store under ~/.password-store. - os.environ["HOME"] = expanduser("~") return _decrypt_object(pass_info) diff --git a/tests/pytests/unit/renderers/test_pass.py b/tests/pytests/unit/renderers/test_pass.py new file mode 100644 index 0000000000..74e822c7ec --- /dev/null +++ b/tests/pytests/unit/renderers/test_pass.py @@ -0,0 +1,164 @@ +import importlib + +import pytest + +import salt.config +import salt.exceptions +from tests.support.mock import MagicMock, patch + +# "pass" is a reserved keyword, we need to import it differently +pass_ = importlib.import_module("salt.renderers.pass") + + [email protected] +def configure_loader_modules(): + return { + pass_: { + "__opts__": salt.config.DEFAULT_MASTER_OPTS.copy(), + "_get_pass_exec": MagicMock(return_value="/usr/bin/pass"), + } + } + + +# The default behavior is that if fetching a secret from pass fails, +# the value is passed through. Even the trailing newlines are preserved. +def test_passthrough(): + pass_path = "secret\n" + expected = pass_path + result = pass_.render(pass_path) + + assert result == expected + + +# Fetch a secret in the strict mode. +def test_strict_fetch(): + config = { + "pass_variable_prefix": "pass:", + "pass_strict_fetch": True, + } + + popen_mock = MagicMock(spec=pass_.Popen) + popen_mock.return_value.communicate.return_value = ("password123456\n", "") + popen_mock.return_value.returncode = 0 + + mocks = { + "Popen": popen_mock, + } + + pass_path = "pass:secret" + expected = "password123456" + with patch.dict(pass_.__opts__, config), patch.dict(pass_.__dict__, mocks): + result = pass_.render(pass_path) + + assert result == expected + + +# Fail to fetch a secret in the strict mode. +def test_strict_fetch_fail(): + config = { + "pass_variable_prefix": "pass:", + "pass_strict_fetch": True, + } + + popen_mock = MagicMock(spec=pass_.Popen) + popen_mock.return_value.communicate.return_value = ("", "Secret not found") + popen_mock.return_value.returncode = 1 + + mocks = { + "Popen": popen_mock, + } + + pass_path = "pass:secret" + with patch.dict(pass_.__opts__, config), patch.dict(pass_.__dict__, mocks): + with pytest.raises(salt.exceptions.SaltRenderError): + pass_.render(pass_path) + + +# Passthrough a value that doesn't have a pass prefix. +def test_strict_fetch_passthrough(): + config = { + "pass_variable_prefix": "pass:", + "pass_strict_fetch": True, + } + + pass_path = "variable-without-pass-prefix\n" + expected = pass_path + with patch.dict(pass_.__opts__, config): + result = pass_.render(pass_path) + + assert result == expected + + +# Fetch a secret in the strict mode. The pass path contains spaces. +def test_strict_fetch_pass_path_with_spaces(): + config = { + "pass_variable_prefix": "pass:", + "pass_strict_fetch": True, + } + + popen_mock = MagicMock(spec=pass_.Popen) + popen_mock.return_value.communicate.return_value = ("password123456\n", "") + popen_mock.return_value.returncode = 0 + + mocks = { + "Popen": popen_mock, + } + + pass_path = "pass:se cr et" + with patch.dict(pass_.__opts__, config), patch.dict(pass_.__dict__, mocks): + pass_.render(pass_path) + + call_args, call_kwargs = popen_mock.call_args_list[0] + assert call_args[0] == ["/usr/bin/pass", "show", "se cr et"] + + +# Fetch a secret in the strict mode. The secret contains leading and trailing whitespaces. +def test_strict_fetch_secret_with_whitespaces(): + config = { + "pass_variable_prefix": "pass:", + "pass_strict_fetch": True, + } + + popen_mock = MagicMock(spec=pass_.Popen) + popen_mock.return_value.communicate.return_value = (" \tpassword123456\t \r\n", "") + popen_mock.return_value.returncode = 0 + + mocks = { + "Popen": popen_mock, + } + + pass_path = "pass:secret" + expected = " \tpassword123456\t " # only the trailing newlines get striped + with patch.dict(pass_.__opts__, config), patch.dict(pass_.__dict__, mocks): + result = pass_.render(pass_path) + + assert result == expected + + +# Test setting env variables based on config values: +# - pass_gnupghome -> GNUPGHOME +# - pass_dir -> PASSWORD_STORE_DIR +def test_env(): + config = { + "pass_variable_prefix": "pass:", + "pass_strict_fetch": True, + "pass_gnupghome": "/path/to/gnupghome", + "pass_dir": "/path/to/secretstore", + } + + popen_mock = MagicMock(spec=pass_.Popen) + popen_mock.return_value.communicate.return_value = ("password123456\n", "") + popen_mock.return_value.returncode = 0 + + mocks = { + "Popen": popen_mock, + } + + pass_path = "pass:secret" + expected = " \tpassword123456\t " # only the trailing newlines get striped + with patch.dict(pass_.__opts__, config), patch.dict(pass_.__dict__, mocks): + result = pass_.render(pass_path) + + call_args, call_kwargs = popen_mock.call_args_list[0] + assert call_kwargs["env"]["GNUPGHOME"] == config["pass_gnupghome"] + assert call_kwargs["env"]["PASSWORD_STORE_DIR"] == config["pass_dir"] -- 2.37.3 ++++++ retry-if-rpm-lock-is-temporarily-unavailable-547.patch ++++++ >From cedde1082b3a11b941327ba8e213f44637fb8a6b Mon Sep 17 00:00:00 2001 From: Witek Bedyk <[email protected]> Date: Mon, 29 Aug 2022 14:16:00 +0200 Subject: [PATCH] Retry if RPM lock is temporarily unavailable (#547) * Retry if RPM lock is temporarily unavailable Backported from saltstack/salt#62204 Signed-off-by: Witek Bedyk <[email protected]> * Sync formating fixes from upstream Signed-off-by: Witek Bedyk <[email protected]> --- changelog/62204.fixed | 1 + salt/modules/zypperpkg.py | 117 +++++++++++++++++---------- tests/unit/modules/test_zypperpkg.py | 45 ++++++++++- 3 files changed, 115 insertions(+), 48 deletions(-) create mode 100644 changelog/62204.fixed diff --git a/changelog/62204.fixed b/changelog/62204.fixed new file mode 100644 index 0000000000..59f1914593 --- /dev/null +++ b/changelog/62204.fixed @@ -0,0 +1 @@ +Fixed Zypper module failing on RPM lock file being temporarily unavailable. diff --git a/salt/modules/zypperpkg.py b/salt/modules/zypperpkg.py index b622105e15..7a249486fb 100644 --- a/salt/modules/zypperpkg.py +++ b/salt/modules/zypperpkg.py @@ -14,6 +14,7 @@ Package support for openSUSE via the zypper package manager import configparser import datetime +import errno import fnmatch import logging import os @@ -39,6 +40,9 @@ from salt.exceptions import CommandExecutionError, MinionError, SaltInvocationEr # pylint: disable=import-error,redefined-builtin,no-name-in-module from salt.utils.versions import LooseVersion +if salt.utils.files.is_fcntl_available(): + import fcntl + log = logging.getLogger(__name__) HAS_ZYPP = False @@ -106,6 +110,7 @@ class _Zypper: XML_DIRECTIVES = ["-x", "--xmlout"] # ZYPPER_LOCK is not affected by --root ZYPPER_LOCK = "/var/run/zypp.pid" + RPM_LOCK = "/var/lib/rpm/.rpm.lock" TAG_RELEASED = "zypper/released" TAG_BLOCKED = "zypper/blocked" @@ -276,7 +281,7 @@ class _Zypper: and self.exit_code not in self.WARNING_EXIT_CODES ) - def _is_lock(self): + def _is_zypper_lock(self): """ Is this is a lock error code? @@ -284,6 +289,23 @@ class _Zypper: """ return self.exit_code == self.LOCK_EXIT_CODE + def _is_rpm_lock(self): + """ + Is this an RPM lock error? + """ + if salt.utils.files.is_fcntl_available(): + if self.exit_code > 0 and os.path.exists(self.RPM_LOCK): + with salt.utils.files.fopen(self.RPM_LOCK, mode="w+") as rfh: + try: + fcntl.lockf(rfh, fcntl.LOCK_EX | fcntl.LOCK_NB) + except OSError as err: + if err.errno == errno.EAGAIN: + return True + else: + fcntl.lockf(rfh, fcntl.LOCK_UN) + + return False + def _is_xml_mode(self): """ Is Zypper's output is in XML format? @@ -306,7 +328,7 @@ class _Zypper: raise CommandExecutionError("No output result from Zypper?") self.exit_code = self.__call_result["retcode"] - if self._is_lock(): + if self._is_zypper_lock() or self._is_rpm_lock(): return False if self._is_error(): @@ -387,48 +409,11 @@ class _Zypper: if self._check_result(): break - if os.path.exists(self.ZYPPER_LOCK): - try: - with salt.utils.files.fopen(self.ZYPPER_LOCK) as rfh: - data = __salt__["ps.proc_info"]( - int(rfh.readline()), - attrs=["pid", "name", "cmdline", "create_time"], - ) - data["cmdline"] = " ".join(data["cmdline"]) - data["info"] = "Blocking process created at {}.".format( - datetime.datetime.utcfromtimestamp( - data["create_time"] - ).isoformat() - ) - data["success"] = True - except Exception as err: # pylint: disable=broad-except - data = { - "info": ( - "Unable to retrieve information about blocking process: {}".format( - err.message - ) - ), - "success": False, - } - else: - data = { - "info": "Zypper is locked, but no Zypper lock has been found.", - "success": False, - } - - if not data["success"]: - log.debug("Unable to collect data about blocking process.") - else: - log.debug("Collected data about blocking process.") - - __salt__["event.fire_master"](data, self.TAG_BLOCKED) - log.debug( - "Fired a Zypper blocked event to the master with the data: %s", data - ) - log.debug("Waiting 5 seconds for Zypper gets released...") - time.sleep(5) - if not was_blocked: - was_blocked = True + if self._is_zypper_lock(): + self._handle_zypper_lock_file() + if self._is_rpm_lock(): + self._handle_rpm_lock_file() + was_blocked = True if was_blocked: __salt__["event.fire_master"]( @@ -451,6 +436,50 @@ class _Zypper: or self.__call_result["stdout"] ) + def _handle_zypper_lock_file(self): + if os.path.exists(self.ZYPPER_LOCK): + try: + with salt.utils.files.fopen(self.ZYPPER_LOCK) as rfh: + data = __salt__["ps.proc_info"]( + int(rfh.readline()), + attrs=["pid", "name", "cmdline", "create_time"], + ) + data["cmdline"] = " ".join(data["cmdline"]) + data["info"] = "Blocking process created at {}.".format( + datetime.datetime.utcfromtimestamp( + data["create_time"] + ).isoformat() + ) + data["success"] = True + except Exception as err: # pylint: disable=broad-except + data = { + "info": ( + "Unable to retrieve information about " + "blocking process: {}".format(err) + ), + "success": False, + } + else: + data = { + "info": "Zypper is locked, but no Zypper lock has been found.", + "success": False, + } + if not data["success"]: + log.debug("Unable to collect data about blocking process.") + else: + log.debug("Collected data about blocking process.") + __salt__["event.fire_master"](data, self.TAG_BLOCKED) + log.debug("Fired a Zypper blocked event to the master with the data: %s", data) + log.debug("Waiting 5 seconds for Zypper gets released...") + time.sleep(5) + + def _handle_rpm_lock_file(self): + data = {"info": "RPM is temporarily locked.", "success": True} + __salt__["event.fire_master"](data, self.TAG_BLOCKED) + log.debug("Fired an RPM blocked event to the master with the data: %s", data) + log.debug("Waiting 5 seconds for RPM to get released...") + time.sleep(5) + __zypper__ = _Zypper() diff --git a/tests/unit/modules/test_zypperpkg.py b/tests/unit/modules/test_zypperpkg.py index 3f1560a385..37d555844c 100644 --- a/tests/unit/modules/test_zypperpkg.py +++ b/tests/unit/modules/test_zypperpkg.py @@ -4,6 +4,7 @@ import configparser +import errno import io import os from xml.dom import minidom @@ -97,7 +98,7 @@ class ZypperTestCase(TestCase, LoaderModuleMockMixin): } with patch.dict( zypper.__salt__, {"cmd.run_all": MagicMock(return_value=ref_out)} - ): + ), patch.object(zypper.__zypper__, "_is_rpm_lock", return_value=False): upgrades = zypper.list_upgrades(refresh=False) self.assertEqual(len(upgrades), 3) for pkg, version in { @@ -198,7 +199,9 @@ class ZypperTestCase(TestCase, LoaderModuleMockMixin): ' type="error">Booya!</message></stream>' ) sniffer = RunSniffer(stdout=stdout_xml_snippet, retcode=1) - with patch.dict("salt.modules.zypperpkg.__salt__", {"cmd.run_all": sniffer}): + with patch.dict( + "salt.modules.zypperpkg.__salt__", {"cmd.run_all": sniffer} + ), patch.object(zypper.__zypper__, "_is_rpm_lock", return_value=False): with self.assertRaisesRegex( CommandExecutionError, "^Zypper command failure: Booya!$" ): @@ -232,7 +235,7 @@ class ZypperTestCase(TestCase, LoaderModuleMockMixin): with patch.dict( "salt.modules.zypperpkg.__salt__", {"cmd.run_all": MagicMock(return_value=ref_out)}, - ): + ), patch.object(zypper.__zypper__, "_is_rpm_lock", return_value=False): with self.assertRaisesRegex( CommandExecutionError, "^Zypper command failure: Some handled zypper internal error{}Another" @@ -245,7 +248,7 @@ class ZypperTestCase(TestCase, LoaderModuleMockMixin): with patch.dict( "salt.modules.zypperpkg.__salt__", {"cmd.run_all": MagicMock(return_value=ref_out)}, - ): + ), patch.object(zypper.__zypper__, "_is_rpm_lock", return_value=False): with self.assertRaisesRegex( CommandExecutionError, "^Zypper command failure: Check Zypper's logs.$" ): @@ -2064,3 +2067,37 @@ pattern() = package-c""" python_shell=False, env={"ZYPP_READONLY_HACK": "1"}, ) + + def test_is_rpm_lock_no_error(self): + with patch.object(os.path, "exists", return_value=True): + self.assertFalse(zypper.__zypper__._is_rpm_lock()) + + def test_rpm_lock_does_not_exist(self): + if salt.utils.files.is_fcntl_available(): + zypper.__zypper__.exit_code = 1 + with patch.object( + os.path, "exists", return_value=False + ) as mock_path_exists: + self.assertFalse(zypper.__zypper__._is_rpm_lock()) + mock_path_exists.assert_called_with(zypper.__zypper__.RPM_LOCK) + zypper.__zypper__._reset() + + def test_rpm_lock_acquirable(self): + if salt.utils.files.is_fcntl_available(): + zypper.__zypper__.exit_code = 1 + with patch.object(os.path, "exists", return_value=True), patch( + "fcntl.lockf", side_effect=OSError(errno.EAGAIN, "") + ) as lockf_mock, patch("salt.utils.files.fopen", mock_open()): + self.assertTrue(zypper.__zypper__._is_rpm_lock()) + lockf_mock.assert_called() + zypper.__zypper__._reset() + + def test_rpm_lock_not_acquirable(self): + if salt.utils.files.is_fcntl_available(): + zypper.__zypper__.exit_code = 1 + with patch.object(os.path, "exists", return_value=True), patch( + "fcntl.lockf" + ) as lockf_mock, patch("salt.utils.files.fopen", mock_open()): + self.assertFalse(zypper.__zypper__._is_rpm_lock()) + self.assertEqual(lockf_mock.call_count, 2) + zypper.__zypper__._reset() -- 2.37.2
