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.
 
 
 

279 lines
9.6 KiB

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