1#
2# Author: Bo Maryniuk <bo@suse.de>
3#
4"""
5Ansible Support
6===============
7
8This module can have an optional minion-level
9configuration in /etc/salt/minion.d/ as follows:
10
11  ansible_timeout: 1200
12
13The timeout is how many seconds Salt should wait for
14any Ansible module to respond.
15"""
16
17import fnmatch
18import json
19import logging
20import subprocess
21import sys
22from tempfile import NamedTemporaryFile
23
24import salt.utils.decorators.path
25import salt.utils.json
26import salt.utils.path
27import salt.utils.platform
28import salt.utils.stringutils
29import salt.utils.timed_subprocess
30import salt.utils.yaml
31from salt.exceptions import CommandExecutionError
32
33# Function alias to make sure not to shadow built-in's
34__func_alias__ = {"list_": "list"}
35
36__virtualname__ = "ansible"
37
38log = logging.getLogger(__name__)
39
40INVENTORY = """
41hosts:
42   vars:
43     ansible_connection: local
44"""
45DEFAULT_TIMEOUT = 1200  # seconds (20 minutes)
46
47__load__ = __non_ansible_functions__ = ["help", "list_", "call", "playbooks"][:]
48
49
50def _set_callables(modules):
51    """
52    Set all Ansible modules callables
53    :return:
54    """
55
56    def _set_function(cmd_name, doc):
57        """
58        Create a Salt function for the Ansible module.
59        """
60
61        def _cmd(*args, **kwargs):
62            """
63            Call an Ansible module as a function from the Salt.
64            """
65            return call(cmd_name, *args, **kwargs)
66
67        _cmd.__doc__ = doc
68        return _cmd
69
70    for mod, doc in modules.items():
71        __load__.append(mod)
72        setattr(sys.modules[__name__], mod, _set_function(mod, doc))
73
74
75def __virtual__():
76    if salt.utils.platform.is_windows():
77        return False, "The ansiblegate module isn't supported on Windows"
78    ansible_bin = salt.utils.path.which("ansible")
79    if not ansible_bin:
80        return False, "The 'ansible' binary was not found."
81    ansible_doc_bin = salt.utils.path.which("ansible-doc")
82    if not ansible_doc_bin:
83        return False, "The 'ansible-doc' binary was not found."
84    ansible_playbook_bin = salt.utils.path.which("ansible-playbook")
85    if not ansible_playbook_bin:
86        return False, "The 'ansible-playbook' binary was not found."
87
88    proc = subprocess.run(
89        [ansible_doc_bin, "--list", "--json", "--type=module"],
90        stdout=subprocess.PIPE,
91        stderr=subprocess.PIPE,
92        check=False,
93        shell=False,
94        universal_newlines=True,
95    )
96    if proc.returncode != 0:
97        return (
98            False,
99            "Failed to get the listing of ansible modules:\n{}".format(proc.stderr),
100        )
101
102    ansible_module_listing = salt.utils.json.loads(proc.stdout)
103    for key in list(ansible_module_listing):
104        if key.startswith("ansible."):
105            # Fyi, str.partition() is faster than str.replace()
106            _, _, alias = key.partition(".")
107            ansible_module_listing[alias] = ansible_module_listing[key]
108    _set_callables(ansible_module_listing)
109    return __virtualname__
110
111
112def help(module=None, *args):
113    """
114    Display help on Ansible standard module.
115
116    :param module: The module to get the help
117
118    CLI Example:
119
120    .. code-block:: bash
121
122        salt * ansible.help ping
123    """
124    if not module:
125        raise CommandExecutionError(
126            "Please tell me what module you want to have helped with. "
127            'Or call "ansible.list" to know what is available.'
128        )
129
130    ansible_doc_bin = salt.utils.path.which("ansible-doc")
131
132    proc = subprocess.run(
133        [ansible_doc_bin, "--json", "--type=module", module],
134        stdout=subprocess.PIPE,
135        stderr=subprocess.PIPE,
136        check=True,
137        shell=False,
138        universal_newlines=True,
139    )
140    data = salt.utils.json.loads(proc.stdout)
141    doc = data[next(iter(data))]
142    if not args:
143        ret = doc["doc"]
144        for section in ("examples", "return", "metadata"):
145            section_data = doc.get(section)
146            if section_data:
147                ret[section] = section_data
148    else:
149        ret = {}
150        for arg in args:
151            info = doc.get(arg)
152            if info is not None:
153                ret[arg] = info
154    return ret
155
156
157def list_(pattern=None):
158    """
159    Lists available modules.
160
161    CLI Example:
162
163    .. code-block:: bash
164
165        salt * ansible.list
166        salt * ansible.list '*win*'  # To get all modules matching 'win' on it's name
167    """
168    if pattern is None:
169        module_list = set(__load__)
170        module_list.discard(set(__non_ansible_functions__))
171        return sorted(module_list)
172    return sorted(fnmatch.filter(__load__, pattern))
173
174
175def call(module, *args, **kwargs):
176    """
177    Call an Ansible module by invoking it.
178
179    :param module: the name of the module.
180    :param args: Arguments to pass to the module
181    :param kwargs: keywords to pass to the module
182
183    CLI Example:
184
185    .. code-block:: bash
186
187        salt * ansible.call ping data=foobar
188    """
189
190    module_args = []
191    for arg in args:
192        module_args.append(salt.utils.json.dumps(arg))
193
194    _kwargs = {}
195    for _kw in kwargs.get("__pub_arg", []):
196        if isinstance(_kw, dict):
197            _kwargs = _kw
198            break
199    else:
200        _kwargs = {k: v for (k, v) in kwargs.items() if not k.startswith("__pub")}
201
202    for key, value in _kwargs.items():
203        module_args.append("{}={}".format(key, salt.utils.json.dumps(value)))
204
205    with NamedTemporaryFile(mode="w") as inventory:
206
207        ansible_binary_path = salt.utils.path.which("ansible")
208        log.debug("Calling ansible module %r", module)
209        try:
210            proc_exc = subprocess.run(
211                [
212                    ansible_binary_path,
213                    "localhost",
214                    "--limit",
215                    "127.0.0.1",
216                    "-m",
217                    module,
218                    "-a",
219                    " ".join(module_args),
220                    "-i",
221                    inventory.name,
222                ],
223                stdout=subprocess.PIPE,
224                stderr=subprocess.PIPE,
225                timeout=__opts__.get("ansible_timeout", DEFAULT_TIMEOUT),
226                universal_newlines=True,
227                check=True,
228                shell=False,
229            )
230
231            original_output = proc_exc.stdout
232            proc_out = original_output.splitlines()
233            if proc_out[0].endswith("{"):
234                proc_out[0] = "{"
235                try:
236                    out = salt.utils.json.loads("\n".join(proc_out))
237                except ValueError as exc:
238                    out = {
239                        "Error": proc_exc.stderr or str(exc),
240                        "Output": original_output,
241                    }
242                    return out
243            elif proc_out[0].endswith(">>"):
244                out = {"output": "\n".join(proc_out[1:])}
245            else:
246                out = {"output": original_output}
247
248        except subprocess.CalledProcessError as exc:
249            out = {"Exitcode": exc.returncode, "Error": exc.stderr or str(exc)}
250            if exc.stdout:
251                out["Given JSON output"] = exc.stdout
252            return out
253
254    for key in ("invocation", "changed"):
255        out.pop(key, None)
256
257    return out
258
259
260@salt.utils.decorators.path.which("ansible-playbook")
261def playbooks(
262    playbook,
263    rundir=None,
264    check=False,
265    diff=False,
266    extra_vars=None,
267    flush_cache=False,
268    forks=5,
269    inventory=None,
270    limit=None,
271    list_hosts=False,
272    list_tags=False,
273    list_tasks=False,
274    module_path=None,
275    skip_tags=None,
276    start_at_task=None,
277    syntax_check=False,
278    tags=None,
279    playbook_kwargs=None,
280):
281    """
282    Run Ansible Playbooks
283
284    :param playbook: Which playbook to run.
285    :param rundir: Directory to run `ansible-playbook` in. (Default: None)
286    :param check: don't make any changes; instead, try to predict some
287                  of the changes that may occur (Default: False)
288    :param diff: when changing (small) files and templates, show the
289                 differences in those files; works great with --check
290                 (default: False)
291    :param extra_vars: set additional variables as key=value or YAML/JSON, if
292                       filename prepend with @, (default: None)
293    :param flush_cache: clear the fact cache for every host in inventory
294                        (default: False)
295    :param forks: specify number of parallel processes to use
296                  (Default: 5)
297    :param inventory: specify inventory host path or comma separated host
298                      list. (Default: None) (Ansible's default is /etc/ansible/hosts)
299    :param limit: further limit selected hosts to an additional pattern (Default: None)
300    :param list_hosts: outputs a list of matching hosts; does not execute anything else
301                       (Default: False)
302    :param list_tags: list all available tags (Default: False)
303    :param list_tasks: list all tasks that would be executed (Default: False)
304    :param module_path: prepend colon-separated path(s) to module library. (Default: None)
305    :param skip_tags: only run plays and tasks whose tags do not match these
306                      values (Default: False)
307    :param start_at_task: start the playbook at the task matching this name (Default: None)
308    :param: syntax_check: perform a syntax check on the playbook, but do not execute it
309                          (Default: False)
310    :param tags: only run plays and tasks tagged with these values (Default: None)
311
312    :return: Playbook return
313
314    CLI Example:
315
316    .. code-block:: bash
317
318        salt 'ansiblehost'  ansible.playbook playbook=/srv/playbooks/play.yml
319    """
320    command = ["ansible-playbook", playbook]
321    if check:
322        command.append("--check")
323    if diff:
324        command.append("--diff")
325    if isinstance(extra_vars, dict):
326        command.append("--extra-vars='{}'".format(json.dumps(extra_vars)))
327    elif isinstance(extra_vars, str) and extra_vars.startswith("@"):
328        command.append("--extra-vars={}".format(extra_vars))
329    if flush_cache:
330        command.append("--flush-cache")
331    if inventory:
332        command.append("--inventory={}".format(inventory))
333    if limit:
334        command.append("--limit={}".format(limit))
335    if list_hosts:
336        command.append("--list-hosts")
337    if list_tags:
338        command.append("--list-tags")
339    if list_tasks:
340        command.append("--list-tasks")
341    if module_path:
342        command.append("--module-path={}".format(module_path))
343    if skip_tags:
344        command.append("--skip-tags={}".format(skip_tags))
345    if start_at_task:
346        command.append("--start-at-task={}".format(start_at_task))
347    if syntax_check:
348        command.append("--syntax-check")
349    if tags:
350        command.append("--tags={}".format(tags))
351    if playbook_kwargs:
352        for key, value in playbook_kwargs.items():
353            key = key.replace("_", "-")
354            if value is True:
355                command.append("--{}".format(key))
356            elif isinstance(value, str):
357                command.append("--{}={}".format(key, value))
358            elif isinstance(value, dict):
359                command.append("--{}={}".format(key, json.dumps(value)))
360    command.append("--forks={}".format(forks))
361    cmd_kwargs = {
362        "env": {"ANSIBLE_STDOUT_CALLBACK": "json", "ANSIBLE_RETRY_FILES_ENABLED": "0"},
363        "cwd": rundir,
364        "cmd": " ".join(command),
365    }
366    ret = __salt__["cmd.run_all"](**cmd_kwargs)
367    log.debug("Ansible Playbook Return: %s", ret)
368    retdata = json.loads(ret["stdout"])
369    if "retcode" in ret:
370        __context__["retcode"] = retdata["retcode"] = ret["retcode"]
371    return retdata
372