libpepper.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505
  1. '''
  2. A Python library for working with Salt's REST API
  3. (Specifically the rest_cherrypy netapi module.)
  4. '''
  5. import json
  6. import logging
  7. import re
  8. import ssl
  9. from pepper.exceptions import PepperException
  10. try:
  11. ssl._create_default_https_context = ssl._create_stdlib_context
  12. except Exception:
  13. pass
  14. try:
  15. from urllib.request import HTTPHandler, HTTPSHandler, Request, urlopen, \
  16. install_opener, build_opener
  17. from urllib.error import HTTPError, URLError
  18. import urllib.parse as urlparse
  19. except ImportError:
  20. from urllib2 import HTTPHandler, HTTPSHandler, Request, urlopen, install_opener, build_opener, \
  21. HTTPError, URLError
  22. import urlparse
  23. logger = logging.getLogger(__name__)
  24. class Pepper(object):
  25. '''
  26. A thin wrapper for making HTTP calls to the salt-api rest_cherrpy REST
  27. interface
  28. >>> api = Pepper('https://localhost:8000')
  29. >>> api.login('saltdev', 'saltdev', 'pam')
  30. {"return": [
  31. {
  32. "eauth": "pam",
  33. "expire": 1370434219.714091,
  34. "perms": [
  35. "test.*"
  36. ],
  37. "start": 1370391019.71409,
  38. "token": "c02a6f4397b5496ba06b70ae5fd1f2ab75de9237",
  39. "user": "saltdev"
  40. }
  41. ]
  42. }
  43. >>> api.low([{'client': 'local', 'tgt': '*', 'fun': 'test.ping'}])
  44. {u'return': [{u'ms-0': True,
  45. u'ms-1': True,
  46. u'ms-2': True,
  47. u'ms-3': True,
  48. u'ms-4': True}]}
  49. '''
  50. def __init__(self, api_url='https://localhost:8000', debug_http=False, ignore_ssl_errors=False):
  51. '''
  52. Initialize the class with the URL of the API
  53. :param api_url: Host or IP address of the salt-api URL;
  54. include the port number
  55. :param debug_http: Add a flag to urllib2 to output the HTTP exchange
  56. :param ignore_ssl_errors: Add a flag to urllib2 to ignore invalid SSL certificates
  57. :raises PepperException: if the api_url is misformed
  58. '''
  59. split = urlparse.urlsplit(api_url)
  60. if split.scheme not in ['http', 'https']:
  61. raise PepperException("salt-api URL missing HTTP(s) protocol: {0}"
  62. .format(api_url))
  63. self.api_url = api_url
  64. self.debug_http = int(debug_http)
  65. self._ssl_verify = not ignore_ssl_errors
  66. self.auth = {}
  67. self.salt_version = None
  68. def req_stream(self, path):
  69. '''
  70. A thin wrapper to get a response from saltstack api.
  71. The body of the response will not be downloaded immediately.
  72. Make sure to close the connection after use.
  73. api = Pepper('http://ipaddress/api/')
  74. print(api.login('salt','salt','pam'))
  75. response = api.req_stream('/events')
  76. :param path: The path to the salt api resource
  77. :return: :class:`Response <Response>` object
  78. :rtype: requests.Response
  79. '''
  80. import requests
  81. headers = {
  82. 'Accept': 'application/json',
  83. 'Content-Type': 'application/json',
  84. 'X-Requested-With': 'XMLHttpRequest',
  85. }
  86. if self.auth and 'token' in self.auth and self.auth['token']:
  87. headers.setdefault('X-Auth-Token', self.auth['token'])
  88. else:
  89. raise PepperException('Authentication required')
  90. return
  91. params = {'url': self._construct_url(path),
  92. 'headers': headers,
  93. 'verify': self._ssl_verify is True,
  94. 'stream': True
  95. }
  96. try:
  97. resp = requests.get(**params)
  98. if resp.status_code == 401:
  99. raise PepperException(str(resp.status_code) + ':Authentication denied')
  100. return
  101. if resp.status_code == 500:
  102. raise PepperException(str(resp.status_code) + ':Server error.')
  103. return
  104. if resp.status_code == 404:
  105. raise PepperException(str(resp.status_code) + ' :This request returns nothing.')
  106. return
  107. except PepperException as e:
  108. print(e)
  109. return
  110. return resp
  111. def req_get(self, path):
  112. '''
  113. A thin wrapper from get http method of saltstack api
  114. api = Pepper('http://ipaddress/api/')
  115. print(api.login('salt','salt','pam'))
  116. print(api.req_get('/keys'))
  117. '''
  118. import requests
  119. headers = {
  120. 'Accept': 'application/json',
  121. 'Content-Type': 'application/json',
  122. 'X-Requested-With': 'XMLHttpRequest',
  123. }
  124. if self.auth and 'token' in self.auth and self.auth['token']:
  125. headers.setdefault('X-Auth-Token', self.auth['token'])
  126. else:
  127. raise PepperException('Authentication required')
  128. return
  129. params = {'url': self._construct_url(path),
  130. 'headers': headers,
  131. 'verify': self._ssl_verify is True,
  132. }
  133. try:
  134. resp = requests.get(**params)
  135. if resp.status_code == 401:
  136. raise PepperException(str(resp.status_code) + ':Authentication denied')
  137. return
  138. if resp.status_code == 500:
  139. raise PepperException(str(resp.status_code) + ':Server error.')
  140. return
  141. if resp.status_code == 404:
  142. raise PepperException(str(resp.status_code) + ' :This request returns nothing.')
  143. return
  144. except PepperException as e:
  145. print(e)
  146. return
  147. return resp.json()
  148. def req(self, path, data=None):
  149. '''
  150. A thin wrapper around urllib2 to send requests and return the response
  151. If the current instance contains an authentication token it will be
  152. attached to the request as a custom header.
  153. :rtype: dictionary
  154. '''
  155. if ((hasattr(data, 'get') and data.get('eauth') == 'kerberos')
  156. or self.auth.get('eauth') == 'kerberos'):
  157. return self.req_requests(path, data)
  158. headers = {
  159. 'Accept': 'application/json',
  160. 'Content-Type': 'application/json',
  161. 'X-Requested-With': 'XMLHttpRequest',
  162. }
  163. opener = build_opener()
  164. for handler in opener.handlers:
  165. if isinstance(handler, HTTPHandler):
  166. handler.set_http_debuglevel(self.debug_http)
  167. if isinstance(handler, HTTPSHandler):
  168. handler.set_http_debuglevel(self.debug_http)
  169. install_opener(opener)
  170. # Build POST data
  171. if data is not None:
  172. postdata = json.dumps(data).encode()
  173. clen = len(postdata)
  174. else:
  175. postdata = None
  176. # Create request object
  177. url = self._construct_url(path)
  178. req = Request(url, postdata, headers)
  179. # Add POST data to request
  180. if data is not None:
  181. req.add_header('Content-Length', clen)
  182. # Add auth header to request
  183. if path != '/run' and self.auth and 'token' in self.auth and self.auth['token']:
  184. req.add_header('X-Auth-Token', self.auth['token'])
  185. # Send request
  186. try:
  187. if not (self._ssl_verify):
  188. con = ssl.SSLContext(ssl.PROTOCOL_SSLv23)
  189. f = urlopen(req, context=con)
  190. else:
  191. f = urlopen(req)
  192. content = f.read().decode('utf-8')
  193. if (self.debug_http):
  194. logger.debug('Response: %s', content)
  195. ret = json.loads(content)
  196. if not self.salt_version and 'x-salt-version' in f.headers:
  197. self._parse_salt_version(f.headers['x-salt-version'])
  198. except (HTTPError, URLError) as exc:
  199. logger.debug('Error with request', exc_info=True)
  200. status = getattr(exc, 'code', None)
  201. if status == 401:
  202. raise PepperException('Authentication denied')
  203. if status == 500:
  204. raise PepperException('Server error.')
  205. logger.error('Error with request: {0}'.format(exc))
  206. raise
  207. except AttributeError:
  208. logger.debug('Error converting response from JSON', exc_info=True)
  209. raise PepperException('Unable to parse the server response.')
  210. return ret
  211. def req_requests(self, path, data=None):
  212. '''
  213. A thin wrapper around request and request_kerberos to send
  214. requests and return the response
  215. If the current instance contains an authentication token it will be
  216. attached to the request as a custom header.
  217. :rtype: dictionary
  218. '''
  219. import requests
  220. from requests_gssapi import HTTPSPNEGOAuth, OPTIONAL
  221. auth = HTTPSPNEGOAuth(mutual_authentication=OPTIONAL)
  222. headers = {
  223. 'Accept': 'application/json',
  224. 'Content-Type': 'application/json',
  225. 'X-Requested-With': 'XMLHttpRequest',
  226. }
  227. if self.auth and 'token' in self.auth and self.auth['token']:
  228. headers.setdefault('X-Auth-Token', self.auth['token'])
  229. # Optionally toggle SSL verification
  230. params = {'url': self._construct_url(path),
  231. 'headers': headers,
  232. 'verify': self._ssl_verify is True,
  233. 'auth': auth,
  234. 'data': json.dumps(data),
  235. }
  236. logger.debug('postdata {0}'.format(params))
  237. resp = requests.post(**params)
  238. if resp.status_code == 401:
  239. # TODO should be resp.raise_from_status
  240. raise PepperException('Authentication denied')
  241. if resp.status_code == 500:
  242. # TODO should be resp.raise_from_status
  243. raise PepperException('Server error.')
  244. if not self.salt_version and 'x-salt-version' in resp.headers:
  245. self._parse_salt_version(resp.headers['x-salt-version'])
  246. return resp.json()
  247. def low(self, lowstate, path='/'):
  248. '''
  249. Execute a command through salt-api and return the response
  250. :param string path: URL path to be joined with the API hostname
  251. :param list lowstate: a list of lowstate dictionaries
  252. '''
  253. return self.req(path, lowstate)
  254. def local(self, tgt, fun, arg=None, kwarg=None, expr_form='glob',
  255. timeout=None, ret=None):
  256. '''
  257. Run a single command using the ``local`` client
  258. Wraps :meth:`low`.
  259. '''
  260. low = {
  261. 'client': 'local',
  262. 'tgt': tgt,
  263. 'fun': fun,
  264. }
  265. if arg:
  266. low['arg'] = arg
  267. if kwarg:
  268. low['kwarg'] = kwarg
  269. if expr_form:
  270. low['expr_form'] = expr_form
  271. if timeout:
  272. low['timeout'] = timeout
  273. if ret:
  274. low['ret'] = ret
  275. return self.low([low])
  276. def local_async(self, tgt, fun, arg=None, kwarg=None, expr_form='glob',
  277. timeout=None, ret=None):
  278. '''
  279. Run a single command using the ``local_async`` client
  280. Wraps :meth:`low`.
  281. '''
  282. low = {
  283. 'client': 'local_async',
  284. 'tgt': tgt,
  285. 'fun': fun,
  286. }
  287. if arg:
  288. low['arg'] = arg
  289. if kwarg:
  290. low['kwarg'] = kwarg
  291. if expr_form:
  292. low['expr_form'] = expr_form
  293. if timeout:
  294. low['timeout'] = timeout
  295. if ret:
  296. low['ret'] = ret
  297. return self.low([low])
  298. def local_batch(self, tgt, fun, arg=None, kwarg=None, expr_form='glob',
  299. batch='50%', ret=None):
  300. '''
  301. Run a single command using the ``local_batch`` client
  302. Wraps :meth:`low`.
  303. '''
  304. low = {
  305. 'client': 'local_batch',
  306. 'tgt': tgt,
  307. 'fun': fun,
  308. }
  309. if arg:
  310. low['arg'] = arg
  311. if kwarg:
  312. low['kwarg'] = kwarg
  313. if expr_form:
  314. low['expr_form'] = expr_form
  315. if batch:
  316. low['batch'] = batch
  317. if ret:
  318. low['ret'] = ret
  319. return self.low([low])
  320. def lookup_jid(self, jid):
  321. '''
  322. Get job results
  323. Wraps :meth:`runner`.
  324. '''
  325. return self.runner('jobs.lookup_jid', jid='{0}'.format(jid))
  326. def runner(self, fun, arg=None, **kwargs):
  327. '''
  328. Run a single command using the ``runner`` client
  329. Usage::
  330. runner('jobs.lookup_jid', jid=12345)
  331. '''
  332. low = {
  333. 'client': 'runner',
  334. 'fun': fun,
  335. }
  336. if arg:
  337. low['arg'] = arg
  338. low.update(kwargs)
  339. return self.low([low])
  340. def wheel(self, fun, arg=None, kwarg=None, **kwargs):
  341. '''
  342. Run a single command using the ``wheel`` client
  343. Usage::
  344. wheel('key.accept', match='myminion')
  345. '''
  346. low = {
  347. 'client': 'wheel',
  348. 'fun': fun,
  349. }
  350. if arg:
  351. low['arg'] = arg
  352. if kwarg:
  353. low['kwarg'] = kwarg
  354. low.update(kwargs)
  355. return self.low([low])
  356. def _send_auth(self, path, **kwargs):
  357. return self.req(path, kwargs)
  358. def login(self, username=None, password=None, eauth=None, **kwargs):
  359. '''
  360. Authenticate with salt-api and return the user permissions and
  361. authentication token or an empty dict
  362. '''
  363. local = locals()
  364. kwargs.update(
  365. dict(
  366. (key, local[key]) for key in (
  367. 'username',
  368. 'password',
  369. 'eauth'
  370. ) if local.get(key, None) is not None
  371. )
  372. )
  373. self.auth = self._send_auth('/login', **kwargs).get('return', [{}])[0]
  374. return self.auth
  375. def token(self, **kwargs):
  376. '''
  377. Get an eauth token from Salt for use with the /run URL
  378. '''
  379. self.auth = self._send_auth('/token', **kwargs)[0]
  380. return self.auth
  381. def _construct_url(self, path):
  382. '''
  383. Construct the url to salt-api for the given path
  384. Args:
  385. path: the path to the salt-api resource
  386. >>> api = Pepper('https://localhost:8000/salt-api/')
  387. >>> api._construct_url('/login')
  388. 'https://localhost:8000/salt-api/login'
  389. '''
  390. relative_path = path.lstrip('/')
  391. return urlparse.urljoin(self.api_url, relative_path)
  392. def _parse_salt_version(self, version):
  393. # borrow from salt.version
  394. git_describe_regex = re.compile(
  395. r'(?:[^\d]+)?(?P<major>[\d]{1,4})'
  396. r'\.(?P<minor>[\d]{1,2})'
  397. r'(?:\.(?P<bugfix>[\d]{0,2}))?'
  398. r'(?:\.(?P<mbugfix>[\d]{0,2}))?'
  399. r'(?:(?P<pre_type>rc|a|b|alpha|beta|nb)(?P<pre_num>[\d]{1}))?'
  400. r'(?:(?:.*)-(?P<noc>(?:[\d]+|n/a))-(?P<sha>[a-z0-9]{8}))?'
  401. )
  402. match = git_describe_regex.match(version)
  403. if match:
  404. self.salt_version = match.groups()