279 lines
9.6 KiB
Python
279 lines
9.6 KiB
Python
"""CherryPy-based server for running NILM filters via HTTP"""
|
|
|
|
import cherrypy
|
|
import os
|
|
import socket
|
|
import traceback
|
|
|
|
from nilmdb.utils.printf import sprintf
|
|
from nilmdb.server.serverutil import (
|
|
exception_to_httperror,
|
|
CORS_allow,
|
|
json_to_request_params,
|
|
json_error_page,
|
|
cherrypy_start,
|
|
cherrypy_stop,
|
|
bool_param,
|
|
)
|
|
from nilmdb.utils import serializer_proxy
|
|
import nilmrun
|
|
import nilmrun.processmanager
|
|
|
|
# Add CORS_allow tool
|
|
cherrypy.tools.CORS_allow = cherrypy.Tool('on_start_resource', CORS_allow)
|
|
|
|
|
|
# CherryPy apps
|
|
class App(object):
|
|
"""Root application for NILM runner"""
|
|
|
|
def __init__(self):
|
|
pass
|
|
|
|
# /
|
|
@cherrypy.expose
|
|
def index(self):
|
|
cherrypy.response.headers['Content-Type'] = 'text/plain'
|
|
msg = sprintf("This is NilmRun version %s, running on host %s.\n",
|
|
nilmrun.__version__, socket.getfqdn())
|
|
return msg
|
|
|
|
# /favicon.ico
|
|
@cherrypy.expose
|
|
def favicon_ico(self):
|
|
raise cherrypy.NotFound()
|
|
|
|
# /version
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out()
|
|
def version(self):
|
|
return nilmrun.__version__
|
|
|
|
|
|
class AppProcess(object):
|
|
|
|
def __init__(self, manager):
|
|
self.manager = manager
|
|
|
|
def process_status(self, pid):
|
|
# We need to convert the log (which is bytes) to Unicode
|
|
# characters, in order to send it via JSON. Treat it as UTF-8
|
|
# but replace invalid characters with markers.
|
|
log = self.manager[pid].log.decode('utf-8', errors='replace')
|
|
return {
|
|
"pid": pid,
|
|
"alive": self.manager[pid].alive,
|
|
"exitcode": self.manager[pid].exitcode,
|
|
"start_time": self.manager[pid].start_time,
|
|
"log": log
|
|
}
|
|
|
|
# /process/status
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out()
|
|
def status(self, pid, clear=False):
|
|
"""Return status about a process. If clear = True, also clear
|
|
the log."""
|
|
clear = bool_param(clear)
|
|
if pid not in self.manager:
|
|
raise cherrypy.HTTPError("404 Not Found", "No such PID")
|
|
status = self.process_status(pid)
|
|
if clear:
|
|
self.manager[pid].clear_log()
|
|
return status
|
|
|
|
# /process/list
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out()
|
|
def list(self):
|
|
"""Return a list of processes in the manager."""
|
|
return list(self.manager)
|
|
|
|
# /process/info
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_out()
|
|
def info(self):
|
|
"""Return detailed CPU and memory info about the system and
|
|
all processes"""
|
|
return self.manager.get_info()
|
|
|
|
# /process/remove
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_in()
|
|
@cherrypy.tools.json_out()
|
|
@cherrypy.tools.CORS_allow(methods=["POST"])
|
|
def remove(self, pid):
|
|
"""Remove a process from the manager, killing it if necessary."""
|
|
if pid not in self.manager:
|
|
raise cherrypy.HTTPError("404 Not Found", "No such PID")
|
|
if not self.manager.terminate(pid): # pragma: no cover
|
|
raise cherrypy.HTTPError("503 Service Unavailable",
|
|
"Failed to stop process")
|
|
status = self.process_status(pid)
|
|
self.manager.remove(pid)
|
|
return status
|
|
|
|
|
|
class AppRun(object):
|
|
def __init__(self, manager):
|
|
self.manager = manager
|
|
|
|
# /run/command
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_in()
|
|
@cherrypy.tools.json_out()
|
|
@exception_to_httperror(nilmrun.processmanager.ProcessError)
|
|
@cherrypy.tools.CORS_allow(methods=["POST"])
|
|
def command(self, argv):
|
|
"""Execute an arbitrary program on the server. argv is a
|
|
list of the program and its arguments: 'argv[0]' is the program
|
|
and 'argv[1:]' are arguments"""
|
|
if not isinstance(argv, list):
|
|
raise cherrypy.HTTPError("400 Bad Request",
|
|
"argv must be a list of strings")
|
|
return self.manager.run_command(argv)
|
|
|
|
# /run/code
|
|
@cherrypy.expose
|
|
@cherrypy.tools.json_in()
|
|
@cherrypy.tools.json_out()
|
|
@exception_to_httperror(nilmrun.processmanager.ProcessError)
|
|
@cherrypy.tools.CORS_allow(methods=["POST"])
|
|
def code(self, code, args=None):
|
|
"""Execute arbitrary Python code. 'code' is a formatted string.
|
|
It will be run as if it were written into a Python file and
|
|
executed. 'args' is a list of strings, and they are passed
|
|
on the command line as additional arguments (i.e., they end up
|
|
in sys.argv[1:])"""
|
|
if args is None:
|
|
args = []
|
|
if not isinstance(args, list):
|
|
raise cherrypy.HTTPError("400 Bad Request",
|
|
"args must be a list of strings")
|
|
return self.manager.run_code(code, args)
|
|
|
|
|
|
class Server(object):
|
|
def __init__(self, host='127.0.0.1', port=8080,
|
|
force_traceback=False, # include traceback in all errors
|
|
basepath='', # base URL path for cherrypy.tree
|
|
):
|
|
|
|
# Build up global server configuration
|
|
cherrypy.config.update({
|
|
'environment': 'embedded',
|
|
'server.socket_host': host,
|
|
'server.socket_port': port,
|
|
'engine.autoreload_on': False,
|
|
'server.max_request_body_size': 8*1024*1024,
|
|
})
|
|
|
|
# Build up application specific configuration
|
|
app_config = {}
|
|
app_config.update({
|
|
'error_page.default': self.json_error_page,
|
|
})
|
|
|
|
# Some default headers to just help identify that things are working
|
|
app_config.update({'response.headers.X-Jim-Is-Awesome': 'yeah'})
|
|
|
|
# Set up Cross-Origin Resource Sharing (CORS) handler so we
|
|
# can correctly respond to browsers' CORS preflight requests.
|
|
# This also limits verbs to GET and HEAD by default.
|
|
app_config.update({
|
|
'tools.CORS_allow.on': True,
|
|
'tools.CORS_allow.methods': ['GET', 'HEAD']
|
|
})
|
|
|
|
# Configure the 'json_in' tool to also allow other content-types
|
|
# (like x-www-form-urlencoded), and to treat JSON as a dict that
|
|
# fills requests.param.
|
|
app_config.update({'tools.json_in.force': False,
|
|
'tools.json_in.processor': json_to_request_params})
|
|
|
|
# Send tracebacks in error responses. They're hidden by the
|
|
# error_page function for client errors (code 400-499).
|
|
app_config.update({'request.show_tracebacks': True})
|
|
self.force_traceback = force_traceback
|
|
|
|
# Patch CherryPy error handler to never pad out error messages.
|
|
# This isn't necessary, but then again, neither is padding the
|
|
# error messages.
|
|
cherrypy._cperror._ie_friendly_error_sizes = {}
|
|
|
|
# The manager maintains internal state and isn't necessarily
|
|
# thread-safe, so wrap it in the serializer.
|
|
manager = serializer_proxy(nilmrun.processmanager.ProcessManager)()
|
|
|
|
# Build up the application and mount it
|
|
self._manager = manager
|
|
root = App()
|
|
root.process = AppProcess(manager)
|
|
root.run = AppRun(manager)
|
|
cherrypy.tree.apps = {}
|
|
cherrypy.tree.mount(root, basepath, config={"/": app_config})
|
|
|
|
# Set up the WSGI application pointer for external programs
|
|
self.wsgi_application = cherrypy.tree
|
|
|
|
def json_error_page(self, status, message, traceback, version):
|
|
"""Return a custom error page in JSON so the client can parse it"""
|
|
return json_error_page(status, message, traceback, version,
|
|
self.force_traceback)
|
|
|
|
def start(self, blocking=False, event=None):
|
|
cherrypy_start(blocking, event)
|
|
|
|
def stop(self):
|
|
cherrypy_stop()
|
|
|
|
|
|
# Multiple processes and threads should be OK here, but we'll still
|
|
# follow the NilmDB approach of having just one globally initialized
|
|
# copy of the server object.
|
|
_wsgi_server = None
|
|
|
|
|
|
def wsgi_application(basepath): # pragma: no cover
|
|
"""Return a WSGI application object.
|
|
|
|
'basepath' is the URL path of the application base, which
|
|
is the same as the first argument to Apache's WSGIScriptAlias
|
|
directive.
|
|
"""
|
|
def application(environ, start_response):
|
|
global _wsgi_server
|
|
if _wsgi_server is None:
|
|
# Try to start the server
|
|
try:
|
|
_wsgi_server = nilmrun.server.Server(
|
|
basepath=basepath.rstrip('/'))
|
|
except Exception:
|
|
# Build an error message on failure
|
|
import pprint
|
|
err = "Initializing nilmrun failed:\n\n"
|
|
err += traceback.format_exc()
|
|
try:
|
|
import pwd
|
|
import grp
|
|
err += sprintf("\nRunning as: uid=%d (%s), gid=%d (%s) "
|
|
"on host %s, pid %d\n",
|
|
os.getuid(), pwd.getpwuid(os.getuid())[0],
|
|
os.getgid(), grp.getgrgid(os.getgid())[0],
|
|
socket.gethostname(), os.getpid())
|
|
except ImportError:
|
|
pass
|
|
err += sprintf("\nEnvironment:\n%s\n", pprint.pformat(environ))
|
|
if _wsgi_server is None:
|
|
# Serve up the error with our own mini WSGI app.
|
|
headers = [
|
|
('Content-type', 'text/plain'),
|
|
('Content-length', str(len(err)))
|
|
]
|
|
start_response("500 Internal Server Error", headers)
|
|
return [err]
|
|
|
|
# Call the normal application
|
|
return _wsgi_server.wsgi_application(environ, start_response)
|
|
return application
|