SHA256
1
0
forked from pool/salt
salt/do-not-make-ansiblegate-to-crash-on-python3-minions.patch

571 lines
20 KiB
Diff

From 5d465a5b392efa1b4df7870161b32e0125efa4af Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Pablo=20Su=C3=A1rez=20Hern=C3=A1ndez?=
<psuarezhernandez@suse.com>
Date: Fri, 28 Jun 2019 15:17:56 +0100
Subject: [PATCH] Do not make ansiblegate to crash on Python3 minions
Fix pylint issues
Move MockTimedProc implementation to tests.support.mock
Add unit test for ansible caller
---
salt/modules/ansiblegate.py | 7 +-
tests/support/mock.py | 128 +++++++++-------
tests/unit/modules/test_ansiblegate.py | 201 +++++++++++++++++++++++++
tests/unit/modules/test_cmdmod.py | 1 +
4 files changed, 280 insertions(+), 57 deletions(-)
create mode 100644 tests/unit/modules/test_ansiblegate.py
diff --git a/salt/modules/ansiblegate.py b/salt/modules/ansiblegate.py
index 0279a26017..5d4b986ec2 100644
--- a/salt/modules/ansiblegate.py
+++ b/salt/modules/ansiblegate.py
@@ -160,6 +160,7 @@ class AnsibleModuleCaller:
:param kwargs: keywords to the module
:return:
"""
+ python_exec = "python3"
module = self._resolver.load_module(module)
if not hasattr(module, "main"):
@@ -182,9 +183,9 @@ class AnsibleModuleCaller:
timeout=self.timeout,
)
proc_out.run()
- proc_out_stdout = salt.utils.stringutils.to_str(proc_out.stdout)
+ proc_out_stdout = proc_out.stdout.decode()
proc_exc = salt.utils.timed_subprocess.TimedProc(
- [sys.executable, module.__file__],
+ [python_exec, module.__file__],
stdin=proc_out_stdout,
stdout=subprocess.PIPE,
timeout=self.timeout,
@@ -298,7 +299,7 @@ def help(module=None, *args):
'Available sections on module "{}"'.format(
module.__name__.replace("ansible.modules.", "")
)
- ] = list(doc)
+ ] = [i for i in doc.keys()]
else:
for arg in args:
info = doc.get(arg)
diff --git a/tests/support/mock.py b/tests/support/mock.py
index 7ef02e0701..87d052c399 100644
--- a/tests/support/mock.py
+++ b/tests/support/mock.py
@@ -1,4 +1,3 @@
-# -*- coding: utf-8 -*-
"""
:codeauthor: Pedro Algarvio (pedro@algarvio.me)
@@ -14,7 +13,6 @@
"""
# pylint: disable=unused-import,function-redefined,blacklisted-module,blacklisted-external-module
-from __future__ import absolute_import
import collections
import copy
@@ -42,8 +40,6 @@ from mock import (
patch,
sentinel,
)
-
-# Import salt libs
from salt.ext import six
# pylint: disable=no-name-in-module,no-member
@@ -57,7 +53,7 @@ if sys.version_info < (3, 6) and __mock_version < (2,):
raise ImportError("Please install mock>=2.0.0")
-class MockFH(object):
+class MockFH:
def __init__(self, filename, read_data, *args, **kwargs):
self.filename = filename
self.read_data = read_data
@@ -89,7 +85,7 @@ class MockFH(object):
"""
# Newline will always be a bytestring on PY2 because mock_open will have
# normalized it to one.
- newline = b"\n" if isinstance(read_data, six.binary_type) else "\n"
+ newline = b"\n" if isinstance(read_data, bytes) else "\n"
read_data = [line + newline for line in read_data.split(newline)]
@@ -103,8 +99,7 @@ class MockFH(object):
# newline that we added in the list comprehension.
read_data[-1] = read_data[-1][:-1]
- for line in read_data:
- yield line
+ yield from read_data
@property
def write_calls(self):
@@ -126,18 +121,18 @@ class MockFH(object):
def __check_read_data(self):
if not self.__read_data_ok:
if self.binary_mode:
- if not isinstance(self.read_data, six.binary_type):
+ if not isinstance(self.read_data, bytes):
raise TypeError(
- "{0} opened in binary mode, expected read_data to be "
- "bytes, not {1}".format(
+ "{} opened in binary mode, expected read_data to be "
+ "bytes, not {}".format(
self.filename, type(self.read_data).__name__
)
)
else:
if not isinstance(self.read_data, str):
raise TypeError(
- "{0} opened in non-binary mode, expected read_data to "
- "be str, not {1}".format(
+ "{} opened in non-binary mode, expected read_data to "
+ "be str, not {}".format(
self.filename, type(self.read_data).__name__
)
)
@@ -147,8 +142,8 @@ class MockFH(object):
def _read(self, size=0):
self.__check_read_data()
if not self.read_mode:
- raise IOError("File not open for reading")
- if not isinstance(size, six.integer_types) or size < 0:
+ raise OSError("File not open for reading")
+ if not isinstance(size, int) or size < 0:
raise TypeError("a positive integer is required")
joined = self.empty_string.join(self.read_data_iter)
@@ -169,7 +164,7 @@ class MockFH(object):
# TODO: Implement "size" argument
self.__check_read_data()
if not self.read_mode:
- raise IOError("File not open for reading")
+ raise OSError("File not open for reading")
ret = list(self.read_data_iter)
self.__loc += sum(len(x) for x in ret)
return ret
@@ -178,7 +173,7 @@ class MockFH(object):
# TODO: Implement "size" argument
self.__check_read_data()
if not self.read_mode:
- raise IOError("File not open for reading")
+ raise OSError("File not open for reading")
try:
ret = next(self.read_data_iter)
self.__loc += len(ret)
@@ -189,7 +184,7 @@ class MockFH(object):
def __iter__(self):
self.__check_read_data()
if not self.read_mode:
- raise IOError("File not open for reading")
+ raise OSError("File not open for reading")
while True:
try:
ret = next(self.read_data_iter)
@@ -200,30 +195,22 @@ class MockFH(object):
def _write(self, content):
if not self.write_mode:
- raise IOError("File not open for writing")
- if six.PY2:
- if isinstance(content, six.text_type):
- # encoding intentionally not specified to force a
- # UnicodeEncodeError when non-ascii unicode type is passed
- content.encode()
- else:
- content_type = type(content)
- if self.binary_mode and content_type is not bytes:
- raise TypeError(
- "a bytes-like object is required, not '{0}'".format(
- content_type.__name__
- )
- )
- elif not self.binary_mode and content_type is not str:
- raise TypeError(
- "write() argument must be str, not {0}".format(
- content_type.__name__
- )
+ raise OSError("File not open for writing")
+ content_type = type(content)
+ if self.binary_mode and content_type is not bytes:
+ raise TypeError(
+ "a bytes-like object is required, not '{}'".format(
+ content_type.__name__
)
+ )
+ elif not self.binary_mode and content_type is not str:
+ raise TypeError(
+ "write() argument must be str, not {}".format(content_type.__name__)
+ )
def _writelines(self, lines):
if not self.write_mode:
- raise IOError("File not open for writing")
+ raise OSError("File not open for writing")
for line in lines:
self._write(line)
@@ -234,26 +221,24 @@ class MockFH(object):
pass
-class MockCall(object):
+class MockCall:
def __init__(self, *args, **kwargs):
self.args = args
self.kwargs = kwargs
def __repr__(self):
# future lint: disable=blacklisted-function
- ret = str("MockCall(")
+ ret = "MockCall("
for arg in self.args:
- ret += repr(arg) + str(", ")
+ ret += repr(arg) + ", "
if not self.kwargs:
if self.args:
# Remove trailing ', '
ret = ret[:-2]
else:
- for key, val in six.iteritems(self.kwargs):
- ret += str("{0}={1}").format(
- salt.utils.stringutils.to_str(key), repr(val)
- )
- ret += str(")")
+ for key, val in self.kwargs.items():
+ ret += "{}={}".format(salt.utils.stringutils.to_str(key), repr(val))
+ ret += ")"
return ret
# future lint: enable=blacklisted-function
@@ -264,7 +249,7 @@ class MockCall(object):
return self.args == other.args and self.kwargs == other.kwargs
-class MockOpen(object):
+class MockOpen:
r'''
This class can be used to mock the use of ``open()``.
@@ -379,7 +364,7 @@ class MockOpen(object):
# .__class__() used here to preserve the dict class in the event that
# an OrderedDict was used.
new_read_data = read_data.__class__()
- for key, val in six.iteritems(read_data):
+ for key, val in read_data.items():
try:
val = salt.utils.data.decode(val, to_str=True)
except TypeError:
@@ -424,7 +409,7 @@ class MockOpen(object):
except IndexError:
# We've run out of file contents, abort!
raise RuntimeError(
- "File matching expression '{0}' opened more times than "
+ "File matching expression '{}' opened more times than "
"expected".format(matched_pattern)
)
@@ -443,7 +428,7 @@ class MockOpen(object):
except KeyError:
# No matching glob in read_data, treat this as a file that does
# not exist and raise the appropriate exception.
- raise IOError(errno.ENOENT, "No such file or directory", name)
+ raise OSError(errno.ENOENT, "No such file or directory", name)
def write_calls(self, path=None):
"""
@@ -451,7 +436,7 @@ class MockOpen(object):
the results to files matching a given pattern.
"""
ret = []
- for filename, handles in six.iteritems(self.filehandles):
+ for filename, handles in self.filehandles.items():
if path is None or fnmatch.fnmatch(filename, path):
for fh_ in handles:
ret.extend(fh_.write_calls)
@@ -463,19 +448,54 @@ class MockOpen(object):
narrow the results to files matching a given pattern.
"""
ret = []
- for filename, handles in six.iteritems(self.filehandles):
+ for filename, handles in self.filehandles.items():
if path is None or fnmatch.fnmatch(filename, path):
for fh_ in handles:
ret.extend(fh_.writelines_calls)
return ret
-class MockTimedProc(object):
+class MockTimedProc:
+ """
+ Class used as a stand-in for salt.utils.timed_subprocess.TimedProc
+ """
+
+ class _Process:
+ """
+ Used to provide a dummy "process" attribute
+ """
+
+ def __init__(self, returncode=0, pid=12345):
+ self.returncode = returncode
+ self.pid = pid
+
+ def __init__(self, stdout=None, stderr=None, returncode=0, pid=12345):
+ if stdout is not None and not isinstance(stdout, bytes):
+ raise TypeError("Must pass stdout to MockTimedProc as bytes")
+ if stderr is not None and not isinstance(stderr, bytes):
+ raise TypeError("Must pass stderr to MockTimedProc as bytes")
+ self._stdout = stdout
+ self._stderr = stderr
+ self.process = self._Process(returncode=returncode, pid=pid)
+
+ def run(self):
+ pass
+
+ @property
+ def stdout(self):
+ return self._stdout
+
+ @property
+ def stderr(self):
+ return self._stderr
+
+
+class MockTimedProc:
"""
Class used as a stand-in for salt.utils.timed_subprocess.TimedProc
"""
- class _Process(object):
+ class _Process:
"""
Used to provide a dummy "process" attribute
"""
diff --git a/tests/unit/modules/test_ansiblegate.py b/tests/unit/modules/test_ansiblegate.py
new file mode 100644
index 0000000000..61aad44b5c
--- /dev/null
+++ b/tests/unit/modules/test_ansiblegate.py
@@ -0,0 +1,201 @@
+#
+# Author: Bo Maryniuk <bo@suse.de>
+#
+# Copyright 2017 SUSE LLC
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import os
+
+import salt.modules.ansiblegate as ansible
+import salt.utils.platform
+from salt.exceptions import LoaderError
+from salt.ext import six
+from tests.support.mixins import LoaderModuleMockMixin
+from tests.support.mock import MagicMock, MockTimedProc, patch
+from tests.support.unit import TestCase, skipIf
+
+try:
+ import pytest
+except ImportError as import_error:
+ pytest = None
+NO_PYTEST = not bool(pytest)
+
+
+@skipIf(NO_PYTEST, False)
+@skipIf(salt.utils.platform.is_windows(), "Not supported on Windows")
+class AnsiblegateTestCase(TestCase, LoaderModuleMockMixin):
+ def setUp(self):
+ self.resolver = ansible.AnsibleModuleResolver({})
+ self.resolver._modules_map = {
+ "one.two.three": os.sep + os.path.join("one", "two", "three.py"),
+ "four.five.six": os.sep + os.path.join("four", "five", "six.py"),
+ "three.six.one": os.sep + os.path.join("three", "six", "one.py"),
+ }
+
+ def tearDown(self):
+ self.resolver = None
+
+ def setup_loader_modules(self):
+ return {ansible: {}}
+
+ def test_ansible_module_help(self):
+ """
+ Test help extraction from the module
+ :return:
+ """
+
+ class Module:
+ """
+ An ansible module mock.
+ """
+
+ __name__ = "foo"
+ DOCUMENTATION = """
+---
+one:
+ text here
+---
+two:
+ text here
+description:
+ describe the second part
+ """
+
+ with patch.object(ansible, "_resolver", self.resolver), patch.object(
+ ansible._resolver, "load_module", MagicMock(return_value=Module())
+ ):
+ ret = ansible.help("dummy")
+ assert sorted(
+ ret.get('Available sections on module "{}"'.format(Module().__name__))
+ ) == ["one", "two"]
+ assert ret.get("Description") == "describe the second part"
+
+ def test_module_resolver_modlist(self):
+ """
+ Test Ansible resolver modules list.
+ :return:
+ """
+ assert self.resolver.get_modules_list() == [
+ "four.five.six",
+ "one.two.three",
+ "three.six.one",
+ ]
+ for ptr in ["five", "fi", "ve"]:
+ assert self.resolver.get_modules_list(ptr) == ["four.five.six"]
+ for ptr in ["si", "ix", "six"]:
+ assert self.resolver.get_modules_list(ptr) == [
+ "four.five.six",
+ "three.six.one",
+ ]
+ assert self.resolver.get_modules_list("one") == [
+ "one.two.three",
+ "three.six.one",
+ ]
+ assert self.resolver.get_modules_list("one.two") == ["one.two.three"]
+ assert self.resolver.get_modules_list("four") == ["four.five.six"]
+
+ def test_resolver_module_loader_failure(self):
+ """
+ Test Ansible module loader.
+ :return:
+ """
+ mod = "four.five.six"
+ with pytest.raises(ImportError) as import_error:
+ self.resolver.load_module(mod)
+
+ mod = "i.even.do.not.exist.at.all"
+ with pytest.raises(LoaderError) as loader_error:
+ self.resolver.load_module(mod)
+
+ def test_resolver_module_loader(self):
+ """
+ Test Ansible module loader.
+ :return:
+ """
+ with patch("salt.modules.ansiblegate.importlib", MagicMock()), patch(
+ "salt.modules.ansiblegate.importlib.import_module", lambda x: x
+ ):
+ assert (
+ self.resolver.load_module("four.five.six")
+ == "ansible.modules.four.five.six"
+ )
+
+ def test_resolver_module_loader_import_failure(self):
+ """
+ Test Ansible module loader failure.
+ :return:
+ """
+ with patch("salt.modules.ansiblegate.importlib", MagicMock()), patch(
+ "salt.modules.ansiblegate.importlib.import_module", lambda x: x
+ ):
+ with pytest.raises(LoaderError) as loader_error:
+ self.resolver.load_module("something.strange")
+
+ def test_virtual_function(self):
+ """
+ Test Ansible module __virtual__ when ansible is not installed on the minion.
+ :return:
+ """
+ with patch("salt.modules.ansiblegate.ansible", None):
+ assert ansible.__virtual__() == "ansible"
+
+ def test_ansible_module_call(self):
+ """
+ Test Ansible module call from ansible gate module
+
+ :return:
+ """
+
+ class Module:
+ """
+ An ansible module mock.
+ """
+
+ __name__ = "one.two.three"
+ __file__ = "foofile"
+
+ def main():
+ pass
+
+ ANSIBLE_MODULE_ARGS = '{"ANSIBLE_MODULE_ARGS": ["arg_1", {"kwarg1": "foobar"}]}'
+
+ proc = MagicMock(
+ side_effect=[
+ MockTimedProc(stdout=ANSIBLE_MODULE_ARGS.encode(), stderr=None),
+ MockTimedProc(stdout=b'{"completed": true}', stderr=None),
+ ]
+ )
+
+ with patch.object(ansible, "_resolver", self.resolver), patch.object(
+ ansible._resolver, "load_module", MagicMock(return_value=Module())
+ ):
+ _ansible_module_caller = ansible.AnsibleModuleCaller(ansible._resolver)
+ with patch("salt.utils.timed_subprocess.TimedProc", proc):
+ ret = _ansible_module_caller.call(
+ "one.two.three", "arg_1", kwarg1="foobar"
+ )
+ proc.assert_any_call(
+ [
+ "echo",
+ '{"ANSIBLE_MODULE_ARGS": {"kwarg1": "foobar", "_raw_params": "arg_1"}}',
+ ],
+ stdout=-1,
+ timeout=1200,
+ )
+ proc.assert_any_call(
+ ["python3", "foofile"],
+ stdin=ANSIBLE_MODULE_ARGS,
+ stdout=-1,
+ timeout=1200,
+ )
+ assert ret == {"completed": True, "timeout": 1200}
diff --git a/tests/unit/modules/test_cmdmod.py b/tests/unit/modules/test_cmdmod.py
index 15b97f8568..f3348bc379 100644
--- a/tests/unit/modules/test_cmdmod.py
+++ b/tests/unit/modules/test_cmdmod.py
@@ -24,6 +24,7 @@ DEFAULT_SHELL = "foo/bar"
MOCK_SHELL_FILE = "# List of acceptable shells\n" "\n" "/bin/bash\n"
+@skipIf(NO_MOCK, NO_MOCK_REASON)
class CMDMODTestCase(TestCase, LoaderModuleMockMixin):
"""
Unit tests for the salt.modules.cmdmod module
--
2.29.2