Skip to content

Instantly share code, notes, and snippets.

@andrewgross
Last active August 29, 2015 13:56
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save andrewgross/8922636 to your computer and use it in GitHub Desktop.
Save andrewgross/8922636 to your computer and use it in GitHub Desktop.
api.py using requests.
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