# pylint: disable=superfluous-parens
import atexit
import binascii
import collections
import datetime
import json
import jwt
import os
import platform
import signal
import stat
import subprocess
import sys
import tempfile
import time
import uuid
from .version import __version__
from .host_http_server import HostHttpServer
from .python_context import PythonContext
from .sqlite_context import SqliteContext
# Types of execution contexts provided by this Host
TYPES = {
'PythonContext': PythonContext,
'SqliteContext': SqliteContext
}
[docs]class Host(object):
"""
A `Host` allows you to create, get, run methods of, and delete instances of various types.
The types can be thought of a "services" provided by the host e.g. `PythonContext`,
`FilesystemStorer`
The API of a host is similar to that of a HTTP server. It's methods names
(e.g. `post`, `get`) are similar to HTTP methods (e.g. `POST`, `GET`) but
the sematics sometimes differ (e.g. a host's `put()` method is used to call an
instance method)
A `Host` is not limited to beng served by HTTP and it's methods are exposed by
`HostHttpServer`. Those other classes are responsible for
tasks associated with their communication protocol (e.g. serialising and deserialising objects).
This is a singleton class. There should only ever be one `Host`
in memory in each process (although, for purposes of testing, this is not enforced)
"""
def __init__(self):
self._id = 'py-host-%s' % uuid.uuid4()
if os.environ.get('STENCILA_AUTH') == 'false':
self._key = ''
else:
self._key = binascii.hexlify(os.urandom(64)).decode()
self._servers = {}
self._started = None
self._heartbeat = None
self._instances = {}
self._counts = {}
@property
def id(self):
"""
Get the identifier of the Host
:returns: An identification string
"""
return self._id
@property
def key(self):
"""
Get the seurity key for this Host
:returns: A key string
"""
return self._key
[docs] def user_dir(self):
"""
Get the current user's Stencila data directory.
This is the directory that Stencila configuration settings, such as the
installed Stencila hosts, and document buffers get stored.
:returns: A filesystem path
"""
osn = platform.system().lower()
if osn == 'darwin':
return os.path.join(os.getenv("HOME"), 'Library', 'Application Support', 'Stencila')
elif osn == 'linux':
return os.path.join(os.getenv("HOME"), '.stencila')
elif osn == 'windows':
return os.path.join(os.getenv("APPDATA"), 'Stencila')
else:
return os.path.join(os.getenv("HOME"), 'stencila')
[docs] def temp_dir(self):
"""
Get the current Stencila temporary directory.
:returns: A filesystem path
"""
return os.path.join(tempfile.gettempdir(), 'stencila')
[docs] def environs(self):
return [
{
"id": "local",
"name": "local",
"version": None
}
]
[docs] def types(self):
return { name: clas.spec for (name, clas) in TYPES.items() }
[docs] def manifest(self):
"""
Get a manifest for this host.
The manifest describes the host and it's capabilities. It is used
by peer hosts to determine which "types" this host provides and
which "instances" have already been instantiated.
:returns: A manifest object
"""
od = collections.OrderedDict
manifest = od([
('id', self._id),
('stencila', od([
('package', 'py'),
('version', __version__)
])),
('spawn', [sys.executable, '-m', 'stencila', 'spawn']),
('environs', self.environs()),
('types', self.types())
])
if self._started:
manifest.update([
('process', {'pid': os.getpid()}),
('servers', self.servers),
('instances', list(self._instances.keys()))
])
return manifest
[docs] def register(self):
"""
Registration of a host involves creating a file `py.json` inside of
the user's Stencila data (see `user_dir()`) directory which describes
the capabilities of this host.
"""
dir = os.path.join(self.user_dir(), 'hosts')
if not os.path.exists(dir):
os.makedirs(dir)
with open(os.path.join(dir, 'py.json'), 'w') as file:
file.write(json.dumps(self.manifest(), indent=True))
[docs] def startup(self, environ):
return [{"path": "/"}]
[docs] def shutdown(self, host):
return True
[docs] def create(self, type, args={}):
"""
Create a new instance of a type
:param type: Type of instance
:param args: Arguments to be passed to type constructor
:returns: Name of newly created instance
"""
Class = TYPES.get(type)
if Class:
try:
self._counts[type] += 1
except KeyError:
self._counts[type] = 1
number = self._counts[type]
name = '%s%s%d' % (type[:1].lower(), type[1:], number)
args['host'] = self
args['name'] = name
instance = Class(**args)
self._instances[name] = instance
return name
else:
raise Exception('Unknown type: %s' % type)
[docs] def get(self, name):
"""
Get an instance
:param name: Name of instance
:returns: The instance
"""
instance = self._instances.get(name)
if instance:
return instance
else:
raise Exception('Unknown instance: %s' % name)
[docs] def call(self, name, method, arg=None):
"""
Call a method of an instance
:param name: Name of instance
:param method: Name of instance method
:param kwargs: A dictionary of method arguments
:returns: Result of method call
"""
instance = self._instances.get(name)
if instance:
try:
func = getattr(instance, method)
except AttributeError:
raise Exception('Unknown method: %s' % method)
else:
return func(arg)
else:
raise Exception('Unknown instance: %s' % name)
[docs] def delete(self, name):
"""
Delete an instance
:param name: Name of instance
"""
if name in self._instances:
del self._instances[name]
else:
raise Exception('Unknown instance: %s' % name)
[docs] def start(self, address='127.0.0.1', port=2000, quiet=False):
"""
Start serving this host
Currently, HTTP is the only server available
for hosts. We plan to implement a `HostWebsocketServer` soon.
:returns: self
"""
if 'http' not in self._servers:
# Start HTTP server
server = HostHttpServer(self, address, port)
self._servers['http'] = server
server.start()
# Record start times
self._started = datetime.datetime.now()
self._heartbeat = datetime.datetime.now()
# Register as a running host by creating a run file
dir = os.path.join(self.temp_dir(), 'hosts')
if not os.path.exists(dir):
os.makedirs(dir)
# Write content to a secure file only readable by current user
# Based on https://stackoverflow.com/a/15015748/4625911
def write_secure(filename, content):
path = os.path.join(dir, filename)
# Remove any existing file with potentially elevated mode
if os.path.isfile(path):
os.remove(path)
# Create a file handle
mode = stat.S_IRUSR | stat.S_IWUSR # This is 0o600 in octal.
umask = 0o777 ^ mode # Prevents always downgrading umask to 0.
umask_original = os.umask(umask)
try:
handle = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
finally:
os.umask(umask_original)
# Open file handle and write to file
with os.fdopen(handle, 'w') as file:
file.write(content)
write_secure(self.id + '.json', json.dumps(self.manifest(), indent=True))
write_secure(self.id + '.key', self._key)
if not quiet:
print('Host has started:')
print(' Id: %s' % self._id)
print(' Key: %s' % self._key)
print(' URLs: %s' % self._servers['http'].url)
# On normal process exit, stop this host
atexit.register(self.stop)
[docs] def stop(self, quiet=False):
"""
Stop serving this host
:returns: self
"""
server = self._servers.get('http')
if server:
server.stop()
del self._servers['http']
# Deregister as a running host
for filename in [self.id + '.json', self.id + '.key']:
path = os.path.join(self.temp_dir(), 'hosts', filename)
if os.path.exists(path):
os.remove(path)
if not quiet:
print('Host has stopped')
[docs] def run(self, address='127.0.0.1', port=2000):
"""
Start serving this host and wait for connections
indefinitely
"""
self.start(address=address, port=port)
print('Use Ctrl+C to stop')
while True:
try:
time.sleep(1000)
except KeyboardInterrupt:
self.stop()
break
[docs] def spawn(self):
self.start(quiet=True)
print(json.dumps({
"id": self.id,
"key": self.key,
"manifest": self.manifest()
}, indent=True))
sys.stdout.flush()
# Handle signals if this process is killed somehow
# (e.g. by a parent peer)
def stop(signum, frame):
self.stop(quiet=True)
sys.exit(0)
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
while True:
time.sleep(1000)
@property
def servers(self):
"""
Get a list of servers for this host.
Currenty, only a `HostHttpServer` is implemented but in the
future onther servers for a host may be added (e.g. `HostWebsocketServer`)
:returns: A dictionary of server details
"""
servers = {}
for name in self._servers.keys():
server = self._servers[name]
servers[name] = {
'url': server.url
}
return servers
[docs] def generate_token(self, host=None):
"""
Generate a request token.
:returns: A JWT token string
"""
if host is None:
key = self.key
else:
# TODO Support token generation for peers based on held keys
raise RuntimeError('Generation of tokens for peer hosts is not yet supported')
now = datetime.datetime.utcnow()
payload = {
'iat': now,
'exp': now + datetime.timedelta(seconds=300),
'iss': self.id,
'jit': binascii.hexlify(os.urandom(32)).decode()
}
return jwt.encode(payload, key, algorithm='HS256').decode('utf-8')
[docs] def authorize_token(self, token):
"""
Authorize a request token.
Throws an error if the token is invalid.
:param token: A JWT token string
"""
payload = jwt.decode(token, self.key, algorithms=['HS256'])
# TODO Check and store `iss` and `jti` to prevent replay attacks
return payload
[docs] def view(self): # pragma: no cover
"""
View this host in the browser
Opens the default browser at the URL of this host
"""
self.start()
url = self._servers['http'].url
if platform.system() == 'Linux':
subprocess.call('2>/dev/null 1>&2 xdg-open "%s"' % url, shell=True)
else:
subprocess.call('open "%s"' % url, shell=True)
host = Host()