You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

264 lines
9.1 KiB

  1. """CherryPy-based server for running NILM filters via HTTP"""
  2. import cherrypy
  3. import sys
  4. import os
  5. import socket
  6. import simplejson as json
  7. import decorator
  8. import psutil
  9. import traceback
  10. import argparse
  11. import time
  12. import nilmdb
  13. from nilmdb.utils.printf import *
  14. from nilmdb.server.serverutil import (
  15. chunked_response,
  16. response_type,
  17. workaround_cp_bug_1200,
  18. exception_to_httperror,
  19. CORS_allow,
  20. json_to_request_params,
  21. json_error_page,
  22. cherrypy_start,
  23. cherrypy_stop,
  24. )
  25. import nilmrun
  26. import nilmrun.filters.trainola
  27. import nilmrun.filters.dummy
  28. # Add CORS_allow tool
  29. cherrypy.tools.CORS_allow = cherrypy.Tool('on_start_resource', CORS_allow)
  30. # CherryPy apps
  31. class App(object):
  32. """Root application for NILM runner"""
  33. def __init__(self):
  34. pass
  35. # /
  36. @cherrypy.expose
  37. def index(self):
  38. cherrypy.response.headers['Content-Type'] = 'text/plain'
  39. msg = sprintf("This is NilmRun version %s, running on host %s.\n",
  40. nilmrun.__version__, socket.getfqdn())
  41. return msg
  42. # /favicon.ico
  43. @cherrypy.expose
  44. def favicon_ico(self):
  45. raise cherrypy.NotFound()
  46. # /version
  47. @cherrypy.expose
  48. @cherrypy.tools.json_out()
  49. def version(self):
  50. return nilmrun.__version__
  51. class AppProcess(object):
  52. def __init__(self, manager):
  53. self.manager = manager
  54. def process_status(self, pid):
  55. return {
  56. "pid": pid,
  57. "alive": self.manager[pid].alive,
  58. "exitcode": self.manager[pid].exitcode,
  59. "name": self.manager[pid].name,
  60. "start_time": self.manager[pid].start_time,
  61. "parameters": self.manager[pid].parameters,
  62. "log": self.manager[pid].log,
  63. }
  64. # /process/status
  65. @cherrypy.expose
  66. @cherrypy.tools.json_out()
  67. def status(self, pid, clear = False):
  68. """Return status about a process. If clear = True, also clear
  69. the log."""
  70. if pid not in self.manager:
  71. raise cherrypy.HTTPError("404 Not Found", "No such PID")
  72. status = self.process_status(pid)
  73. if clear:
  74. self.manager[pid].clear_log()
  75. return status
  76. # /process/list
  77. @cherrypy.expose
  78. @cherrypy.tools.json_out()
  79. def list(self):
  80. """Return a list of processes in the manager."""
  81. return list(self.manager)
  82. # /process/remove
  83. @cherrypy.expose
  84. @cherrypy.tools.json_in()
  85. @cherrypy.tools.json_out()
  86. @cherrypy.tools.CORS_allow(methods = ["POST"])
  87. def remove(self, pid):
  88. """Remove a process from the manager, killing it if necessary."""
  89. if pid not in self.manager:
  90. raise cherrypy.HTTPError("404 Not Found", "No such PID")
  91. if not self.manager.terminate(pid): # pragma: no cover
  92. raise cherrypy.HTTPError("503 Service Unavailable",
  93. "Failed to stop process")
  94. status = self.process_status(pid)
  95. self.manager.remove(pid)
  96. return status
  97. class AppRun(object):
  98. def __init__(self, manager):
  99. self.manager = manager
  100. # /run/command
  101. @cherrypy.expose
  102. @cherrypy.tools.json_in()
  103. @cherrypy.tools.json_out()
  104. @cherrypy.tools.CORS_allow(methods = ["POST"])
  105. def command(self, args):
  106. """Execute an arbitrary program on the server. 'args' is the
  107. argument list, with 'args[0]' being the program and 'args[1]',
  108. 'args[2]', etc as arguments."""
  109. return self.manager.run_command("command", args)
  110. # /run/trainola
  111. @cherrypy.expose
  112. @cherrypy.tools.json_in()
  113. @cherrypy.tools.json_out()
  114. @exception_to_httperror(KeyError, ValueError)
  115. @cherrypy.tools.CORS_allow(methods = ["POST"])
  116. def trainola(self, data):
  117. return self.manager.run_function(
  118. "trainola", nilmrun.filters.trainola.filterfunc, data)
  119. # /run/dummy
  120. @cherrypy.expose
  121. @cherrypy.tools.json_in()
  122. @cherrypy.tools.json_out()
  123. @exception_to_httperror(KeyError, ValueError)
  124. @cherrypy.tools.CORS_allow(methods = ["POST"])
  125. def dummy(self, data):
  126. return self.manager.run_function(
  127. "dummy", nilmrun.filters.dummy.filterfunc, data)
  128. class Server(object):
  129. def __init__(self, host = '127.0.0.1', port = 8080,
  130. embedded = True, # hide diagnostics and output, etc
  131. force_traceback = False, # include traceback in all errors
  132. basepath = '', # base URL path for cherrypy.tree
  133. ):
  134. self.embedded = embedded
  135. # Build up global server configuration
  136. cherrypy.config.update({
  137. 'server.socket_host': host,
  138. 'server.socket_port': port,
  139. 'engine.autoreload_on': False,
  140. 'server.max_request_body_size': 8*1024*1024,
  141. })
  142. if self.embedded:
  143. cherrypy.config.update({ 'environment': 'embedded' })
  144. # Build up application specific configuration
  145. app_config = {}
  146. app_config.update({
  147. 'error_page.default': self.json_error_page,
  148. })
  149. # Some default headers to just help identify that things are working
  150. app_config.update({ 'response.headers.X-Jim-Is-Awesome': 'yeah' })
  151. # Set up Cross-Origin Resource Sharing (CORS) handler so we
  152. # can correctly respond to browsers' CORS preflight requests.
  153. # This also limits verbs to GET and HEAD by default.
  154. app_config.update({ 'tools.CORS_allow.on': True,
  155. 'tools.CORS_allow.methods': ['GET', 'HEAD'] })
  156. # Configure the 'json_in' tool to also allow other content-types
  157. # (like x-www-form-urlencoded), and to treat JSON as a dict that
  158. # fills requests.param.
  159. app_config.update({ 'tools.json_in.force': False,
  160. 'tools.json_in.processor': json_to_request_params })
  161. # Send tracebacks in error responses. They're hidden by the
  162. # error_page function for client errors (code 400-499).
  163. app_config.update({ 'request.show_tracebacks' : True })
  164. self.force_traceback = force_traceback
  165. # Patch CherryPy error handler to never pad out error messages.
  166. # This isn't necessary, but then again, neither is padding the
  167. # error messages.
  168. cherrypy._cperror._ie_friendly_error_sizes = {}
  169. # Build up the application and mount it
  170. manager = nilmrun.processmanager.ProcessManager()
  171. root = App()
  172. root.process = AppProcess(manager)
  173. root.run = AppRun(manager)
  174. cherrypy.tree.apps = {}
  175. cherrypy.tree.mount(root, basepath, config = { "/" : app_config })
  176. # Set up the WSGI application pointer for external programs
  177. self.wsgi_application = cherrypy.tree
  178. def json_error_page(self, status, message, traceback, version):
  179. """Return a custom error page in JSON so the client can parse it"""
  180. return json_error_page(status, message, traceback, version,
  181. self.force_traceback)
  182. def start(self, blocking = False, event = None):
  183. cherrypy_start(blocking, event, self.embedded)
  184. def stop(self):
  185. cherrypy_stop()
  186. # Multiple processes and threads should be OK here, but we'll still
  187. # follow the NilmDB approach of having just one globally initialized
  188. # copy of the server object.
  189. _wsgi_server = None
  190. def wsgi_application(basepath): # pragma: no cover
  191. """Return a WSGI application object.
  192. 'basepath' is the URL path of the application base, which
  193. is the same as the first argument to Apache's WSGIScriptAlias
  194. directive.
  195. """
  196. def application(environ, start_response):
  197. global _wsgi_server
  198. if _wsgi_server is None:
  199. # Try to start the server
  200. try:
  201. _wsgi_server = nilmrun.server.Server(
  202. embedded = True,
  203. basepath = basepath.rstrip('/'))
  204. except Exception:
  205. # Build an error message on failure
  206. import pprint
  207. err = sprintf("Initializing nilmrun failed:\n\n",
  208. dbpath)
  209. err += traceback.format_exc()
  210. try:
  211. import pwd
  212. import grp
  213. err += sprintf("\nRunning as: uid=%d (%s), gid=%d (%s) "
  214. "on host %s, pid %d\n",
  215. os.getuid(), pwd.getpwuid(os.getuid())[0],
  216. os.getgid(), grp.getgrgid(os.getgid())[0],
  217. socket.gethostname(), os.getpid())
  218. except ImportError:
  219. pass
  220. err += sprintf("\nEnvironment:\n%s\n", pprint.pformat(environ))
  221. if _wsgi_server is None:
  222. # Serve up the error with our own mini WSGI app.
  223. headers = [ ('Content-type', 'text/plain'),
  224. ('Content-length', str(len(err))) ]
  225. start_response("500 Internal Server Error", headers)
  226. return [err]
  227. # Call the normal application
  228. return _wsgi_server.wsgi_application(environ, start_response)
  229. return application