Merge pull request #266 from GeorgianaElena/replace_chp_traefik

Replace chp with traefik-proxy
This commit is contained in:
Min RK
2019-02-22 17:32:57 +01:00
committed by GitHub
18 changed files with 294 additions and 122 deletions

View File

@@ -108,7 +108,7 @@ def show_logs(container_name):
) )
run_container_command( run_container_command(
container_name, container_name,
'systemctl --no-pager status jupyterhub configurable-http-proxy' 'systemctl --no-pager status jupyterhub traefik'
) )
def main(): def main():

View File

@@ -34,10 +34,6 @@ async def test_user_code_execute():
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'auth.type', 'dummyauthenticator.DummyAuthenticator')).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'auth.type', 'dummyauthenticator.DummyAuthenticator')).wait()
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait()
# FIXME: wait for reload to finish & hub to come up
# Should be part of tljh-config reload
await asyncio.sleep(1)
async with User(username, hub_url, partial(login_dummy, password='')) as u: async with User(username, hub_url, partial(login_dummy, password='')) as u:
await u.login() await u.login()
await u.ensure_server() await u.ensure_server()
@@ -62,9 +58,6 @@ async def test_user_admin_add():
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'add-item', 'users.admin', username)).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'add-item', 'users.admin', username)).wait()
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait()
# FIXME: wait for reload to finish & hub to come up
# Should be part of tljh-config reload
await asyncio.sleep(1)
async with User(username, hub_url, partial(login_dummy, password='')) as u: async with User(username, hub_url, partial(login_dummy, password='')) as u:
await u.login() await u.login()
await u.ensure_server() await u.ensure_server()
@@ -94,9 +87,6 @@ async def test_user_admin_remove():
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'add-item', 'users.admin', username)).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'add-item', 'users.admin', username)).wait()
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait()
# FIXME: wait for reload to finish & hub to come up
# Should be part of tljh-config reload
await asyncio.sleep(1)
async with User(username, hub_url, partial(login_dummy, password='')) as u: async with User(username, hub_url, partial(login_dummy, password='')) as u:
await u.login() await u.login()
await u.ensure_server() await u.ensure_server()
@@ -107,16 +97,14 @@ async def test_user_admin_remove():
# Assert that the user has admin rights # Assert that the user has admin rights
assert f'jupyter-{username}' in grp.getgrnam('jupyterhub-admins').gr_mem assert f'jupyter-{username}' in grp.getgrnam('jupyterhub-admins').gr_mem
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'remove-item', 'users.admin', username)).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'remove-item', 'users.admin', username)).wait()
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait()
await asyncio.sleep(1)
await u.stop_server() await u.stop_server()
await u.ensure_server() await u.ensure_server()
# Assert that the user does *not* have admin rights # Assert that the user does *not* have admin rights
assert f'jupyter-{username}' in grp.getgrnam('jupyterhub-admins').gr_mem assert f'jupyter-{username}' not in grp.getgrnam('jupyterhub-admins').gr_mem
@pytest.mark.asyncio @pytest.mark.asyncio
@@ -132,9 +120,6 @@ async def test_long_username():
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'auth.type', 'dummyauthenticator.DummyAuthenticator')).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'set', 'auth.type', 'dummyauthenticator.DummyAuthenticator')).wait()
assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait() assert 0 == await (await asyncio.create_subprocess_exec(*TLJH_CONFIG_PATH, 'reload')).wait()
# FIXME: wait for reload to finish & hub to come up
# Should be part of tljh-config reload
await asyncio.sleep(1)
try: try:
async with User(username, hub_url, partial(login_dummy, password='')) as u: async with User(username, hub_url, partial(login_dummy, password='')) as u:
await u.login() await u.login()

View File

@@ -53,8 +53,13 @@ def test_manual_https(preserve_config):
# verify that our certificate was loaded by traefik # verify that our certificate was loaded by traefik
assert server_cert == file_cert assert server_cert == file_cert
for i in range(5):
time.sleep(i)
# verify that we can still connect to the hub # verify that we can still connect to the hub
r = requests.get("https://127.0.0.1/hub/api", verify=False) r = requests.get("https://127.0.0.1/hub/api", verify=False)
if r.status_code == 200:
break;
r.raise_for_status() r.raise_for_status()
# cleanup # cleanup

