diff --git a/pepper/cli.py b/pepper/cli.py index b1670fb..3700db1 100644 --- a/pepper/cli.py +++ b/pepper/cli.py @@ -196,6 +196,12 @@ def add_authopts(self): dest='password', help=textwrap.dedent("""\ Optional, but will be prompted unless --non-interactive""")) + optgroup.add_option('--token-expire', + dest='token_expire', help=textwrap.dedent("""\ + Set eauth token expiry in seconds. Must be allowed per + user. See the `token_expire_user_override` Master setting + for more info.""")) + optgroup.add_option('--non-interactive', action='store_false', dest='interactive', help=textwrap.dedent("""\ Optional, fail rather than waiting for input"""), default=True) @@ -207,6 +213,13 @@ def add_authopts(self): generated and made available for the period defined in the Salt Master.""")) + optgroup.add_option('-r', '--run-uri', default=False, + dest='userun', action='store_true', + help=textwrap.dedent("""\ + Use an eauth token from /token and send commands through the + /run URL instead of the traditional session token + approach.""")) + optgroup.add_option('-x', dest='cache', default=os.environ.get('PEPPERCACHE', os.path.join(os.path.expanduser('~'), '.peppercache')), @@ -253,6 +266,8 @@ def get_login_details(self): if self.options.eauth: results['SALTAPI_EAUTH'] = self.options.eauth + if self.options.token_expire: + results['SALTAPI_TOKEN_EXPIRE'] = self.options.token_expire if self.options.username is None and results['SALTAPI_USER'] is None: if self.options.interactive: results['SALTAPI_USER'] = input('Username: ') @@ -308,11 +323,17 @@ def parse_login(self): login_details = self.get_login_details() # Auth values placeholder; grab interactively at CLI or from config - user = login_details['SALTAPI_USER'] - passwd = login_details['SALTAPI_PASS'] + username = login_details['SALTAPI_USER'] + password = login_details['SALTAPI_PASS'] eauth = login_details['SALTAPI_EAUTH'] - return user, passwd, eauth + ret = dict(username=username, password=password, eauth=eauth) + + token_expire = login_details.get('SALTAPI_TOKEN_EXPIRE', None) + if token_expire: + ret['token_expire'] = int(token_expire) + + return ret def parse_cmd(self): ''' @@ -396,7 +417,7 @@ def poll_for_returns(self, api, load): cache for returns for the job. ''' load[0]['client'] = 'local_async' - async_ret = api.low(load) + async_ret = self.low(api, load) jid = async_ret['return'][0]['jid'] nodes = async_ret['return'][0]['minions'] ret_nodes = [] @@ -413,7 +434,14 @@ def poll_for_returns(self, api, load): exit_code = 1 break - jid_ret = api.lookup_jid(jid) + jid_ret = self.low(api, [{ + 'client': 'runner', + 'fun': 'jobs.lookup_jid', + 'kwarg': { + 'jid': jid, + }, + }]) + responded = set(jid_ret['return'][0].keys()) ^ set(ret_nodes) for node in responded: yield None, "{{{}: {}}}".format( @@ -431,51 +459,71 @@ def poll_for_returns(self, api, load): yield exit_code, "{{Failed: {}}}".format( list(set(ret_nodes) ^ set(nodes))) - def run(self): - ''' - Parse all arguments and call salt-api - ''' - self.parse() - - # move logger instantiation to method? - logger.addHandler(logging.StreamHandler()) - logger.setLevel(max(logging.ERROR - (self.options.verbose * 10), 1)) - - load = self.parse_cmd() + def login(self, api): + login = api.token if self.options.userun else api.login - api = pepper.Pepper( - self.parse_url(), - debug_http=self.options.debug_http, - ignore_ssl_errors=self.options.ignore_ssl_certificate_errors) if self.options.mktoken: token_file = self.options.cache try: with open(token_file, 'rt') as f: - api.auth = json.load(f) - if api.auth['expire'] < time.time()+30: + auth = json.load(f) + if auth['expire'] < time.time()+30: logger.error('Login token expired') raise Exception('Login token expired') - api.req('/stats') except Exception as e: if e.args[0] is not 2: - logger.error('Unable to load login token from {0} {1}'.format(token_file, str(e))) - auth = api.login(*self.parse_login()) + logger.error('Unable to load login token from {0} {1}' + .format(token_file, str(e))) + auth = login(**self.parse_login()) try: oldumask = os.umask(0) fdsc = os.open(token_file, os.O_WRONLY | os.O_CREAT, 0o600) with os.fdopen(fdsc, 'wt') as f: json.dump(auth, f) except Exception as e: - logger.error('Unable to save token to {0} {1}'.format(token_file, str(e))) + logger.error('Unable to save token to {0} {1}' + .format(token_file, str(e))) finally: os.umask(oldumask) else: - auth = api.login(*self.parse_login()) + auth = login(**self.parse_login()) + + api.auth = auth + self.auth = auth + return auth + + def low(self, api, load): + path = '/run' if self.options.userun else '/' + + if self.options.userun: + for i in load: + i['token'] = self.auth['token'] + + return api.low(load, path=path) + + def run(self): + ''' + Parse all arguments and call salt-api + ''' + self.parse() + + # move logger instantiation to method? + logger.addHandler(logging.StreamHandler()) + logger.setLevel(max(logging.ERROR - (self.options.verbose * 10), 1)) + + load = self.parse_cmd() + + api = pepper.Pepper( + self.parse_url(), + debug_http=self.options.debug_http, + ignore_ssl_errors=self.options.ignore_ssl_certificate_errors) + + self.login(api) if self.options.fail_if_minions_dont_respond: for exit_code, ret in self.poll_for_returns(api, load): yield exit_code, json.dumps(ret, sort_keys=True, indent=4) else: - ret = api.low(load) + ret = self.low(api, load) exit_code = 0 yield exit_code, json.dumps(ret, sort_keys=True, indent=4) diff --git a/pepper/libpepper.py b/pepper/libpepper.py index b641fc0..f0c6709 100644 --- a/pepper/libpepper.py +++ b/pepper/libpepper.py @@ -57,7 +57,10 @@ class Pepper(object): u'ms-4': True}]} ''' - def __init__(self, api_url='https://localhost:8000', debug_http=False, ignore_ssl_errors=False): + def __init__(self, + api_url='https://localhost:8000', + debug_http=False, + ignore_ssl_errors=False): ''' Initialize the class with the URL of the API @@ -188,7 +191,8 @@ def req(self, path, data=None): :rtype: dictionary ''' - if (hasattr(data, 'get') and data.get('eauth') == 'kerberos') or self.auth.get('eauth') == 'kerberos': + if ((hasattr(data, 'get') and data.get('eauth') == 'kerberos') + or self.auth.get('eauth') == 'kerberos'): return self.req_requests(path, data) headers = { @@ -324,7 +328,7 @@ def local(self, tgt, fun, arg=None, kwarg=None, expr_form='glob', if ret: low['ret'] = ret - return self.low([low], path='/') + return self.low([low]) def local_async(self, tgt, fun, arg=None, kwarg=None, expr_form='glob', timeout=None, ret=None): @@ -354,7 +358,7 @@ def local_async(self, tgt, fun, arg=None, kwarg=None, expr_form='glob', if ret: low['ret'] = ret - return self.low([low], path='/') + return self.low([low]) def local_batch(self, tgt, fun, arg=None, kwarg=None, expr_form='glob', batch='50%', ret=None): @@ -384,7 +388,7 @@ def local_batch(self, tgt, fun, arg=None, kwarg=None, expr_form='glob', if ret: low['ret'] = ret - return self.low([low], path='/') + return self.low([low]) def lookup_jid(self, jid): ''' @@ -411,7 +415,7 @@ def runner(self, fun, arg=None, **kwargs): low.update(kwargs) - return self.low([low], path='/') + return self.low([low]) def wheel(self, fun, arg=None, kwarg=None, **kwargs): ''' @@ -432,19 +436,26 @@ def wheel(self, fun, arg=None, kwarg=None, **kwargs): low.update(kwargs) - return self.low([low], path='/') + return self.low([low]) - def login(self, username, password, eauth): + def _send_auth(self, path, **kwargs): + return self.req(path, kwargs) + + def login(self, **kwargs): ''' Authenticate with salt-api and return the user permissions and authentication token or an empty dict ''' - self.auth = self.req('/login', { - 'username': username, - 'password': password, - 'eauth': eauth}).get('return', [{}])[0] + self.auth = self._send_auth('/login', **kwargs).get('return', [{}])[0] + return self.auth + def token(self, **kwargs): + ''' + Get an eauth token from Salt for use with the /run URL + + ''' + self.auth = self._send_auth('/token', **kwargs)[0] return self.auth def _construct_url(self, path): diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration.py b/tests/integration.py new file mode 100755 index 0000000..921616f --- /dev/null +++ b/tests/integration.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python +''' +These integration tests will execute non-destructive commands against a real +Salt and salt-api instance. Those must be set up and started independently. +This will take several minutes to run. + +Usage: + +SALTAPI_URL=http://localhost:8000 \ +SALTAPI_USER=saltdev \ +SALTAPI_PASS=saltdev \ +SALTAPI_EAUTH=auto \ + python -m unittest tests.integration +''' +import itertools +import json +import os +import shutil +import subprocess +import tempfile +import time +import unittest + +try: + SALTAPI_URL = os.environ['SALTAPI_URL'] + SALTAPI_USER = os.environ['SALTAPI_USER'] + SALTAPI_PASS = os.environ['SALTAPI_PASS'] + SALTAPI_EAUTH = os.environ['SALTAPI_EAUTH'] +except KeyError: + raise SystemExit('The following environment variables must be set: ' + 'SALTAPI_URL, SALTAPI_USER, SALTAPI_PASS, SALTAPI_EAUTH.') + + +def _pepper(*args): + ''' + Wrapper to invoke Pepper with common params and inside an empty env + ''' + def_args = [ + 'pepper', + '--saltapi-url={0}'.format(SALTAPI_URL), + '--username={0}'.format(SALTAPI_USER), + '--password={0}'.format(SALTAPI_PASS), + '--eauth={0}'.format(SALTAPI_EAUTH), + ] + + return subprocess.check_output(itertools.chain(def_args, args)) + + +class TestVanilla(unittest.TestCase): + def _pepper(self, *args): + return json.loads(_pepper(*args))['return'][0] + + def test_local(self): + '''Sanity-check: Has at least one minion''' + ret = self._pepper('*', 'test.ping') + self.assertTrue(ret.values()[0]) + + def test_run(self): + '''Run command via /run URI''' + ret = self._pepper('--run-uri', '*', 'test.ping') + self.assertTrue(ret.values()[0]) + + def test_long_local(self): + '''Test a long call blocks until the return''' + ret = self._pepper('*', 'test.sleep', '30') + self.assertTrue(ret.values()[0]) + + +class TestPoller(unittest.TestCase): + def _pepper(self, *args): + return _pepper(*args).splitlines()[0] + + def test_local_poll(self): + '''Test the returns poller for localclient''' + ret = self._pepper('--run-uri', '--fail-if-incomplete', '*', 'test.sleep', '30') + self.assertTrue('True' in ret) + + +class TestTokens(unittest.TestCase): + def setUp(self): + self.tokdir = tempfile.mkdtemp() + self.tokfile = os.path.join(self.tokdir, 'peppertok.json') + + def tearDown(self): + shutil.rmtree(self.tokdir) + + def _pepper(self, *args): + return json.loads(_pepper(*args))['return'][0] + + def test_local_token(self): + '''Test local execution with token file''' + ret = self._pepper('-x', self.tokfile, + '--make-token', '--run-uri', '*', 'test.ping') + self.assertTrue(ret.values()[0]) + + def test_runner_token(self): + '''Test runner execution with token file''' + ret = self._pepper('-x', self.tokfile, + '--make-token', '--run-uri', + '--client', 'runner', 'test.metasyntactic') + self.assertTrue(ret[0] == 'foo') + + def test_token_expire(self): + '''Test token override param''' + now = time.time() + self._pepper('-x', self.tokfile, '--make-token', '--run-uri', + '--token-expire', '94670856', + '*', 'test.ping') + + with open(self.tokfile, 'r') as f: + token = json.load(f) + diff = (now + float(94670856)) - token['expire'] + # Allow for 10-second window between request and master-side auth. + self.assertTrue(diff < 10)