libpepper.py 14 KB

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