View File

@@ -13,7 +13,9 @@ setup(
install_requires=[ install_requires=[
'ruamel.yaml==0.15.*', 'ruamel.yaml==0.15.*',
'jinja2', 'jinja2',
'pluggy>0.7<1.0' 'pluggy>0.7<1.0',
'passlib',
'jupyterhub-traefik-proxy==0.1.*'
], ],
entry_points={ entry_points={
'console_scripts': [ 'console_scripts': [

View File

@@ -75,6 +75,7 @@ def test_add_to_config_zero_level():
'a': ['b'] 'a': ['b']
} }
def test_add_to_config_multiple(): def test_add_to_config_multiple():
conf = {} conf = {}
@@ -116,16 +117,21 @@ def test_remove_from_config_error():
def test_reload_hub(): def test_reload_hub():
with mock.patch('tljh.systemd.restart_service') as restart_service: with mock.patch('tljh.systemd.restart_service') as restart_service, mock.patch(
'tljh.systemd.check_service_active'
) as check_active, mock.patch('tljh.config.check_hub_ready') as check_ready:
config.reload_component('hub') config.reload_component('hub')
assert restart_service.called_with('jupyterhub') assert restart_service.called_with('jupyterhub')
assert check_active.called_with('jupyterhub')
def test_reload_proxy(tljh_dir): def test_reload_proxy(tljh_dir):
with mock.patch('tljh.systemd.restart_service') as restart_service: with mock.patch("tljh.systemd.restart_service") as restart_service, mock.patch(
"tljh.systemd.check_service_active"
) as check_active:
config.reload_component('proxy') config.reload_component('proxy')
assert restart_service.called_with('configurable-http-proxy')
assert restart_service.called_with('traefik') assert restart_service.called_with('traefik')
assert check_active.called_with('traefik')
assert os.path.exists(os.path.join(config.STATE_DIR, 'traefik.toml')) assert os.path.exists(os.path.join(config.STATE_DIR, 'traefik.toml'))
@@ -140,8 +146,8 @@ def test_cli_no_command(capsys):
"arg, value", "arg, value",
[ [
("true", True), ("true", True),
("FALSE", False), ("FALSE", False)
], ]
) )
def test_cli_set_bool(tljh_dir, arg, value): def test_cli_set_bool(tljh_dir, arg, value):
config.main(["set", "https.enabled", arg]) config.main(["set", "https.enabled", arg])

View File

@@ -1,7 +1,9 @@
""" """
Test Test configurer
""" """
import os
from tljh import configurer from tljh import configurer
@@ -161,6 +163,43 @@ def test_auth_github():
assert c.GitHubOAuthenticator.client_secret == 'something-else' assert c.GitHubOAuthenticator.client_secret == 'something-else'
def test_traefik_api_default():
"""
Test default traefik api authentication settings with no overrides
"""
c = apply_mock_config({})
assert c.TraefikTomlProxy.traefik_api_username == 'api_admin'
assert len(c.TraefikTomlProxy.traefik_api_password) == 0
def test_set_traefik_api():
"""
Test setting per traefik api credentials
"""
c = apply_mock_config({
'traefik_api': {
'username': 'some_user',
'password': '1234'
}
})
assert c.TraefikTomlProxy.traefik_api_username == 'some_user'
assert c.TraefikTomlProxy.traefik_api_password == '1234'
def test_load_secrets(tljh_dir):
"""
Test loading secret files
"""
with open(os.path.join(tljh_dir, 'state', 'traefik-api.secret'), 'w') as f:
f.write("traefik-password")
tljh_config = configurer.load_config()
assert tljh_config['traefik_api']['password'] == "traefik-password"
c = apply_mock_config(tljh_config)
assert c.TraefikTomlProxy.traefik_api_password == "traefik-password"
def test_auth_native(): def test_auth_native():
""" """
Test setting Native Authenticator Test setting Native Authenticator
@@ -175,3 +214,4 @@ def test_auth_native():
}) })
assert c.JupyterHub.authenticator_class == 'nativeauthenticator.NativeAuthenticator' assert c.JupyterHub.authenticator_class == 'nativeauthenticator.NativeAuthenticator'
assert c.NativeAuthenticator.open_signup == True assert c.NativeAuthenticator.open_signup == True

