Files
the-littlest-jupyterhub/tljh/installer.py
2018-12-12 14:45:39 -05:00

446 lines
15 KiB
Python

"""Installation logic for TLJH"""
import argparse
import itertools
import logging
import os
import secrets
import subprocess
import sys
import time
from urllib.error import HTTPError
from urllib.request import urlopen, URLError
import pluggy
from tljh import (
apt,
conda,
hooks,
migrator,
systemd,
traefik,
user,
)
from .config import (
CONFIG_DIR,
CONFIG_FILE,
HUB_ENV_PREFIX,
INSTALL_PREFIX,
STATE_DIR,
USER_ENV_PREFIX,
)
from .yaml import yaml
HERE = os.path.abspath(os.path.dirname(__file__))
logger = logging.getLogger("tljh")
def ensure_node():
"""
Ensure nodejs from nodesource is installed
"""
key = b"""
-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: GnuPG v1
Comment: GPGTools - https://gpgtools.org
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
=CLGF
-----END PGP PUBLIC KEY BLOCK-----
""".strip()
apt.trust_gpg_key(key)
apt.add_source('nodesource', 'https://deb.nodesource.com/node_10.x', 'main')
apt.install_packages(['nodejs'])
def ensure_chp_package(prefix):
"""
Ensure CHP is installed
"""
if not os.path.exists(os.path.join(prefix, 'node_modules', '.bin', 'configurable-http-proxy')):
subprocess.check_output([
'npm', 'install', 'configurable-http-proxy@3.1.0'
], cwd=prefix, stderr=subprocess.STDOUT)
def ensure_jupyterhub_service(prefix):
"""
Ensure JupyterHub Services are set up properly
"""
os.makedirs(STATE_DIR, mode=0o700, exist_ok=True)
with open(os.path.join(HERE, 'systemd-units', 'jupyterhub.service')) as f:
hub_unit_template = f.read()
with open(os.path.join(HERE, 'systemd-units', 'configurable-http-proxy.service')) as f:
proxy_unit_template = f.read()
with open(os.path.join(HERE, 'systemd-units', 'traefik.service')) as f:
traefik_unit_template = f.read()
traefik.ensure_traefik_config(STATE_DIR)
unit_params = dict(
python_interpreter_path=sys.executable,
jupyterhub_config_path=os.path.join(HERE, 'jupyterhub_config.py'),
install_prefix=INSTALL_PREFIX,
)
systemd.install_unit('configurable-http-proxy.service', proxy_unit_template.format(**unit_params))
systemd.install_unit('jupyterhub.service', hub_unit_template.format(**unit_params))
systemd.install_unit('traefik.service', traefik_unit_template.format(**unit_params))
systemd.reload_daemon()
# Set up proxy / hub secret oken if it is not already setup
proxy_secret_path = os.path.join(STATE_DIR, 'configurable-http-proxy.secret')
if not os.path.exists(proxy_secret_path):
with open(proxy_secret_path, 'w') as f:
f.write('CONFIGPROXY_AUTH_TOKEN=' + secrets.token_hex(32))
# If we are changing CONFIGPROXY_AUTH_TOKEN, restart configurable-http-proxy!
systemd.restart_service('configurable-http-proxy')
# Start CHP if it has already not been started
systemd.start_service('configurable-http-proxy')
# If JupyterHub is running, we want to restart it.
systemd.restart_service('jupyterhub')
systemd.restart_service('traefik')
# Mark JupyterHub & CHP to start at boot time
systemd.enable_service('jupyterhub')
systemd.enable_service('configurable-http-proxy')
systemd.enable_service('traefik')
def ensure_jupyterlab_extensions():
"""
Install the JupyterLab extensions we want.
"""
extensions = [
'@jupyterlab/hub-extension'
]
subprocess.check_output([
os.path.join(USER_ENV_PREFIX, 'bin/jupyter'),
'labextension',
'install'
] + extensions)
def ensure_jupyterhub_package(prefix):
"""
Install JupyterHub into our conda environment if needed.
We install all python packages from PyPI as much as possible in the
hub environment. A lot of spawners & authenticators do not have conda-forge
packages, but do have pip packages. Keeping all python packages in the
hub environment be installed with pip prevents accidental mixing of python
and conda packages!
"""
conda.ensure_pip_packages(prefix, [
'jupyterhub==0.9.4',
'jupyterhub-dummyauthenticator==0.3.1',
'jupyterhub-systemdspawner==0.11',
'jupyterhub-firstuseauthenticator==0.11',
'jupyterhub-ldapauthenticator==1.2.2',
'oauthenticator==0.8.0',
])
traefik.ensure_traefik_binary(prefix)
def ensure_usergroups():
"""
Sets up user groups & sudo rules
"""
user.ensure_group('jupyterhub-admins')
user.ensure_group('jupyterhub-users')
logger.info("Granting passwordless sudo to JupyterHub admins...")
with open('/etc/sudoers.d/jupyterhub-admins', 'w') as f:
# JupyterHub admins should have full passwordless sudo access
f.write('%jupyterhub-admins ALL = (ALL) NOPASSWD: ALL\n')
# `sudo -E` should preserve the $PATH we set. This allows
# admins in jupyter terminals to do `sudo -E pip install <package>`,
# `pip` is in the $PATH we set in jupyterhub_config.py to include the user conda env.
f.write('Defaults exempt_group = jupyterhub-admins\n')
def ensure_user_environment(user_requirements_txt_file):
"""
Set up user conda environment with required packages
"""
logger.info("Setting up user environment...")
miniconda_version = '4.5.4'
miniconda_installer_md5 = "a946ea1d0c4a642ddf0c3a26a18bb16d"
if not conda.check_miniconda_version(USER_ENV_PREFIX, miniconda_version):
logger.info('Downloading & setting up user environment...')
with conda.download_miniconda_installer(miniconda_version, miniconda_installer_md5) as installer_path:
conda.install_miniconda(installer_path, USER_ENV_PREFIX)
# nbresuse needs psutil, which requires gcc
apt.install_packages([
'gcc'
])
conda.ensure_conda_packages(USER_ENV_PREFIX, [
# Conda's latest version is on conda much more so than on PyPI.
'conda==4.5.8'
])
conda.ensure_pip_packages(USER_ENV_PREFIX, [
# JupyterHub + notebook package are base requirements for user environment
'jupyterhub==0.9.4',
'notebook==5.7.0',
# Install additional notebook frontends!
'jupyterlab==0.35.3',
'nteract-on-jupyter==1.9.12',
# nbgitpuller for easily pulling in Git repositories
'nbgitpuller==0.6.1',
# nbresuse to show people how much RAM they are using
'nbresuse==0.3.0'
])
if user_requirements_txt_file:
# FIXME: This currently fails hard, should fail soft and not abort installer
conda.ensure_pip_requirements(USER_ENV_PREFIX, user_requirements_txt_file)
def ensure_admins(admins):
"""
Setup given list of users as admins.
"""
if not admins:
return
logger.info("Setting up admin users")
config_path = CONFIG_FILE
if os.path.exists(config_path):
with open(config_path, 'r') as f:
config = yaml.load(f)
else:
config = {}
config['users'] = config.get('users', {})
config['users']['admin'] = list(admins)
with open(config_path, 'w+') as f:
yaml.dump(config, f)
def ensure_jupyterhub_running(times=4):
"""
Ensure that JupyterHub is up and running
Loops given number of times, waiting a second each.
"""
for i in range(times):
try:
logger.info('Waiting for JupyterHub to come up ({}/{} tries)'.format(i + 1, times))
urlopen('http://127.0.0.1')
return
except HTTPError as h:
if h.code in [404, 502, 503]:
# May be transient
time.sleep(1)
continue
# Everything else should immediately abort
raise
except URLError as e:
if isinstance(e.reason, ConnectionRefusedError):
# Hub isn't up yet, sleep & loop
time.sleep(1)
continue
# Everything else should immediately abort
raise
raise Exception("Installation failed: JupyterHub did not start in {}s".format(times))
def ensure_symlinks(prefix):
"""
Ensure we symlink appropriate things into /usr/bin
We add the user conda environment to PATH for notebook terminals,
but not the hub venv. This means tljh-config is not actually accessible.
We symlink to /usr/bin and not /usr/local/bin, since /usr/local/bin is
not place, and works with sudo -E in sudo's search $PATH. We can work
around this with sudo -E and extra entries in the sudoers file, but this
is far more secure at the cost of upsetting some FHS purists.
"""
tljh_config_src = os.path.join(prefix, 'bin', 'tljh-config')
tljh_config_dest = '/usr/bin/tljh-config'
if os.path.exists(tljh_config_dest):
if os.path.realpath(tljh_config_dest) != tljh_config_src:
# tljh-config exists that isn't ours. We should *not* delete this file,
# instead we throw an error and abort. Deleting files owned by other people
# while running as root is dangerous, especially with symlinks involved.
raise FileExistsError(f'/usr/bin/tljh-config exists but is not a symlink to {tljh_config_src}')
else:
# We have a working symlink, so do nothing
return
os.symlink(tljh_config_src, tljh_config_dest)
def setup_plugins(plugins=None):
"""
Install plugins & setup a pluginmanager
"""
# Install plugins
if plugins:
conda.ensure_pip_packages(HUB_ENV_PREFIX, plugins)
# Set up plugin infrastructure
pm = pluggy.PluginManager('tljh')
pm.add_hookspecs(hooks)
pm.load_setuptools_entrypoints('tljh')
return pm
def run_plugin_actions(plugin_manager, plugins):
"""
Run installer hooks defined in plugins
"""
hook = plugin_manager.hook
# Install apt packages
apt_packages = list(set(itertools.chain(*hook.tljh_extra_apt_packages())))
if apt_packages:
logger.info('Installing {} apt packages collected from plugins: {}'.format(
len(apt_packages), ' '.join(apt_packages)
))
apt.install_packages(apt_packages)
# Install conda packages
conda_packages = list(set(itertools.chain(*hook.tljh_extra_user_conda_packages())))
if conda_packages:
logger.info('Installing {} conda packages collected from plugins: {}'.format(
len(conda_packages), ' '.join(conda_packages)
))
conda.ensure_conda_packages(USER_ENV_PREFIX, conda_packages)
# Install pip packages
pip_packages = list(set(itertools.chain(*hook.tljh_extra_user_pip_packages())))
if pip_packages:
logger.info('Installing {} pip packages collected from plugins: {}'.format(
len(pip_packages), ' '.join(pip_packages)
))
conda.ensure_pip_packages(USER_ENV_PREFIX, pip_packages)
def ensure_config_yaml(plugin_manager):
"""
Ensure we have a config.yaml present
"""
# ensure config dir exists and is private
for path in [CONFIG_DIR, os.path.join(CONFIG_DIR, 'jupyterhub_config.d')]:
os.makedirs(path, mode=0o700, exist_ok=True)
migrator.migrate_config_files()
if os.path.exists(CONFIG_FILE):
with open(CONFIG_FILE, 'r') as f:
config = yaml.load(f)
else:
config = {}
hook = plugin_manager.hook
hook.tljh_config_post_install(config=config)
with open(CONFIG_FILE, 'w+') as f:
yaml.dump(config, f)
def main():
from .log import init_logging
init_logging()
argparser = argparse.ArgumentParser()
argparser.add_argument(
'--admin',
nargs='*',
help='List of usernames set to be admin'
)
argparser.add_argument(
'--user-requirements-txt-url',
help='URL to a requirements.txt file that should be installed in the user enviornment'
)
argparser.add_argument(
'--plugin',
nargs='*',
help='Plugin pip-specs to install'
)
args = argparser.parse_args()
pm = setup_plugins(args.plugin)
ensure_config_yaml(pm)
ensure_admins(args.admin)
ensure_usergroups()
ensure_user_environment(args.user_requirements_txt_url)
logger.info("Setting up JupyterHub...")
ensure_node()
ensure_jupyterhub_package(HUB_ENV_PREFIX)
ensure_chp_package(HUB_ENV_PREFIX)
ensure_jupyterlab_extensions()
ensure_jupyterhub_service(HUB_ENV_PREFIX)
ensure_jupyterhub_running()
ensure_symlinks(HUB_ENV_PREFIX)
# Run installer plugins last
run_plugin_actions(pm, args.plugin)
logger.info("Done!")
if __name__ == '__main__':
main()