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