View File

@@ -1,5 +1,6 @@
"""Test traefik configuration""" """Test traefik configuration"""
import os import os
from unittest import mock
import pytoml as toml import pytoml as toml
@@ -27,12 +28,19 @@ def test_default_config(tmpdir, tljh_dir):
print(toml_cfg) print(toml_cfg)
cfg = toml.loads(toml_cfg) cfg = toml.loads(toml_cfg)
assert cfg["defaultEntryPoints"] == ["http"] assert cfg["defaultEntryPoints"] == ["http"]
assert cfg["entryPoints"] == {"http": {"address": ":80"}} assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1
assert cfg["frontends"] == { # runtime generated entry, value not testable
"jupyterhub": {"backend": "jupyterhub", "passHostHeader": True} cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""]
}
assert cfg["backends"] == { assert cfg["entryPoints"] == {
"jupyterhub": {"servers": {"chp": {"url": "http://127.0.0.1:15003"}}} "http": {"address": ":80"},
"auth_api": {
"address": "127.0.0.1:8099",
"auth": {
"basic": {"users": [""]}
},
"whiteList": {"sourceRange": ["127.0.0.1"]}
},
} }
@@ -55,9 +63,20 @@ def test_letsencrypt_config(tljh_dir):
cfg = toml.loads(toml_cfg) cfg = toml.loads(toml_cfg)
assert cfg["defaultEntryPoints"] == ["http", "https"] assert cfg["defaultEntryPoints"] == ["http", "https"]
assert "acme" in cfg assert "acme" in cfg
assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1
# runtime generated entry, value not testable
cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""]
assert cfg["entryPoints"] == { assert cfg["entryPoints"] == {
"http": {"address": ":80", "redirect": {"entryPoint": "https"}}, "http": {"address": ":80", "redirect": {"entryPoint": "https"}},
"https": {"address": ":443", "backend": "jupyterhub", "tls": {}}, "https": {"address": ":443", "tls": {}},
"auth_api": {
"address": "127.0.0.1:8099",
"auth": {
"basic": {"users": [""]}
},
"whiteList": {"sourceRange": ["127.0.0.1"]}
},
} }
assert cfg["acme"] == { assert cfg["acme"] == {
"email": "fake@jupyter.org", "email": "fake@jupyter.org",
@@ -83,15 +102,24 @@ def test_manual_ssl_config(tljh_dir):
cfg = toml.loads(toml_cfg) cfg = toml.loads(toml_cfg)
assert cfg["defaultEntryPoints"] == ["http", "https"] assert cfg["defaultEntryPoints"] == ["http", "https"]
assert "acme" not in cfg assert "acme" not in cfg
assert len(cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"]) == 1
# runtime generated entry, value not testable
cfg["entryPoints"]["auth_api"]["auth"]["basic"]["users"] = [""]
assert cfg["entryPoints"] == { assert cfg["entryPoints"] == {
"http": {"address": ":80", "redirect": {"entryPoint": "https"}}, "http": {"address": ":80", "redirect": {"entryPoint": "https"}},
"https": { "https": {
"address": ":443", "address": ":443",
"backend": "jupyterhub",
"tls": { "tls": {
"certificates": [ "certificates": [
{"certFile": "/path/to/ssl.cert", "keyFile": "/path/to/ssl.key"} {"certFile": "/path/to/ssl.cert", "keyFile": "/path/to/ssl.key"}
] ]
}, },
}, },
"auth_api": {
"address": "127.0.0.1:8099",
"auth": {
"basic": {"users": [""]}
},
"whiteList": {"sourceRange": ["127.0.0.1"]}
},
} }

View File

@@ -13,11 +13,15 @@ tljh-config show firstlevel.second_level
""" """
import argparse import argparse
import asyncio
from collections import Sequence, Mapping from collections import Sequence, Mapping
from copy import deepcopy from copy import deepcopy
import os import os
import re import re
import sys import sys
import time
import requests
from .yaml import yaml from .yaml import yaml
@@ -85,7 +89,7 @@ def add_item_to_config(config, property_path, value):
def remove_item_from_config(config, property_path, value): def remove_item_from_config(config, property_path, value):
""" """
Add an item to a list in config. Remove an item from a list in config.
""" """
path_components = property_path.split('.') path_components = property_path.split('.')
@@ -172,6 +176,12 @@ def remove_config_value(config_path, key_path, value):
with open(config_path, 'w') as f: with open(config_path, 'w') as f:
yaml.dump(config, f) yaml.dump(config, f)
def check_hub_ready():
try:
r = requests.get('http://127.0.0.1:80', verify=False)
return r.status_code == 200
except:
return False
def reload_component(component): def reload_component(component):
""" """
@@ -181,14 +191,20 @@ def reload_component(component):
""" """
# import here to avoid circular imports # import here to avoid circular imports
from tljh import systemd, traefik from tljh import systemd, traefik
if component == 'hub': if component == 'hub':
systemd.restart_service('jupyterhub') systemd.restart_service('jupyterhub')
# FIXME: Verify hub is back up? # Ensure hub is back up
while not systemd.check_service_active('jupyterhub'):
time.sleep(1)
while not check_hub_ready():
time.sleep(1)
print('Hub reload with new configuration complete') print('Hub reload with new configuration complete')
elif component == 'proxy': elif component == 'proxy':
traefik.ensure_traefik_config(STATE_DIR) traefik.ensure_traefik_config(STATE_DIR)
systemd.restart_service('configurable-http-proxy')
systemd.restart_service('traefik') systemd.restart_service('traefik')
while not systemd.check_service_active('traefik'):
time.sleep(1)
print('Proxy reload with new configuration complete') print('Proxy reload with new configuration complete')

View File

@@ -10,7 +10,7 @@ FIXME: A strong feeling that JSON Schema should be involved somehow.
import os import os
from .config import CONFIG_FILE from .config import CONFIG_FILE, STATE_DIR
from .yaml import yaml from .yaml import yaml
# Default configuration for tljh # Default configuration for tljh
@@ -46,12 +46,17 @@ default = {
'domains': [], 'domains': [],
}, },
}, },
'traefik_api': {
'ip': "127.0.0.1",
'port': 8099,
'username': 'api_admin',
'password': '',
},
'user_environment': { 'user_environment': {
'default_app': 'classic', 'default_app': 'classic',
}, },
} }
def load_config(config_file=CONFIG_FILE): def load_config(config_file=CONFIG_FILE):
"""Load the current config as a dictionary """Load the current config as a dictionary
@@ -62,7 +67,11 @@ def load_config(config_file=CONFIG_FILE):
config_overrides = yaml.load(f) config_overrides = yaml.load(f)
else: else:
config_overrides = {} config_overrides = {}
return _merge_dictionaries(dict(default), config_overrides)
secrets = load_secrets()
config = _merge_dictionaries(dict(default), secrets)
config = _merge_dictionaries(config, config_overrides)
return config
def apply_config(config_overrides, c): def apply_config(config_overrides, c):
@@ -76,6 +85,7 @@ def apply_config(config_overrides, c):
update_limits(c, tljh_config) update_limits(c, tljh_config)
update_user_environment(c, tljh_config) update_user_environment(c, tljh_config)
update_user_account_config(c, tljh_config) update_user_account_config(c, tljh_config)
update_traefik_api(c, tljh_config)
def set_if_not_none(parent, key, value): def set_if_not_none(parent, key, value):
@@ -86,6 +96,30 @@ def set_if_not_none(parent, key, value):
setattr(parent, key, value) setattr(parent, key, value)
def load_traefik_api_credentials():
"""Load traefik api secret from a file"""
proxy_secret_path = os.path.join(STATE_DIR, 'traefik-api.secret')
if not os.path.exists(proxy_secret_path):
return {}
with open(proxy_secret_path,'r') as f:
password = f.read()
return {
'traefik_api': {
'password': password,
}
}
def load_secrets():
"""Load any secret values stored on disk
Returns dict to be merged into config during load
"""
config = {}
config = _merge_dictionaries(config, load_traefik_api_credentials())
return config
def update_auth(c, config): def update_auth(c, config):
""" """
Set auth related configuration from YAML config file Set auth related configuration from YAML config file
@@ -149,6 +183,14 @@ def update_user_account_config(c, config):
c.SystemdSpawner.username_template = 'jupyter-{USERNAME}' c.SystemdSpawner.username_template = 'jupyter-{USERNAME}'
def update_traefik_api(c, config):
"""
Set traefik api endpoint credentials
"""
c.TraefikTomlProxy.traefik_api_username = config['traefik_api']['username']
c.TraefikTomlProxy.traefik_api_password = config['traefik_api']['password']
def _merge_dictionaries(a, b, path=None, update=True): def _merge_dictionaries(a, b, path=None, update=True):
""" """
Merge two dictionaries recursively. Merge two dictionaries recursively.

