1# Copyright: (c) 2014, Nandor Sivok <dominis@haxor.hu>
2# Copyright: (c) 2016, Redhat Inc
3# Copyright: (c) 2018, Ansible Project
4# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
5
6from __future__ import (absolute_import, division, print_function)
7__metaclass__ = type
8
9########################################################
10# ansible-console is an interactive REPL shell for ansible
11# with built-in tab completion for all the documented modules
12#
13# Available commands:
14#  cd - change host/group (you can use host patterns eg.: app*.dc*:!app01*)
15#  list - list available hosts in the current path
16#  forks - change fork
17#  become - become
18#  ! - forces shell module instead of the ansible module (!yum update -y)
19
20import atexit
21import cmd
22import getpass
23import readline
24import os
25import sys
26
27from ansible import constants as C
28from ansible import context
29from ansible.cli import CLI
30from ansible.cli.arguments import option_helpers as opt_help
31from ansible.executor.task_queue_manager import TaskQueueManager
32from ansible.module_utils._text import to_native, to_text
33from ansible.module_utils.parsing.convert_bool import boolean
34from ansible.parsing.splitter import parse_kv
35from ansible.playbook.play import Play
36from ansible.plugins.loader import module_loader, fragment_loader
37from ansible.utils import plugin_docs
38from ansible.utils.color import stringc
39from ansible.utils.display import Display
40
41display = Display()
42
43
44class ConsoleCLI(CLI, cmd.Cmd):
45    ''' a REPL that allows for running ad-hoc tasks against a chosen inventory (based on dominis' ansible-shell).'''
46
47    modules = []
48    ARGUMENTS = {'host-pattern': 'A name of a group in the inventory, a shell-like glob '
49                                 'selecting hosts in inventory or any combination of the two separated by commas.'}
50
51    # use specific to console, but fallback to highlight for backwards compatibility
52    NORMAL_PROMPT = C.COLOR_CONSOLE_PROMPT or C.COLOR_HIGHLIGHT
53
54    def __init__(self, args):
55
56        super(ConsoleCLI, self).__init__(args)
57
58        self.intro = 'Welcome to the ansible console.\nType help or ? to list commands.\n'
59
60        self.groups = []
61        self.hosts = []
62        self.pattern = None
63        self.variable_manager = None
64        self.loader = None
65        self.passwords = dict()
66
67        self.modules = None
68        self.cwd = '*'
69
70        # Defaults for these are set from the CLI in run()
71        self.remote_user = None
72        self.become = None
73        self.become_user = None
74        self.become_method = None
75        self.check_mode = None
76        self.diff = None
77        self.forks = None
78
79        cmd.Cmd.__init__(self)
80
81    def init_parser(self):
82        super(ConsoleCLI, self).init_parser(
83            desc="REPL console for executing Ansible tasks.",
84            epilog="This is not a live session/connection, each task executes in the background and returns it's results."
85        )
86        opt_help.add_runas_options(self.parser)
87        opt_help.add_inventory_options(self.parser)
88        opt_help.add_connect_options(self.parser)
89        opt_help.add_check_options(self.parser)
90        opt_help.add_vault_options(self.parser)
91        opt_help.add_fork_options(self.parser)
92        opt_help.add_module_options(self.parser)
93        opt_help.add_basedir_options(self.parser)
94
95        # options unique to shell
96        self.parser.add_argument('pattern', help='host pattern', metavar='pattern', default='all', nargs='?')
97        self.parser.add_argument('--step', dest='step', action='store_true',
98                                 help="one-step-at-a-time: confirm each task before running")
99
100    def post_process_args(self, options):
101        options = super(ConsoleCLI, self).post_process_args(options)
102        display.verbosity = options.verbosity
103        self.validate_conflicts(options, runas_opts=True, fork_opts=True)
104        return options
105
106    def get_names(self):
107        return dir(self)
108
109    def cmdloop(self):
110        try:
111            cmd.Cmd.cmdloop(self)
112        except KeyboardInterrupt:
113            self.do_exit(self)
114
115    def set_prompt(self):
116        login_user = self.remote_user or getpass.getuser()
117        self.selected = self.inventory.list_hosts(self.cwd)
118        prompt = "%s@%s (%d)[f:%s]" % (login_user, self.cwd, len(self.selected), self.forks)
119        if self.become and self.become_user in [None, 'root']:
120            prompt += "# "
121            color = C.COLOR_ERROR
122        else:
123            prompt += "$ "
124            color = self.NORMAL_PROMPT
125        self.prompt = stringc(prompt, color, wrap_nonvisible_chars=True)
126
127    def list_modules(self):
128        modules = set()
129        if context.CLIARGS['module_path']:
130            for path in context.CLIARGS['module_path']:
131                if path:
132                    module_loader.add_directory(path)
133
134        module_paths = module_loader._get_paths()
135        for path in module_paths:
136            if path is not None:
137                modules.update(self._find_modules_in_path(path))
138        return modules
139
140    def _find_modules_in_path(self, path):
141
142        if os.path.isdir(path):
143            for module in os.listdir(path):
144                if module.startswith('.'):
145                    continue
146                elif os.path.isdir(module):
147                    self._find_modules_in_path(module)
148                elif module.startswith('__'):
149                    continue
150                elif any(module.endswith(x) for x in C.BLACKLIST_EXTS):
151                    continue
152                elif module in C.IGNORE_FILES:
153                    continue
154                elif module.startswith('_'):
155                    fullpath = '/'.join([path, module])
156                    if os.path.islink(fullpath):  # avoids aliases
157                        continue
158                    module = module.replace('_', '', 1)
159
160                module = os.path.splitext(module)[0]  # removes the extension
161                yield module
162
163    def default(self, arg, forceshell=False):
164        """ actually runs modules """
165        if arg.startswith("#"):
166            return False
167
168        if not self.cwd:
169            display.error("No host found")
170            return False
171
172        if arg.split()[0] in self.modules:
173            module = arg.split()[0]
174            module_args = ' '.join(arg.split()[1:])
175        else:
176            module = 'shell'
177            module_args = arg
178
179        if forceshell is True:
180            module = 'shell'
181            module_args = arg
182
183        result = None
184        try:
185            check_raw = module in C._ACTION_ALLOWS_RAW_ARGS
186            play_ds = dict(
187                name="Ansible Shell",
188                hosts=self.cwd,
189                gather_facts='no',
190                tasks=[dict(action=dict(module=module, args=parse_kv(module_args, check_raw=check_raw)))],
191                remote_user=self.remote_user,
192                become=self.become,
193                become_user=self.become_user,
194                become_method=self.become_method,
195                check_mode=self.check_mode,
196                diff=self.diff,
197            )
198            play = Play().load(play_ds, variable_manager=self.variable_manager, loader=self.loader)
199        except Exception as e:
200            display.error(u"Unable to build command: %s" % to_text(e))
201            return False
202
203        try:
204            cb = 'minimal'  # FIXME: make callbacks configurable
205            # now create a task queue manager to execute the play
206            self._tqm = None
207            try:
208                self._tqm = TaskQueueManager(
209                    inventory=self.inventory,
210                    variable_manager=self.variable_manager,
211                    loader=self.loader,
212                    passwords=self.passwords,
213                    stdout_callback=cb,
214                    run_additional_callbacks=C.DEFAULT_LOAD_CALLBACK_PLUGINS,
215                    run_tree=False,
216                    forks=self.forks,
217                )
218
219                result = self._tqm.run(play)
220            finally:
221                if self._tqm:
222                    self._tqm.cleanup()
223                if self.loader:
224                    self.loader.cleanup_all_tmp_files()
225
226            if result is None:
227                display.error("No hosts found")
228                return False
229        except KeyboardInterrupt:
230            display.error('User interrupted execution')
231            return False
232        except Exception as e:
233            display.error(to_text(e))
234            # FIXME: add traceback in very very verbose mode
235            return False
236
237    def emptyline(self):
238        return
239
240    def do_shell(self, arg):
241        """
242        You can run shell commands through the shell module.
243
244        eg.:
245        shell ps uax | grep java | wc -l
246        shell killall python
247        shell halt -n
248
249        You can use the ! to force the shell module. eg.:
250        !ps aux | grep java | wc -l
251        """
252        self.default(arg, True)
253
254    def do_forks(self, arg):
255        """Set the number of forks"""
256        if not arg:
257            display.display('Usage: forks <number>')
258            return
259
260        forks = int(arg)
261        if forks <= 0:
262            display.display('forks must be greater than or equal to 1')
263            return
264
265        self.forks = forks
266        self.set_prompt()
267
268    do_serial = do_forks
269
270    def do_verbosity(self, arg):
271        """Set verbosity level"""
272        if not arg:
273            display.display('Usage: verbosity <number>')
274        else:
275            display.verbosity = int(arg)
276            display.v('verbosity level set to %s' % arg)
277
278    def do_cd(self, arg):
279        """
280            Change active host/group. You can use hosts patterns as well eg.:
281            cd webservers
282            cd webservers:dbservers
283            cd webservers:!phoenix
284            cd webservers:&staging
285            cd webservers:dbservers:&staging:!phoenix
286        """
287        if not arg:
288            self.cwd = '*'
289        elif arg in '/*':
290            self.cwd = 'all'
291        elif self.inventory.get_hosts(arg):
292            self.cwd = arg
293        else:
294            display.display("no host matched")
295
296        self.set_prompt()
297
298    def do_list(self, arg):
299        """List the hosts in the current group"""
300        if arg == 'groups':
301            for group in self.groups:
302                display.display(group)
303        else:
304            for host in self.selected:
305                display.display(host.name)
306
307    def do_become(self, arg):
308        """Toggle whether plays run with become"""
309        if arg:
310            self.become = boolean(arg, strict=False)
311            display.v("become changed to %s" % self.become)
312            self.set_prompt()
313        else:
314            display.display("Please specify become value, e.g. `become yes`")
315
316    def do_remote_user(self, arg):
317        """Given a username, set the remote user plays are run by"""
318        if arg:
319            self.remote_user = arg
320            self.set_prompt()
321        else:
322            display.display("Please specify a remote user, e.g. `remote_user root`")
323
324    def do_become_user(self, arg):
325        """Given a username, set the user that plays are run by when using become"""
326        if arg:
327            self.become_user = arg
328        else:
329            display.display("Please specify a user, e.g. `become_user jenkins`")
330            display.v("Current user is %s" % self.become_user)
331        self.set_prompt()
332
333    def do_become_method(self, arg):
334        """Given a become_method, set the privilege escalation method when using become"""
335        if arg:
336            self.become_method = arg
337            display.v("become_method changed to %s" % self.become_method)
338        else:
339            display.display("Please specify a become_method, e.g. `become_method su`")
340
341    def do_check(self, arg):
342        """Toggle whether plays run with check mode"""
343        if arg:
344            self.check_mode = boolean(arg, strict=False)
345            display.v("check mode changed to %s" % self.check_mode)
346        else:
347            display.display("Please specify check mode value, e.g. `check yes`")
348
349    def do_diff(self, arg):
350        """Toggle whether plays run with diff"""
351        if arg:
352            self.diff = boolean(arg, strict=False)
353            display.v("diff mode changed to %s" % self.diff)
354        else:
355            display.display("Please specify a diff value , e.g. `diff yes`")
356
357    def do_exit(self, args):
358        """Exits from the console"""
359        sys.stdout.write('\n')
360        return -1
361
362    do_EOF = do_exit
363
364    def helpdefault(self, module_name):
365        if module_name in self.modules:
366            in_path = module_loader.find_plugin(module_name)
367            if in_path:
368                oc, a, _, _ = plugin_docs.get_docstring(in_path, fragment_loader)
369                if oc:
370                    display.display(oc['short_description'])
371                    display.display('Parameters:')
372                    for opt in oc['options'].keys():
373                        display.display('  ' + stringc(opt, self.NORMAL_PROMPT) + ' ' + oc['options'][opt]['description'][0])
374                else:
375                    display.error('No documentation found for %s.' % module_name)
376            else:
377                display.error('%s is not a valid command, use ? to list all valid commands.' % module_name)
378
379    def complete_cd(self, text, line, begidx, endidx):
380        mline = line.partition(' ')[2]
381        offs = len(mline) - len(text)
382
383        if self.cwd in ('all', '*', '\\'):
384            completions = self.hosts + self.groups
385        else:
386            completions = [x.name for x in self.inventory.list_hosts(self.cwd)]
387
388        return [to_native(s)[offs:] for s in completions if to_native(s).startswith(to_native(mline))]
389
390    def completedefault(self, text, line, begidx, endidx):
391        if line.split()[0] in self.modules:
392            mline = line.split(' ')[-1]
393            offs = len(mline) - len(text)
394            completions = self.module_args(line.split()[0])
395
396            return [s[offs:] + '=' for s in completions if s.startswith(mline)]
397
398    def module_args(self, module_name):
399        in_path = module_loader.find_plugin(module_name)
400        oc, a, _, _ = plugin_docs.get_docstring(in_path, fragment_loader, is_module=True)
401        return list(oc['options'].keys())
402
403    def run(self):
404
405        super(ConsoleCLI, self).run()
406
407        sshpass = None
408        becomepass = None
409
410        # hosts
411        self.pattern = context.CLIARGS['pattern']
412        self.cwd = self.pattern
413
414        # Defaults from the command line
415        self.remote_user = context.CLIARGS['remote_user']
416        self.become = context.CLIARGS['become']
417        self.become_user = context.CLIARGS['become_user']
418        self.become_method = context.CLIARGS['become_method']
419        self.check_mode = context.CLIARGS['check']
420        self.diff = context.CLIARGS['diff']
421        self.forks = context.CLIARGS['forks']
422
423        # dynamically add modules as commands
424        self.modules = self.list_modules()
425        for module in self.modules:
426            setattr(self, 'do_' + module, lambda arg, module=module: self.default(module + ' ' + arg))
427            setattr(self, 'help_' + module, lambda module=module: self.helpdefault(module))
428
429        (sshpass, becomepass) = self.ask_passwords()
430        self.passwords = {'conn_pass': sshpass, 'become_pass': becomepass}
431
432        self.loader, self.inventory, self.variable_manager = self._play_prereqs()
433
434        hosts = self.get_host_list(self.inventory, context.CLIARGS['subset'], self.pattern)
435
436        self.groups = self.inventory.list_groups()
437        self.hosts = [x.name for x in hosts]
438
439        # This hack is to work around readline issues on a mac:
440        #  http://stackoverflow.com/a/7116997/541202
441        if 'libedit' in readline.__doc__:
442            readline.parse_and_bind("bind ^I rl_complete")
443        else:
444            readline.parse_and_bind("tab: complete")
445
446        histfile = os.path.join(os.path.expanduser("~"), ".ansible-console_history")
447        try:
448            readline.read_history_file(histfile)
449        except IOError:
450            pass
451
452        atexit.register(readline.write_history_file, histfile)
453        self.set_prompt()
454        self.cmdloop()
455