salt-pepper-0.5.5/0000755000076500000240000000000013246266722014346 5ustar danielstaff00000000000000salt-pepper-0.5.5/PKG-INFO0000644000076500000240000000157013246266722015446 0ustar danielstaff00000000000000Metadata-Version: 1.1 Name: salt-pepper Version: 0.5.5 Summary: A CLI front-end to a running salt-api system Home-page: http://saltstack.com Author: Seth House Author-email: shouse@saltstack.com License: UNKNOWN Description: UNKNOWN Platform: UNKNOWN Classifier: Programming Language :: Python Classifier: Programming Language :: Cython Classifier: Programming Language :: Python :: 2.6 Classifier: Programming Language :: Python :: 2.7 Classifier: Development Status :: 5 - Production/Stable Classifier: Environment :: Console Classifier: Intended Audience :: Developers Classifier: Intended Audience :: Information Technology Classifier: Intended Audience :: System Administrators Classifier: License :: OSI Approved :: Apache Software License Classifier: Operating System :: POSIX :: Linux Classifier: Topic :: System :: Clustering Classifier: Topic :: System :: Distributed Computing salt-pepper-0.5.5/LICENSE0000644000076500000240000000116213137170346015346 0ustar danielstaff00000000000000 Salt - Remote execution system Copyright 2014-2016 SaltStack Team Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. salt-pepper-0.5.5/setup.py0000644000076500000240000000733313210122254016044 0ustar danielstaff00000000000000#!/usr/bin/env python ''' A CLI front-end to a running salt-api system ''' import json import os import re from distutils.core import setup from distutils.dist import Distribution from distutils.command import sdist, install_data setup_kwargs = { 'name': 'salt-pepper', 'description': __doc__.strip(), 'author': 'Seth House', 'author_email': 'shouse@saltstack.com', 'url': 'http://saltstack.com', 'classifiers': [ 'Programming Language :: Python', 'Programming Language :: Cython', 'Programming Language :: Python :: 2.6', 'Programming Language :: Python :: 2.7', 'Development Status :: 5 - Production/Stable', 'Environment :: Console', 'Intended Audience :: Developers', 'Intended Audience :: Information Technology', 'Intended Audience :: System Administrators', 'License :: OSI Approved :: Apache Software License', 'Operating System :: POSIX :: Linux', 'Topic :: System :: Clustering', 'Topic :: System :: Distributed Computing', ], 'packages': [ 'pepper', ], 'package_data': { 'pepper': ['version.json'], }, 'scripts': [ 'scripts/pepper', ] } def read_version_tag(): git_dir = os.path.join(os.path.dirname(__file__), '.git') if os.path.isdir(git_dir): import subprocess try: p = subprocess.Popen(['git', 'describe'], stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = p.communicate() except Exception: pass else: return out.strip() or None else: return read_version_from_json_file() return None def read_version_from_json_file(): with open(os.path.join(os.path.dirname(__file__), "pepper", "version.json")) as f: return json.load(f)['version'] def parse_version_tag(tag): ''' Parse the output from Git describe Returns a tuple of the version number, number of commits (if any), and the Git SHA (if available). ''' if isinstance(tag, bytes): tag = tag.decode() if not tag or '-g' not in tag: return tag, None, None match = re.search('(?P.*)-(?P[0-9]+)-g(?P[0-9a-fA-F]+)', tag) if not match: return tag, None, None match_dict = match.groupdict() return ( match_dict.get('version'), match_dict.get('num_commits'), match_dict.get('sha')) def get_version(): ''' Return a tuple of the version and Git SHA ''' version, num_commits, sha = parse_version_tag(read_version_tag()) if sha: version = '{0}.dev{1}'.format(version, num_commits) return version, sha def write_version_file(base_dir): ver_path = os.path.join(base_dir, 'pepper', 'version.json') version, sha = get_version() with open(ver_path, 'wb') as f: json.dump({'version': version, 'sha': sha}, f) class PepperSdist(sdist.sdist): ''' Write the version.json file to the sdist tarball build directory ''' def make_release_tree(self, base_dir, files): sdist.sdist.make_release_tree(self, base_dir, files) write_version_file(base_dir) class PepperInstallData(install_data.install_data): ''' Write the version.json file to the installation directory ''' def run(self): install_cmd = self.get_finalized_command('install') install_dir = getattr(install_cmd, 'install_lib') write_version_file(install_dir) return install_data.install_data.run(self) if __name__ == '__main__': version, sha = get_version() setup(cmdclass={ 'sdist': PepperSdist, 'install_data': PepperInstallData, }, version=version, git_sha=sha, **setup_kwargs) salt-pepper-0.5.5/scripts/0000755000076500000240000000000013246266722016035 5ustar danielstaff00000000000000salt-pepper-0.5.5/scripts/pepper0000755000076500000240000000227613246024321017250 0ustar danielstaff00000000000000#!/usr/bin/env python ''' A CLI interface to a remote salt-api instance ''' from __future__ import print_function import sys import logging from pepper.cli import PepperCli from pepper import PepperException try: from logging import NullHandler except ImportError: # Python < 2.7 class NullHandler(logging.Handler): def emit(self, record): pass logging.basicConfig(format='%(levelname)s %(asctime)s %(module)s: %(message)s') logger = logging.getLogger('pepper') logger.addHandler(NullHandler()) if __name__ == '__main__': try: cli = PepperCli() for exit_code, result in cli.run(): print(result) if exit_code is not None: raise SystemExit(exit_code) except PepperException as exc: print('Pepper error: {0}'.format(exc), file=sys.stderr) raise SystemExit(1) except KeyboardInterrupt: # TODO: mimic CLI and output JID on ctrl-c raise SystemExit(0) except Exception as e: print(e) print('Uncaught Pepper error (increase verbosity for the full traceback).', file=sys.stderr) logger.debug('Uncaught traceback:', exc_info=True) raise SystemExit(1) salt-pepper-0.5.5/README.rst0000644000076500000240000000403513137170346016032 0ustar danielstaff00000000000000====== Pepper ====== Pepper contains a Python library and CLI scripts for accessing a remote `salt-api`__ instance. ``pepperlib`` abstracts the HTTP calls to ``salt-api`` so existing Python projects can easily integrate with a remote Salt installation just by instantiating a class. The ``pepper`` CLI script allows users to execute Salt commands from computers that are external to computers running the ``salt-master`` or ``salt-minion`` daemons as though they were running Salt locally. The long-term goal is to add additional CLI scripts maintain the same interface as Salt's own CLI scripts (``salt``, ``salt-run``, ``salt-key``, etc). It does not require any additional dependencies and runs on Python 2.5+ and Python 3. (Python 3 support is new, please file an issue if you encounter trouble.) .. __: https://github.com/saltstack/salt-api Installation ------------ .. code-block:: bash pip install salt-pepper Usage ----- Basic usage is in heavy flux. .. code-block:: bash export SALTAPI_USER=saltdev SALTAPI_PASS=saltdev SALTAPI_EAUTH=pam pepper '*' test.ping pepper '*' test.kwarg hello=dolly Examples leveraging the runner client. .. code-block:: bash pepper --client runner reactor.list pepper --client runner reactor.add event='test/provision/*' reactors='/srv/salt/state/reactor/test-provision.sls' Configuration ------------- You can configure pepper through the command line, using environment variables or in a configuration file ``$HOME/.pepperrc`` with the following syntax : .. code-block:: [main] SALTAPI_URL=https://localhost:8000/ SALTAPI_USER=saltdev SALTAPI_PASS=saltdev SALTAPI_EAUTH=pam Contributing ------------ Please feel free to get involved by sending pull requests or join us on the Salt mailing list or on IRC in #salt or #salt-devel. This repo follows the same `contributing guidelines`__ as Salt and uses separate develop and master branches for in-progress additions and bug-fix changes respectively. .. __: https://docs.saltstack.com/en/latest/topics/development/contributing.html salt-pepper-0.5.5/pepper/0000755000076500000240000000000013246266722015641 5ustar danielstaff00000000000000salt-pepper-0.5.5/pepper/version.json0000644000076500000240000000004113246266722020214 0ustar danielstaff00000000000000{"sha": null, "version": "0.5.5"}salt-pepper-0.5.5/pepper/libpepper.py0000644000076500000240000003354513246266464020212 0ustar danielstaff00000000000000''' A Python library for working with Salt's REST API (Specifically the rest_cherrypy netapi module.) ''' import json import logging import ssl try: ssl._create_default_https_context = ssl._create_stdlib_context except: pass try: from urllib.request import HTTPHandler, Request, urlopen, \ install_opener, build_opener from urllib.error import HTTPError, URLError import urllib.parse as urlparse except ImportError: from urllib2 import HTTPHandler, Request, urlopen, install_opener, build_opener, \ HTTPError, URLError import urlparse logger = logging.getLogger('pepper') class PepperException(Exception): pass class Pepper(object): ''' A thin wrapper for making HTTP calls to the salt-api rest_cherrpy REST interface >>> api = Pepper('https://localhost:8000') >>> api.login('saltdev', 'saltdev', 'pam') {"return": [ { "eauth": "pam", "expire": 1370434219.714091, "perms": [ "test.*" ], "start": 1370391019.71409, "token": "c02a6f4397b5496ba06b70ae5fd1f2ab75de9237", "user": "saltdev" } ] } >>> api.low([{'client': 'local', 'tgt': '*', 'fun': 'test.ping'}]) {u'return': [{u'ms-0': True, u'ms-1': True, u'ms-2': True, u'ms-3': True, u'ms-4': True}]} ''' def __init__(self, api_url='https://localhost:8000', debug_http=False, ignore_ssl_errors=False): ''' Initialize the class with the URL of the API :param api_url: Host or IP address of the salt-api URL; include the port number :param debug_http: Add a flag to urllib2 to output the HTTP exchange :param ignore_ssl_errors: Add a flag to urllib2 to ignore invalid SSL certificates :raises PepperException: if the api_url is misformed ''' split = urlparse.urlsplit(api_url) if split.scheme not in ['http', 'https']: raise PepperException("salt-api URL missing HTTP(s) protocol: {0}" .format(api_url)) self.api_url = api_url self.debug_http = int(debug_http) self._ssl_verify = not ignore_ssl_errors self.auth = {} def req_stream(self, path): ''' A thin wrapper to get a response from saltstack api. The body of the response will not be downloaded immediately. Make sure to close the connection after use. api = Pepper('http://ipaddress/api/') print(api.login('salt','salt','pam')) response = api.req_stream('/events') :param path: The path to the salt api resource :return: :class:`Response ` object :rtype: requests.Response ''' import requests headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', } if self.auth and 'token' in self.auth and self.auth['token']: headers.setdefault('X-Auth-Token', self.auth['token']) else: raise PepperException('Authentication required') return # Optionally toggle SSL verification #self._ssl_verify = self.ignore_ssl_errors params = {'url': self._construct_url(path), 'headers': headers, 'verify': self._ssl_verify == True, 'stream': True } try: resp = requests.get(**params) if resp.status_code == 401: raise PepperException(str(resp.status_code) + ':Authentication denied') return if resp.status_code == 500: raise PepperException(str(resp.status_code) + ':Server error.') return if resp.status_code == 404: raise PepperException(str(resp.status_code) +' :This request returns nothing.') return except PepperException as e: print(e) return return resp def req_get(self, path): ''' A thin wrapper from get http method of saltstack api api = Pepper('http://ipaddress/api/') print(api.login('salt','salt','pam')) print(api.req_get('/keys')) ''' import requests headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', } if self.auth and 'token' in self.auth and self.auth['token']: headers.setdefault('X-Auth-Token', self.auth['token']) else: raise PepperException('Authentication required') return # Optionally toggle SSL verification #self._ssl_verify = self.ignore_ssl_errors params = {'url': self._construct_url(path), 'headers': headers, 'verify': self._ssl_verify == True, } try: resp = requests.get(**params) if resp.status_code == 401: raise PepperException(str(resp.status_code) + ':Authentication denied') return if resp.status_code == 500: raise PepperException(str(resp.status_code) + ':Server error.') return if resp.status_code == 404: raise PepperException(str(resp.status_code) +' :This request returns nothing.') return except PepperException as e: print(e) return return resp.json() def req(self, path, data=None): ''' A thin wrapper around urllib2 to send requests and return the response If the current instance contains an authentication token it will be attached to the request as a custom header. :rtype: dictionary ''' if ((hasattr(data, 'get') and data.get('eauth') == 'kerberos') or self.auth.get('eauth') == 'kerberos'): return self.req_requests(path, data) headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', } handler = HTTPHandler(debuglevel=self.debug_http) opener = build_opener(handler) install_opener(opener) # Build POST data if data is not None: postdata = json.dumps(data).encode() clen = len(postdata) else: postdata = None # Create request object url = self._construct_url(path) req = Request(url, postdata, headers) # Add POST data to request if data is not None: req.add_header('Content-Length', clen) # Add auth header to request if self.auth and 'token' in self.auth and self.auth['token']: req.add_header('X-Auth-Token', self.auth['token']) # Send request try: if not (self._ssl_verify): con = ssl.SSLContext(ssl.PROTOCOL_SSLv23) #con.check_hostname = False #con.verify_mode = ssl.CERT_NONE f = urlopen(req, context=con) else: f = urlopen(req) ret = json.loads(f.read().decode('utf-8')) except (HTTPError, URLError) as exc: logger.debug('Error with request', exc_info=True) status = getattr(exc, 'code', None) if status == 401: raise PepperException('Authentication denied') if status == 500: raise PepperException('Server error.') logger.error('Error with request: {0}'.format(exc)) raise except AttributeError: logger.debug('Error converting response from JSON', exc_info=True) raise PepperException('Unable to parse the server response.') return ret def req_requests(self, path, data=None): ''' A thin wrapper around request and request_kerberos to send requests and return the response If the current instance contains an authentication token it will be attached to the request as a custom header. :rtype: dictionary ''' import requests from requests_kerberos import HTTPKerberosAuth, OPTIONAL auth = HTTPKerberosAuth(mutual_authentication=OPTIONAL) headers = { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest', } if self.auth and 'token' in self.auth and self.auth['token']: headers.setdefault('X-Auth-Token', self.auth['token']) # Optionally toggle SSL verification params = {'url': self._construct_url(path), 'headers': headers, 'verify': self._ssl_verify is True, 'auth': auth, 'data': json.dumps(data), } logger.debug('postdata {0}'.format(params)) resp = requests.post(**params) if resp.status_code == 401: # TODO should be resp.raise_from_status raise PepperException('Authentication denied') if resp.status_code == 500: # TODO should be resp.raise_from_status raise PepperException('Server error.') return resp.json() def low(self, lowstate, path='/'): ''' Execute a command through salt-api and return the response :param string path: URL path to be joined with the API hostname :param list lowstate: a list of lowstate dictionaries ''' return self.req(path, lowstate) def local(self, tgt, fun, arg=None, kwarg=None, expr_form='glob', timeout=None, ret=None): ''' Run a single command using the ``local`` client Wraps :meth:`low`. ''' low = { 'client': 'local', 'tgt': tgt, 'fun': fun, } if arg: low['arg'] = arg if kwarg: low['kwarg'] = kwarg if expr_form: low['expr_form'] = expr_form if timeout: low['timeout'] = timeout if ret: low['ret'] = ret return self.low([low]) def local_async(self, tgt, fun, arg=None, kwarg=None, expr_form='glob', timeout=None, ret=None): ''' Run a single command using the ``local_async`` client Wraps :meth:`low`. ''' low = { 'client': 'local_async', 'tgt': tgt, 'fun': fun, } if arg: low['arg'] = arg if kwarg: low['kwarg'] = kwarg if expr_form: low['expr_form'] = expr_form if timeout: low['timeout'] = timeout if ret: low['ret'] = ret return self.low([low]) def local_batch(self, tgt, fun, arg=None, kwarg=None, expr_form='glob', batch='50%', ret=None): ''' Run a single command using the ``local_batch`` client Wraps :meth:`low`. ''' low = { 'client': 'local_batch', 'tgt': tgt, 'fun': fun, } if arg: low['arg'] = arg if kwarg: low['kwarg'] = kwarg if expr_form: low['expr_form'] = expr_form if batch: low['batch'] = batch if ret: low['ret'] = ret return self.low([low]) def lookup_jid(self, jid): ''' Get job results Wraps :meth:`runner`. ''' return self.runner('jobs.lookup_jid', jid='{0}'.format(jid)) def runner(self, fun, arg=None, **kwargs): ''' Run a single command using the ``runner`` client Usage:: runner('jobs.lookup_jid', jid=12345) ''' low = { 'client': 'runner', 'fun': fun, } if arg: low['arg'] = arg low.update(kwargs) return self.low([low]) def wheel(self, fun, arg=None, kwarg=None, **kwargs): ''' Run a single command using the ``wheel`` client Usage:: wheel('key.accept', match='myminion') ''' low = { 'client': 'wheel', 'fun': fun, } if arg: low['arg'] = arg if kwarg: low['kwarg'] = kwarg low.update(kwargs) return self.low([low]) def _send_auth(self, path, **kwargs): return self.req(path, kwargs) def login(self, username=None, password=None, eauth=None, **kwargs): ''' Authenticate with salt-api and return the user permissions and authentication token or an empty dict ''' local = locals() kwargs.update( dict( (key, local[key]) for key in ( 'username', 'password', 'eauth' ) if local.get(key, None) is not None ) ) 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): ''' Construct the url to salt-api for the given path Args: path: the path to the salt-api resource >>> api = Pepper('https://localhost:8000/salt-api/') >>> api._construct_url('/login') 'https://localhost:8000/salt-api/login' ''' relative_path = path.lstrip('/') return urlparse.urljoin(self.api_url, relative_path) salt-pepper-0.5.5/pepper/__init__.py0000644000076500000240000000164413137170346017752 0ustar danielstaff00000000000000''' Pepper is a CLI front-end to salt-api ''' import json import os from pepper.libpepper import Pepper, PepperException __all__ = ('__version__', '__gitrev__', 'Pepper', 'PepperException') try: # First try to grab the version from the version.json build file. vfile = os.path.join(os.path.dirname(__file__), 'version.json') with open(vfile, 'rb') as f: data = f.read().decode("utf-8") ret = json.loads(data) version = ret.get('version') sha = ret.get('sha') except IOError: # Build version file doesn't exist; we may be running from a clone. setup_file = os.path.join(os.path.dirname(__file__), os.pardir, 'setup.py') if os.path.exists(setup_file): import imp setup = imp.load_source('pepper_setup', setup_file) version, sha = setup.get_version() else: version, sha = None, None __version__ = version or 'Unknown' __gitrev__ = sha salt-pepper-0.5.5/pepper/cli.py0000644000076500000240000004537213231727207016767 0ustar danielstaff00000000000000''' A CLI interface to a remote salt-api instance ''' from __future__ import print_function import json import logging import optparse import os import textwrap import getpass import time try: # Python 3 from configparser import ConfigParser, RawConfigParser except ImportError: # Python 2 from ConfigParser import ConfigParser, RawConfigParser try: input = raw_input except NameError: pass import pepper try: from logging import NullHandler except ImportError: # Python < 2.7 class NullHandler(logging.Handler): def emit(self, record): pass logging.basicConfig(format='%(levelname)s %(asctime)s %(module)s: %(message)s') logger = logging.getLogger('pepper') logger.addHandler(NullHandler()) class PepperCli(object): def __init__(self, seconds_to_wait=3): self.seconds_to_wait = seconds_to_wait self.parser = self.get_parser() self.parser.option_groups.extend([self.add_globalopts(), self.add_tgtopts(), self.add_authopts()]) def get_parser(self): return optparse.OptionParser( description=__doc__, usage='%prog [opts]', version=pepper.__version__) def parse(self): ''' Parse all args ''' self.parser.add_option('-c', dest='config', default=os.environ.get('PEPPERRC', os.path.join(os.path.expanduser('~'), '.pepperrc')), help=textwrap.dedent('''\ Configuration file location. Default is a file path in the "PEPPERRC" environment variable or ~/.pepperrc.''')) self.parser.add_option('-v', dest='verbose', default=0, action='count', help=textwrap.dedent('''\ Increment output verbosity; may be specified multiple times''')) self.parser.add_option('-H', '--debug-http', dest='debug_http', default=False, action='store_true', help=textwrap.dedent('''\ Output the HTTP request/response headers on stderr''')) self.parser.add_option('--ignore-ssl-errors', action='store_true', dest='ignore_ssl_certificate_errors', default=False, help=textwrap.dedent('''\ Ignore any SSL certificate that may be encountered. Note that it is recommended to resolve certificate errors for production.''')) self.options, self.args = self.parser.parse_args() def add_globalopts(self): ''' Misc global options ''' optgroup = optparse.OptionGroup(self.parser, "Pepper ``salt`` Options", "Mimic the ``salt`` CLI") optgroup.add_option('-t', '--timeout', dest='timeout', type='int', default=60, help=textwrap.dedent('''\ Specify wait time (in seconds) before returning control to the shell''')) optgroup.add_option('--client', dest='client', default='local', help=textwrap.dedent('''\ specify the salt-api client to use (local, local_async, runner, etc)''')) optgroup.add_option('--json', dest='json_input', help=textwrap.dedent('''\ Enter JSON at the CLI instead of positional (text) arguments. This is useful for arguments that need complex data structures. Specifying this argument will cause positional arguments to be ignored.''')) optgroup.add_option('--json-file', dest='json_file', help=textwrap.dedent('''\ Specify file containing the JSON to be used by pepper''')) # optgroup.add_option('--out', '--output', dest='output', # help="Specify the output format for the command output") # optgroup.add_option('--return', default='', metavar='RETURNER', # help="Redirect the output from a command to a persistent data store") optgroup.add_option('--fail-if-incomplete', action='store_true', dest='fail_if_minions_dont_respond', default=False, help=textwrap.dedent('''\ Return a failure exit code if not all minions respond. This option requires the authenticated user have access to run the `jobs.list_jobs` runner function.''')) return optgroup def add_tgtopts(self): ''' Targeting ''' optgroup = optparse.OptionGroup(self.parser, "Targeting Options", "Target which minions to run commands on") optgroup.defaults.update({'expr_form': 'glob'}) optgroup.add_option('-E', '--pcre', dest='expr_form', action='store_const', const='pcre', help="Target hostnames using PCRE regular expressions") optgroup.add_option('-L', '--list', dest='expr_form', action='store_const', const='list', help="Specify a comma delimited list of hostnames") optgroup.add_option('-G', '--grain', dest='expr_form', action='store_const', const='grain', help="Target based on system properties") optgroup.add_option('--grain-pcre', dest='expr_form', action='store_const', const='grain_pcre', help="Target based on PCRE matches on system properties") optgroup.add_option('-I', '--pillar', dest='expr_form', action='store_const', const='pillar', help="Target based on pillar values") optgroup.add_option('--pillar-pcre', dest='expr_form', action='store_const', const='pillar_pcre', help="Target based on PCRE matches on pillar values") optgroup.add_option('-R', '--range', dest='expr_form', action='store_const', const='range', help="Target based on range expression") optgroup.add_option('-C', '--compound', dest='expr_form', action='store_const', const='compound', help="Target based on compound expression") optgroup.add_option('-N', '--nodegroup', dest='expr_form', action='store_const', const='nodegroup', help="Target based on a named nodegroup") optgroup.add_option('--batch', dest='batch', default=None) return optgroup def add_authopts(self): ''' Authentication options ''' optgroup = optparse.OptionGroup(self.parser, "Authentication Options", textwrap.dedent("""\ Authentication credentials can optionally be supplied via the environment variables: SALTAPI_URL, SALTAPI_USER, SALTAPI_PASS, SALTAPI_EAUTH. """)) optgroup.add_option('-u', '--saltapi-url', dest='saltapiurl', help="Specify the host url. Defaults to https://localhost:8080") optgroup.add_option('-a', '--auth', '--eauth', '--extended-auth', dest='eauth', help=textwrap.dedent("""\ Specify the external_auth backend to authenticate against and interactively prompt for credentials""")) optgroup.add_option('--username', dest='username', help=textwrap.dedent("""\ Optional, defaults to user name. will be prompt if empty unless --non-interactive""")) optgroup.add_option('--password', 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) optgroup.add_option('-T', '--make-token', default=False, dest='mktoken', action='store_true', help=textwrap.dedent("""\ Generate and save an authentication token for re-use. The token is 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')), help=textwrap.dedent('''\ Cache file location. Default is a file path in the "PEPPERCACHE" environment variable or ~/.peppercache.''')) return optgroup def get_login_details(self): ''' This parses the config file, environment variables and command line options and returns the config values Order of parsing: command line options, ~/.pepperrc, environment, defaults ''' # setting default values results = { 'SALTAPI_USER': None, 'SALTAPI_PASS': None, 'SALTAPI_EAUTH': 'auto', } try: config = ConfigParser(interpolation=None) except TypeError: config = RawConfigParser() config.read(self.options.config) # read file profile = 'main' if config.has_section(profile): for key, value in list(results.items()): if config.has_option(profile, key): results[key] = config.get(profile, key) # get environment values for key, value in list(results.items()): results[key] = os.environ.get(key, results[key]) if results['SALTAPI_EAUTH'] == 'kerberos': results['SALTAPI_PASS'] = None 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: ') else: logger.error("SALTAPI_USER required") raise SystemExit(1) else: if self.options.username is not None: results['SALTAPI_USER'] = self.options.username if self.options.password is None and results['SALTAPI_PASS'] is None: if self.options.interactive: results['SALTAPI_PASS'] = getpass.getpass(prompt='Password: ') else: logger.error("SALTAPI_PASS required") raise SystemExit(1) else: if self.options.password is not None: results['SALTAPI_PASS'] = self.options.password return results def parse_url(self): ''' Determine api url ''' url = 'https://localhost:8000/' try: config = ConfigParser(interpolation=None) except TypeError: config = RawConfigParser() config.read(self.options.config) # read file profile = 'main' if config.has_section(profile): if config.has_option(profile, "SALTAPI_URL"): url = config.get(profile, "SALTAPI_URL") # get environment values url = os.environ.get("SALTAPI_URL", url) # get eauth prompt options if self.options.saltapiurl: url = self.options.saltapiurl return url def parse_login(self): ''' Extract the authentication credentials ''' login_details = self.get_login_details() # Auth values placeholder; grab interactively at CLI or from config username = login_details['SALTAPI_USER'] password = login_details['SALTAPI_PASS'] eauth = login_details['SALTAPI_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): ''' Extract the low data for a command from the passed CLI params ''' # Short-circuit if JSON was given. if self.options.json_input: try: return json.loads(self.options.json_input) except ValueError: logger.error("Invalid JSON given.") raise SystemExit(1) if self.options.json_file: try: with open(self.options.json_file, 'r') as json_content: try: return json.load(json_content) except ValueError: logger.error("Invalid JSON given.") raise SystemExit(1) except Exception as e: logger.error( 'Cannot open file: {0}, {1}'.format( self.options.json_file, repr(e) ) ) raise SystemExit(1) args = list(self.args) client = self.options.client if not self.options.batch else 'local_batch' low = {'client': client} if client.startswith('local'): if len(args) < 2: self.parser.error("Command or target not specified") low['expr_form'] = self.options.expr_form low['tgt'] = args.pop(0) low['fun'] = args.pop(0) low['batch'] = self.options.batch low['arg'] = args elif client.startswith('runner'): low['fun'] = args.pop(0) for arg in args: if '=' in arg: key, value = arg.split('=', 1) low[key] = value else: low.setdefault('args', []).append(arg) elif client.startswith('wheel'): low['fun'] = args.pop(0) for arg in args: if '=' in arg: key, value = arg.split('=', 1) low[key] = value else: low.setdefault('args', []).append(arg) elif client.startswith('ssh'): if len(args) < 2: self.parser.error("Command or target not specified") low['expr_form'] = self.options.expr_form low['tgt'] = args.pop(0) low['fun'] = args.pop(0) low['batch'] = self.options.batch low['arg'] = args else: if len(args) < 1: self.parser.error("Command not specified") low['fun'] = args.pop(0) low['arg'] = args return [low] def poll_for_returns(self, api, load): ''' Run a command with the local_async client and periodically poll the job cache for returns for the job. ''' load[0]['client'] = 'local_async' async_ret = self.low(api, load) jid = async_ret['return'][0]['jid'] nodes = async_ret['return'][0]['minions'] ret_nodes = [] exit_code = 1 # keep trying until all expected nodes return total_time = 0 start_time = time.time() ret = {} exit_code = 0 while True: total_time = time.time() - start_time if total_time > self.options.timeout: exit_code = 1 break 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( node, jid_ret['return'][0][node]) ret_nodes = list(jid_ret['return'][0].keys()) if set(ret_nodes) == set(nodes): exit_code = 0 break else: time.sleep(self.seconds_to_wait) exit_code = exit_code if self.options.fail_if_minions_dont_respond else 0 yield exit_code, "{{Failed: {}}}".format( list(set(ret_nodes) ^ set(nodes))) def login(self, api): login = api.token if self.options.userun else api.login if self.options.mktoken: token_file = self.options.cache try: with open(token_file, 'rt') as f: auth = json.load(f) if auth['expire'] < time.time()+30: logger.error('Login token expired') raise Exception('Login token expired') 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 = 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))) finally: os.umask(oldumask) else: 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 = self.low(api, load) exit_code = 0 yield exit_code, json.dumps(ret, sort_keys=True, indent=4)