View File

@@ -99,15 +99,25 @@ sckuXINIU3DFWzZGr0QrqkuE/jyr7FXeUJj9B7cLo+s/TXo+RaVfi3kOc9BoxIvy
apt.add_source('nodesource', 'https://deb.nodesource.com/node_10.x', 'main') apt.add_source('nodesource', 'https://deb.nodesource.com/node_10.x', 'main')
apt.install_packages(['nodejs']) apt.install_packages(['nodejs'])
def remove_chp():
def ensure_chp_package(prefix):
""" """
Ensure CHP is installed Ensure CHP is not running
""" """
if not os.path.exists(os.path.join(prefix, 'node_modules', '.bin', 'configurable-http-proxy')): if os.path.exists("/etc/systemd/system/configurable-http-proxy.service"):
subprocess.check_output([ if systemd.check_service_active('configurable-http-proxy.service'):
'npm', 'install', 'configurable-http-proxy@3.1.0' try:
], cwd=prefix, stderr=subprocess.STDOUT) systemd.stop_service('configurable-http-proxy.service')
except subprocess.CalledProcessError:
logger.info("Cannot stop configurable-http-proxy...")
if systemd.check_service_enabled('configurable-http-proxy.service'):
try:
systemd.disable_service('configurable-http-proxy.service')
except subprocess.CalledProcessError:
logger.info("Cannot disable configurable-http-proxy...")
try:
systemd.uninstall_unit('configurable-http-proxy.service')
except subprocess.CalledProcessError:
logger.info("Cannot uninstall configurable-http-proxy...")
def ensure_jupyterhub_service(prefix): def ensure_jupyterhub_service(prefix):
@@ -117,15 +127,22 @@ def ensure_jupyterhub_service(prefix):
os.makedirs(STATE_DIR, mode=0o700, exist_ok=True) os.makedirs(STATE_DIR, mode=0o700, exist_ok=True)
remove_chp()
systemd.reload_daemon()
with open(os.path.join(HERE, 'systemd-units', 'jupyterhub.service')) as f: with open(os.path.join(HERE, 'systemd-units', 'jupyterhub.service')) as f:
hub_unit_template = f.read() 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: with open(os.path.join(HERE, 'systemd-units', 'traefik.service')) as f:
traefik_unit_template = f.read() traefik_unit_template = f.read()
#Set up proxy / hub secret token if it is not already setup
proxy_secret_path = os.path.join(STATE_DIR, 'traefik-api.secret')
if not os.path.exists(proxy_secret_path):
with open(proxy_secret_path, 'w') as f:
f.write(secrets.token_hex(32))
traefik.ensure_traefik_config(STATE_DIR) traefik.ensure_traefik_config(STATE_DIR)
unit_params = dict( unit_params = dict(
@@ -133,28 +150,16 @@ def ensure_jupyterhub_service(prefix):
jupyterhub_config_path=os.path.join(HERE, 'jupyterhub_config.py'), jupyterhub_config_path=os.path.join(HERE, 'jupyterhub_config.py'),
install_prefix=INSTALL_PREFIX, 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('jupyterhub.service', hub_unit_template.format(**unit_params))
systemd.install_unit('traefik.service', traefik_unit_template.format(**unit_params)) systemd.install_unit('traefik.service', traefik_unit_template.format(**unit_params))
systemd.reload_daemon() 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. # If JupyterHub is running, we want to restart it.
systemd.restart_service('jupyterhub') systemd.restart_service('jupyterhub')
systemd.restart_service('traefik') systemd.restart_service('traefik')
# Mark JupyterHub & CHP to start at boot time # Mark JupyterHub & traefik to start at boot time
systemd.enable_service('jupyterhub') systemd.enable_service('jupyterhub')
systemd.enable_service('configurable-http-proxy')
systemd.enable_service('traefik') systemd.enable_service('traefik')
@@ -276,7 +281,7 @@ def ensure_admins(admins):
yaml.dump(config, f) yaml.dump(config, f)
def ensure_jupyterhub_running(times=4): def ensure_jupyterhub_running(times=20):
""" """
Ensure that JupyterHub is up and running Ensure that JupyterHub is up and running
@@ -433,7 +438,6 @@ def main():
logger.info("Setting up JupyterHub...") logger.info("Setting up JupyterHub...")
ensure_node() ensure_node()
ensure_jupyterhub_package(HUB_ENV_PREFIX) ensure_jupyterhub_package(HUB_ENV_PREFIX)
ensure_chp_package(HUB_ENV_PREFIX)
ensure_jupyterlab_extensions() ensure_jupyterlab_extensions()
ensure_jupyterhub_service(HUB_ENV_PREFIX) ensure_jupyterhub_service(HUB_ENV_PREFIX)
ensure_jupyterhub_running() ensure_jupyterhub_running()

View File

@@ -10,7 +10,7 @@ from tljh import configurer, user
from tljh.config import INSTALL_PREFIX, USER_ENV_PREFIX, CONFIG_DIR from tljh.config import INSTALL_PREFIX, USER_ENV_PREFIX, CONFIG_DIR
from tljh.normalize import generate_system_username from tljh.normalize import generate_system_username
from tljh.yaml import yaml from tljh.yaml import yaml
from jupyterhub_traefik_proxy import TraefikTomlProxy
class UserCreatingSpawner(SystemdSpawner): class UserCreatingSpawner(SystemdSpawner):
""" """
@@ -43,21 +43,19 @@ c.JupyterHub.cleanup_servers = False
# Use a high port so users can try this on machines with a JupyterHub already present # Use a high port so users can try this on machines with a JupyterHub already present
c.JupyterHub.hub_port = 15001 c.JupyterHub.hub_port = 15001
c.ConfigurableHTTPProxy.should_start = False c.TraefikTomlProxy.should_start = False
c.ConfigurableHTTPProxy.api_url = 'http://127.0.0.1:15002'
dynamic_conf_file_path = os.path.join(INSTALL_PREFIX, 'state', 'rules.toml')
c.TraefikTomlProxy.toml_dynamic_config_file = dynamic_conf_file_path
c.JupyterHub.proxy_class = TraefikTomlProxy
c.SystemdSpawner.extra_paths = [os.path.join(USER_ENV_PREFIX, 'bin')] c.SystemdSpawner.extra_paths = [os.path.join(USER_ENV_PREFIX, 'bin')]
c.SystemdSpawner.default_shell = '/bin/bash' c.SystemdSpawner.default_shell = '/bin/bash'
# Drop the '-singleuser' suffix present in the default template # Drop the '-singleuser' suffix present in the default template
c.SystemdSpawner.unit_name_template = 'jupyter-{USERNAME}' c.SystemdSpawner.unit_name_template = 'jupyter-{USERNAME}'
config_overrides_path = os.path.join(CONFIG_DIR, 'config.yaml') tljh_config = configurer.load_config()
if os.path.exists(config_overrides_path): configurer.apply_config(tljh_config, c)
with open(config_overrides_path) as f:
config_overrides = yaml.load(f)
else:
config_overrides = {}
configurer.apply_config(config_overrides, c)
# Load arbitrary .py config files if they exist. # Load arbitrary .py config files if they exist.
# This is our escape hatch # This is our escape hatch

View File

@@ -1,27 +0,0 @@
# Template file for Configurable HTTP Proxy systemd service
# Uses simple string.format() for 'templating'
[Unit]
# Wait for network stack to be fully up before starting CHP
After=network.target
[Service]
User=nobody
Restart=always
# chp process should have no write access anywhere on disk
ProtectHome=tmpfs
ProtectSystem=strict
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
EnvironmentFile={install_prefix}/state/configurable-http-proxy.secret
ExecStart={install_prefix}/hub/node_modules/.bin/configurable-http-proxy \
--ip 127.0.0.1 \
--port 15003 \
--api-ip 127.0.0.1 \
--api-port 15002 \
--error-target http://127.0.0.1:15001/hub/error
[Install]
# Start service when system boots
WantedBy=multi-user.target

View File

@@ -1,9 +1,9 @@
# Template file for JupyterHub systemd service # Template file for JupyterHub systemd service
# Uses simple string.format() for 'templating' # Uses simple string.format() for 'templating'
[Unit] [Unit]
# CHP must have successfully started *before* we launch JupyterHub # Traefik must have successfully started *before* we launch JupyterHub
Requires=configurable-http-proxy.service Requires=traefik.service
After=configurable-http-proxy.service After=traefik.service
[Service] [Service]
User=root User=root
@@ -16,8 +16,6 @@ PrivateTmp=yes
PrivateDevices=yes PrivateDevices=yes
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
# Source CONFIGPROXY_AUTH_TOKEN from here!
EnvironmentFile={install_prefix}/state/configurable-http-proxy.secret
Environment=TLJH_INSTALL_PREFIX={install_prefix} Environment=TLJH_INSTALL_PREFIX={install_prefix}
ExecStart={python_interpreter_path} -m jupyterhub.app -f {jupyterhub_config_path} ExecStart={python_interpreter_path} -m jupyterhub.app -f {jupyterhub_config_path}

View File

@@ -7,13 +7,13 @@ After=network.target
[Service] [Service]
User=root User=root
Restart=always Restart=always
# process only needs to write state/acme.json file, no other files
ProtectHome=tmpfs ProtectHome=tmpfs
ProtectSystem=strict ProtectSystem=strict
PrivateTmp=yes PrivateTmp=yes
PrivateDevices=yes PrivateDevices=yes
ProtectKernelTunables=yes ProtectKernelTunables=yes
ProtectKernelModules=yes ProtectKernelModules=yes
ReadWritePaths={install_prefix}/state/rules.toml
ReadWritePaths={install_prefix}/state/acme.json ReadWritePaths={install_prefix}/state/acme.json
WorkingDirectory={install_prefix}/state WorkingDirectory={install_prefix}/state
ExecStart={install_prefix}/hub/bin/traefik \ ExecStart={install_prefix}/hub/bin/traefik \

View File

@@ -48,6 +48,17 @@ def start_service(name):
], check=True) ], check=True)
def stop_service(name):
"""
Start service with given name.
"""
subprocess.run([
'systemctl',
'stop',
name
], check=True)
def restart_service(name): def restart_service(name):
""" """
Restart service with given name. Restart service with given name.
@@ -70,3 +81,45 @@ def enable_service(name):
'enable', 'enable',
name name
], check=True) ], check=True)
def disable_service(name):
"""
Enable a service with given name.
This most likely makes the service start on bootup
"""
subprocess.run([
'systemctl',
'disable',
name
], check=True)
def check_service_active(name):
"""
Check if a service is currently active (running)
"""
try:
subprocess.run([
'systemctl',
'is-active',
name
], check=True)
return True
except subprocess.CalledProcessError:
return False
def check_service_enabled(name):
"""
Check if a service is enabled
"""
try:
subprocess.run([
'systemctl',
'is-enabled',
name
], check=True)
return True
except subprocess.CalledProcessError:
return False

