1# Copyright: (c) 2017, Brian Coca <bcoca@ansible.com>
2# Copyright: (c) 2018, Ansible Project
3# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
4
5from __future__ import (absolute_import, division, print_function)
6__metaclass__ = type
7
8import sys
9
10import argparse
11from operator import attrgetter
12
13from ansible import constants as C
14from ansible import context
15from ansible.cli import CLI
16from ansible.cli.arguments import option_helpers as opt_help
17from ansible.errors import AnsibleError, AnsibleOptionsError
18from ansible.module_utils._text import to_bytes, to_native, to_text
19from ansible.utils.vars import combine_vars
20from ansible.utils.display import Display
21from ansible.vars.plugins import get_vars_from_inventory_sources, get_vars_from_path
22
23display = Display()
24
25INTERNAL_VARS = frozenset(['ansible_diff_mode',
26                           'ansible_config_file',
27                           'ansible_facts',
28                           'ansible_forks',
29                           'ansible_inventory_sources',
30                           'ansible_limit',
31                           'ansible_playbook_python',
32                           'ansible_run_tags',
33                           'ansible_skip_tags',
34                           'ansible_verbosity',
35                           'ansible_version',
36                           'inventory_dir',
37                           'inventory_file',
38                           'inventory_hostname',
39                           'inventory_hostname_short',
40                           'groups',
41                           'group_names',
42                           'omit',
43                           'playbook_dir', ])
44
45
46class InventoryCLI(CLI):
47    ''' used to display or dump the configured inventory as Ansible sees it '''
48
49    ARGUMENTS = {'host': 'The name of a host to match in the inventory, relevant when using --list',
50                 'group': 'The name of a group in the inventory, relevant when using --graph', }
51
52    def __init__(self, args):
53
54        super(InventoryCLI, self).__init__(args)
55        self.vm = None
56        self.loader = None
57        self.inventory = None
58
59    def init_parser(self):
60        super(InventoryCLI, self).init_parser(
61            usage='usage: %prog [options] [host|group]',
62            epilog='Show Ansible inventory information, by default it uses the inventory script JSON format')
63
64        opt_help.add_inventory_options(self.parser)
65        opt_help.add_vault_options(self.parser)
66        opt_help.add_basedir_options(self.parser)
67        opt_help.add_runtask_options(self.parser)
68
69        # remove unused default options
70        self.parser.add_argument('-l', '--limit', help=argparse.SUPPRESS, action=opt_help.UnrecognizedArgument, nargs='?')
71        self.parser.add_argument('--list-hosts', help=argparse.SUPPRESS, action=opt_help.UnrecognizedArgument)
72
73        self.parser.add_argument('args', metavar='host|group', nargs='?')
74
75        # Actions
76        action_group = self.parser.add_argument_group("Actions", "One of following must be used on invocation, ONLY ONE!")
77        action_group.add_argument("--list", action="store_true", default=False, dest='list', help='Output all hosts info, works as inventory script')
78        action_group.add_argument("--host", action="store", default=None, dest='host', help='Output specific host info, works as inventory script')
79        action_group.add_argument("--graph", action="store_true", default=False, dest='graph',
80                                  help='create inventory graph, if supplying pattern it must be a valid group name')
81        self.parser.add_argument_group(action_group)
82
83        # graph
84        self.parser.add_argument("-y", "--yaml", action="store_true", default=False, dest='yaml',
85                                 help='Use YAML format instead of default JSON, ignored for --graph')
86        self.parser.add_argument('--toml', action='store_true', default=False, dest='toml',
87                                 help='Use TOML format instead of default JSON, ignored for --graph')
88        self.parser.add_argument("--vars", action="store_true", default=False, dest='show_vars',
89                                 help='Add vars to graph display, ignored unless used with --graph')
90
91        # list
92        self.parser.add_argument("--export", action="store_true", default=C.INVENTORY_EXPORT, dest='export',
93                                 help="When doing an --list, represent in a way that is optimized for export,"
94                                      "not as an accurate representation of how Ansible has processed it")
95        self.parser.add_argument('--output', default=None, dest='output_file',
96                                 help="When doing --list, send the inventory to a file instead of to the screen")
97        # self.parser.add_argument("--ignore-vars-plugins", action="store_true", default=False, dest='ignore_vars_plugins',
98        #                          help="When doing an --list, skip vars data from vars plugins, by default, this would include group_vars/ and host_vars/")
99
100    def post_process_args(self, options):
101        options = super(InventoryCLI, self).post_process_args(options)
102
103        display.verbosity = options.verbosity
104        self.validate_conflicts(options)
105
106        # there can be only one! and, at least, one!
107        used = 0
108        for opt in (options.list, options.host, options.graph):
109            if opt:
110                used += 1
111        if used == 0:
112            raise AnsibleOptionsError("No action selected, at least one of --host, --graph or --list needs to be specified.")
113        elif used > 1:
114            raise AnsibleOptionsError("Conflicting options used, only one of --host, --graph or --list can be used at the same time.")
115
116        # set host pattern to default if not supplied
117        if options.args:
118            options.pattern = options.args
119        else:
120            options.pattern = 'all'
121
122        return options
123
124    def run(self):
125
126        super(InventoryCLI, self).run()
127
128        # Initialize needed objects
129        self.loader, self.inventory, self.vm = self._play_prereqs()
130
131        results = None
132        if context.CLIARGS['host']:
133            hosts = self.inventory.get_hosts(context.CLIARGS['host'])
134            if len(hosts) != 1:
135                raise AnsibleOptionsError("You must pass a single valid host to --host parameter")
136
137            myvars = self._get_host_variables(host=hosts[0])
138
139            # FIXME: should we template first?
140            results = self.dump(myvars)
141
142        elif context.CLIARGS['graph']:
143            results = self.inventory_graph()
144        elif context.CLIARGS['list']:
145            top = self._get_group('all')
146            if context.CLIARGS['yaml']:
147                results = self.yaml_inventory(top)
148            elif context.CLIARGS['toml']:
149                results = self.toml_inventory(top)
150            else:
151                results = self.json_inventory(top)
152            results = self.dump(results)
153
154        if results:
155            outfile = context.CLIARGS['output_file']
156            if outfile is None:
157                # FIXME: pager?
158                display.display(results)
159            else:
160                try:
161                    with open(to_bytes(outfile), 'wb') as f:
162                        f.write(to_bytes(results))
163                except (OSError, IOError) as e:
164                    raise AnsibleError('Unable to write to destination file (%s): %s' % (to_native(outfile), to_native(e)))
165            sys.exit(0)
166
167        sys.exit(1)
168
169    @staticmethod
170    def dump(stuff):
171
172        if context.CLIARGS['yaml']:
173            import yaml
174            from ansible.parsing.yaml.dumper import AnsibleDumper
175            results = to_text(yaml.dump(stuff, Dumper=AnsibleDumper, default_flow_style=False, allow_unicode=True))
176        elif context.CLIARGS['toml']:
177            from ansible.plugins.inventory.toml import toml_dumps, HAS_TOML
178            if not HAS_TOML:
179                raise AnsibleError(
180                    'The python "toml" library is required when using the TOML output format'
181                )
182            results = toml_dumps(stuff)
183        else:
184            import json
185            from ansible.parsing.ajson import AnsibleJSONEncoder
186            try:
187                results = json.dumps(stuff, cls=AnsibleJSONEncoder, sort_keys=True, indent=4, preprocess_unsafe=True, ensure_ascii=False)
188            except TypeError as e:
189                results = json.dumps(stuff, cls=AnsibleJSONEncoder, sort_keys=False, indent=4, preprocess_unsafe=True, ensure_ascii=False)
190                display.warning("Could not sort JSON output due to issues while sorting keys: %s" % to_native(e))
191
192        return results
193
194    def _get_group_variables(self, group):
195
196        # get info from inventory source
197        res = group.get_vars()
198
199        # Always load vars plugins
200        res = combine_vars(res, get_vars_from_inventory_sources(self.loader, self.inventory._sources, [group], 'all'))
201        if context.CLIARGS['basedir']:
202            res = combine_vars(res, get_vars_from_path(self.loader, context.CLIARGS['basedir'], [group], 'all'))
203
204        if group.priority != 1:
205            res['ansible_group_priority'] = group.priority
206
207        return self._remove_internal(res)
208
209    def _get_host_variables(self, host):
210
211        if context.CLIARGS['export']:
212            # only get vars defined directly host
213            hostvars = host.get_vars()
214
215            # Always load vars plugins
216            hostvars = combine_vars(hostvars, get_vars_from_inventory_sources(self.loader, self.inventory._sources, [host], 'all'))
217            if context.CLIARGS['basedir']:
218                hostvars = combine_vars(hostvars, get_vars_from_path(self.loader, context.CLIARGS['basedir'], [host], 'all'))
219        else:
220            # get all vars flattened by host, but skip magic hostvars
221            hostvars = self.vm.get_vars(host=host, include_hostvars=False, stage='all')
222
223        return self._remove_internal(hostvars)
224
225    def _get_group(self, gname):
226        group = self.inventory.groups.get(gname)
227        return group
228
229    @staticmethod
230    def _remove_internal(dump):
231
232        for internal in INTERNAL_VARS:
233            if internal in dump:
234                del dump[internal]
235
236        return dump
237
238    @staticmethod
239    def _remove_empty(dump):
240        # remove empty keys
241        for x in ('hosts', 'vars', 'children'):
242            if x in dump and not dump[x]:
243                del dump[x]
244
245    @staticmethod
246    def _show_vars(dump, depth):
247        result = []
248        for (name, val) in sorted(dump.items()):
249            result.append(InventoryCLI._graph_name('{%s = %s}' % (name, val), depth))
250        return result
251
252    @staticmethod
253    def _graph_name(name, depth=0):
254        if depth:
255            name = "  |" * (depth) + "--%s" % name
256        return name
257
258    def _graph_group(self, group, depth=0):
259
260        result = [self._graph_name('@%s:' % group.name, depth)]
261        depth = depth + 1
262        for kid in sorted(group.child_groups, key=attrgetter('name')):
263            result.extend(self._graph_group(kid, depth))
264
265        if group.name != 'all':
266            for host in sorted(group.hosts, key=attrgetter('name')):
267                result.append(self._graph_name(host.name, depth))
268                if context.CLIARGS['show_vars']:
269                    result.extend(self._show_vars(self._get_host_variables(host), depth + 1))
270
271        if context.CLIARGS['show_vars']:
272            result.extend(self._show_vars(self._get_group_variables(group), depth))
273
274        return result
275
276    def inventory_graph(self):
277
278        start_at = self._get_group(context.CLIARGS['pattern'])
279        if start_at:
280            return '\n'.join(self._graph_group(start_at))
281        else:
282            raise AnsibleOptionsError("Pattern must be valid group name when using --graph")
283
284    def json_inventory(self, top):
285
286        seen = set()
287
288        def format_group(group):
289            results = {}
290            results[group.name] = {}
291            if group.name != 'all':
292                results[group.name]['hosts'] = [h.name for h in sorted(group.hosts, key=attrgetter('name'))]
293            results[group.name]['children'] = []
294            for subgroup in sorted(group.child_groups, key=attrgetter('name')):
295                results[group.name]['children'].append(subgroup.name)
296                if subgroup.name not in seen:
297                    results.update(format_group(subgroup))
298                    seen.add(subgroup.name)
299            if context.CLIARGS['export']:
300                results[group.name]['vars'] = self._get_group_variables(group)
301
302            self._remove_empty(results[group.name])
303            if not results[group.name]:
304                del results[group.name]
305
306            return results
307
308        results = format_group(top)
309
310        # populate meta
311        results['_meta'] = {'hostvars': {}}
312        hosts = self.inventory.get_hosts()
313        for host in hosts:
314            hvars = self._get_host_variables(host)
315            if hvars:
316                results['_meta']['hostvars'][host.name] = hvars
317
318        return results
319
320    def yaml_inventory(self, top):
321
322        seen = []
323
324        def format_group(group):
325            results = {}
326
327            # initialize group + vars
328            results[group.name] = {}
329
330            # subgroups
331            results[group.name]['children'] = {}
332            for subgroup in sorted(group.child_groups, key=attrgetter('name')):
333                if subgroup.name != 'all':
334                    results[group.name]['children'].update(format_group(subgroup))
335
336            # hosts for group
337            results[group.name]['hosts'] = {}
338            if group.name != 'all':
339                for h in sorted(group.hosts, key=attrgetter('name')):
340                    myvars = {}
341                    if h.name not in seen:  # avoid defining host vars more than once
342                        seen.append(h.name)
343                        myvars = self._get_host_variables(host=h)
344                    results[group.name]['hosts'][h.name] = myvars
345
346            if context.CLIARGS['export']:
347                gvars = self._get_group_variables(group)
348                if gvars:
349                    results[group.name]['vars'] = gvars
350
351            self._remove_empty(results[group.name])
352
353            return results
354
355        return format_group(top)
356
357    def toml_inventory(self, top):
358        seen = set()
359        has_ungrouped = bool(next(g.hosts for g in top.child_groups if g.name == 'ungrouped'))
360
361        def format_group(group):
362            results = {}
363            results[group.name] = {}
364
365            results[group.name]['children'] = []
366            for subgroup in sorted(group.child_groups, key=attrgetter('name')):
367                if subgroup.name == 'ungrouped' and not has_ungrouped:
368                    continue
369                if group.name != 'all':
370                    results[group.name]['children'].append(subgroup.name)
371                results.update(format_group(subgroup))
372
373            if group.name != 'all':
374                for host in sorted(group.hosts, key=attrgetter('name')):
375                    if host.name not in seen:
376                        seen.add(host.name)
377                        host_vars = self._get_host_variables(host=host)
378                    else:
379                        host_vars = {}
380                    try:
381                        results[group.name]['hosts'][host.name] = host_vars
382                    except KeyError:
383                        results[group.name]['hosts'] = {host.name: host_vars}
384
385            if context.CLIARGS['export']:
386                results[group.name]['vars'] = self._get_group_variables(group)
387
388            self._remove_empty(results[group.name])
389            if not results[group.name]:
390                del results[group.name]
391
392            return results
393
394        results = format_group(top)
395
396        return results
397