1
# vim: ts=4 sw=4 smarttab expandtab
6
import logging.handlers
10
import xml.etree.ElementTree
11
import xml.sax.saxutils
14
from ceph_argparse import \
15
ArgumentError, CephPgid, CephOsdName, CephChoices, CephPrefix, \
16
concise_sig, descsort, parse_funcsig, parse_json_funcsigs, \
17
validate, json_command
20
# Globals and defaults
23
DEFAULT_ADDR = '0.0.0.0'
25
DEFAULT_ID = 'restapi'
27
DEFAULT_BASEURL = '/api/v0.1'
28
DEFAULT_LOG_LEVEL = 'warning'
29
DEFAULT_LOGDIR = '/var/log/ceph'
30
# default client name will be 'client.<DEFAULT_ID>'
32
# 'app' must be global for decorators, etc.
34
app = flask.Flask(APPNAME)
37
'critical':logging.CRITICAL,
38
'error':logging.ERROR,
39
'warning':logging.WARNING,
41
'debug':logging.DEBUG,
46
Find an up OSD. Return the last one that's up.
49
ret, outbuf, outs = json_command(app.ceph_cluster, prefix="osd dump",
50
argdict=dict(format='json'))
52
raise EnvironmentError(ret, 'Can\'t get osd dump output')
54
osddump = json.loads(outbuf)
56
raise EnvironmentError(errno.EINVAL, 'Invalid JSON back from osd dump')
57
osds = [osd['osd'] for osd in osddump['osds'] if osd['up']]
63
METHOD_DICT = {'r':['GET'], 'w':['PUT', 'DELETE']}
65
def api_setup(app, conf, cluster, clientname, clientid, args):
67
This is done globally, and cluster connection kept open for
68
the lifetime of the daemon. librados should assure that even
69
if the cluster goes away and comes back, our connection remains.
71
Initialize the running instance. Open the cluster, get the command
72
signatures, module, perms, and help; stuff them away in the app.ceph_urls
73
dict. Also save app.ceph_sigdict for help() handling.
75
def get_command_descriptions(cluster, target=('mon','')):
76
ret, outbuf, outs = json_command(cluster, target,
77
prefix='get_command_descriptions',
80
err = "Can't get command descriptions: {0}".format(outs)
82
raise EnvironmentError(ret, err)
85
sigdict = parse_json_funcsigs(outbuf, 'rest')
86
except Exception as e:
87
err = "Can't parse command descriptions: {}".format(e)
89
raise EnvironmentError(err)
92
app.ceph_cluster = cluster or 'ceph'
98
cluster = cluster or 'ceph'
99
clientid = clientid or DEFAULT_ID
100
clientname = clientname or 'client.' + clientid
102
app.ceph_cluster = rados.Rados(name=clientname, conffile=conf)
103
app.ceph_cluster.conf_parse_argv(args)
104
app.ceph_cluster.connect()
106
app.ceph_baseurl = app.ceph_cluster.conf_get('restapi_base_url') \
108
if app.ceph_baseurl.endswith('/'):
109
app.ceph_baseurl = app.ceph_baseurl[:-1]
110
addr = app.ceph_cluster.conf_get('public_addr') or DEFAULT_ADDR
112
# remove any nonce from the conf value
113
addr = addr.split('/')[0]
114
addr, port = addr.rsplit(':', 1)
115
addr = addr or DEFAULT_ADDR
116
port = port or DEFAULT_PORT
119
loglevel = app.ceph_cluster.conf_get('restapi_log_level') \
121
# ceph has a default log file for daemons only; clients (like this)
122
# default to "". Override that for this particular client.
123
logfile = app.ceph_cluster.conf_get('log_file')
125
logfile = os.path.join(
127
'{cluster}-{clientname}.{pid}.log'.format(
129
clientname=clientname,
133
app.logger.addHandler(logging.handlers.WatchedFileHandler(logfile))
134
app.logger.setLevel(LOGLEVELS[loglevel.lower()])
135
for h in app.logger.handlers:
136
h.setFormatter(logging.Formatter(
137
'%(asctime)s %(name)s %(levelname)s: %(message)s'))
139
app.ceph_sigdict = get_command_descriptions(app.ceph_cluster)
141
osdid = find_up_osd(app)
142
if osdid is not None:
143
osd_sigdict = get_command_descriptions(app.ceph_cluster,
144
target=('osd', int(osdid)))
146
# shift osd_sigdict keys up to fit at the end of the mon's app.ceph_sigdict
147
maxkey = sorted(app.ceph_sigdict.keys())[-1]
148
maxkey = int(maxkey.replace('cmd', ''))
150
for k, v in osd_sigdict.iteritems():
152
newv['flavor'] = 'tell'
153
globk = 'cmd' + str(osdkey)
154
app.ceph_sigdict[globk] = newv
157
# app.ceph_sigdict maps "cmdNNN" to a dict containing:
158
# 'sig', an array of argdescs
159
# 'help', the helptext
160
# 'module', the Ceph module this command relates to
161
# 'perm', a 'rwx*' string representing required permissions, and also
162
# a hint as to whether this is a GET or POST/PUT operation
163
# 'avail', a comma-separated list of strings of consumers that should
164
# display this command (filtered by parse_json_funcsigs() above)
166
for cmdnum, cmddict in app.ceph_sigdict.iteritems():
167
cmdsig = cmddict['sig']
168
flavor = cmddict.get('flavor', 'mon')
169
url, params = generate_url_and_params(app, cmdsig, flavor)
170
perm = cmddict['perm']
171
for k in METHOD_DICT.iterkeys():
173
methods = METHOD_DICT[k]
174
urldict = {'paramsig':params,
175
'help':cmddict['help'],
176
'module':cmddict['module'],
182
# app.ceph_urls contains a list of urldicts (usually only one long)
183
if url not in app.ceph_urls:
184
app.ceph_urls[url] = [urldict]
186
# If more than one, need to make union of methods of all.
187
# Method must be checked in handler
188
methodset = set(methods)
189
for old_urldict in app.ceph_urls[url]:
190
methodset |= set(old_urldict['methods'])
191
methods = list(methodset)
192
app.ceph_urls[url].append(urldict)
194
# add, or re-add, rule with all methods and urldicts
195
app.add_url_rule(url, url, handler, methods=methods)
197
app.add_url_rule(url, url, handler, methods=methods)
199
app.logger.debug("urls added: %d", len(app.ceph_urls))
201
app.add_url_rule('/<path:catchall_path>', '/<path:catchall_path>',
202
handler, methods=['GET', 'PUT'])
206
def generate_url_and_params(app, sig, flavor):
208
Digest command signature from cluster; generate an absolute
209
(including app.ceph_baseurl) endpoint from all the prefix words,
210
and a list of non-prefix param descs
215
# the OSD command descriptors don't include the 'tell <target>', so
216
# tack it onto the front of sig
218
tellsig = parse_funcsig(['tell',
219
{'name':'target', 'type':'CephOsdName'}])
223
# prefixes go in the URL path
224
if desc.t == CephPrefix:
225
url += '/' + desc.instance.prefix
226
# CephChoices with 1 required string (not --) do too, unless
227
# we've already started collecting params, in which case they
229
elif desc.t == CephChoices and \
230
len(desc.instance.strings) == 1 and \
232
not str(desc.instance).startswith('--') and \
234
url += '/' + str(desc.instance)
236
# tell/<target> is a weird case; the URL includes what
237
# would everywhere else be a parameter
238
if flavor == 'tell' and \
239
(desc.t, desc.name) == (CephOsdName, 'target'):
244
return app.ceph_baseurl + url, params
248
# end setup (import-time) functions, begin request-time functions
251
def concise_sig_for_uri(sig, flavor):
253
Return a generic description of how one would send a REST request for sig
259
ret = 'tell/<osdid-or-pgid>/'
261
if d.t == CephPrefix:
262
prefix.append(d.instance.prefix)
264
args.append(d.name + '=' + str(d))
265
ret += '/'.join(prefix)
267
ret += '?' + '&'.join(args)
270
def show_human_help(prefix):
272
Dump table showing commands matching prefix
274
# XXX There ought to be a better discovery mechanism than an HTML table
275
s = '<html><body><table border=1><th>Possible commands:</th><th>Method</th><th>Description</th>'
277
permmap = {'r':'GET', 'rw':'PUT'}
279
for cmdsig in sorted(app.ceph_sigdict.itervalues(), cmp=descsort):
280
concise = concise_sig(cmdsig['sig'])
281
flavor = cmdsig.get('flavor', 'mon')
283
concise = 'tell/<target>/' + concise
284
if concise.startswith(prefix):
286
wrapped_sig = textwrap.wrap(
287
concise_sig_for_uri(cmdsig['sig'], flavor), 40
289
for sigline in wrapped_sig:
290
line.append(flask.escape(sigline) + '\n')
291
line.append('</td><td>')
292
line.append(permmap[cmdsig['perm']])
293
line.append('</td><td>')
294
line.append(flask.escape(cmdsig['help']))
295
line.append('</td></tr>\n')
298
s += '</table></body></html>'
307
For every request, log it. XXX Probably overkill for production
309
app.logger.info(flask.request.url + " from " + flask.request.remote_addr + " " + flask.request.user_agent.string)
310
app.logger.debug("Accept: %s", flask.request.accept_mimetypes.values())
314
return flask.redirect(app.ceph_baseurl)
316
def make_response(fmt, output, statusmsg, errorcode):
318
If formatted output, cobble up a response object that contains the
319
output and status wrapped in enclosing objects; if nonformatted, just
320
use output+status. Return HTTP status errorcode in any event.
326
native_output = json.loads(output or '[]')
327
response = json.dumps({"output":native_output,
330
return flask.make_response("Error decoding JSON from " +
334
# one is tempted to do this with xml.etree, but figuring out how
335
# to 'un-XML' the XML-dumped output so it can be reassembled into
336
# a piece of the tree here is beyond me right now.
337
#ET = xml.etree.ElementTree
338
#resp_elem = ET.Element('response')
339
#o = ET.SubElement(resp_elem, 'output')
341
#s = ET.SubElement(resp_elem, 'status')
343
#response = ET.tostring(resp_elem)
352
</response>'''.format(response, xml.sax.saxutils.escape(statusmsg))
354
if not 200 <= errorcode < 300:
355
response = response + '\n' + statusmsg + '\n'
357
return flask.make_response(response, errorcode)
359
def handler(catchall_path=None, fmt=None, target=None):
361
Main endpoint handler; generic for every endpoint, including catchall.
362
Handles the catchall, anything with <.fmt>, anything with embedded
363
<target>. Partial match or ?help cause the HTML-table
364
"show_human_help" output.
367
ep = catchall_path or flask.request.endpoint
368
ep = ep.replace('.<fmt>', '')
373
# demand that endpoint begin with app.ceph_baseurl
374
if not ep.startswith(app.ceph_baseurl):
375
return make_response(fmt, '', 'Page not found', 404)
377
rel_ep = ep[len(app.ceph_baseurl)+1:]
379
# Extensions override Accept: headers override defaults
381
if 'application/json' in flask.request.accept_mimetypes.values():
383
elif 'application/xml' in flask.request.accept_mimetypes.values():
388
cmdtarget = 'mon', ''
391
# got tell/<target>; validate osdid or pgid
396
except ArgumentError:
399
pgidobj.valid(target)
400
except ArgumentError:
401
return flask.make_response("invalid osdid or pgid", 400)
405
cmdtarget = 'pg', pgid
408
cmdtarget = name.nametype, name.nameid
410
# prefix does not include tell/<target>/
411
prefix = ' '.join(rel_ep.split('/')[2:]).strip()
413
# non-target command: prefix is entire path
414
prefix = ' '.join(rel_ep.split('/')).strip()
416
# show "match as much as you gave me" help for unknown endpoints
417
if not ep in app.ceph_urls:
418
helptext = show_human_help(prefix)
420
resp = flask.make_response(helptext, 400)
421
resp.headers['Content-Type'] = 'text/html'
424
return make_response(fmt, '', 'Invalid endpoint ' + ep, 400)
428
for urldict in app.ceph_urls[ep]:
429
if flask.request.method not in urldict['methods']:
431
paramsig = urldict['paramsig']
433
# allow '?help' for any specifically-known endpoint
434
if 'help' in flask.request.args:
435
response = flask.make_response('{0}: {1}'.\
436
format(prefix + concise_sig(paramsig), urldict['help']))
437
response.headers['Content-Type'] = 'text/plain'
440
# if there are parameters for this endpoint, process them
443
for k, l in flask.request.args.iterlists():
449
# is this a valid set of params?
451
argdict = validate(args, paramsig)
454
except Exception as e:
458
if flask.request.args:
465
return make_response(fmt, '', exc + '\n', 400)
467
argdict['format'] = fmt or 'plain'
468
argdict['module'] = found['module']
469
argdict['perm'] = found['perm']
471
argdict['pgid'] = pgid
474
cmdtarget = ('mon', '')
476
app.logger.debug('sending command prefix %s argdict %s', prefix, argdict)
477
ret, outbuf, outs = json_command(app.ceph_cluster, prefix=prefix,
479
inbuf=flask.request.data, argdict=argdict)
481
return make_response(fmt, '', 'Error: {0} ({1})'.format(outs, ret), 400)
483
response = make_response(fmt, outbuf, outs or 'OK', 200)
485
contenttype = 'application/' + fmt.replace('-pretty','')
487
contenttype = 'text/plain'
488
response.headers['Content-Type'] = contenttype
492
# Main entry point from wrapper/WSGI server: call with cmdline args,
493
# get back the WSGI app entry point
495
def generate_app(conf, cluster, clientname, clientid, args):
496
addr, port = api_setup(app, conf, cluster, clientname, clientid, args)