View File

@@ -4,16 +4,17 @@ import os
from urllib.request import urlretrieve from urllib.request import urlretrieve
from jinja2 import Template from jinja2 import Template
from passlib.apache import HtpasswdFile
from tljh.configurer import load_config from tljh.configurer import load_config
# FIXME: support more than one platform here # FIXME: support more than one platform here
plat = "linux-amd64" plat = "linux-amd64"
traefik_version = "1.6.5" traefik_version = "1.7.5"
# record sha256 hashes for supported platforms here # record sha256 hashes for supported platforms here
checksums = { checksums = {
"linux-amd64": "9e77c7664e316953e3f5463c323dffeeecbb35d0b1db7fb49f52e1d9464ca193" "linux-amd64": "4417a9d83753e1ad6bdd64bbbeaeb4b279bcc71542e779b7bcb3b027c6e3356e"
} }
@@ -55,9 +56,23 @@ def ensure_traefik_binary(prefix):
raise IOError(f"Checksum failed {traefik_bin}: {checksum} != {checksums[plat]}") raise IOError(f"Checksum failed {traefik_bin}: {checksum} != {checksums[plat]}")
def compute_basic_auth(username, password):
"""Generate hashed HTTP basic auth from traefik_api username+password"""
ht = HtpasswdFile()
# generate htpassword
ht.set_password(username, password)
hashed_password = str(ht.to_string()).split(":")[1][:-3]
return username + ":" + hashed_password
def ensure_traefik_config(state_dir): def ensure_traefik_config(state_dir):
"""Render the traefik.toml config file""" """Render the traefik.toml config file"""
config = load_config() config = load_config()
config['traefik_api']['basic_auth'] = compute_basic_auth(
config['traefik_api']['username'],
config['traefik_api']['password'],
)
with open(os.path.join(os.path.dirname(__file__), "traefik.toml.tpl")) as f: with open(os.path.join(os.path.dirname(__file__), "traefik.toml.tpl")) as f:
template = Template(f.read()) template = Template(f.read())
new_toml = template.render(config) new_toml = template.render(config)
@@ -75,9 +90,12 @@ def ensure_traefik_config(state_dir):
): ):
raise ValueError("Both email and domains must be set for letsencrypt") raise ValueError("Both email and domains must be set for letsencrypt")
with open(os.path.join(state_dir, "traefik.toml"), "w") as f: with open(os.path.join(state_dir, "traefik.toml"), "w") as f:
os.fchmod(f.fileno(), 0o744) os.fchmod(f.fileno(), 0o600)
f.write(new_toml) f.write(new_toml)
with open(os.path.join(state_dir, "rules.toml"), "w") as f:
os.fchmod(f.fileno(), 0o600)
# ensure acme.json exists and is private # ensure acme.json exists and is private
with open(os.path.join(state_dir, "acme.json"), "a") as f: with open(os.path.join(state_dir, "acme.json"), "a") as f:
os.fchmod(f.fileno(), 0o600) os.fchmod(f.fileno(), 0o600)

