From 3e24ae9666e2ddd1f54c62c1f5b2f5752d0f1a2c79c600a0c357bda4c18718d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pablo=20Su=C3=A1rez=20Hern=C3=A1ndez?= Date: Thu, 6 Oct 2022 15:18:21 +0000 Subject: [PATCH] osc copypac from project:systemsmanagement:saltstack:testing package:salt revision:448 OBS-URL: https://build.opensuse.org/package/show/systemsmanagement:saltstack/salt?expand=0&rev=204 --- _lastrevision | 2 +- _service | 2 +- ...etection-for-virtual-grains-bsc-1195.patch | 224 +++++ backport-syndic-auth-fixes.patch | 355 ++++++++ ...eters-to-prevent-possible-tracebacks.patch | 80 ++ ...in-test-mode-with-file-state-module-.patch | 247 ++++++ fix-test_ipc-unit-tests.patch | 37 + ...on-in-schedule-module-releasded-in-3.patch | 820 ++++++++++++++++++ ...nd-bad-buffering-for-binary-mode-563.patch | 106 +++ ...characters-while-reading-files-with-.patch | 213 +++++ ...enderer-configurable-other-fixes-532.patch | 414 +++++++++ ...-lock-is-temporarily-unavailable-547.patch | 297 +++++++ salt.changes | 40 + salt.spec | 20 + 14 files changed, 2855 insertions(+), 2 deletions(-) create mode 100644 add-amazon-ec2-detection-for-virtual-grains-bsc-1195.patch create mode 100644 backport-syndic-auth-fixes.patch create mode 100644 change-the-delimeters-to-prevent-possible-tracebacks.patch create mode 100644 fix-state.apply-in-test-mode-with-file-state-module-.patch create mode 100644 fix-test_ipc-unit-tests.patch create mode 100644 fix-the-regression-in-schedule-module-releasded-in-3.patch create mode 100644 fopen-workaround-bad-buffering-for-binary-mode-563.patch create mode 100644 ignore-non-utf8-characters-while-reading-files-with-.patch create mode 100644 make-pass-renderer-configurable-other-fixes-532.patch create mode 100644 retry-if-rpm-lock-is-temporarily-unavailable-547.patch diff --git a/_lastrevision b/_lastrevision index dbca5c3..499e5b6 100644 --- a/_lastrevision +++ b/_lastrevision @@ -1 +1 @@ -e07459bfeea39239f6b446f40f6502e72dea488f \ No newline at end of file +e04acec89d982e3bd465742afffe6ae5ec82620b \ No newline at end of file diff --git a/_service b/_service index 177b899..a708e01 100644 --- a/_service +++ b/_service @@ -3,7 +3,7 @@ https://github.com/openSUSE/salt-packaging.git salt package - 3004 + release/3004 git diff --git a/add-amazon-ec2-detection-for-virtual-grains-bsc-1195.patch b/add-amazon-ec2-detection-for-virtual-grains-bsc-1195.patch new file mode 100644 index 0000000..a9c7a3d --- /dev/null +++ b/add-amazon-ec2-detection-for-virtual-grains-bsc-1195.patch @@ -0,0 +1,224 @@ +From 77e90c4925a4268c5975cf1ce0bb0e4c457618c1 Mon Sep 17 00:00:00 2001 +From: Victor Zhestkov +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 ++ ++ ++@pytest.mark.skip_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 + + diff --git a/backport-syndic-auth-fixes.patch b/backport-syndic-auth-fixes.patch new file mode 100644 index 0000000..c04903b --- /dev/null +++ b/backport-syndic-auth-fixes.patch @@ -0,0 +1,355 @@ +From 54ab69e74beb83710d0bf6049039d13e260d5517 Mon Sep 17 00:00:00 2001 +From: Alexander Graul +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 +--- + 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 ++ ++ ++@pytest.fixture ++def fake_keys(): ++ with patch("salt.crypt.AsyncAuth.get_keys", autospec=True): ++ yield ++ ++ ++@pytest.fixture ++def fake_crypto(): ++ with patch("salt.transport.tcp.PKCS1_OAEP", create=True) as fake_crypto: ++ yield fake_crypto ++ ++ ++@pytest.fixture ++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 ++ ++ ++@pytest.fixture ++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 + diff --git a/change-the-delimeters-to-prevent-possible-tracebacks.patch b/change-the-delimeters-to-prevent-possible-tracebacks.patch new file mode 100644 index 0000000..fba2d14 --- /dev/null +++ b/change-the-delimeters-to-prevent-possible-tracebacks.patch @@ -0,0 +1,80 @@ +From e28385eb37932809a11ec81c81834a51e094f507 Mon Sep 17 00:00:00 2001 +From: Victor Zhestkov +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 + + diff --git a/fix-state.apply-in-test-mode-with-file-state-module-.patch b/fix-state.apply-in-test-mode-with-file-state-module-.patch new file mode 100644 index 0000000..a8f3318 --- /dev/null +++ b/fix-state.apply-in-test-mode-with-file-state-module-.patch @@ -0,0 +1,247 @@ +From ed567e5f339f7bf95d4361ac47e67427db71714c Mon Sep 17 00:00:00 2001 +From: Victor Zhestkov +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 + +* Add tests for _check_user usage + +Co-authored-by: nicholasmhughes +--- + 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 + + diff --git a/fix-test_ipc-unit-tests.patch b/fix-test_ipc-unit-tests.patch new file mode 100644 index 0000000..bfa49f5 --- /dev/null +++ b/fix-test_ipc-unit-tests.patch @@ -0,0 +1,37 @@ +From 61d9b5e4ceaa0f5feb7fc364c9089cb624006812 Mon Sep 17 00:00:00 2001 +From: Alexander Graul +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 + + diff --git a/fix-the-regression-in-schedule-module-releasded-in-3.patch b/fix-the-regression-in-schedule-module-releasded-in-3.patch new file mode 100644 index 0000000..f95b939 --- /dev/null +++ b/fix-the-regression-in-schedule-module-releasded-in-3.patch @@ -0,0 +1,820 @@ +From 7803275a8aaeedf2124706f51b6a54cfcfb2d032 Mon Sep 17 00:00:00 2001 +From: Victor Zhestkov +Date: Thu, 1 Sep 2022 14:45:13 +0300 +Subject: [PATCH] Fix the regression in schedule module releasded in + 3004 (bsc#1202631) + +Co-authored-by: Gareth J. Greenaway +--- + changelog/61324.changed | 1 + + salt/modules/schedule.py | 449 ++++++++++++++------ + tests/pytests/unit/modules/test_schedule.py | 138 +++++- + 3 files changed, 442 insertions(+), 146 deletions(-) + create mode 100644 changelog/61324.changed + +diff --git a/changelog/61324.changed b/changelog/61324.changed +new file mode 100644 +index 0000000000..d67051a8da +--- /dev/null ++++ b/changelog/61324.changed +@@ -0,0 +1 @@ ++Adding the ability to add, delete, purge, and modify Salt scheduler jobs when the Salt minion is not running. +diff --git a/salt/modules/schedule.py b/salt/modules/schedule.py +index bcd64f2851..913a101ea6 100644 +--- a/salt/modules/schedule.py ++++ b/salt/modules/schedule.py +@@ -15,6 +15,7 @@ import salt.utils.event + import salt.utils.files + import salt.utils.odict + import salt.utils.yaml ++import yaml + + try: + import dateutil.parser as dateutil_parser +@@ -64,7 +65,35 @@ SCHEDULE_CONF = [ + ] + + +-def list_(show_all=False, show_disabled=True, where=None, return_yaml=True): ++def _get_schedule_config_file(): ++ """ ++ Return the minion schedule configuration file ++ """ ++ config_dir = __opts__.get("conf_dir", None) ++ if config_dir is None and "conf_file" in __opts__: ++ config_dir = os.path.dirname(__opts__["conf_file"]) ++ if config_dir is None: ++ config_dir = salt.syspaths.CONFIG_DIR ++ ++ minion_d_dir = os.path.join( ++ config_dir, ++ os.path.dirname( ++ __opts__.get( ++ "default_include", ++ salt.config.DEFAULT_MINION_OPTS["default_include"], ++ ) ++ ), ++ ) ++ ++ if not os.path.isdir(minion_d_dir): ++ os.makedirs(minion_d_dir) ++ ++ return os.path.join(minion_d_dir, "_schedule.conf") ++ ++ ++def list_( ++ show_all=False, show_disabled=True, where=None, return_yaml=True, offline=False ++): + """ + List the jobs currently scheduled on the minion + +@@ -83,24 +112,33 @@ def list_(show_all=False, show_disabled=True, where=None, return_yaml=True): + """ + + schedule = {} +- try: +- with salt.utils.event.get_event("minion", opts=__opts__) as event_bus: +- res = __salt__["event.fire"]( +- {"func": "list", "where": where}, "manage_schedule" +- ) +- if res: +- event_ret = event_bus.get_event( +- tag="/salt/minion/minion_schedule_list_complete", wait=30 ++ if offline: ++ schedule_config = _get_schedule_config_file() ++ if os.path.exists(schedule_config): ++ with salt.utils.files.fopen(schedule_config) as fp_: ++ schedule_yaml = fp_.read() ++ if schedule_yaml: ++ schedule_contents = yaml.safe_load(schedule_yaml) ++ schedule = schedule_contents.get("schedule", {}) ++ else: ++ try: ++ with salt.utils.event.get_event("minion", opts=__opts__) as event_bus: ++ res = __salt__["event.fire"]( ++ {"func": "list", "where": where}, "manage_schedule" + ) +- if event_ret and event_ret["complete"]: +- schedule = event_ret["schedule"] +- except KeyError: +- # Effectively a no-op, since we can't really return without an event system +- ret = {} +- ret["comment"] = "Event module not available. Schedule list failed." +- ret["result"] = True +- log.debug("Event module not available. Schedule list failed.") +- return ret ++ if res: ++ event_ret = event_bus.get_event( ++ tag="/salt/minion/minion_schedule_list_complete", wait=30 ++ ) ++ if event_ret and event_ret["complete"]: ++ schedule = event_ret["schedule"] ++ except KeyError: ++ # Effectively a no-op, since we can't really return without an event system ++ ret = {} ++ ret["comment"] = "Event module not available. Schedule list failed." ++ ret["result"] = True ++ log.debug("Event module not available. Schedule list failed.") ++ return ret + + _hidden = ["enabled", "skip_function", "skip_during_range"] + for job in list(schedule.keys()): # iterate over a copy since we will mutate it +@@ -139,14 +177,11 @@ def list_(show_all=False, show_disabled=True, where=None, return_yaml=True): + # remove _seconds from the listing + del schedule[job]["_seconds"] + +- if schedule: +- if return_yaml: +- tmp = {"schedule": schedule} +- return salt.utils.yaml.safe_dump(tmp, default_flow_style=False) +- else: +- return schedule ++ if return_yaml: ++ tmp = {"schedule": schedule} ++ return salt.utils.yaml.safe_dump(tmp, default_flow_style=False) + else: +- return {"schedule": {}} ++ return schedule + + + def is_enabled(name=None): +@@ -186,11 +221,18 @@ def purge(**kwargs): + .. code-block:: bash + + salt '*' schedule.purge ++ ++ # Purge jobs on Salt minion ++ salt '*' schedule.purge ++ + """ + +- ret = {"comment": [], "result": True} ++ ret = {"comment": [], "changes": {}, "result": True} + +- for name in list_(show_all=True, return_yaml=False): ++ current_schedule = list_( ++ show_all=True, return_yaml=False, offline=kwargs.get("offline") ++ ) ++ for name in pycopy.deepcopy(current_schedule): + if name == "enabled": + continue + if name.startswith("__"): +@@ -202,37 +244,65 @@ def purge(**kwargs): + "Job: {} would be deleted from schedule.".format(name) + ) + else: +- persist = kwargs.get("persist", True) ++ if kwargs.get("offline"): ++ del current_schedule[name] + +- try: +- with salt.utils.event.get_event("minion", opts=__opts__) as event_bus: +- res = __salt__["event.fire"]( +- {"name": name, "func": "delete", "persist": persist}, +- "manage_schedule", +- ) +- if res: +- event_ret = event_bus.get_event( +- tag="/salt/minion/minion_schedule_delete_complete", wait=30 ++ ret["comment"].append("Deleted job: {} from schedule.".format(name)) ++ ret["changes"][name] = "removed" ++ ++ else: ++ persist = kwargs.get("persist", True) ++ try: ++ with salt.utils.event.get_event( ++ "minion", opts=__opts__ ++ ) as event_bus: ++ res = __salt__["event.fire"]( ++ {"name": name, "func": "delete", "persist": persist}, ++ "manage_schedule", + ) +- if event_ret and event_ret["complete"]: +- _schedule_ret = event_ret["schedule"] +- if name not in _schedule_ret: +- ret["result"] = True +- ret["comment"].append( +- "Deleted job: {} from schedule.".format(name) +- ) +- else: +- ret["comment"].append( +- "Failed to delete job {} from schedule.".format( +- name ++ if res: ++ event_ret = event_bus.get_event( ++ tag="/salt/minion/minion_schedule_delete_complete", ++ wait=30, ++ ) ++ if event_ret and event_ret["complete"]: ++ _schedule_ret = event_ret["schedule"] ++ if name not in _schedule_ret: ++ ret["result"] = True ++ ret["changes"][name] = "removed" ++ ret["comment"].append( ++ "Deleted job: {} from schedule.".format(name) + ) +- ) +- ret["result"] = True ++ else: ++ ret["comment"].append( ++ "Failed to delete job {} from schedule.".format( ++ name ++ ) ++ ) ++ ret["result"] = True ++ ++ except KeyError: ++ # Effectively a no-op, since we can't really return without an event system ++ ret["comment"] = "Event module not available. Schedule add failed." ++ ret["result"] = True ++ ++ # wait until the end to write file in offline mode ++ if kwargs.get("offline"): ++ schedule_conf = _get_schedule_config_file() ++ ++ try: ++ with salt.utils.files.fopen(schedule_conf, "wb+") as fp_: ++ fp_.write( ++ salt.utils.stringutils.to_bytes( ++ salt.utils.yaml.safe_dump({"schedule": current_schedule}) ++ ) ++ ) ++ except OSError: ++ log.error( ++ "Failed to persist the updated schedule", ++ exc_info_on_loglevel=logging.DEBUG, ++ ) + +- except KeyError: +- # Effectively a no-op, since we can't really return without an event system +- ret["comment"] = "Event module not available. Schedule add failed." +- ret["result"] = True + return ret + + +@@ -245,6 +315,10 @@ def delete(name, **kwargs): + .. code-block:: bash + + salt '*' schedule.delete job1 ++ ++ # Delete job on Salt minion when the Salt minion is not running ++ salt '*' schedule.delete job1 ++ + """ + + ret = { +@@ -260,45 +334,86 @@ def delete(name, **kwargs): + ret["comment"] = "Job: {} would be deleted from schedule.".format(name) + ret["result"] = True + else: +- persist = kwargs.get("persist", True) ++ if kwargs.get("offline"): ++ current_schedule = list_( ++ show_all=True, ++ where="opts", ++ return_yaml=False, ++ offline=kwargs.get("offline"), ++ ) + +- if name in list_(show_all=True, where="opts", return_yaml=False): +- event_data = {"name": name, "func": "delete", "persist": persist} +- elif name in list_(show_all=True, where="pillar", return_yaml=False): +- event_data = { +- "name": name, +- "where": "pillar", +- "func": "delete", +- "persist": False, +- } +- else: +- ret["comment"] = "Job {} does not exist.".format(name) +- return ret ++ del current_schedule[name] + +- try: +- with salt.utils.event.get_event("minion", opts=__opts__) as event_bus: +- res = __salt__["event.fire"](event_data, "manage_schedule") +- if res: +- event_ret = event_bus.get_event( +- tag="/salt/minion/minion_schedule_delete_complete", +- wait=30, ++ schedule_conf = _get_schedule_config_file() ++ ++ try: ++ with salt.utils.files.fopen(schedule_conf, "wb+") as fp_: ++ fp_.write( ++ salt.utils.stringutils.to_bytes( ++ salt.utils.yaml.safe_dump({"schedule": current_schedule}) ++ ) + ) +- if event_ret and event_ret["complete"]: +- schedule = event_ret["schedule"] +- if name not in schedule: +- ret["result"] = True +- ret["comment"] = "Deleted Job {} from schedule.".format( +- name +- ) +- ret["changes"][name] = "removed" +- else: +- ret[ +- "comment" +- ] = "Failed to delete job {} from schedule.".format(name) +- return ret +- except KeyError: +- # Effectively a no-op, since we can't really return without an event system +- ret["comment"] = "Event module not available. Schedule add failed." ++ except OSError: ++ log.error( ++ "Failed to persist the updated schedule", ++ exc_info_on_loglevel=logging.DEBUG, ++ ) ++ ++ ret["result"] = True ++ ret["comment"] = "Deleted Job {} from schedule.".format(name) ++ ret["changes"][name] = "removed" ++ else: ++ persist = kwargs.get("persist", True) ++ ++ if name in list_( ++ show_all=True, ++ where="opts", ++ return_yaml=False, ++ offline=kwargs.get("offline"), ++ ): ++ event_data = {"name": name, "func": "delete", "persist": persist} ++ elif name in list_( ++ show_all=True, ++ where="pillar", ++ return_yaml=False, ++ offline=kwargs.get("offline"), ++ ): ++ event_data = { ++ "name": name, ++ "where": "pillar", ++ "func": "delete", ++ "persist": False, ++ } ++ else: ++ ret["comment"] = "Job {} does not exist.".format(name) ++ return ret ++ ++ try: ++ with salt.utils.event.get_event("minion", opts=__opts__) as event_bus: ++ res = __salt__["event.fire"](event_data, "manage_schedule") ++ if res: ++ event_ret = event_bus.get_event( ++ tag="/salt/minion/minion_schedule_delete_complete", ++ wait=30, ++ ) ++ if event_ret and event_ret["complete"]: ++ schedule = event_ret["schedule"] ++ if name not in schedule: ++ ret["result"] = True ++ ret["comment"] = "Deleted Job {} from schedule.".format( ++ name ++ ) ++ ret["changes"][name] = "removed" ++ else: ++ ret[ ++ "comment" ++ ] = "Failed to delete job {} from schedule.".format( ++ name ++ ) ++ return ret ++ except KeyError: ++ # Effectively a no-op, since we can't really return without an event system ++ ret["comment"] = "Event module not available. Schedule add failed." + return ret + + +@@ -438,6 +553,10 @@ def add(name, **kwargs): + salt '*' schedule.add job1 function='test.ping' seconds=3600 + # If function have some arguments, use job_args + salt '*' schedule.add job2 function='cmd.run' job_args="['date >> /tmp/date.log']" seconds=60 ++ ++ # Add job to Salt minion when the Salt minion is not running ++ salt '*' schedule.add job1 function='test.ping' seconds=3600 offline=True ++ + """ + + ret = { +@@ -445,8 +564,11 @@ def add(name, **kwargs): + "result": False, + "changes": {}, + } ++ current_schedule = list_( ++ show_all=True, return_yaml=False, offline=kwargs.get("offline") ++ ) + +- if name in list_(show_all=True, return_yaml=False): ++ if name in current_schedule: + ret["comment"] = "Job {} already exists in schedule.".format(name) + ret["result"] = False + return ret +@@ -486,32 +608,56 @@ def add(name, **kwargs): + ret["comment"] = "Job: {} would be added to schedule.".format(name) + ret["result"] = True + else: +- try: +- with salt.utils.event.get_event("minion", opts=__opts__) as event_bus: +- res = __salt__["event.fire"]( +- { +- "name": name, +- "schedule": schedule_data, +- "func": "add", +- "persist": persist, +- }, +- "manage_schedule", ++ if kwargs.get("offline"): ++ current_schedule.update(schedule_data) ++ ++ schedule_conf = _get_schedule_config_file() ++ ++ try: ++ with salt.utils.files.fopen(schedule_conf, "wb+") as fp_: ++ fp_.write( ++ salt.utils.stringutils.to_bytes( ++ salt.utils.yaml.safe_dump({"schedule": current_schedule}) ++ ) ++ ) ++ except OSError: ++ log.error( ++ "Failed to persist the updated schedule", ++ exc_info_on_loglevel=logging.DEBUG, + ) +- if res: +- event_ret = event_bus.get_event( +- tag="/salt/minion/minion_schedule_add_complete", +- wait=30, ++ ++ ret["result"] = True ++ ret["comment"] = "Added job: {} to schedule.".format(name) ++ ret["changes"][name] = "added" ++ else: ++ try: ++ with salt.utils.event.get_event("minion", opts=__opts__) as event_bus: ++ res = __salt__["event.fire"]( ++ { ++ "name": name, ++ "schedule": schedule_data, ++ "func": "add", ++ "persist": persist, ++ }, ++ "manage_schedule", + ) +- if event_ret and event_ret["complete"]: +- schedule = event_ret["schedule"] +- if name in schedule: +- ret["result"] = True +- ret["comment"] = "Added job: {} to schedule.".format(name) +- ret["changes"][name] = "added" +- return ret +- except KeyError: +- # Effectively a no-op, since we can't really return without an event system +- ret["comment"] = "Event module not available. Schedule add failed." ++ if res: ++ event_ret = event_bus.get_event( ++ tag="/salt/minion/minion_schedule_add_complete", ++ wait=30, ++ ) ++ if event_ret and event_ret["complete"]: ++ schedule = event_ret["schedule"] ++ if name in schedule: ++ ret["result"] = True ++ ret["comment"] = "Added job: {} to schedule.".format( ++ name ++ ) ++ ret["changes"][name] = "added" ++ return ret ++ except KeyError: ++ # Effectively a no-op, since we can't really return without an event system ++ ret["comment"] = "Event module not available. Schedule add failed." + return ret + + +@@ -524,6 +670,10 @@ def modify(name, **kwargs): + .. code-block:: bash + + salt '*' schedule.modify job1 function='test.ping' seconds=3600 ++ ++ # Modify job on Salt minion when the Salt minion is not running ++ salt '*' schedule.modify job1 function='test.ping' seconds=3600 offline=True ++ + """ + + ret = {"comment": "", "changes": {}, "result": True} +@@ -549,7 +699,9 @@ def modify(name, **kwargs): + ret["comment"] = 'Unable to use "when" and "cron" options together. Ignoring.' + return ret + +- current_schedule = list_(show_all=True, return_yaml=False) ++ current_schedule = list_( ++ show_all=True, return_yaml=False, offline=kwargs.get("offline") ++ ) + + if name not in current_schedule: + ret["comment"] = "Job {} does not exist in schedule.".format(name) +@@ -566,8 +718,7 @@ def modify(name, **kwargs): + _current["seconds"] = _current.pop("_seconds") + + # Copy _current _new, then update values from kwargs +- _new = pycopy.deepcopy(_current) +- _new.update(kwargs) ++ _new = build_schedule_item(name, **kwargs) + + # Remove test from kwargs, it's not a valid schedule option + _new.pop("test", None) +@@ -587,29 +738,51 @@ def modify(name, **kwargs): + if "test" in kwargs and kwargs["test"]: + ret["comment"] = "Job: {} would be modified in schedule.".format(name) + else: +- persist = kwargs.get("persist", True) +- if name in list_(show_all=True, where="opts", return_yaml=False): +- event_data = { +- "name": name, +- "schedule": _new, +- "func": "modify", +- "persist": persist, +- } +- elif name in list_(show_all=True, where="pillar", return_yaml=False): +- event_data = { +- "name": name, +- "schedule": _new, +- "where": "pillar", +- "func": "modify", +- "persist": False, +- } ++ if kwargs.get("offline"): ++ current_schedule[name].update(_new) + +- out = __salt__["event.fire"](event_data, "manage_schedule") +- if out: ++ schedule_conf = _get_schedule_config_file() ++ ++ try: ++ with salt.utils.files.fopen(schedule_conf, "wb+") as fp_: ++ fp_.write( ++ salt.utils.stringutils.to_bytes( ++ salt.utils.yaml.safe_dump({"schedule": current_schedule}) ++ ) ++ ) ++ except OSError: ++ log.error( ++ "Failed to persist the updated schedule", ++ exc_info_on_loglevel=logging.DEBUG, ++ ) ++ ++ ret["result"] = True + ret["comment"] = "Modified job: {} in schedule.".format(name) ++ + else: +- ret["comment"] = "Failed to modify job {} in schedule.".format(name) +- ret["result"] = False ++ persist = kwargs.get("persist", True) ++ if name in list_(show_all=True, where="opts", return_yaml=False): ++ event_data = { ++ "name": name, ++ "schedule": _new, ++ "func": "modify", ++ "persist": persist, ++ } ++ elif name in list_(show_all=True, where="pillar", return_yaml=False): ++ event_data = { ++ "name": name, ++ "schedule": _new, ++ "where": "pillar", ++ "func": "modify", ++ "persist": False, ++ } ++ ++ out = __salt__["event.fire"](event_data, "manage_schedule") ++ if out: ++ ret["comment"] = "Modified job: {} in schedule.".format(name) ++ else: ++ ret["comment"] = "Failed to modify job {} in schedule.".format(name) ++ ret["result"] = False + return ret + + +diff --git a/tests/pytests/unit/modules/test_schedule.py b/tests/pytests/unit/modules/test_schedule.py +index e6cb134982..02914be82f 100644 +--- a/tests/pytests/unit/modules/test_schedule.py ++++ b/tests/pytests/unit/modules/test_schedule.py +@@ -8,7 +8,8 @@ import pytest + import salt.modules.schedule as schedule + import salt.utils.odict + from salt.utils.event import SaltEvent +-from tests.support.mock import MagicMock, patch ++from salt.utils.odict import OrderedDict ++from tests.support.mock import MagicMock, call, mock_open, patch + + log = logging.getLogger(__name__) + +@@ -29,6 +30,11 @@ def sock_dir(tmp_path): + return str(tmp_path / "test-socks") + + ++@pytest.fixture ++def schedule_config_file(tmp_path): ++ return "/etc/salt/minion.d/_schedule.conf" ++ ++ + @pytest.fixture + def configure_loader_modules(): + return {schedule: {}} +@@ -36,24 +42,56 @@ def configure_loader_modules(): + + # 'purge' function tests: 1 + @pytest.mark.slow_test +-def test_purge(sock_dir): ++def test_purge(sock_dir, job1, schedule_config_file): + """ + Test if it purge all the jobs currently scheduled on the minion. + """ ++ _schedule_data = {"job1": job1} + with patch.dict(schedule.__opts__, {"schedule": {}, "sock_dir": sock_dir}): + mock = MagicMock(return_value=True) + with patch.dict(schedule.__salt__, {"event.fire": mock}): + _ret_value = {"complete": True, "schedule": {}} + with patch.object(SaltEvent, "get_event", return_value=_ret_value): +- assert schedule.purge() == { +- "comment": ["Deleted job: schedule from schedule."], ++ with patch.object( ++ schedule, "list_", MagicMock(return_value=_schedule_data) ++ ): ++ assert schedule.purge() == { ++ "comment": ["Deleted job: job1 from schedule."], ++ "changes": {"job1": "removed"}, ++ "result": True, ++ } ++ ++ _schedule_data = {"job1": job1, "job2": job1, "job3": job1} ++ comm = [ ++ "Deleted job: job1 from schedule.", ++ "Deleted job: job2 from schedule.", ++ "Deleted job: job3 from schedule.", ++ ] ++ ++ changes = {"job1": "removed", "job2": "removed", "job3": "removed"} ++ ++ with patch.dict( ++ schedule.__opts__, {"schedule": {"job1": "salt"}, "sock_dir": sock_dir} ++ ): ++ with patch("salt.utils.files.fopen", mock_open(read_data="")) as fopen_mock: ++ with patch.object( ++ schedule, "list_", MagicMock(return_value=_schedule_data) ++ ): ++ assert schedule.purge(offline=True) == { ++ "comment": comm, ++ "changes": changes, + "result": True, + } ++ _call = call(b"schedule: {}\n") ++ write_calls = fopen_mock.filehandles[schedule_config_file][ ++ 0 ++ ].write._mock_mock_calls ++ assert _call in write_calls + + + # 'delete' function tests: 1 + @pytest.mark.slow_test +-def test_delete(sock_dir): ++def test_delete(sock_dir, job1, schedule_config_file): + """ + Test if it delete a job from the minion's schedule. + """ +@@ -68,6 +106,28 @@ def test_delete(sock_dir): + "result": False, + } + ++ _schedule_data = {"job1": job1} ++ comm = "Deleted Job job1 from schedule." ++ changes = {"job1": "removed"} ++ with patch.dict( ++ schedule.__opts__, {"schedule": {"job1": "salt"}, "sock_dir": sock_dir} ++ ): ++ with patch("salt.utils.files.fopen", mock_open(read_data="")) as fopen_mock: ++ with patch.object( ++ schedule, "list_", MagicMock(return_value=_schedule_data) ++ ): ++ assert schedule.delete("job1", offline="True") == { ++ "comment": comm, ++ "changes": changes, ++ "result": True, ++ } ++ ++ _call = call(b"schedule: {}\n") ++ write_calls = fopen_mock.filehandles[schedule_config_file][ ++ 0 ++ ].write._mock_mock_calls ++ assert _call in write_calls ++ + + # 'build_schedule_item' function tests: 1 + def test_build_schedule_item(sock_dir): +@@ -120,7 +180,7 @@ def test_build_schedule_item_invalid_when(sock_dir): + + + @pytest.mark.slow_test +-def test_add(sock_dir): ++def test_add(sock_dir, schedule_config_file): + """ + Test if it add a job to the schedule. + """ +@@ -163,6 +223,24 @@ def test_add(sock_dir): + "result": True, + } + ++ comm1 = "Added job: job3 to schedule." ++ changes1 = {"job3": "added"} ++ with patch.dict( ++ schedule.__opts__, {"schedule": {"job1": "salt"}, "sock_dir": sock_dir} ++ ): ++ with patch("salt.utils.files.fopen", mock_open(read_data="")) as fopen_mock: ++ assert schedule.add( ++ "job3", function="test.ping", seconds=3600, offline="True" ++ ) == {"comment": comm1, "changes": changes1, "result": True} ++ ++ _call = call( ++ b"schedule:\n job3: {function: test.ping, seconds: 3600, maxrunning: 1, name: job3, enabled: true,\n jid_include: true}\n" ++ ) ++ write_calls = fopen_mock.filehandles[schedule_config_file][ ++ 1 ++ ].write._mock_mock_calls ++ assert _call in write_calls ++ + + # 'run_job' function tests: 1 + +@@ -444,7 +522,7 @@ def test_copy(sock_dir, job1): + + + @pytest.mark.slow_test +-def test_modify(sock_dir): ++def test_modify(sock_dir, job1, schedule_config_file): + """ + Test if modifying job to the schedule. + """ +@@ -564,7 +642,6 @@ def test_modify(sock_dir): + for key in [ + "maxrunning", + "function", +- "seconds", + "jid_include", + "name", + "enabled", +@@ -586,6 +663,51 @@ def test_modify(sock_dir): + ret = schedule.modify("job2", function="test.version", test=True) + assert ret == expected5 + ++ _schedule_data = {"job1": job1} ++ comm = "Modified job: job1 in schedule." ++ changes = {"job1": "removed"} ++ ++ changes = { ++ "job1": { ++ "new": OrderedDict( ++ [ ++ ("function", "test.version"), ++ ("maxrunning", 1), ++ ("name", "job1"), ++ ("enabled", True), ++ ("jid_include", True), ++ ] ++ ), ++ "old": OrderedDict( ++ [ ++ ("function", "test.ping"), ++ ("maxrunning", 1), ++ ("name", "job1"), ++ ("jid_include", True), ++ ("enabled", True), ++ ] ++ ), ++ } ++ } ++ with patch.dict( ++ schedule.__opts__, {"schedule": {"job1": "salt"}, "sock_dir": sock_dir} ++ ): ++ with patch("salt.utils.files.fopen", mock_open(read_data="")) as fopen_mock: ++ with patch.object( ++ schedule, "list_", MagicMock(return_value=_schedule_data) ++ ): ++ assert schedule.modify( ++ "job1", function="test.version", offline="True" ++ ) == {"comment": comm, "changes": changes, "result": True} ++ ++ _call = call( ++ b"schedule:\n job1: {enabled: true, function: test.version, jid_include: true, maxrunning: 1,\n name: job1}\n" ++ ) ++ write_calls = fopen_mock.filehandles[schedule_config_file][ ++ 0 ++ ].write._mock_mock_calls ++ assert _call in write_calls ++ + + # 'is_enabled' function tests: 1 + +-- +2.37.2 + + diff --git a/fopen-workaround-bad-buffering-for-binary-mode-563.patch b/fopen-workaround-bad-buffering-for-binary-mode-563.patch new file mode 100644 index 0000000..c936a05 --- /dev/null +++ b/fopen-workaround-bad-buffering-for-binary-mode-563.patch @@ -0,0 +1,106 @@ +From 6c1c81aba71711632a14b725426077f9183065e9 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Pablo=20Su=C3=A1rez=20Hern=C3=A1ndez?= + +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 + +Co-authored-by: Ismael Luceno +--- + 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 + + diff --git a/ignore-non-utf8-characters-while-reading-files-with-.patch b/ignore-non-utf8-characters-while-reading-files-with-.patch new file mode 100644 index 0000000..65942b1 --- /dev/null +++ b/ignore-non-utf8-characters-while-reading-files-with-.patch @@ -0,0 +1,213 @@ +From b4945a0608b3d8996e8b5593dcc458c15b11d6ba Mon Sep 17 00:00:00 2001 +From: Victor Zhestkov +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 + + ++@pytest.mark.skip_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 ++ ++ ++@pytest.mark.skip_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 != {} ++ ++ ++@pytest.mark.skip_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 + diff --git a/make-pass-renderer-configurable-other-fixes-532.patch b/make-pass-renderer-configurable-other-fixes-532.patch new file mode 100644 index 0000000..bb79dbf --- /dev/null +++ b/make-pass-renderer-configurable-other-fixes-532.patch @@ -0,0 +1,414 @@ +From 7b4f5007b7e6a35386d197afe53d02c8d7b41d53 Mon Sep 17 00:00:00 2001 +From: Daniel Mach +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 +--- + 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: ++ ++ # Set PASSWORD_STORE_DIR env for Pass. ++ # Defaults to: ~/.password-store ++ pass_dir: + """ + + +@@ -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") ++ ++ ++@pytest.fixture ++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 + + diff --git a/retry-if-rpm-lock-is-temporarily-unavailable-547.patch b/retry-if-rpm-lock-is-temporarily-unavailable-547.patch new file mode 100644 index 0000000..ceb3777 --- /dev/null +++ b/retry-if-rpm-lock-is-temporarily-unavailable-547.patch @@ -0,0 +1,297 @@ +From cedde1082b3a11b941327ba8e213f44637fb8a6b Mon Sep 17 00:00:00 2001 +From: Witek Bedyk +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 + +* Sync formating fixes from upstream + +Signed-off-by: Witek Bedyk +--- + 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!' + ) + 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 + + diff --git a/salt.changes b/salt.changes index 5007feb..3829959 100644 --- a/salt.changes +++ b/salt.changes @@ -1,3 +1,43 @@ +------------------------------------------------------------------- +Thu Oct 6 10:10:16 UTC 2022 - Pablo Suárez Hernández + +- 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 + +- 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 + +- Fix test_ipc unit test + +- Added: + * fix-test_ipc-unit-tests.patch + ------------------------------------------------------------------- Fri Jul 8 09:45:54 UTC 2022 - Pablo Suárez Hernández diff --git a/salt.spec b/salt.spec index f1c9331..14b9636 100644 --- a/salt.spec +++ b/salt.spec @@ -330,6 +330,26 @@ Patch90: fix-salt.states.file.managed-for-follow_symlinks-tru.patch 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