diff --git a/.circleci/integration-test.py b/.circleci/integration-test.py index 731a78a..18dbeeb 100755 --- a/.circleci/integration-test.py +++ b/.circleci/integration-test.py @@ -108,7 +108,7 @@ def show_logs(container_name): ) run_container_command( container_name, - 'systemctl --no-pager status jupyterhub configurable-http-proxy' + 'systemctl --no-pager status jupyterhub traefik' ) def main(): diff --git a/bootstrap/bootstrap.py b/bootstrap/bootstrap.py index 3a42fb2..e448438 100644 --- a/bootstrap/bootstrap.py +++ b/bootstrap/bootstrap.py @@ -86,12 +86,20 @@ def main(): 'git+https://github.com/jupyterhub/the-littlest-jupyterhub.git' ) + traefik_proxy_repo_path = 'git+https://github.com/jupyterhub/traefik-proxy.git' + subprocess.check_output([ os.path.join(hub_prefix, 'bin', 'pip'), 'install' ] + pip_flags + [tljh_repo_path], stderr=subprocess.STDOUT) logger.info('Setup tljh package') + subprocess.check_output([ + os.path.join(hub_prefix, 'bin', 'pip'), + 'install' + ] + [traefik_proxy_repo_path], stderr=subprocess.STDOUT) + logger.info('Setup traefik-proxy package') + logger.info('Starting TLJH installer...') os.execv( os.path.join(hub_prefix, 'bin', 'python3'), diff --git a/integration-tests/test_hub.py b/integration-tests/test_hub.py index 6ee8661..c598667 100644 --- a/integration-tests/test_hub.py +++ b/integration-tests/test_hub.py @@ -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, '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: await u.login() 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, '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: await u.login() 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, '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: await u.login() await u.ensure_server() @@ -107,16 +97,14 @@ async def test_user_admin_remove(): # Assert that the user has admin rights 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, 'reload')).wait() - await asyncio.sleep(1) await u.stop_server() await u.ensure_server() # 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 @@ -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, 'reload')).wait() - # FIXME: wait for reload to finish & hub to come up - # Should be part of tljh-config reload - await asyncio.sleep(1) try: async with User(username, hub_url, partial(login_dummy, password='')) as u: await u.login() diff --git a/tests/test_config.py b/tests/test_config.py index f84582a..7b3dda8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -75,6 +75,7 @@ def test_add_to_config_zero_level(): 'a': ['b'] } + def test_add_to_config_multiple(): conf = {} @@ -116,15 +117,17 @@ def test_remove_from_config_error(): 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.systemd.check_hub_ready') as check_ready: config.reload_component('hub') assert restart_service.called_with('jupyterhub') + assert check_active.called_with('jupyterhub') def test_reload_proxy(tljh_dir): with mock.patch('tljh.systemd.restart_service') as restart_service: config.reload_component('proxy') - assert restart_service.called_with('configurable-http-proxy') assert restart_service.called_with('traefik') assert os.path.exists(os.path.join(config.STATE_DIR, 'traefik.toml')) @@ -140,8 +143,8 @@ def test_cli_no_command(capsys): "arg, value", [ ("true", True), - ("FALSE", False), - ], + ("FALSE", False) + ] ) def test_cli_set_bool(tljh_dir, arg, value): config.main(["set", "https.enabled", arg]) diff --git a/tests/test_traefik.py b/tests/test_traefik.py index 0d811ec..302b9e6 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -27,12 +27,14 @@ def test_default_config(tmpdir, tljh_dir): print(toml_cfg) cfg = toml.loads(toml_cfg) assert cfg["defaultEntryPoints"] == ["http"] - assert cfg["entryPoints"] == {"http": {"address": ":80"}} - assert cfg["frontends"] == { - "jupyterhub": {"backend": "jupyterhub", "passHostHeader": True} - } - assert cfg["backends"] == { - "jupyterhub": {"servers": {"chp": {"url": "http://127.0.0.1:15003"}}} + assert cfg["entryPoints"] == { + "http": {"address": ":80"}, + "auth_api": { + "address": ":8099", + "auth": { + "basic": {"users": ["api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./"]} + }, + }, } @@ -57,7 +59,13 @@ def test_letsencrypt_config(tljh_dir): assert "acme" in cfg assert cfg["entryPoints"] == { "http": {"address": ":80", "redirect": {"entryPoint": "https"}}, - "https": {"address": ":443", "backend": "jupyterhub", "tls": {}}, + "https": {"address": ":443", "tls": {}}, + "auth_api": { + "address": ":8099", + "auth": { + "basic": {"users": ["api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./"]} + }, + }, } assert cfg["acme"] == { "email": "fake@jupyter.org", @@ -87,11 +95,16 @@ def test_manual_ssl_config(tljh_dir): "http": {"address": ":80", "redirect": {"entryPoint": "https"}}, "https": { "address": ":443", - "backend": "jupyterhub", "tls": { "certificates": [ {"certFile": "/path/to/ssl.cert", "keyFile": "/path/to/ssl.key"} ] }, }, + "auth_api": { + "address": ":8099", + "auth": { + "basic": {"users": ["api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./"]} + }, + }, } diff --git a/tljh/config.py b/tljh/config.py index 9c862d5..c322421 100644 --- a/tljh/config.py +++ b/tljh/config.py @@ -18,6 +18,7 @@ from copy import deepcopy import os import re import sys +import asyncio from .yaml import yaml @@ -85,7 +86,7 @@ def add_item_to_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('.') @@ -183,11 +184,14 @@ def reload_component(component): from tljh import systemd, traefik if component == 'hub': systemd.restart_service('jupyterhub') - # FIXME: Verify hub is back up? + # Ensure hub is back up + while not systemd.check_service_active('jupyterhub'): + asyncio.sleep(1) + while not systemd.check_hub_ready(): + asyncio.sleep(1) print('Hub reload with new configuration complete') elif component == 'proxy': traefik.ensure_traefik_config(STATE_DIR) - systemd.restart_service('configurable-http-proxy') systemd.restart_service('traefik') print('Proxy reload with new configuration complete') diff --git a/tljh/installer.py b/tljh/installer.py index 10c5796..2b35a56 100644 --- a/tljh/installer.py +++ b/tljh/installer.py @@ -120,9 +120,6 @@ def ensure_jupyterhub_service(prefix): 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() @@ -133,28 +130,16 @@ def ensure_jupyterhub_service(prefix): 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') @@ -275,7 +260,7 @@ def ensure_admins(admins): yaml.dump(config, f) -def ensure_jupyterhub_running(times=4): +def ensure_jupyterhub_running(times=20): """ Ensure that JupyterHub is up and running @@ -432,7 +417,6 @@ def main(): 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() diff --git a/tljh/jupyterhub_config.py b/tljh/jupyterhub_config.py index 5012fcc..da1999d 100644 --- a/tljh/jupyterhub_config.py +++ b/tljh/jupyterhub_config.py @@ -10,7 +10,7 @@ from tljh import configurer, user from tljh.config import INSTALL_PREFIX, USER_ENV_PREFIX, CONFIG_DIR from tljh.normalize import generate_system_username from tljh.yaml import yaml - +from jupyterhub_traefik_proxy import TraefikTomlProxy class UserCreatingSpawner(SystemdSpawner): """ @@ -43,8 +43,11 @@ c.JupyterHub.cleanup_servers = False # Use a high port so users can try this on machines with a JupyterHub already present c.JupyterHub.hub_port = 15001 -c.ConfigurableHTTPProxy.should_start = False -c.ConfigurableHTTPProxy.api_url = 'http://127.0.0.1:15002' +c.TraefikTomlProxy.should_start = False +c.TraefikTomlProxy.traefik_api_password = "admin" +c.TraefikTomlProxy.traefik_api_username = "api_admin" +c.TraefikTomlProxy.toml_dynamic_config_file = "/opt/tljh/state/rules.toml" +c.JupyterHub.proxy_class = TraefikTomlProxy c.SystemdSpawner.extra_paths = [os.path.join(USER_ENV_PREFIX, 'bin')] c.SystemdSpawner.default_shell = '/bin/bash' diff --git a/tljh/systemd-units/configurable-http-proxy.service b/tljh/systemd-units/configurable-http-proxy.service deleted file mode 100644 index d782380..0000000 --- a/tljh/systemd-units/configurable-http-proxy.service +++ /dev/null @@ -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 diff --git a/tljh/systemd-units/jupyterhub.service b/tljh/systemd-units/jupyterhub.service index dd94868..676b3e6 100644 --- a/tljh/systemd-units/jupyterhub.service +++ b/tljh/systemd-units/jupyterhub.service @@ -1,9 +1,9 @@ # Template file for JupyterHub systemd service # Uses simple string.format() for 'templating' [Unit] -# CHP must have successfully started *before* we launch JupyterHub -Requires=configurable-http-proxy.service -After=configurable-http-proxy.service +# Traefik must have successfully started *before* we launch JupyterHub +Requires=traefik.service +After=traefik.service [Service] User=root @@ -17,7 +17,6 @@ PrivateDevices=yes ProtectKernelTunables=yes ProtectKernelModules=yes # Source CONFIGPROXY_AUTH_TOKEN from here! -EnvironmentFile={install_prefix}/state/configurable-http-proxy.secret Environment=TLJH_INSTALL_PREFIX={install_prefix} ExecStart={python_interpreter_path} -m jupyterhub.app -f {jupyterhub_config_path} diff --git a/tljh/systemd-units/traefik.service b/tljh/systemd-units/traefik.service index 01da967..04f37ee 100644 --- a/tljh/systemd-units/traefik.service +++ b/tljh/systemd-units/traefik.service @@ -7,13 +7,13 @@ After=network.target [Service] User=root Restart=always -# process only needs to write state/acme.json file, no other files ProtectHome=tmpfs ProtectSystem=strict PrivateTmp=yes PrivateDevices=yes ProtectKernelTunables=yes ProtectKernelModules=yes +ReadWritePaths={install_prefix}/state/rules.toml ReadWritePaths={install_prefix}/state/acme.json WorkingDirectory={install_prefix}/state ExecStart={install_prefix}/hub/bin/traefik \ diff --git a/tljh/systemd.py b/tljh/systemd.py index 2d11934..44b10d0 100644 --- a/tljh/systemd.py +++ b/tljh/systemd.py @@ -70,3 +70,48 @@ def enable_service(name): 'enable', 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_hub_ready(): + """ + Check if the hub is ready + """ + + try: + last_restart = subprocess.check_output([ + 'systemctl', + 'show', + 'jupyterhub', + '-p', + 'ActiveEnterTimestamp' + ]).decode().strip() + + last_restart = " ".join(last_restart.split(" ")[-3:-1]) + + out = subprocess.check_output([ + 'journalctl', + '-u', + 'jupyterhub', + '--since', + last_restart + ]) + + if "JupyterHub is now running at" in out.decode(): + return True + except subprocess.CalledProcessError: + return False diff --git a/tljh/traefik.py b/tljh/traefik.py index 6fee96f..1230b47 100644 --- a/tljh/traefik.py +++ b/tljh/traefik.py @@ -9,11 +9,11 @@ from tljh.configurer import load_config # FIXME: support more than one platform here plat = "linux-amd64" -traefik_version = "1.6.5" +traefik_version = "1.7.5" # record sha256 hashes for supported platforms here checksums = { - "linux-amd64": "9e77c7664e316953e3f5463c323dffeeecbb35d0b1db7fb49f52e1d9464ca193" + "linux-amd64": "4417a9d83753e1ad6bdd64bbbeaeb4b279bcc71542e779b7bcb3b027c6e3356e" } @@ -78,6 +78,9 @@ def ensure_traefik_config(state_dir): os.fchmod(f.fileno(), 0o744) f.write(new_toml) + with open(os.path.join(state_dir, "rules.toml"), "w") as f: + os.fchmod(f.fileno(), 0o744) + # ensure acme.json exists and is private with open(os.path.join(state_dir, "acme.json"), "a") as f: os.fchmod(f.fileno(), 0o600) diff --git a/tljh/traefik.toml.tpl b/tljh/traefik.toml.tpl index 40f71b4..9ed4625 100644 --- a/tljh/traefik.toml.tpl +++ b/tljh/traefik.toml.tpl @@ -33,7 +33,6 @@ idleTimeout = "10m0s" {% if https['enabled'] %} [entryPoints.https] address = ":{{https['port']}}" - backend = "jupyterhub" [entryPoints.https.tls] {% if https['tls']['cert'] %} [[entryPoints.https.tls.certificates]] @@ -41,6 +40,17 @@ idleTimeout = "10m0s" keyFile = "{{https['tls']['key']}}" {% endif %} {% endif %} + [entryPoints.auth_api] + address = ":8099" + [entryPoints.auth_api.auth.basic] + users = ["api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./"] + +[wss] +protocol = "http" + +[api] +dashboard = true +entrypoint = "auth_api" {% if https['enabled'] and https['letsencrypt']['email'] %} [acme] @@ -57,13 +67,5 @@ entryPoint = "https" {% endif %} [file] - -[frontends] - [frontends.jupyterhub] - backend = "jupyterhub" - passHostHeader = true -[backends] - [backends.jupyterhub] - [backends.jupyterhub.servers.chp] - url = "http://127.0.0.1:15003" - +filename = "/opt/tljh/state/rules.toml" +watch = true