View File

@@ -33,7 +33,6 @@ idleTimeout = "10m0s"
{% if https['enabled'] %} {% if https['enabled'] %}
[entryPoints.https] [entryPoints.https]
address = ":{{https['port']}}" address = ":{{https['port']}}"
backend = "jupyterhub"
[entryPoints.https.tls] [entryPoints.https.tls]
{% if https['tls']['cert'] %} {% if https['tls']['cert'] %}
[[entryPoints.https.tls.certificates]] [[entryPoints.https.tls.certificates]]
@@ -41,6 +40,19 @@ idleTimeout = "10m0s"
keyFile = "{{https['tls']['key']}}" keyFile = "{{https['tls']['key']}}"
{% endif %} {% endif %}
{% endif %} {% endif %}
[entryPoints.auth_api]
address = "127.0.0.1:{{traefik_api['port']}}"
[entryPoints.auth_api.whiteList]
sourceRange = ['{{traefik_api['ip']}}']
[entryPoints.auth_api.auth.basic]
users = ['{{ traefik_api['basic_auth'] }}']
[wss]
protocol = "http"
[api]
dashboard = true
entrypoint = "auth_api"
{% if https['enabled'] and https['letsencrypt']['email'] %} {% if https['enabled'] and https['letsencrypt']['email'] %}
[acme] [acme]
@@ -57,13 +69,5 @@ entryPoint = "https"
{% endif %} {% endif %}
[file] [file]
filename = "rules.toml"
[frontends] watch = true
[frontends.jupyterhub]
backend = "jupyterhub"
passHostHeader = true
[backends]
[backends.jupyterhub]
[backends.jupyterhub.servers.chp]
url = "http://127.0.0.1:15003"