No hacks when interacting with ansible. Access all available modules, not just internal.

This commit is contained in:
Pedro Algarvio 2021-05-20 15:03:23 +01:00 committed by Gareth J. Greenaway
parent d83dd0319f
commit de6f56ccaf
5 changed files with 306 additions and 425 deletions

View file

@ -1,19 +1,6 @@
#
# 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.
"""
Ansible Support
===============
@ -27,30 +14,21 @@ The timeout is how many seconds Salt should wait for
any Ansible module to respond.
"""
import fnmatch
import importlib
import json
import logging
import os
import subprocess
import sys
from tempfile import NamedTemporaryFile
import salt.utils.decorators.path
import salt.utils.json
import salt.utils.path
import salt.utils.platform
import salt.utils.stringutils
import salt.utils.timed_subprocess
import salt.utils.yaml
from salt.exceptions import CommandExecutionError, LoaderError
from salt.utils.decorators import depends
try:
import ansible
import ansible.constants # pylint: disable=no-name-in-module
import ansible.modules # pylint: disable=no-name-in-module
except ImportError:
ansible = None
from salt.exceptions import CommandExecutionError
# Function alias to make sure not to shadow built-in's
__func_alias__ = {"list_": "list"}
@ -59,155 +37,14 @@ __virtualname__ = "ansible"
log = logging.getLogger(__name__)
INVENTORY = """
hosts:
vars:
ansible_connection: local
"""
DEFAULT_TIMEOUT = 1200 # seconds (20 minutes)
class AnsibleModuleResolver:
"""
This class is to resolve all available modules in Ansible.
"""
def __init__(self, opts):
self.opts = opts
self._modules_map = {}
def _get_modules_map(self, path=None):
"""
Get installed Ansible modules
:return:
"""
paths = {}
root = ansible.modules.__path__[0]
if not path:
path = root
for p_el in os.listdir(path):
p_el_path = os.path.join(path, p_el)
if os.path.islink(p_el_path):
continue
if os.path.isdir(p_el_path):
paths.update(self._get_modules_map(p_el_path))
else:
if (
any(p_el.startswith(elm) for elm in ["__", "."])
or not p_el.endswith(".py")
or p_el in ansible.constants.IGNORE_FILES
):
continue
p_el_path = p_el_path.replace(root, "").split(".")[0]
als_name = (
p_el_path.replace(".", "").replace("/", "", 1).replace("/", ".")
)
paths[als_name] = p_el_path
return paths
def load_module(self, module):
"""
Introspect Ansible module.
:param module:
:return:
"""
m_ref = self._modules_map.get(module)
if m_ref is None:
raise LoaderError('Module "{}" was not found'.format(module))
mod = importlib.import_module(
"ansible.modules{}".format(
".".join([elm.split(".")[0] for elm in m_ref.split(os.path.sep)])
)
)
return mod
def get_modules_list(self, pattern=None):
"""
Return module map references.
:return:
"""
if pattern and "*" not in pattern:
pattern = "*{}*".format(pattern)
modules = []
for m_name, m_path in self._modules_map.items():
m_path = m_path.split(".")[0]
m_name = ".".join([elm for elm in m_path.split(os.path.sep) if elm])
if pattern and fnmatch.fnmatch(m_name, pattern) or not pattern:
modules.append(m_name)
return sorted(modules)
def resolve(self):
log.debug("Resolving Ansible modules")
self._modules_map = self._get_modules_map()
return self
def install(self):
log.debug("Installing Ansible modules")
return self
class AnsibleModuleCaller:
DEFAULT_TIMEOUT = 1200 # seconds (20 minutes)
OPT_TIMEOUT_KEY = "ansible_timeout"
def __init__(self, resolver):
self._resolver = resolver
self.timeout = self._resolver.opts.get(
self.OPT_TIMEOUT_KEY, self.DEFAULT_TIMEOUT
)
def call(self, module, *args, **kwargs):
"""
Call an Ansible module by invoking it.
:param module: the name of the module.
:param args: Arguments to the module
:param kwargs: keywords to the module
:return:
"""
module = self._resolver.load_module(module)
if not hasattr(module, "main"):
raise CommandExecutionError(
"This module is not callable "
'(see "ansible.help {}")'.format(
module.__name__.replace("ansible.modules.", "")
)
)
if args:
kwargs["_raw_params"] = " ".join(args)
js_args = salt.utils.json.dumps({"ANSIBLE_MODULE_ARGS": kwargs})
proc_exc = subprocess.run(
[
sys.executable,
"-c",
"import sys, {0}; print({0}.main(), file=sys.stdout); sys.stdout.flush()".format(
module.__name__
),
],
input=js_args,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=self.timeout,
universal_newlines=True,
check=True,
shell=False,
)
try:
out = salt.utils.json.loads(proc_exc.stdout)
except ValueError as ex:
out = {"Error": (proc_exc.stderr and (proc_exc.stderr + ".") or str(ex))}
if proc_exc.stdout:
out["Given JSON output"] = proc_exc.stdout
return out
if "invocation" in out:
del out["invocation"]
out["timeout"] = self.timeout
return out
_resolver = None
_caller = None
__load__ = __non_ansible_functions__ = ["help", "list_", "call", "playbooks"][:]
def _set_callables(modules):
@ -221,99 +58,203 @@ def _set_callables(modules):
Create a Salt function for the Ansible module.
"""
def _cmd(*args, **kw):
def _cmd(*args, **kwargs):
"""
Call an Ansible module as a function from the Salt.
"""
kwargs = {}
for _kw in kw.get("__pub_arg", []):
if isinstance(_kw, dict):
kwargs = _kw
break
else:
kwargs = {k: v for (k, v) in kw.items() if not k.statsrtwith("__pub")}
return _caller.call(cmd_name, *args, **kwargs)
return call(cmd_name, *args, **kwargs)
_cmd.__doc__ = doc
return _cmd
for mod in modules:
setattr(sys.modules[__name__], mod, _set_function(mod, "Available"))
for mod, doc in modules.items():
__load__.append(mod)
setattr(sys.modules[__name__], mod, _set_function(mod, doc))
def __virtual__():
"""
Ansible module caller.
:return:
"""
if salt.utils.platform.is_windows():
return False, "The ansiblegate module isn't supported on Windows"
if ansible is None:
return False, "Ansible is not installed on this system"
global _resolver
global _caller
_resolver = AnsibleModuleResolver(__opts__).resolve().install()
_caller = AnsibleModuleCaller(_resolver)
_set_callables(list_())
ansible_bin = salt.utils.path.which("ansible")
if not ansible_bin:
return False, "The 'ansible' binary was not found."
ansible_doc_bin = salt.utils.path.which("ansible-doc")
if not ansible_doc_bin:
return False, "The 'ansible-doc' binary was not found."
ansible_playbook_bin = salt.utils.path.which("ansible-playbook")
if not ansible_playbook_bin:
return False, "The 'ansible-playbook' binary was not found."
proc = subprocess.run(
[ansible_doc_bin, "--list", "--json", "--type=module"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
shell=False,
universal_newlines=True,
)
if proc.returncode != 0:
return (
False,
"Failed to get the listing of ansible modules:\n{}".format(proc.stderr),
)
ansible_module_listing = salt.utils.json.loads(proc.stdout)
for key in list(ansible_module_listing):
if key.startswith("ansible."):
# Fyi, str.partition() is faster than str.replace()
_, _, alias = key.partition(".")
ansible_module_listing[alias] = ansible_module_listing[key]
_set_callables(ansible_module_listing)
return __virtualname__
@depends("ansible")
def help(module=None, *args):
"""
Display help on Ansible standard module.
:param module:
:return:
:param module: The module to get the help
CLI Example:
.. code-block:: bash
salt * ansible.help ping
"""
if not module:
raise CommandExecutionError(
"Please tell me what module you want to have helped with. "
'Or call "ansible.list" to know what is available.'
)
try:
module = _resolver.load_module(module)
except (ImportError, LoaderError) as err:
raise CommandExecutionError(
'Module "{}" is currently not functional on your system.'.format(module)
)
doc = {}
ret = {}
for docset in module.DOCUMENTATION.split("---"):
try:
docset = salt.utils.yaml.safe_load(docset)
if docset:
doc.update(docset)
except Exception as err: # pylint: disable=broad-except
log.error("Error parsing doc section: %s", err)
ansible_doc_bin = salt.utils.path.which("ansible-doc")
proc = subprocess.run(
[ansible_doc_bin, "--json", "--type=module", module],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=True,
shell=False,
universal_newlines=True,
)
data = salt.utils.json.loads(proc.stdout)
doc = data[next(iter(data))]
if not args:
if "description" in doc:
description = doc.get("description") or ""
del doc["description"]
ret["Description"] = description
ret[
'Available sections on module "{}"'.format(
module.__name__.replace("ansible.modules.", "")
)
] = list(doc)
ret = doc["doc"]
for section in ("examples", "return", "metadata"):
section_data = doc.get(section)
if section_data:
ret[section] = section_data
else:
ret = {}
for arg in args:
info = doc.get(arg)
if info is not None:
ret[arg] = info
return ret
@depends("ansible")
def list_(pattern=None):
"""
Lists available modules.
:return:
CLI Example:
.. code-block:: bash
salt * ansible.list
salt * ansible.list '*win*' # To get all modules matching 'win' on it's name
"""
return _resolver.get_modules_list(pattern=pattern)
if pattern is None:
module_list = set(__load__)
module_list.discard(set(__non_ansible_functions__))
return sorted(module_list)
return sorted(fnmatch.filter(__load__, pattern))
def call(module, *args, **kwargs):
"""
Call an Ansible module by invoking it.
:param module: the name of the module.
:param args: Arguments to pass to the module
:param kwargs: keywords to pass to the module
CLI Example:
.. code-block:: bash
salt * ansible.call ping data=foobar
"""
module_args = []
for arg in args:
module_args.append(salt.utils.json.dumps(arg))
_kwargs = {}
for _kw in kwargs.get("__pub_arg", []):
if isinstance(_kw, dict):
_kwargs = _kw
break
else:
_kwargs = {k: v for (k, v) in kwargs.items() if not k.startswith("__pub")}
for key, value in _kwargs.items():
module_args.append("{}={}".format(key, salt.utils.json.dumps(value)))
with NamedTemporaryFile(mode="w") as inventory:
ansible_binary_path = salt.utils.path.which("ansible")
log.debug("Calling ansible module %r", module)
try:
proc_exc = subprocess.run(
[
ansible_binary_path,
"localhost",
"--limit",
"127.0.0.1",
"-m",
module,
"-a",
" ".join(module_args),
"-i",
inventory.name,
],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=__opts__.get("ansible_timeout", DEFAULT_TIMEOUT),
universal_newlines=True,
check=True,
shell=False,
)
original_output = proc_exc.stdout
proc_out = original_output.splitlines()
if proc_out[0].endswith("{"):
proc_out[0] = "{"
try:
out = salt.utils.json.loads("\n".join(proc_out))
except ValueError as exc:
out = {
"Error": proc_exc.stderr or str(exc),
"Output": original_output,
}
return out
elif proc_out[0].endswith(">>"):
out = {"output": "\n".join(proc_out[1:])}
else:
out = {"output": original_output}
except subprocess.CalledProcessError as exc:
out = {"Exitcode": exc.returncode, "Error": exc.stderr or str(exc)}
if exc.stdout:
out["Given JSON output"] = exc.stdout
return out
for key in ("invocation", "changed"):
out.pop(key, None)
return out
@salt.utils.decorators.path.which("ansible-playbook")

View file

@ -1,44 +1,43 @@
import pytest
import salt.modules.ansiblegate as ansiblegate
pytestmark = [
pytest.mark.skipif(ansiblegate.ansible is None, reason="Ansible is not installed"),
pytest.mark.skip_on_windows(reason="Not supported on Windows"),
pytest.mark.skip_if_binaries_missing(
"ansible",
"ansible-doc",
"ansible-playbook",
check_all=True,
reason="ansible is not installed",
),
]
def test_ansible_functions_loaded(modules):
"""
Test that the ansible functions are actually loaded
"""
@pytest.fixture
def ansible_ping_func(modules):
if "ansible.system.ping" in modules:
# we need to go by getattr() because salt's loader will try to find "system" in the dictionary and fail
# The ansible hack injects, in this case, "system.ping" as an attribute to the loaded module
ret = getattr(modules.ansible, "system.ping")()
elif "ansible.ping" in modules:
# Ansible >= 2.10
ret = modules.ansible.ping()
else:
pytest.fail("Where is the ping function these days in Ansible?!")
return getattr(modules.ansible, "system.ping")
ret.pop("timeout", None)
if "ansible.ping" in modules:
# Ansible >= 2.10
return modules.ansible.ping
pytest.fail("Where is the ping function these days in Ansible?!")
def test_ansible_functions_loaded(ansible_ping_func):
"""
Test that the ansible functions are actually loaded
"""
ret = ansible_ping_func()
assert ret == {"ping": "pong"}
def test_passing_data_to_ansible_modules(modules):
def test_passing_data_to_ansible_modules(ansible_ping_func):
"""
Test that the ansible functions are actually loaded
"""
expected = "foobar"
if "ansible.system.ping" in modules:
# we need to go by getattr() because salt's loader will try to find "system" in the dictionary and fail
# The ansible hack injects, in this case, "system.ping" as an attribute to the loaded module
ret = getattr(modules.ansible, "system.ping")(data=expected)
elif "ansible.ping" in modules:
# Ansible >= 2.10
ret = modules.ansible.ping(data=expected)
else:
pytest.fail("Where is the ping function these days in Ansible?!")
ret.pop("timeout", None)
ret = ansible_ping_func(data=expected)
assert ret == {"ping": expected}

View file

@ -17,16 +17,13 @@ pytestmark = [
# Because of the above, these are also destructive tests
pytest.mark.destructive_test,
pytest.mark.skip_if_binaries_missing(
"ansible-playbook", message="ansible-playbook is not installed"
"ansible-playbook", reason="ansible-playbook is not installed"
),
]
@pytest.fixture(scope="module")
def ansible_inventory_directory(tmp_path_factory, grains):
import pprint
pprint.pprint(grains)
if grains["os_family"] != "RedHat":
pytest.skip("Currently, the test targets the RedHat OS familly only.")
tmp_dir = tmp_path_factory.mktemp("ansible")
@ -38,7 +35,7 @@ def ansible_inventory_directory(tmp_path_factory, grains):
@pytest.fixture(scope="module", autouse=True)
def ansible_inventory(ansible_inventory_directory, sshd_server):
inventory = ansible_inventory_directory / "inventory"
inventory = str(ansible_inventory_directory / "inventory")
client_key = str(sshd_server.config_dir / "client_key")
data = {
"all": {
@ -56,9 +53,9 @@ def ansible_inventory(ansible_inventory_directory, sshd_server):
},
},
}
with salt.utils.files.fopen(str(inventory), "w") as yaml_file:
with salt.utils.files.fopen(inventory, "w") as yaml_file:
yaml.dump(data, yaml_file, default_flow_style=False)
return str(inventory)
return inventory
@pytest.mark.requires_sshd_server

View file

@ -1,12 +1,9 @@
# Author: Bo Maryniuk <bo@suse.de>
import os
import sys
import pytest
import salt.modules.ansiblegate as ansiblegate
from salt.exceptions import LoaderError
from tests.support.mock import MagicMock, patch
import salt.utils.json
from tests.support.mock import ANY, MagicMock, patch
pytestmark = [
pytest.mark.skip_on_windows(reason="Not supported on Windows"),
@ -18,165 +15,112 @@ def configure_loader_modules():
return {ansiblegate: {}}
@pytest.fixture
def resolver():
_resolver = ansiblegate.AnsibleModuleResolver({})
_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"),
}
return _resolver
def test_ansible_module_help(resolver):
def test_ansible_module_help():
"""
Test help extraction from the module
:return:
"""
extension = {
"foo": {
"doc": {"description": "The description of foo"},
"examples": "These are the examples",
"return": {"a": "A return"},
}
}
class Module:
"""
An ansible module mock.
"""
__name__ = "foo"
DOCUMENTATION = """
---
one:
text here
---
two:
text here
description:
describe the second part
"""
with patch.object(ansiblegate, "_resolver", resolver), patch.object(
ansiblegate._resolver, "load_module", MagicMock(return_value=Module())
):
ret = ansiblegate.help("dummy")
assert sorted(
ret.get('Available sections on module "{}"'.format(Module().__name__))
) == ["one", "two"]
assert ret.get("Description") == "describe the second part"
with patch("subprocess.run") as proc_run_mock:
proc_run_mock.return_value.stdout = salt.utils.json.dumps(extension)
ret = ansiblegate.help("foo")
assert ret["description"] == extension["foo"]["doc"]["description"]
def test_module_resolver_modlist(resolver):
"""
Test Ansible resolver modules list.
:return:
"""
assert resolver.get_modules_list() == [
"four.five.six",
"one.two.three",
"three.six.one",
]
for ptr in ["five", "fi", "ve"]:
assert resolver.get_modules_list(ptr) == ["four.five.six"]
for ptr in ["si", "ix", "six"]:
assert resolver.get_modules_list(ptr) == ["four.five.six", "three.six.one"]
assert resolver.get_modules_list("one") == ["one.two.three", "three.six.one"]
assert resolver.get_modules_list("one.two") == ["one.two.three"]
assert resolver.get_modules_list("four") == ["four.five.six"]
def test_resolver_module_loader_failure(resolver):
"""
Test Ansible module loader.
:return:
"""
mod = "four.five.six"
with pytest.raises(ImportError):
resolver.load_module(mod)
mod = "i.even.do.not.exist.at.all"
with pytest.raises(LoaderError):
resolver.load_module(mod)
def test_resolver_module_loader(resolver):
"""
Test Ansible module loader.
:return:
"""
with patch("salt.modules.ansiblegate.importlib", MagicMock()), patch(
"salt.modules.ansiblegate.importlib.import_module", lambda x: x
):
assert resolver.load_module("four.five.six") == "ansible.modules.four.five.six"
def test_resolver_module_loader_import_failure(resolver):
"""
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):
resolver.load_module("something.strange")
def test_virtual_function(resolver):
def test_virtual_function(subtests):
"""
Test Ansible module __virtual__ when ansible is not installed on the minion.
:return:
"""
with patch("salt.modules.ansiblegate.ansible", None):
assert ansiblegate.__virtual__() == (
False,
"Ansible is not installed on this system",
)
with subtests.test("missing ansible binary"):
with patch("salt.utils.path.which", side_effect=[None]):
assert ansiblegate.__virtual__() == (
False,
"The 'ansible' binary was not found.",
)
with subtests.test("missing ansible-doc binary"):
with patch(
"salt.utils.path.which", side_effect=["/path/to/ansible", None],
):
assert ansiblegate.__virtual__() == (
False,
"The 'ansible-doc' binary was not found.",
)
with subtests.test("missing ansible-playbook binary"):
with patch(
"salt.utils.path.which",
side_effect=["/path/to/ansible", "/path/to/ansible-doc", None],
):
assert ansiblegate.__virtual__() == (
False,
"The 'ansible-playbook' binary was not found.",
)
with subtests.test("Failing to load the ansible modules listing"):
with patch(
"salt.utils.path.which",
side_effect=[
"/path/to/ansible",
"/path/to/ansible-doc",
"/path/to/ansible-playbook",
],
):
with patch("subprocess.run") as proc_run_mock:
proc_run_mock.return_value.retcode = 1
proc_run_mock.return_value.stderr = "bar"
proc_run_mock.return_value.stdout = "{}"
assert ansiblegate.__virtual__() == (
False,
"Failed to get the listing of ansible modules:\nbar",
)
@pytest.mark.skipif(
sys.version_info < (3, 6),
reason="Skipped on Py3.5, the mock of subprocess.run is different",
)
def test_ansible_module_call(resolver):
def test_ansible_module_call():
"""
Test Ansible module call from ansible gate module
:return:
"""
class Module:
"""
An ansible module mock.
"""
with patch("subprocess.run") as proc_run_mock:
proc_run_mock.return_value.stdout = (
'localhost | SUCCESS => {\n "completed": true \n}'
)
__name__ = "one.two.three"
__file__ = "foofile"
def main(): # pylint: disable=no-method-argument
pass
with patch.object(ansiblegate, "_resolver", resolver), patch.object(
ansiblegate._resolver, "load_module", MagicMock(return_value=Module())
):
_ansible_module_caller = ansiblegate.AnsibleModuleCaller(ansiblegate._resolver)
with patch("subprocess.run") as proc_run_mock:
proc_run_mock.return_value.stdout = '{"completed": true}'
ret = _ansible_module_caller.call("one.two.three", "arg_1", kwarg1="foobar")
proc_run_mock.assert_any_call(
[
sys.executable,
"-c",
"import sys, one.two.three; print(one.two.three.main(), file=sys.stdout); sys.stdout.flush()",
],
input='{"ANSIBLE_MODULE_ARGS": {"kwarg1": "foobar", "_raw_params": "arg_1"}}',
stdout=-1,
stderr=-1,
check=True,
shell=False,
universal_newlines=True,
timeout=1200,
)
assert ret == {"completed": True, "timeout": 1200}
ret = ansiblegate.call("one.two.three", "arg_1", kwarg1="foobar")
proc_run_mock.assert_any_call(
[
ANY,
"localhost",
"--limit",
"127.0.0.1",
"-m",
"one.two.three",
"-a",
'"arg_1" kwarg1="foobar"',
"-i",
ANY,
],
check=True,
shell=False,
stderr=-1,
stdout=-1,
timeout=1200,
universal_newlines=True,
)
assert ret == {"completed": True}
def test_ansible_playbooks_return_retcode(resolver):
def test_ansible_playbooks_return_retcode():
"""
Test ansible.playbooks execution module function include retcode in the return.
:return:

View file

@ -29,16 +29,17 @@ def test_ansible_playbooks_states_success(playbooks_examples_dir):
with patch.dict(
ansiblegate.__salt__,
{"ansible.playbooks": MagicMock(return_value=success_output)},
), patch("salt.utils.path.which", MagicMock(return_value=True)):
with patch.dict(ansiblegate.__opts__, {"test": False}):
ret = ansiblegate.playbooks("foobar")
assert ret["result"] is True
assert ret["comment"] == "Changes were made by playbook foobar"
assert ret["changes"] == {
"py2hosts": {
"Ansible copy file to remote server": {"centos7-host1.tf.local": {}}
}
), patch("salt.utils.path.which", return_value=True), patch.dict(
ansiblegate.__opts__, {"test": False}
):
ret = ansiblegate.playbooks("foobar")
assert ret["result"] is True
assert ret["comment"] == "Changes were made by playbook foobar"
assert ret["changes"] == {
"py2hosts": {
"Ansible copy file to remote server": {"centos7-host1.tf.local": {}}
}
}
def test_ansible_playbooks_states_failed(playbooks_examples_dir):
@ -52,19 +53,18 @@ def test_ansible_playbooks_states_failed(playbooks_examples_dir):
with patch.dict(
ansiblegate.__salt__,
{"ansible.playbooks": MagicMock(return_value=failed_output)},
), patch("salt.utils.path.which", MagicMock(return_value=True)):
with patch.dict(ansiblegate.__opts__, {"test": False}):
ret = ansiblegate.playbooks("foobar")
assert ret["result"] is False
assert (
ret["comment"] == "There were some issues running the playbook foobar"
)
assert ret["changes"] == {
"py2hosts": {
"yum": {
"centos7-host1.tf.local": [
"No package matching 'rsyndc' found available, installed or updated"
]
}
), patch("salt.utils.path.which", return_value=True), patch.dict(
ansiblegate.__opts__, {"test": False}
):
ret = ansiblegate.playbooks("foobar")
assert ret["result"] is False
assert ret["comment"] == "There were some issues running the playbook foobar"
assert ret["changes"] == {
"py2hosts": {
"yum": {
"centos7-host1.tf.local": [
"No package matching 'rsyndc' found available, installed or updated"
]
}
}
}