Fix some bugs when testing opensds ansible
[stor4nfv.git] / src / ceph / src / pybind / ceph_rest_api.py
1 # vim: ts=4 sw=4 smarttab expandtab
2
3 import errno
4 import json
5 import logging
6 import logging.handlers
7 import os
8 import rados
9 import textwrap
10 import xml.etree.ElementTree
11 import xml.sax.saxutils
12
13 import flask
14 from ceph_argparse import \
15     ArgumentError, CephPgid, CephOsdName, CephChoices, CephPrefix, \
16     concise_sig, descsort, parse_funcsig, parse_json_funcsigs, \
17     validate, json_command
18
19 #
20 # Globals and defaults
21 #
22
23 DEFAULT_ADDR = '::'
24 DEFAULT_PORT = '5000'
25 DEFAULT_ID = 'restapi'
26
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>'
31
32 # network failure could keep the underlying json_command() waiting forever,
33 # set a timeout, so it bails out on timeout.
34 DEFAULT_TIMEOUT = 20
35 # and retry in that case.
36 DEFAULT_TRIES = 5
37
38 # 'app' must be global for decorators, etc.
39 APPNAME = '__main__'
40 app = flask.Flask(APPNAME)
41
42 LOGLEVELS = {
43     'critical': logging.CRITICAL,
44     'error': logging.ERROR,
45     'warning': logging.WARNING,
46     'info': logging.INFO,
47     'debug': logging.DEBUG,
48 }
49
50
51 def find_up_osd(app):
52     '''
53     Find an up OSD.  Return the last one that's up.
54     Returns id as an int.
55     '''
56     ret, outbuf, outs = json_command(app.ceph_cluster, prefix="osd dump",
57                                      argdict=dict(format='json'))
58     if ret:
59         raise EnvironmentError(ret, 'Can\'t get osd dump output')
60     try:
61         osddump = json.loads(outbuf)
62     except:
63         raise EnvironmentError(errno.EINVAL, 'Invalid JSON back from osd dump')
64     osds = [osd['osd'] for osd in osddump['osds'] if osd['up']]
65     if not osds:
66         return None
67     return int(osds[-1])
68
69
70 METHOD_DICT = {'r': ['GET'], 'w': ['PUT', 'DELETE']}
71
72
73 def api_setup(app, conf, cluster, clientname, clientid, args):
74     '''
75     This is done globally, and cluster connection kept open for
76     the lifetime of the daemon.  librados should assure that even
77     if the cluster goes away and comes back, our connection remains.
78
79     Initialize the running instance.  Open the cluster, get the command
80     signatures, module, perms, and help; stuff them away in the app.ceph_urls
81     dict.  Also save app.ceph_sigdict for help() handling.
82     '''
83     def get_command_descriptions(cluster, target=('mon', '')):
84         ret, outbuf, outs = json_command(cluster, target,
85                                          prefix='get_command_descriptions',
86                                          timeout=30)
87         if ret:
88             err = "Can't get command descriptions: {0}".format(outs)
89             app.logger.error(err)
90             raise EnvironmentError(ret, err)
91
92         try:
93             sigdict = parse_json_funcsigs(outbuf, 'rest')
94         except Exception as e:
95             err = "Can't parse command descriptions: {}".format(e)
96             app.logger.error(err)
97             raise EnvironmentError(err)
98         return sigdict
99
100     app.ceph_cluster = cluster or 'ceph'
101     app.ceph_urls = {}
102     app.ceph_sigdict = {}
103     app.ceph_baseurl = ''
104
105     conf = conf or ''
106     cluster = cluster or 'ceph'
107     clientid = clientid or DEFAULT_ID
108     clientname = clientname or 'client.' + clientid
109
110     app.ceph_cluster = rados.Rados(name=clientname, conffile=conf)
111     app.ceph_cluster.conf_parse_argv(args)
112     app.ceph_cluster.connect()
113
114     app.ceph_baseurl = app.ceph_cluster.conf_get('restapi_base_url') \
115         or DEFAULT_BASEURL
116     if app.ceph_baseurl.endswith('/'):
117         app.ceph_baseurl = app.ceph_baseurl[:-1]
118     addr = app.ceph_cluster.conf_get('public_addr') or DEFAULT_ADDR
119
120     if addr == '-':
121         addr = None
122         port = None
123     else:
124         # remove the type prefix from the conf value if any
125         for t in ('legacy:', 'msgr2:'):
126             if addr.startswith(t):
127                 addr = addr[len(t):]
128                 break
129         # remove any nonce from the conf value
130         addr = addr.split('/')[0]
131         addr, port = addr.rsplit(':', 1)
132     addr = addr or DEFAULT_ADDR
133     port = port or DEFAULT_PORT
134     port = int(port)
135
136     loglevel = app.ceph_cluster.conf_get('restapi_log_level') \
137         or DEFAULT_LOG_LEVEL
138     # ceph has a default log file for daemons only; clients (like this)
139     # default to "".  Override that for this particular client.
140     logfile = app.ceph_cluster.conf_get('log_file')
141     if not logfile:
142         logfile = os.path.join(
143             DEFAULT_LOGDIR,
144             '{cluster}-{clientname}.{pid}.log'.format(
145                 cluster=cluster,
146                 clientname=clientname,
147                 pid=os.getpid()
148             )
149         )
150     app.logger.addHandler(logging.handlers.WatchedFileHandler(logfile))
151     app.logger.setLevel(LOGLEVELS[loglevel.lower()])
152     for h in app.logger.handlers:
153         h.setFormatter(logging.Formatter(
154             '%(asctime)s %(name)s %(levelname)s: %(message)s'))
155
156     app.ceph_sigdict = get_command_descriptions(app.ceph_cluster)
157
158     osdid = find_up_osd(app)
159     if osdid is not None:
160         osd_sigdict = get_command_descriptions(app.ceph_cluster,
161                                                target=('osd', int(osdid)))
162
163         # shift osd_sigdict keys up to fit at the end of the mon's app.ceph_sigdict
164         maxkey = sorted(app.ceph_sigdict.keys())[-1]
165         maxkey = int(maxkey.replace('cmd', ''))
166         osdkey = maxkey + 1
167         for k, v in osd_sigdict.iteritems():
168             newv = v
169             newv['flavor'] = 'tell'
170             globk = 'cmd' + str(osdkey)
171             app.ceph_sigdict[globk] = newv
172             osdkey += 1
173
174     # app.ceph_sigdict maps "cmdNNN" to a dict containing:
175     # 'sig', an array of argdescs
176     # 'help', the helptext
177     # 'module', the Ceph module this command relates to
178     # 'perm', a 'rwx*' string representing required permissions, and also
179     #    a hint as to whether this is a GET or POST/PUT operation
180     # 'avail', a comma-separated list of strings of consumers that should
181     #    display this command (filtered by parse_json_funcsigs() above)
182     app.ceph_urls = {}
183     for cmdnum, cmddict in app.ceph_sigdict.iteritems():
184         cmdsig = cmddict['sig']
185         flavor = cmddict.get('flavor', 'mon')
186         url, params = generate_url_and_params(app, cmdsig, flavor)
187         perm = cmddict['perm']
188         for k in METHOD_DICT.iterkeys():
189             if k in perm:
190                 methods = METHOD_DICT[k]
191         urldict = {'paramsig': params,
192                    'help': cmddict['help'],
193                    'module': cmddict['module'],
194                    'perm': perm,
195                    'flavor': flavor,
196                    'methods': methods, }
197
198         # app.ceph_urls contains a list of urldicts (usually only one long)
199         if url not in app.ceph_urls:
200             app.ceph_urls[url] = [urldict]
201         else:
202             # If more than one, need to make union of methods of all.
203             # Method must be checked in handler
204             methodset = set(methods)
205             for old_urldict in app.ceph_urls[url]:
206                 methodset |= set(old_urldict['methods'])
207             methods = list(methodset)
208             app.ceph_urls[url].append(urldict)
209
210         # add, or re-add, rule with all methods and urldicts
211         app.add_url_rule(url, url, handler, methods=methods)
212         url += '.<fmt>'
213         app.add_url_rule(url, url, handler, methods=methods)
214
215     app.logger.debug("urls added: %d", len(app.ceph_urls))
216
217     app.add_url_rule('/<path:catchall_path>', '/<path:catchall_path>',
218                      handler, methods=['GET', 'PUT'])
219     return addr, port
220
221
222 def generate_url_and_params(app, sig, flavor):
223     '''
224     Digest command signature from cluster; generate an absolute
225     (including app.ceph_baseurl) endpoint from all the prefix words,
226     and a list of non-prefix param descs
227     '''
228
229     url = ''
230     params = []
231     # the OSD command descriptors don't include the 'tell <target>', so
232     # tack it onto the front of sig
233     if flavor == 'tell':
234         tellsig = parse_funcsig(['tell',
235                                 {'name': 'target', 'type': 'CephOsdName'}])
236         sig = tellsig + sig
237
238     for desc in sig:
239         # prefixes go in the URL path
240         if desc.t == CephPrefix:
241             url += '/' + desc.instance.prefix
242         else:
243             # tell/<target> is a weird case; the URL includes what
244             # would everywhere else be a parameter
245             if flavor == 'tell' and ((desc.t, desc.name) ==
246                (CephOsdName, 'target')):
247                 url += '/<target>'
248             else:
249                 params.append(desc)
250
251     return app.ceph_baseurl + url, params
252
253
254 #
255 # end setup (import-time) functions, begin request-time functions
256 #
257 def concise_sig_for_uri(sig, flavor):
258     '''
259     Return a generic description of how one would send a REST request for sig
260     '''
261     prefix = []
262     args = []
263     ret = ''
264     if flavor == 'tell':
265         ret = 'tell/<osdid-or-pgid>/'
266     for d in sig:
267         if d.t == CephPrefix:
268             prefix.append(d.instance.prefix)
269         else:
270             args.append(d.name + '=' + str(d))
271     ret += '/'.join(prefix)
272     if args:
273         ret += '?' + '&'.join(args)
274     return ret
275
276
277 def show_human_help(prefix):
278     '''
279     Dump table showing commands matching prefix
280     '''
281     # XXX There ought to be a better discovery mechanism than an HTML table
282     s = '<html><body><table border=1><th>Possible commands:</th><th>Method</th><th>Description</th>'
283
284     permmap = {'r': 'GET', 'rw': 'PUT', 'rx': 'GET', 'rwx': 'PUT'}
285     line = ''
286     for cmdsig in sorted(app.ceph_sigdict.itervalues(), cmp=descsort):
287         concise = concise_sig(cmdsig['sig'])
288         flavor = cmdsig.get('flavor', 'mon')
289         if flavor == 'tell':
290             concise = 'tell/<target>/' + concise
291         if concise.startswith(prefix):
292             line = ['<tr><td>']
293             wrapped_sig = textwrap.wrap(
294                 concise_sig_for_uri(cmdsig['sig'], flavor), 40
295             )
296             for sigline in wrapped_sig:
297                 line.append(flask.escape(sigline) + '\n')
298             line.append('</td><td>')
299             line.append(permmap[cmdsig['perm']])
300             line.append('</td><td>')
301             line.append(flask.escape(cmdsig['help']))
302             line.append('</td></tr>\n')
303             s += ''.join(line)
304
305     s += '</table></body></html>'
306     if line:
307         return s
308     else:
309         return ''
310
311
312 @app.before_request
313 def log_request():
314     '''
315     For every request, log it.  XXX Probably overkill for production
316     '''
317     app.logger.info(flask.request.url + " from " + flask.request.remote_addr + " " + flask.request.user_agent.string)
318     app.logger.debug("Accept: %s", flask.request.accept_mimetypes.values())
319
320
321 @app.route('/')
322 def root_redir():
323     return flask.redirect(app.ceph_baseurl)
324
325
326 def make_response(fmt, output, statusmsg, errorcode):
327     '''
328     If formatted output, cobble up a response object that contains the
329     output and status wrapped in enclosing objects; if nonformatted, just
330     use output+status.  Return HTTP status errorcode in any event.
331     '''
332     response = output
333     if fmt:
334         if 'json' in fmt:
335             try:
336                 native_output = json.loads(output or '[]')
337                 response = json.dumps({"output": native_output,
338                                        "status": statusmsg})
339             except:
340                 return flask.make_response("Error decoding JSON from " +
341                                            output, 500)
342         elif 'xml' in fmt:
343             # XXX
344             # one is tempted to do this with xml.etree, but figuring out how
345             # to 'un-XML' the XML-dumped output so it can be reassembled into
346             # a piece of the tree here is beyond me right now.
347             # ET = xml.etree.ElementTree
348             # resp_elem = ET.Element('response')
349             # o = ET.SubElement(resp_elem, 'output')
350             # o.text = output
351             # s = ET.SubElement(resp_elem, 'status')
352             # s.text = statusmsg
353             # response = ET.tostring(resp_elem)
354             response = '''
355 <response>
356   <output>
357     {0}
358   </output>
359   <status>
360     {1}
361   </status>
362 </response>'''.format(response, xml.sax.saxutils.escape(statusmsg))
363     else:
364         if not 200 <= errorcode < 300:
365             response = response + '\n' + statusmsg + '\n'
366
367     return flask.make_response(response, errorcode)
368
369
370 def handler(catchall_path=None, fmt=None, target=None):
371     '''
372     Main endpoint handler; generic for every endpoint, including catchall.
373     Handles the catchall, anything with <.fmt>, anything with embedded
374     <target>.  Partial match or ?help cause the HTML-table
375     "show_human_help" output.
376     '''
377
378     ep = catchall_path or flask.request.endpoint
379     ep = ep.replace('.<fmt>', '')
380
381     if ep[0] != '/':
382         ep = '/' + ep
383
384     # demand that endpoint begin with app.ceph_baseurl
385     if not ep.startswith(app.ceph_baseurl):
386         return make_response(fmt, '', 'Page not found', 404)
387
388     rel_ep = ep[len(app.ceph_baseurl) + 1:]
389
390     # Extensions override Accept: headers override defaults
391     if not fmt:
392         if 'application/json' in flask.request.accept_mimetypes.values():
393             fmt = 'json'
394         elif 'application/xml' in flask.request.accept_mimetypes.values():
395             fmt = 'xml'
396
397     prefix = ''
398     pgid = None
399     cmdtarget = 'mon', ''
400
401     if target:
402         # got tell/<target>; validate osdid or pgid
403         name = CephOsdName()
404         pgidobj = CephPgid()
405         try:
406             name.valid(target)
407         except ArgumentError:
408             # try pgid
409             try:
410                 pgidobj.valid(target)
411             except ArgumentError:
412                 return flask.make_response("invalid osdid or pgid", 400)
413             else:
414                 # it's a pgid
415                 pgid = pgidobj.val
416                 cmdtarget = 'pg', pgid
417         else:
418             # it's an osd
419             cmdtarget = name.nametype, name.nameid
420
421         # prefix does not include tell/<target>/
422         prefix = ' '.join(rel_ep.split('/')[2:]).strip()
423     else:
424         # non-target command: prefix is entire path
425         prefix = ' '.join(rel_ep.split('/')).strip()
426
427     # show "match as much as you gave me" help for unknown endpoints
428     if ep not in app.ceph_urls:
429         helptext = show_human_help(prefix)
430         if helptext:
431             resp = flask.make_response(helptext, 400)
432             resp.headers['Content-Type'] = 'text/html'
433             return resp
434         else:
435             return make_response(fmt, '', 'Invalid endpoint ' + ep, 400)
436
437     found = None
438     exc = ''
439     for urldict in app.ceph_urls[ep]:
440         if flask.request.method not in urldict['methods']:
441             continue
442         paramsig = urldict['paramsig']
443
444         # allow '?help' for any specifically-known endpoint
445         if 'help' in flask.request.args:
446             response = flask.make_response('{0}: {1}'.
447                                            format(prefix +
448                                                   concise_sig(paramsig),
449                                                   urldict['help']))
450             response.headers['Content-Type'] = 'text/plain'
451             return response
452
453         # if there are parameters for this endpoint, process them
454         if paramsig:
455             args = {}
456             for k, l in flask.request.args.iterlists():
457                 if len(l) == 1:
458                     args[k] = l[0]
459                 else:
460                     args[k] = l
461
462             # is this a valid set of params?
463             try:
464                 argdict = validate(args, paramsig)
465                 found = urldict
466                 break
467             except Exception as e:
468                 exc += str(e)
469                 continue
470         else:
471             if flask.request.args:
472                 continue
473             found = urldict
474             argdict = {}
475             break
476
477     if not found:
478         return make_response(fmt, '', exc + '\n', 400)
479
480     argdict['format'] = fmt or 'plain'
481     argdict['module'] = found['module']
482     argdict['perm'] = found['perm']
483     if pgid:
484         argdict['pgid'] = pgid
485
486     if not cmdtarget:
487         cmdtarget = ('mon', '')
488
489     app.logger.debug('sending command prefix %s argdict %s', prefix, argdict)
490
491     for _ in range(DEFAULT_TRIES):
492         ret, outbuf, outs = json_command(app.ceph_cluster, prefix=prefix,
493                                          target=cmdtarget,
494                                          inbuf=flask.request.data,
495                                          argdict=argdict,
496                                          timeout=DEFAULT_TIMEOUT)
497         if ret != -errno.EINTR:
498             break
499     else:
500         return make_response(fmt, '',
501                              'Timedout: {0} ({1})'.format(outs, ret), 504)
502     if ret:
503         return make_response(fmt, '', 'Error: {0} ({1})'.format(outs, ret), 400)
504
505     response = make_response(fmt, outbuf, outs or 'OK', 200)
506     if fmt:
507         contenttype = 'application/' + fmt.replace('-pretty', '')
508     else:
509         contenttype = 'text/plain'
510     response.headers['Content-Type'] = contenttype
511     return response
512
513
514 #
515 # Main entry point from wrapper/WSGI server: call with cmdline args,
516 # get back the WSGI app entry point
517 #
518 def generate_app(conf, cluster, clientname, clientid, args):
519     addr, port = api_setup(app, conf, cluster, clientname, clientid, args)
520     app.ceph_addr = addr
521     app.ceph_port = port
522     return app