Last active
August 29, 2015 13:56
-
-
Save andrewgross/8922636 to your computer and use it in GitHub Desktop.
api.py using requests.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import copy | |
import datetime | |
import itertools | |
import logging | |
import os | |
import re | |
import socket | |
import subprocess | |
import threading | |
import urllib2 | |
import urlparse | |
import weakref | |
import pkg_resources | |
from chef.auth import sign_request | |
from chef.exceptions import ChefServerError | |
from chef.rsa import Key | |
from chef.utils import json | |
from chef.utils.file import walk_backwards | |
api_stack = threading.local() | |
log = logging.getLogger('chef.api') | |
config_ruby_script = """ | |
require 'chef' | |
Chef::Config.from_file('%s') | |
puts Chef::Config.configuration.to_json | |
""".strip() | |
def api_stack_value(): | |
if not hasattr(api_stack, 'value'): | |
api_stack.value = [] | |
return api_stack.value | |
class UnknownRubyExpression(Exception): | |
"""Token exception for unprocessed Ruby expressions.""" | |
class ChefRequest(urllib2.Request): | |
"""Workaround for using PUT/DELETE with urllib2.""" | |
def __init__(self, *args, **kwargs): | |
self._method = kwargs.pop('method', None) | |
# Request is an old-style class, no super() allowed. | |
urllib2.Request.__init__(self, *args, **kwargs) | |
def get_method(self): | |
if self._method: | |
return self._method | |
return urllib2.Request.get_method(self) | |
class ChefAPI(object): | |
"""The ChefAPI object is a wrapper for a single Chef server. | |
.. admonition:: The API stack | |
PyChef maintains a stack of :class:`ChefAPI` objects to be use with | |
other methods if an API object isn't given explicitly. The first | |
ChefAPI created will become the default, though you can set a specific | |
default using :meth:`ChefAPI.set_default`. You can also use a ChefAPI | |
as a context manager to create a scoped default:: | |
with ChefAPI('http://localhost:4000', 'client.pem', 'admin'): | |
n = Node('web1') | |
""" | |
ruby_value_re = re.compile(r'#\{([^}]+)\}') | |
env_value_re = re.compile(r'ENV\[(.+)\]') | |
ruby_string_re = re.compile(r'^\s*(["\'])(.*?)\1\s*$') | |
def __init__(self, url, key, client, version='0.10.8', headers={}): | |
self.url = url.rstrip('/') | |
self.parsed_url = urlparse.urlparse(self.url) | |
if not isinstance(key, Key): | |
key = Key(key) | |
self.key = key | |
self.client = client | |
self.version = version | |
self.headers = dict((k.lower(), v) for k, v in headers.iteritems()) | |
self.version_parsed = pkg_resources.parse_version(self.version) | |
self.platform = self.parsed_url.hostname == 'api.opscode.com' | |
if not api_stack_value(): | |
self.set_default() | |
@classmethod | |
def from_config_file(cls, path): | |
"""Load Chef API paraters from a config file. Returns None if the | |
config can't be used. | |
""" | |
log.debug('Trying to load from "%s"', path) | |
if not os.path.isfile(path) or not os.access(path, os.R_OK): | |
# Can't even read the config file | |
log.debug('Unable to read config file "%s"', path) | |
return | |
url = key_path = client_name = None | |
for line in open(path): | |
if not line.strip() or line.startswith('#'): | |
continue # Skip blanks and comments | |
parts = line.split(None, 1) | |
if len(parts) != 2: | |
continue # Not a simple key/value, we can't parse it anyway | |
key, value = parts | |
md = cls.ruby_string_re.search(value) | |
if md: | |
value = md.group(2) | |
else: | |
# Not a string, don't even try | |
log.debug('Value for %s does not look like a string: %s'%(key, value)) | |
continue | |
def _ruby_value(match): | |
expr = match.group(1).strip() | |
if expr == 'current_dir': | |
return os.path.dirname(path) | |
envmatch = cls.env_value_re.match(expr) | |
if envmatch: | |
envmatch = envmatch.group(1).strip('"').strip("'") | |
return os.environ.get(envmatch) or '' | |
log.debug('Unknown ruby expression in line "%s"', line) | |
raise UnknownRubyExpression | |
try: | |
value = cls.ruby_value_re.sub(_ruby_value, value) | |
except UnknownRubyExpression: | |
continue | |
if key == 'chef_server_url': | |
log.debug('Found URL: %r', value) | |
url = value | |
elif key == 'node_name': | |
log.debug('Found client name: %r', value) | |
client_name = value | |
elif key == 'client_key': | |
log.debug('Found key path: %r', value) | |
key_path = value | |
if not os.path.isabs(key_path): | |
# Relative paths are relative to the config file | |
key_path = os.path.abspath(os.path.join(os.path.dirname(path), key_path)) | |
if not (url and client_name and key_path): | |
# No URL, no chance this was valid, try running Ruby | |
log.debug('No Chef server config found, trying Ruby parse') | |
url = key_path = client_name = None | |
proc = subprocess.Popen('ruby', stdin=subprocess.PIPE, stdout=subprocess.PIPE) | |
script = config_ruby_script % path.replace('\\', '\\\\').replace("'", "\\'") | |
out, err = proc.communicate(script) | |
if proc.returncode == 0 and out.strip(): | |
data = json.loads(out) | |
log.debug('Ruby parse succeeded with %r', data) | |
url = data.get('chef_server_url') | |
client_name = data.get('node_name') | |
key_path = data.get('client_key') | |
else: | |
log.debug('Ruby parse failed with exit code %s: %s', proc.returncode, out.strip()) | |
if not url: | |
# Still no URL, can't use this config | |
log.debug('Still no Chef server URL found') | |
return | |
if not key_path: | |
# Try and use ./client.pem | |
key_path = os.path.join(os.path.dirname(path), 'client.pem') | |
if not os.path.isfile(key_path) or not os.access(key_path, os.R_OK): | |
# Can't read the client key | |
log.debug('Unable to read key file "%s"', key_path) | |
return | |
if not client_name: | |
client_name = socket.getfqdn() | |
return cls(url, key_path, client_name) | |
@staticmethod | |
def get_global(): | |
"""Return the API on the top of the stack.""" | |
while api_stack_value(): | |
api = api_stack_value()[-1]() | |
if api is not None: | |
return api | |
del api_stack_value()[-1] | |
def set_default(self): | |
"""Make this the default API in the stack. Returns the old default if any.""" | |
old = None | |
if api_stack_value(): | |
old = api_stack_value().pop(0) | |
api_stack_value().insert(0, weakref.ref(self)) | |
return old | |
def __enter__(self): | |
api_stack_value().append(weakref.ref(self)) | |
return self | |
def __exit__(self, type, value, traceback): | |
del api_stack_value()[-1] | |
def _request(self, method, url, data, headers): | |
# Testing hook, subclass and override for WSGI intercept | |
request = ChefRequest(url, data, headers, method=method) | |
return urllib2.urlopen(request).read() | |
def request(self, method, path, headers={}, data=None): | |
auth_headers = sign_request(key=self.key, http_method=method, | |
path=self.parsed_url.path+path.split('?', 1)[0], body=data, | |
host=self.parsed_url.netloc, timestamp=datetime.datetime.utcnow(), | |
user_id=self.client) | |
request_headers = {} | |
request_headers.update(self.headers) | |
request_headers.update(dict((k.lower(), v) for k, v in headers.iteritems())) | |
request_headers['x-chef-version'] = self.version | |
request_headers.update(auth_headers) | |
try: | |
response = self._request(method, self.url+path, data, dict((k.capitalize(), v) for k, v in request_headers.iteritems())) | |
except urllib2.HTTPError, e: | |
e.content = e.read() | |
try: | |
e.content = json.loads(e.content) | |
raise ChefServerError.from_error(e.content['error'], code=e.code) | |
except ValueError: | |
pass | |
raise e | |
return response | |
def api_request(self, method, path, headers={}, data=None): | |
headers = dict((k.lower(), v) for k, v in headers.iteritems()) | |
headers['accept'] = 'application/json' | |
if data is not None: | |
headers['content-type'] = 'application/json' | |
data = json.dumps(data) | |
response = self.request(method, path, headers, data) | |
return json.loads(response) | |
def __getitem__(self, path): | |
return self.api_request('GET', path) | |
def autoconfigure(base_path=None): | |
"""Try to find a knife or chef-client config file to load parameters from, | |
starting from either the given base path or the current working directory. | |
The lookup order mirrors the one from Chef, first all folders from the base | |
path are walked back looking for .chef/knife.rb, then ~/.chef/knife.rb, | |
and finally /etc/chef/client.rb. | |
The first file that is found and can be loaded successfully will be loaded | |
into a :class:`ChefAPI` object. | |
""" | |
base_path = base_path or os.getcwd() | |
# Scan up the tree for a knife.rb or client.rb. If that fails try looking | |
# in /etc/chef. The /etc/chef check will never work in Win32, but it doesn't | |
# hurt either. | |
for path in walk_backwards(base_path): | |
config_path = os.path.join(path, '.chef', 'knife.rb') | |
api = ChefAPI.from_config_file(config_path) | |
if api is not None: | |
return api | |
# The walk didn't work, try ~/.chef/knife.rb | |
config_path = os.path.expanduser(os.path.join('~', '.chef', 'knife.rb')) | |
api = ChefAPI.from_config_file(config_path) | |
if api is not None: | |
return api | |
# Nothing in the home dir, try /etc/chef/client.rb | |
config_path = os.path.join(os.path.sep, 'etc', 'chef', 'client.rb') | |
api = ChefAPI.from_config_file(config_path) | |
if api is not None: | |
return api |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment