cli.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658
  1. '''
  2. A CLI interface to a remote salt-api instance
  3. '''
  4. from __future__ import print_function
  5. import getpass
  6. import json
  7. import logging
  8. import optparse
  9. import os
  10. import sys
  11. import textwrap
  12. import time
  13. # Import Pepper Libraries
  14. import pepper
  15. from pepper.exceptions import (
  16. PepperAuthException,
  17. PepperArgumentsException,
  18. PepperException,
  19. )
  20. try:
  21. # Python 3
  22. from configparser import ConfigParser, RawConfigParser
  23. except ImportError:
  24. # Python 2
  25. from ConfigParser import ConfigParser, RawConfigParser
  26. try:
  27. # Python 3
  28. JSONDecodeError = json.decode.JSONDecodeError
  29. except AttributeError:
  30. # Python 2
  31. JSONDecodeError = ValueError
  32. try:
  33. input = raw_input
  34. except NameError:
  35. pass
  36. if sys.version_info[0] == 2:
  37. FileNotFoundError = IOError
  38. logger = logging.getLogger(__name__)
  39. class PepperCli(object):
  40. def __init__(self, seconds_to_wait=3):
  41. self.seconds_to_wait = seconds_to_wait
  42. self.parser = self.get_parser()
  43. self.parser.option_groups.extend([
  44. self.add_globalopts(),
  45. self.add_tgtopts(),
  46. self.add_authopts(),
  47. self.add_retcodeopts(),
  48. ])
  49. self.parse()
  50. def get_parser(self):
  51. return optparse.OptionParser(
  52. description=__doc__,
  53. usage='%prog [opts]',
  54. version=pepper.__version__)
  55. def parse(self):
  56. '''
  57. Parse all args
  58. '''
  59. self.parser.add_option(
  60. '-c', dest='config',
  61. default=os.environ.get(
  62. 'PEPPERRC',
  63. os.path.join(os.path.expanduser('~'), '.pepperrc')
  64. ),
  65. help=textwrap.dedent('''
  66. Configuration file location. Default is a file path in the
  67. "PEPPERRC" environment variable or ~/.pepperrc.
  68. '''),
  69. )
  70. self.parser.add_option(
  71. '-p', dest='profile',
  72. default=os.environ.get('PEPPERPROFILE', 'main'),
  73. help=textwrap.dedent('''
  74. Profile in config file to use. Default is "PEPPERPROFILE" environment
  75. variable or 'main'
  76. '''),
  77. )
  78. self.parser.add_option(
  79. '-m', dest='master',
  80. default=os.environ.get(
  81. 'MASTER_CONFIG',
  82. os.path.join(os.path.expanduser('~'), '.config', 'pepper', 'master')
  83. ),
  84. help=textwrap.dedent('''
  85. Master Configuration file location for configuring outputters.
  86. default: ~/.config/pepper/master
  87. '''),
  88. )
  89. self.parser.add_option(
  90. '-o', '--out', dest='output', default=None,
  91. help=textwrap.dedent('''
  92. Salt outputter to use for printing out returns.
  93. ''')
  94. )
  95. self.parser.add_option(
  96. '--output-file', dest='output_file', default=None,
  97. help=textwrap.dedent('''
  98. File to put command output in
  99. ''')
  100. )
  101. self.parser.add_option(
  102. '-v', dest='verbose', default=0, action='count',
  103. help=textwrap.dedent('''
  104. Increment output verbosity; may be specified multiple times
  105. '''),
  106. )
  107. self.parser.add_option(
  108. '-H', '--debug-http', dest='debug_http', default=False, action='store_true',
  109. help=textwrap.dedent('''
  110. Output the HTTP request/response headers on stderr
  111. '''),
  112. )
  113. self.parser.add_option(
  114. '--ignore-ssl-errors', action='store_true', dest='ignore_ssl_certificate_errors', default=False,
  115. help=textwrap.dedent('''
  116. Ignore any SSL certificate that may be encountered. Note that it is
  117. recommended to resolve certificate errors for production.
  118. '''),
  119. )
  120. self.options, self.args = self.parser.parse_args()
  121. option_names = ["fail_any", "fail_any_none", "fail_all", "fail_all_none"]
  122. toggled_options = [name for name in option_names if getattr(self.options, name)]
  123. if len(toggled_options) > 1:
  124. s = repr(toggled_options).strip("[]")
  125. self.parser.error("Options %s are mutually exclusive" % s)
  126. def add_globalopts(self):
  127. '''
  128. Misc global options
  129. '''
  130. optgroup = optparse.OptionGroup(self.parser, "Pepper ``salt`` Options", "Mimic the ``salt`` CLI")
  131. optgroup.add_option(
  132. '-t', '--timeout', dest='timeout', type='int', default=60,
  133. help=textwrap.dedent('''
  134. Specify wait time (in seconds) before returning control to the shell
  135. '''),
  136. )
  137. optgroup.add_option(
  138. '--client', dest='client', default='local',
  139. help=textwrap.dedent('''
  140. specify the salt-api client to use (local, local_async,
  141. runner, etc)
  142. '''),
  143. )
  144. optgroup.add_option(
  145. '--json', dest='json_input',
  146. help=textwrap.dedent('''
  147. Enter JSON at the CLI instead of positional (text) arguments. This
  148. is useful for arguments that need complex data structures.
  149. Specifying this argument will cause positional arguments to be
  150. ignored.
  151. '''),
  152. )
  153. optgroup.add_option(
  154. '--json-file', dest='json_file',
  155. help=textwrap.dedent('''
  156. Specify file containing the JSON to be used by pepper
  157. '''),
  158. )
  159. optgroup.add_option(
  160. '--fail-if-incomplete', action='store_true', dest='fail_if_minions_dont_respond', default=False,
  161. help=textwrap.dedent('''
  162. Return a failure exit code if not all minions respond. This option
  163. requires the authenticated user have access to run the
  164. `jobs.list_jobs` runner function.
  165. '''),
  166. )
  167. return optgroup
  168. def add_tgtopts(self):
  169. '''
  170. Targeting
  171. '''
  172. optgroup = optparse.OptionGroup(self.parser, "Targeting Options", "Target which minions to run commands on")
  173. optgroup.defaults.update({'expr_form': 'glob'})
  174. optgroup.add_option(
  175. '-E', '--pcre', dest='expr_form', action='store_const', const='pcre',
  176. help="Target hostnames using PCRE regular expressions",
  177. )
  178. optgroup.add_option(
  179. '-L', '--list', dest='expr_form', action='store_const', const='list',
  180. help="Specify a comma delimited list of hostnames",
  181. )
  182. optgroup.add_option(
  183. '-G', '--grain', dest='expr_form', action='store_const', const='grain',
  184. help="Target based on system properties",
  185. )
  186. optgroup.add_option(
  187. '--grain-pcre', dest='expr_form', action='store_const', const='grain_pcre',
  188. help="Target based on PCRE matches on system properties",
  189. )
  190. optgroup.add_option(
  191. '-I', '--pillar', dest='expr_form', action='store_const', const='pillar',
  192. help="Target based on pillar values",
  193. )
  194. optgroup.add_option(
  195. '--pillar-pcre', dest='expr_form', action='store_const', const='pillar_pcre',
  196. help="Target based on PCRE matches on pillar values"
  197. )
  198. optgroup.add_option(
  199. '-R', '--range', dest='expr_form', action='store_const', const='range',
  200. help="Target based on range expression",
  201. )
  202. optgroup.add_option(
  203. '-C', '--compound', dest='expr_form', action='store_const', const='compound',
  204. help="Target based on compound expression",
  205. )
  206. optgroup.add_option(
  207. '-N', '--nodegroup', dest='expr_form', action='store_const', const='nodegroup',
  208. help="Target based on a named nodegroup",
  209. )
  210. optgroup.add_option('--batch', dest='batch', default=None)
  211. return optgroup
  212. def add_authopts(self):
  213. '''
  214. Authentication options
  215. '''
  216. optgroup = optparse.OptionGroup(
  217. self.parser, "Authentication Options",
  218. textwrap.dedent('''
  219. Authentication credentials can optionally be supplied via the
  220. environment variables:
  221. SALTAPI_URL, SALTAPI_USER, SALTAPI_PASS, SALTAPI_EAUTH.
  222. '''),
  223. )
  224. optgroup.add_option(
  225. '-u', '--saltapi-url', dest='saltapiurl',
  226. help="Specify the host url. Defaults to https://localhost:8080"
  227. )
  228. optgroup.add_option(
  229. '-a', '--auth', '--eauth', '--extended-auth', dest='eauth',
  230. help=textwrap.dedent('''
  231. Specify the external_auth backend to authenticate against and
  232. interactively prompt for credentials
  233. '''),
  234. )
  235. optgroup.add_option(
  236. '--username', dest='username',
  237. help=textwrap.dedent('''
  238. Optional, defaults to user name. will be prompt if empty unless --non-interactive
  239. '''),
  240. )
  241. optgroup.add_option(
  242. '--password', dest='password',
  243. help=textwrap.dedent('''
  244. Optional, but will be prompted unless --non-interactive
  245. '''),
  246. )
  247. optgroup.add_option(
  248. '--token-expire', dest='token_expire',
  249. help=textwrap.dedent('''
  250. Set eauth token expiry in seconds. Must be allowed per
  251. user. See the `token_expire_user_override` Master setting
  252. for more info.
  253. '''),
  254. )
  255. optgroup.add_option(
  256. '--non-interactive', action='store_false', dest='interactive', default=True,
  257. help=textwrap.dedent('''
  258. Optional, fail rather than waiting for input
  259. ''')
  260. )
  261. optgroup.add_option(
  262. '-T', '--make-token', default=False, dest='mktoken', action='store_true',
  263. help=textwrap.dedent('''
  264. Generate and save an authentication token for re-use. The token is
  265. generated and made available for the period defined in the Salt
  266. Master.
  267. '''),
  268. )
  269. optgroup.add_option(
  270. '-r', '--run-uri', default=False, dest='userun', action='store_true',
  271. help=textwrap.dedent('''
  272. Use an eauth token from /token and send commands through the
  273. /run URL instead of the traditional session token
  274. approach.
  275. '''),
  276. )
  277. optgroup.add_option(
  278. '-x', dest='cache',
  279. default=os.environ.get(
  280. 'PEPPERCACHE',
  281. os.path.join(os.path.expanduser('~'), '.peppercache')
  282. ),
  283. help=textwrap.dedent('''
  284. Cache file location. Default is a file path in the
  285. "PEPPERCACHE" environment variable or ~/.peppercache.
  286. '''),
  287. )
  288. return optgroup
  289. def add_retcodeopts(self):
  290. '''
  291. ret code validation options
  292. '''
  293. optgroup = optparse.OptionGroup(
  294. self.parser, "retcode Field Validation Options", "Validate return.HOST.retcode fields")
  295. optgroup.add_option(
  296. '--fail-any', dest='fail_any', action='store_true',
  297. help="Fail if any of retcode field is non zero.")
  298. optgroup.add_option(
  299. '--fail-any-none', dest='fail_any_none', action='store_true',
  300. help="Fail if any of retcode field is non zero or there is no retcode at all.")
  301. optgroup.add_option(
  302. '--fail-all', dest='fail_all', action='store_true',
  303. help="Fail if all retcode fields are non zero.")
  304. optgroup.add_option(
  305. '--fail-all-none', dest='fail_all_none', action='store_true',
  306. help="Fail if all retcode fields are non zero or there is no retcode at all.")
  307. return optgroup
  308. def get_login_details(self):
  309. '''
  310. This parses the config file, environment variables and command line options
  311. and returns the config values
  312. Order of parsing:
  313. command line options, ~/.pepperrc, environment, defaults
  314. '''
  315. # setting default values
  316. results = {
  317. 'SALTAPI_USER': None,
  318. 'SALTAPI_PASS': None,
  319. 'SALTAPI_EAUTH': 'auto',
  320. }
  321. try:
  322. config = ConfigParser(interpolation=None)
  323. except TypeError:
  324. config = RawConfigParser()
  325. config.read(self.options.config)
  326. # read file
  327. profile = self.options.profile
  328. if config.has_section(profile):
  329. for key, value in list(results.items()):
  330. if config.has_option(profile, key):
  331. results[key] = config.get(profile, key)
  332. # get environment values
  333. for key, value in list(results.items()):
  334. results[key] = os.environ.get(key, results[key])
  335. if results['SALTAPI_EAUTH'] == 'kerberos':
  336. results['SALTAPI_PASS'] = None
  337. if self.options.eauth:
  338. results['SALTAPI_EAUTH'] = self.options.eauth
  339. if self.options.token_expire:
  340. results['SALTAPI_TOKEN_EXPIRE'] = self.options.token_expire
  341. if self.options.username is None and results['SALTAPI_USER'] is None:
  342. if self.options.interactive:
  343. results['SALTAPI_USER'] = input('Username: ')
  344. else:
  345. raise PepperAuthException("SALTAPI_USER required")
  346. else:
  347. if self.options.username is not None:
  348. results['SALTAPI_USER'] = self.options.username
  349. if self.options.password is None and results['SALTAPI_PASS'] is None:
  350. if self.options.interactive:
  351. results['SALTAPI_PASS'] = getpass.getpass(prompt='Password: ')
  352. else:
  353. raise PepperAuthException("SALTAPI_PASS required")
  354. else:
  355. if self.options.password is not None:
  356. results['SALTAPI_PASS'] = self.options.password
  357. return results
  358. def parse_url(self):
  359. '''
  360. Determine api url
  361. '''
  362. url = 'https://localhost:8000/'
  363. try:
  364. config = ConfigParser(interpolation=None)
  365. except TypeError:
  366. config = RawConfigParser()
  367. config.read(self.options.config)
  368. # read file
  369. profile = self.options.profile
  370. if config.has_section(profile):
  371. if config.has_option(profile, "SALTAPI_URL"):
  372. url = config.get(profile, "SALTAPI_URL")
  373. # get environment values
  374. url = os.environ.get("SALTAPI_URL", url)
  375. # get eauth prompt options
  376. if self.options.saltapiurl:
  377. url = self.options.saltapiurl
  378. return url
  379. def parse_login(self):
  380. '''
  381. Extract the authentication credentials
  382. '''
  383. login_details = self.get_login_details()
  384. # Auth values placeholder; grab interactively at CLI or from config
  385. username = login_details['SALTAPI_USER']
  386. password = login_details['SALTAPI_PASS']
  387. eauth = login_details['SALTAPI_EAUTH']
  388. ret = dict(username=username, password=password, eauth=eauth)
  389. token_expire = login_details.get('SALTAPI_TOKEN_EXPIRE', None)
  390. if token_expire:
  391. ret['token_expire'] = int(token_expire)
  392. return ret
  393. def parse_cmd(self):
  394. '''
  395. Extract the low data for a command from the passed CLI params
  396. '''
  397. # Short-circuit if JSON was given.
  398. if self.options.json_input:
  399. try:
  400. return json.loads(self.options.json_input)
  401. except JSONDecodeError:
  402. raise PepperArgumentsException("Invalid JSON given.")
  403. if self.options.json_file:
  404. try:
  405. with open(self.options.json_file, 'r') as json_content:
  406. try:
  407. return json.load(json_content)
  408. except JSONDecodeError:
  409. raise PepperArgumentsException("Invalid JSON given.")
  410. except FileNotFoundError:
  411. raise PepperArgumentsException('Cannot open file: %s', self.options.json_file)
  412. args = list(self.args)
  413. client = self.options.client if not self.options.batch else 'local_batch'
  414. low = {'client': client}
  415. if client.startswith('local'):
  416. if len(args) < 2:
  417. self.parser.error("Command or target not specified")
  418. low['tgt_type'] = self.options.expr_form
  419. low['tgt'] = args.pop(0)
  420. low['fun'] = args.pop(0)
  421. low['batch'] = self.options.batch
  422. low['arg'] = args
  423. elif client.startswith('runner'):
  424. low['fun'] = args.pop(0)
  425. for arg in args:
  426. if '=' in arg:
  427. key, value = arg.split('=', 1)
  428. try:
  429. low[key] = json.loads(value)
  430. except JSONDecodeError:
  431. low[key] = value
  432. else:
  433. low.setdefault('arg', []).append(arg)
  434. elif client.startswith('wheel'):
  435. low['fun'] = args.pop(0)
  436. for arg in args:
  437. if '=' in arg:
  438. key, value = arg.split('=', 1)
  439. try:
  440. low[key] = json.loads(value)
  441. except JSONDecodeError:
  442. low[key] = value
  443. else:
  444. low.setdefault('arg', []).append(arg)
  445. elif client.startswith('ssh'):
  446. if len(args) < 2:
  447. self.parser.error("Command or target not specified")
  448. low['tgt_type'] = self.options.expr_form
  449. low['tgt'] = args.pop(0)
  450. low['fun'] = args.pop(0)
  451. low['batch'] = self.options.batch
  452. low['arg'] = args
  453. else:
  454. raise PepperException('Client not implemented: {0}'.format(client))
  455. return [low]
  456. def poll_for_returns(self, api, load):
  457. '''
  458. Run a command with the local_async client and periodically poll the job
  459. cache for returns for the job.
  460. '''
  461. load[0]['client'] = 'local_async'
  462. async_ret = self.low(api, load)
  463. jid = async_ret['return'][0]['jid']
  464. nodes = async_ret['return'][0]['minions']
  465. ret_nodes = []
  466. exit_code = 1
  467. # keep trying until all expected nodes return
  468. total_time = 0
  469. start_time = time.time()
  470. exit_code = 0
  471. while True:
  472. total_time = time.time() - start_time
  473. if total_time > self.options.timeout:
  474. exit_code = 1
  475. break
  476. jid_ret = self.low(api, [{
  477. 'client': 'runner',
  478. 'fun': 'jobs.lookup_jid',
  479. 'kwarg': {
  480. 'jid': jid,
  481. },
  482. }])
  483. responded = set(jid_ret['return'][0].keys()) ^ set(ret_nodes)
  484. for node in responded:
  485. yield None, "{{{}: {}}}".format(
  486. node,
  487. jid_ret['return'][0][node])
  488. ret_nodes = list(jid_ret['return'][0].keys())
  489. if set(ret_nodes) == set(nodes):
  490. exit_code = 0
  491. break
  492. else:
  493. time.sleep(self.seconds_to_wait)
  494. exit_code = exit_code if self.options.fail_if_minions_dont_respond else 0
  495. yield exit_code, "{{Failed: {}}}".format(
  496. list(set(ret_nodes) ^ set(nodes)))
  497. def login(self, api):
  498. login = api.token if self.options.userun else api.login
  499. if self.options.mktoken:
  500. token_file = self.options.cache
  501. try:
  502. with open(token_file, 'rt') as f:
  503. auth = json.load(f)
  504. if auth['expire'] < time.time()+30:
  505. logger.error('Login token expired')
  506. raise Exception('Login token expired')
  507. except Exception as e:
  508. if e.args[0] != 2:
  509. logger.error('Unable to load login token from {0} {1}'.format(token_file, str(e)))
  510. if os.path.isfile(token_file):
  511. os.remove(token_file)
  512. auth = login(**self.parse_login())
  513. try:
  514. oldumask = os.umask(0)
  515. fdsc = os.open(token_file, os.O_WRONLY | os.O_CREAT, 0o600)
  516. with os.fdopen(fdsc, 'wt') as f:
  517. json.dump(auth, f)
  518. except Exception as e:
  519. logger.error('Unable to save token to {0} {1}'.format(token_file, str(e)))
  520. finally:
  521. os.umask(oldumask)
  522. else:
  523. auth = login(**self.parse_login())
  524. api.auth = auth
  525. self.auth = auth
  526. return auth
  527. def low(self, api, load):
  528. path = '/run' if self.options.userun else '/'
  529. if self.options.userun:
  530. for i in load:
  531. i['token'] = self.auth['token']
  532. return api.low(load, path=path)
  533. def run(self):
  534. '''
  535. Parse all arguments and call salt-api
  536. '''
  537. # move logger instantiation to method?
  538. logger.addHandler(logging.StreamHandler())
  539. logger.setLevel(max(logging.ERROR - (self.options.verbose * 10), 1))
  540. load = self.parse_cmd()
  541. for entry in load:
  542. if entry.get('client', '').startswith('local'):
  543. entry['full_return'] = True
  544. api = pepper.Pepper(
  545. self.parse_url(),
  546. debug_http=self.options.debug_http,
  547. ignore_ssl_errors=self.options.ignore_ssl_certificate_errors)
  548. self.login(api)
  549. if self.options.fail_if_minions_dont_respond:
  550. for exit_code, ret in self.poll_for_returns(api, load): # pragma: no cover
  551. yield exit_code, json.dumps(ret, sort_keys=True, indent=4)
  552. else:
  553. ret = self.low(api, load)
  554. exit_code = 0
  555. yield exit_code, json.dumps(ret, sort_keys=True, indent=4)