diff options
author | Dan Mick <dan.mick@inktank.com> | 2013-07-10 17:39:47 -0700 |
---|---|---|
committer | Dan Mick <dan.mick@inktank.com> | 2013-07-10 20:58:51 -0700 |
commit | 4cb0e062661a7190366753554d7202aa0168f265 (patch) | |
tree | 0bd7ad6f7fd46e994ee2108b86910d1533d5c10f | |
parent | 45dc57316aa7ce271006d4e45e1ab745d136bcc0 (diff) | |
download | ceph-4cb0e062661a7190366753554d7202aa0168f265.tar.gz |
Add 'ceph-rest-api'
ceph-rest-api is a Python WSGI module for accessing the Ceph cluster.
It supports most of the commands supported by the ceph CLI,
appropriately translated to HTTP GET/PUT requests. It is not a
truly RESTful interface.
Not supported at this moment: "tell", "pg <pgid>", and "daemon"
commands.
Configuration options are specified in ceph.conf, specified with
-c/--conf or obtained from $CEPH_CONF, /etc/ceph/ceph.conf,
~/.ceph/ceph.conf, or ./ceph.conf.
-n/--name specifies the client name, used for the cluster
authentication key and for the ceph.conf section name (default
is client.restapi).
restapi keyring = <keyring file>
restapi public addr = listenIP:port (default 0.0.0.0:5000)
restapi base url = <base path> (default /api/v0.1)
restapi log level = (error, warning, info, debug)
restapi log file = (default /var/log/ceph/<clientname>.log)
Primitive human-level command discovery is supported; GET from
BASEURL (say, http://localhost:5000/api/v0.1) will show an HTML
table of all commands and arguments, method supported, and help strings.
Signed-off-by: Dan Mick <dan.mick@inktank.com>
-rw-r--r-- | README | 3 | ||||
-rw-r--r-- | ceph.spec.in | 1 | ||||
-rw-r--r-- | debian/ceph-common.install | 1 | ||||
-rw-r--r-- | src/Makefile.am | 5 | ||||
-rwxr-xr-x | src/ceph-rest-api | 454 |
5 files changed, 461 insertions, 3 deletions
@@ -127,7 +127,8 @@ To build the source code, you must install the following: - libsnappy-dev - libcurl4-gnutls-dev - python-argparse +- python-flask For example: - $ apt-get install automake autoconf pkg-config gcc g++ make libboost-dev libedit-dev libssl-dev libtool libfcgi libfcgi-dev libfuse-dev linux-kernel-headers libcrypto++-dev libaio-dev libgoogle-perftools-dev libkeyutils-dev uuid-dev libatomic-ops-dev libboost-program-options-dev libboost-thread-dev libexpat1-dev libleveldb-dev libsnappy-dev libcurl4-gnutls-dev python-argparse + $ apt-get install automake autoconf pkg-config gcc g++ make libboost-dev libedit-dev libssl-dev libtool libfcgi libfcgi-dev libfuse-dev linux-kernel-headers libcrypto++-dev libaio-dev libgoogle-perftools-dev libkeyutils-dev uuid-dev libatomic-ops-dev libboost-program-options-dev libboost-thread-dev libexpat1-dev libleveldb-dev libsnappy-dev libcurl4-gnutls-dev python-argparse python-flask diff --git a/ceph.spec.in b/ceph.spec.in index 9834a3473dd..b8578d83c8a 100644 --- a/ceph.spec.in +++ b/ceph.spec.in @@ -366,6 +366,7 @@ fi %{_bindir}/cephfs %{_bindir}/ceph-conf %{_bindir}/ceph-clsinfo +%{_bindir}/ceph-rest-api %{_bindir}/crushtool %{_bindir}/monmaptool %{_bindir}/osdmaptool diff --git a/debian/ceph-common.install b/debian/ceph-common.install index 893fa2be465..9c309ab7a1c 100644 --- a/debian/ceph-common.install +++ b/debian/ceph-common.install @@ -4,6 +4,7 @@ usr/bin/ceph usr/bin/ceph-authtool usr/bin/ceph-conf usr/bin/ceph-dencoder +usr/bin/ceph-rest-api usr/bin/ceph-syn usr/bin/rados usr/bin/rbd diff --git a/src/Makefile.am b/src/Makefile.am index e2e60720dac..3762107bfe9 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -39,7 +39,7 @@ ceph_sbin_SCRIPTS = \ sbin_SCRIPTS = \ mount.fuse.ceph -bin_SCRIPTS = ceph ceph-run $(srcdir)/ceph-clsinfo ceph-debugpack ceph-rbdnamer +bin_SCRIPTS = ceph ceph-run ceph-rest-api ceph-clsinfo ceph-debugpack ceph-rbdnamer dist_bin_SCRIPTS = # C/C++ tests to build will be appended to this check_PROGRAMS = @@ -1362,9 +1362,10 @@ EXTRA_DIST += \ ceph-disk-udev \ ceph-create-keys \ mount.fuse.ceph \ + ceph-rest-api \ + mount.fuse.ceph \ rbdmap - EXTRA_DIST += $(srcdir)/$(shell_scripts:%=%.in) # work around old versions of automake that don't define $docdir diff --git a/src/ceph-rest-api b/src/ceph-rest-api new file mode 100755 index 00000000000..3dc16cff2bd --- /dev/null +++ b/src/ceph-rest-api @@ -0,0 +1,454 @@ +#!/usr/bin/python +# vim: ts=4 sw=4 smarttab expandtab + +import os +import sys + +# Make life easier on developers + +MYPATH = os.path.abspath(__file__) +MYDIR = os.path.dirname(MYPATH) +DEVMODEMSG = '*** DEVELOPER MODE: setting PYTHONPATH and LD_LIBRARY_PATH' + +if MYDIR.endswith('src') and \ + os.path.exists(os.path.join(MYDIR, '.libs')) and \ + os.path.exists(os.path.join(MYDIR, 'pybind')): + MYLIBPATH = os.path.join(MYDIR, '.libs') + if 'LD_LIBRARY_PATH' in os.environ: + if MYLIBPATH not in os.environ['LD_LIBRARY_PATH']: + os.environ['LD_LIBRARY_PATH'] += ':' + MYLIBPATH + print >> sys.stderr, DEVMODEMSG + os.execvp('python', ['python'] + sys.argv) + else: + os.environ['LD_LIBRARY_PATH'] = MYLIBPATH + print >> sys.stderr, DEVMODEMSG + os.execvp('python', ['python'] + sys.argv) + sys.path.insert(0, os.path.join(MYDIR, 'pybind')) + +import argparse +import collections +import ConfigParser +import errno +import json +import logging +import logging.handlers +import rados +import textwrap +import xml.etree.ElementTree +import xml.sax.saxutils + +import flask +from ceph_argparse import * + +# +# Globals +# + +APPNAME = '__main__' +DEFAULT_BASEURL = '/api/v0.1' +DEFAULT_ADDR = '0.0.0.0:5000' +DEFAULT_LOG_LEVEL = 'warning' +DEFAULT_CLIENTNAME = 'client.restapi' +DEFAULT_LOG_FILE = '/var/log/ceph/' + DEFAULT_CLIENTNAME + '.log' + +app = flask.Flask(APPNAME) + +LOGLEVELS = { + 'critical':logging.CRITICAL, + 'error':logging.ERROR, + 'warning':logging.WARNING, + 'info':logging.INFO, + 'debug':logging.DEBUG, +} + + +# my globals, in a named tuple for usage clarity + +glob = collections.namedtuple('gvars', + 'args cluster urls sigdict baseurl clientname') +glob.args = None +glob.cluster = None +glob.urls = {} +glob.sigdict = {} +glob.baseurl = '' +glob.clientname = '' + +def parse_args(): + parser = argparse.ArgumentParser(description="Ceph REST API webapp") + parser.add_argument('-c', '--conf', help='Ceph configuration file') + parser.add_argument('-n', '--name', help='Ceph client config/key name') + + return parser.parse_args() + +def load_conf(conffile=None): + import contextlib + + class _TrimIndentFile(object): + def __init__(self, fp): + self.fp = fp + + def readline(self): + line = self.fp.readline() + return line.lstrip(' \t') + + + def _optionxform(s): + s = s.replace('_', ' ') + s = '_'.join(s.split()) + return s + + + def parse(fp): + cfg = ConfigParser.RawConfigParser() + cfg.optionxform = _optionxform + ifp = _TrimIndentFile(fp) + cfg.readfp(ifp) + return cfg + + + def load(path): + f = file(path) + with contextlib.closing(f): + return parse(f) + + # XXX this should probably use cluster name + if conffile: + return load(conffile) + elif 'CEPH_CONF' in os.environ: + conffile = os.environ['CEPH_CONF'] + elif os.path.exists('/etc/ceph/ceph.conf'): + conffile = '/etc/ceph/ceph.conf' + elif os.path.exists(os.path.expanduser('~/.ceph/ceph.conf')): + conffile = os.path.expanduser('~/.ceph/ceph.conf') + elif os.path.exists('ceph.conf'): + conffile = 'ceph.conf' + else: + return None + + return load(conffile) + +def get_conf(cfg, key): + try: + return cfg.get(glob.clientname, 'restapi_' + key) + except ConfigParser.NoOptionError: + return None + + +# XXX this is done globally, and cluster connection kept open; there +# are facilities to pass around global info to requests and to +# tear down connections between requests if it becomes important + +def api_setup(): + """ + Initialize the running instance. Open the cluster, get the command + signatures, module,, perms, and help; stuff them away in the glob.urls + dict. + """ + + glob.args = parse_args() + + conffile = glob.args.conf or '' + if glob.args.name: + glob.clientname = glob.args.name + glob.logfile = '/var/log/ceph' + glob.clientname + '.log' + + glob.clientname = glob.args.name or DEFAULT_CLIENTNAME + glob.cluster = rados.Rados(name='client.restapi', conffile=conffile) + glob.cluster.connect() + + cfg = load_conf(conffile) + glob.baseurl = get_conf(cfg, 'base_url') or DEFAULT_BASEURL + if glob.baseurl.endswith('/'): + glob.baseurl + addr = get_conf(cfg, 'public_addr') or DEFAULT_ADDR + addrport = addr.rsplit(':', 1) + addr = addrport[0] + if len(addrport) > 1: + port = addrport[1] + else: + port = DEFAULT_ADDR.rsplit(':', 1) + port = int(port) + + loglevel = get_conf(cfg, 'log_level') or 'warning' + logfile = get_conf(cfg, 'log_file') or glob.logfile + app.logger.addHandler(logging.handlers.WatchedFileHandler(logfile)) + app.logger.setLevel(LOGLEVELS[loglevel.lower()]) + for h in app.logger.handlers: + h.setFormatter(logging.Formatter( + '%(asctime)s %(name)s %(levelname)s: %(message)s')) + + ret, outbuf, outs = json_command(glob.cluster, + prefix='get_command_descriptions') + if ret: + app.logger.error('Can\'t contact cluster for command descriptions: %s', + outs) + sys.exit(1) + + try: + glob.sigdict = parse_json_funcsigs(outbuf, 'rest') + except Exception as e: + app.logger.error('Can\'t parse command descriptions: %s', e) + sys.exit(1) + + # glob.sigdict maps "cmdNNN" to a dict containing: + # 'sig', an array of argdescs + # 'help', the helptext + # 'module', the Ceph module this command relates to + # 'perm', a 'rwx*' string representing required permissions, and also + # a hint as to whether this is a GET or POST/PUT operation + # 'avail', a comma-separated list of strings of consumers that should + # display this command (filtered by parse_json_funcsigs() above) + glob.urls = {} + for cmdnum, cmddict in glob.sigdict.iteritems(): + cmdsig = cmddict['sig'] + url, params = generate_url_and_params(cmdsig) + if url in glob.urls: + continue + else: + perm = cmddict['perm'] + urldict = {'paramsig':params, + 'help':cmddict['help'], + 'module':cmddict['module'], + 'perm':perm, + } + method_dict = {'r':['GET'], + 'w':['PUT', 'DELETE']} + for k in method_dict.iterkeys(): + if k in perm: + methods = method_dict[k] + app.add_url_rule(url, url, handler, methods=methods) + glob.urls[url] = urldict + + url += '.<fmt>' + app.add_url_rule(url, url, handler, methods=methods) + glob.urls[url] = urldict + app.logger.debug("urls added: %d", len(glob.urls)) + + app.add_url_rule('/<path:catchall_path>', '/<path:catchall_path>', + handler, methods=['GET', 'PUT']) + return addr, port + + +def generate_url_and_params(sig): + """ + Digest command signature from cluster; generate an absolute + (including glob.baseurl) endpoint from all the prefix words, + and a dictionary of non-prefix parameters + """ + + url = '' + params = [] + for desc in sig: + if desc.t == CephPrefix: + url += '/' + desc.instance.prefix + elif desc.t == CephChoices and \ + len(desc.instance.strings) == 1 and \ + desc.req and \ + not str(desc.instance).startswith('--'): + url += '/' + str(desc.instance) + else: + params.append(desc) + return glob.baseurl + url, params + + +def concise_sig_for_uri(sig): + """ + Return a generic description of how one would send a REST request for sig + """ + prefix = [] + args = [] + for d in sig: + if d.t == CephPrefix: + prefix.append(d.instance.prefix) + else: + args.append(d.name + '=' + str(d)) + sig = '/'.join(prefix) + if args: + sig += '?' + '&'.join(args) + return sig + +def show_human_help(prefix): + """ + Dump table showing commands matching prefix + """ + # XXX this really needs to be a template + #s = '<html><body><style>.colhalf { width: 50%;} body{word-wrap:break-word;}</style>' + #s += '<table border=1><col class=colhalf /><col class=colhalf />' + #s += '<th>Possible commands:</th>' + # XXX the above mucking with css doesn't cause sensible columns. + s = '<html><body><table border=1><th>Possible commands:</th><th>Method</th><th>Description</th>' + + possible = [] + permmap = {'r':'GET', 'rw':'PUT'} + line = '' + for cmdsig in sorted(glob.sigdict.itervalues(), cmp=descsort): + concise = concise_sig(cmdsig['sig']) + if concise.startswith(prefix): + line = ['<tr><td>'] + wrapped_sig = textwrap.wrap(concise_sig_for_uri(cmdsig['sig']), 40) + for sigline in wrapped_sig: + line.append(flask.escape(sigline) + '\n') + line.append('</td><td>') + line.append(permmap[cmdsig['perm']]) + line.append('</td><td>') + line.append(flask.escape(cmdsig['help'])) + line.append('</td></tr>\n') + s += ''.join(line) + + s += '</table></body></html>' + if line: + return s + else: + return '' + +@app.before_request +def log_request(): + """ + For every request, log it. XXX Probably overkill for production + """ + app.logger.info(flask.request.url + " from " + flask.request.remote_addr + " " + flask.request.user_agent.string) + app.logger.debug("Accept: %s", flask.request.accept_mimetypes.values()) + + +@app.route('/') +def root_redir(): + return flask.redirect(glob.baseurl) + +def make_response(fmt, output, statusmsg, errorcode): + """ + If formatted output, cobble up a response object that contains the + output and status wrapped in enclosing objects; if nonformatted, just + use output. Return HTTP status errorcode in any event. + """ + response = output + if fmt: + if 'json' in fmt: + try: + native_output = json.loads(output or '[]') + response = json.dumps({"output":native_output, + "status":statusmsg}) + except: + return flask.make_response("Error decoding JSON from " + + output, 500) + elif 'xml' in fmt: + # one is tempted to do this with xml.etree, but figuring out how + # to 'un-XML' the XML-dumped output so it can be reassembled into + # a piece of the tree here is beyond me right now. + #ET = xml.etree.ElementTree + #resp_elem = ET.Element('response') + #o = ET.SubElement(resp_elem, 'output') + #o.text = output + #s = ET.SubElement(resp_elem, 'status') + #s.text = statusmsg + #response = ET.tostring(resp_elem) + response = ''' +<response> + <output> + {0} + </output> + <status> + {1} + </status> +</response>'''.format(response, xml.sax.saxutils.escape(statusmsg)) + + return flask.make_response(response, errorcode) + +def handler(catchall_path=None, fmt=None): + """ + Main endpoint handler; generic for every endpoint + """ + + if (catchall_path): + ep = catchall_path.replace('.<fmt>', '') + else: + ep = flask.request.endpoint.replace('.<fmt>', '') + + if ep[0] != '/': + ep = '/' + ep + + # Extensions override Accept: headers override defaults + if not fmt: + if 'application/json' in flask.request.accept_mimetypes.values(): + fmt = 'json' + elif 'application/xml' in flask.request.accept_mimetypes.values(): + fmt = 'xml' + + # demand that endpoint begin with glob.baseurl + if not ep.startswith(glob.baseurl): + return make_response(fmt, '', 'Page not found', 404) + + relative_endpoint = ep[len(glob.baseurl)+1:] + prefix = ' '.join(relative_endpoint.split('/')).strip() + + # show "match as much as you gave me" help for unknown endpoints + if not ep in glob.urls: + helptext = show_human_help(prefix) + if helptext: + resp = flask.make_response(helptext, 400) + resp.headers['Content-Type'] = 'text/html' + return resp + else: + return make_response(fmt, '', 'Invalid endpoint ' + ep, 400) + + urldict = glob.urls[ep] + paramsig = urldict['paramsig'] + + # allow '?help' for any specifically-known endpoint + if 'help' in flask.request.args: + response = flask.make_response('{0}: {1}'.\ + format(prefix + concise_sig(paramsig), urldict['help'])) + response.headers['Content-Type'] = 'text/plain' + return response + + # if there are parameters for this endpoint, process them + if paramsig: + args = {} + for k, l in flask.request.args.iterlists(): + if len(l) == 1: + args[k] = l[0] + else: + args[k] = l + + # is this a valid set of params? + try: + argdict = validate(args, paramsig) + except Exception as e: + return make_response(fmt, '', str(e) + '\n', 400) + else: + # no parameters for this endpoint; complain if args are supplied + if flask.request.args: + return make_response(fmt, '', ep + 'takes no params', 400) + argdict = {} + + + argdict['format'] = fmt or 'plain' + argdict['module'] = urldict['module'] + argdict['perm'] = urldict['perm'] + + app.logger.debug('sending command prefix %s argdict %s', prefix, argdict) + ret, outbuf, outs = json_command(glob.cluster, prefix=prefix, + inbuf=flask.request.data, argdict=argdict) + if ret: + return make_response(fmt, '', 'Error: {0} ({1})'.format(outs, ret), 400) + + response = make_response(fmt, outbuf, outs or 'OK', 200) + if fmt: + contenttype = 'application/' + fmt.replace('-pretty','') + else: + contenttype = 'text/plain' + response.headers['Content-Type'] = contenttype + return response + +# +# main +# + +addr, port = api_setup() + +if __name__ == '__main__': + import inspect + files = [os.path.split(fr[1])[-1] for fr in inspect.stack()] + if 'pdb.py' in files: + app.run(host=addr, port=port, debug=True, use_reloader=False, use_debugger=False) + else: + app.run(host=addr, port=port, debug=True) |