cli.py 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684
  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 \
  350. results['SALTAPI_PASS'] is None and \
  351. results['SALTAPI_EAUTH'] != 'kerberos':
  352. if self.options.interactive:
  353. results['SALTAPI_PASS'] = getpass.getpass(prompt='Password: ')
  354. else:
  355. raise PepperAuthException("SALTAPI_PASS required")
  356. else:
  357. if self.options.password is not None:
  358. results['SALTAPI_PASS'] = self.options.password
  359. return results
  360. def parse_url(self):
  361. '''
  362. Determine api url
  363. '''
  364. url = 'https://localhost:8000/'
  365. try:
  366. config = ConfigParser(interpolation=None)
  367. except TypeError:
  368. config = RawConfigParser()
  369. config.read(self.options.config)
  370. # read file
  371. profile = self.options.profile
  372. if config.has_section(profile):
  373. if config.has_option(profile, "SALTAPI_URL"):
  374. url = config.get(profile, "SALTAPI_URL")
  375. # get environment values
  376. url = os.environ.get("SALTAPI_URL", url)
  377. # get eauth prompt options
  378. if self.options.saltapiurl:
  379. url = self.options.saltapiurl
  380. return url
  381. def parse_login(self):
  382. '''
  383. Extract the authentication credentials
  384. '''
  385. login_details = self.get_login_details()
  386. # Auth values placeholder; grab interactively at CLI or from config
  387. username = login_details['SALTAPI_USER']
  388. password = login_details['SALTAPI_PASS']
  389. eauth = login_details['SALTAPI_EAUTH']
  390. ret = dict(username=username, password=password, eauth=eauth)
  391. token_expire = login_details.get('SALTAPI_TOKEN_EXPIRE', None)
  392. if token_expire:
  393. ret['token_expire'] = int(token_expire)
  394. return ret
  395. def parse_cmd(self, api):
  396. '''
  397. Extract the low data for a command from the passed CLI params
  398. '''
  399. # Short-circuit if JSON was given.
  400. if self.options.json_input:
  401. try:
  402. return json.loads(self.options.json_input)
  403. except JSONDecodeError:
  404. raise PepperArgumentsException("Invalid JSON given.")
  405. if self.options.json_file:
  406. try:
  407. with open(self.options.json_file, 'r') as json_content:
  408. try:
  409. return json.load(json_content)
  410. except JSONDecodeError:
  411. raise PepperArgumentsException("Invalid JSON given.")
  412. except FileNotFoundError:
  413. raise PepperArgumentsException('Cannot open file: %s', self.options.json_file)
  414. args = list(self.args)
  415. client = self.options.client if not self.options.batch else 'local_batch'
  416. low = {'client': client}
  417. if client.startswith('local'):
  418. if len(args) < 2:
  419. self.parser.error("Command or target not specified")
  420. low['tgt_type'] = self.options.expr_form
  421. low['tgt'] = args.pop(0)
  422. low['fun'] = args.pop(0)
  423. low['batch'] = self.options.batch
  424. low['arg'] = args
  425. elif client.startswith('runner'):
  426. low['fun'] = args.pop(0)
  427. # post https://github.com/saltstack/salt/pull/50124, kwargs can be
  428. # passed as is in foo=bar form, splitting and deserializing will
  429. # happen in salt-api. additionally, the presence of salt-version header
  430. # means we are neon or newer, so don't need a finer grained check
  431. if api.salt_version:
  432. low['arg'] = args
  433. else:
  434. for arg in args:
  435. if '=' in arg:
  436. key, value = arg.split('=', 1)
  437. try:
  438. low[key] = json.loads(value)
  439. except JSONDecodeError:
  440. low[key] = value
  441. else:
  442. low.setdefault('arg', []).append(arg)
  443. elif client.startswith('wheel'):
  444. low['fun'] = args.pop(0)
  445. # see above comment in runner arg handling
  446. if api.salt_version:
  447. low['arg'] = args
  448. else:
  449. for arg in args:
  450. if '=' in arg:
  451. key, value = arg.split('=', 1)
  452. try:
  453. low[key] = json.loads(value)
  454. except JSONDecodeError:
  455. low[key] = value
  456. else:
  457. low.setdefault('arg', []).append(arg)
  458. elif client.startswith('ssh'):
  459. if len(args) < 2:
  460. self.parser.error("Command or target not specified")
  461. low['tgt_type'] = self.options.expr_form
  462. low['tgt'] = args.pop(0)
  463. low['fun'] = args.pop(0)
  464. low['batch'] = self.options.batch
  465. low['arg'] = args
  466. else:
  467. raise PepperException('Client not implemented: {0}'.format(client))
  468. return [low]
  469. def poll_for_returns(self, api, load):
  470. '''
  471. Run a command with the local_async client and periodically poll the job
  472. cache for returns for the job.
  473. '''
  474. load[0]['client'] = 'local_async'
  475. async_ret = self.low(api, load)
  476. jid = async_ret['return'][0]['jid']
  477. nodes = async_ret['return'][0]['minions']
  478. ret_nodes = []
  479. exit_code = 1
  480. # keep trying until all expected nodes return
  481. total_time = 0
  482. start_time = time.time()
  483. exit_code = 0
  484. while True:
  485. total_time = time.time() - start_time
  486. if total_time > self.options.timeout:
  487. exit_code = 1
  488. break
  489. jid_ret = self.low(api, [{
  490. 'client': 'runner',
  491. 'fun': 'jobs.lookup_jid',
  492. 'kwarg': {
  493. 'jid': jid,
  494. },
  495. }])
  496. inner_ret = jid_ret['return'][0]
  497. # sometimes ret is nested in data
  498. if 'data' in inner_ret:
  499. inner_ret = inner_ret['data']
  500. responded = set(inner_ret.keys()) ^ set(ret_nodes)
  501. for node in responded:
  502. yield None, [{node: inner_ret[node]}]
  503. ret_nodes = list(inner_ret.keys())
  504. if set(ret_nodes) == set(nodes):
  505. exit_code = 0
  506. break
  507. else:
  508. time.sleep(self.seconds_to_wait)
  509. exit_code = exit_code if self.options.fail_if_minions_dont_respond else 0
  510. failed = list(set(ret_nodes) ^ set(nodes))
  511. if failed:
  512. yield exit_code, [{'Failed': failed}]
  513. def login(self, api):
  514. login = api.token if self.options.userun else api.login
  515. if self.options.mktoken:
  516. token_file = self.options.cache
  517. try:
  518. with open(token_file, 'rt') as f:
  519. auth = json.load(f)
  520. if auth['expire'] < time.time()+30:
  521. logger.error('Login token expired')
  522. raise Exception('Login token expired')
  523. except Exception as e:
  524. if e.args[0] != 2:
  525. logger.error('Unable to load login token from {0} {1}'.format(token_file, str(e)))
  526. if os.path.isfile(token_file):
  527. os.remove(token_file)
  528. auth = login(**self.parse_login())
  529. try:
  530. oldumask = os.umask(0)
  531. fdsc = os.open(token_file, os.O_WRONLY | os.O_CREAT, 0o600)
  532. with os.fdopen(fdsc, 'wt') as f:
  533. json.dump(auth, f)
  534. except Exception as e:
  535. logger.error('Unable to save token to {0} {1}'.format(token_file, str(e)))
  536. finally:
  537. os.umask(oldumask)
  538. else:
  539. auth = login(**self.parse_login())
  540. api.auth = auth
  541. self.auth = auth
  542. return auth
  543. def low(self, api, load):
  544. path = '/run' if self.options.userun else '/'
  545. if self.options.userun:
  546. for i in load:
  547. i['token'] = self.auth['token']
  548. # having a defined salt_version means changes from https://github.com/saltstack/salt/pull/51979
  549. # are available if backend is tornado, so safe to supply timeout
  550. if self.options.timeout and api.salt_version:
  551. for i in load:
  552. if not i.get('client', '').startswith('wheel'):
  553. i['timeout'] = self.options.timeout
  554. return api.low(load, path=path)
  555. def run(self):
  556. '''
  557. Parse all arguments and call salt-api
  558. '''
  559. # set up logging
  560. rootLogger = logging.getLogger(name=None)
  561. rootLogger.addHandler(logging.StreamHandler())
  562. rootLogger.setLevel(max(logging.ERROR - (self.options.verbose * 10), 1))
  563. api = pepper.Pepper(
  564. self.parse_url(),
  565. debug_http=self.options.debug_http,
  566. ignore_ssl_errors=self.options.ignore_ssl_certificate_errors)
  567. self.login(api)
  568. load = self.parse_cmd(api)
  569. for entry in load:
  570. if not entry.get('client', '').startswith('wheel'):
  571. entry['full_return'] = True
  572. if self.options.fail_if_minions_dont_respond:
  573. for exit_code, ret in self.poll_for_returns(api, load): # pragma: no cover
  574. yield exit_code, json.dumps(ret, sort_keys=True, indent=4)
  575. else:
  576. ret = self.low(api, load)
  577. exit_code = 0
  578. yield exit_code, json.dumps(ret, sort_keys=True, indent=4)