diff --git a/.circleci/config.yml b/.circleci/config.yml index 98fde0c..6736b3e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -58,8 +58,10 @@ commands: .circleci/integration-test.py run-test \ --bootstrap-pip-spec "$BOOTSTRAP_PIP_SPEC" \ - basic-tests test_hub.py test_install.py test_extensions.py \ + basic-tests test_hub.py test_proxy.py \ + test_install.py test_extensions.py \ << parameters.upgrade >> + admin_tests: parameters: upgrade: diff --git a/dev-requirements.txt b/dev-requirements.txt index 6296fdd..0c50f19 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,4 +2,3 @@ pytest pytest-cov pytest-mock codecov -pytoml \ No newline at end of file diff --git a/docs/topic/escape-hatch.rst b/docs/topic/escape-hatch.rst index d6beea0..9e2649f 100644 --- a/docs/topic/escape-hatch.rst +++ b/docs/topic/escape-hatch.rst @@ -1,17 +1,67 @@ .. _topic/escape-hatch: -======================================== -Custom ``jupyterhub_config.py`` snippets -======================================== -Sometimes you need to customize TLJH in ways that are not officially supported. -We provide an easy escape hatch for those cases with a ``jupyterhub_conf.d`` -directory that lets you load multiple ``jupyterhub_config.py`` snippets for -your configuration. You need to create the directory when you use it for -the first time. +============================= +Custom configuration snippets +============================= -Any files in ``/opt/tljh/config/jupyterhub_config.d`` that end in ``.py`` will be -loaded in alphabetical order as python files to provide configuration for -JupyterHub. Any config that can go in a regular ``jupyterhub_config.py`` -file is valid in these files. They will be loaded *after* any of the config -options specified with ``tljh-config`` are loaded. +The two main TLJH components are **JupyterHub** and **Traefik**. + +* JupyterHub takes its configuration from the ``jupyterhub_config.py`` file. +* Traefik takes its configuration from the ``traefik.toml`` file. + +These files are created by TLJH during installation and can be edited by the +user only through ``tljh-config``. Any direct modification to these files +is unsupported, and will cause hard to debug issues. + +But because sometimes TLJH needs to be customized in ways that are not officially +supported, an escape hatch has been introduced to allow easily extending the +configuration. Please follow the sections below for how to extend JupyterHub's +and Traefik's configuration outside of ``tljh-config`` scope. + +Extending ``jupyterhub_config.py`` +================================== + +The ``jupyterhub_config.d`` directory lets you load multiple ``jupyterhub_config.py`` +snippets for your configuration. + +* Any files in ``/opt/tljh/config/jupyterhub_config.d`` that end in ``.py`` will + be loaded in alphabetical order as python files to provide configuration for + JupyterHub. +* The configuration files can have any name, but they need to have the `.py` + extension and to respect this format. +* Any config that can go in a regular ``jupyterhub_config.py`` file is valid in + these files. +* They will be loaded *after* any of the config options specified with ``tljh-config`` + are loaded. + +Once you have created and defined your custom JupyterHub config file/s, just reload the +hub for the new configuration to take effect: + +.. code-block:: bash + + sudo tljh-config reload hub + + +Extending ``traefik.toml`` +========================== + +The ``traefik_config.d`` directory lets you load multiple ``traefik.toml`` +snippets for your configuration. + +* Any files in ``/opt/tljh/config/traefik_config.d`` that end in ``.toml`` will be + loaded in alphabetical order to provide configuration for Traefik. +* The configuration files can have any name, but they need to have the `.toml` + extension and to respect this format. +* Any config that can go in a regular ``traefik.toml`` file is valid in these files. +* They will be loaded *after* any of the config options specified with ``tljh-config`` + are loaded. + +Once you have created and defined your custom Traefik config file/s, just reload the +proxy for the new configuration to take effect: + +.. code-block:: bash + + sudo tljh-config reload proxy + +.. warning:: This instructions might change when TLJH will switch to Traefik > 2.0 diff --git a/integration-tests/test_proxy.py b/integration-tests/test_proxy.py index 1ee236f..fd8946c 100644 --- a/integration-tests/test_proxy.py +++ b/integration-tests/test_proxy.py @@ -5,9 +5,11 @@ import ssl from subprocess import check_call import time -import requests +import toml +from tornado.httpclient import HTTPClient, HTTPRequest, HTTPClientError +import pytest -from tljh.config import reload_component, set_config_value, CONFIG_FILE +from tljh.config import reload_component, set_config_value, CONFIG_FILE, CONFIG_DIR def test_manual_https(preserve_config): @@ -53,14 +55,64 @@ def test_manual_https(preserve_config): # verify that our certificate was loaded by traefik assert server_cert == file_cert - for i in range(5): + for i in range(10): time.sleep(i) # verify that we can still connect to the hub - r = requests.get("https://127.0.0.1/hub/api", verify=False) - if r.status_code == 200: - break; - - r.raise_for_status() + try: + req = HTTPRequest( + "https://127.0.0.1/hub/api", method="GET", validate_cert=False + ) + resp = HTTPClient().fetch(req) + break + except Exception as e: + pass + assert resp.code == 200 # cleanup shutil.rmtree(ssl_dir) + + +def test_extra_traefik_config(): + extra_config_dir = os.path.join(CONFIG_DIR, "traefik_config.d") + os.makedirs(extra_config_dir, exist_ok=True) + + extra_config = { + "entryPoints": {"no_auth_api": {"address": "127.0.0.1:9999"}}, + "api": {"dashboard": True, "entrypoint": "no_auth_api"}, + } + + success = False + for i in range(5): + time.sleep(i) + try: + with pytest.raises(HTTPClientError, match="HTTP 401: Unauthorized"): + # The default dashboard entrypoint requires authentication, so it should fail + req = HTTPRequest("http://127.0.0.1:8099/dashboard/", method="GET") + HTTPClient().fetch(req) + success = True + break + except Exception as e: + pass + + assert success == True + + # Load the extra config + with open(os.path.join(extra_config_dir, "extra.toml"), "w+") as extra_config_file: + toml.dump(extra_config, extra_config_file) + reload_component("proxy") + + for i in range(5): + time.sleep(i) + try: + # The new dashboard entrypoint shouldn't require authentication anymore + req = HTTPRequest("http://127.0.0.1:9999/dashboard/", method="GET") + resp = HTTPClient().fetch(req) + break + except ConnectionRefusedError: + pass + # If the request didn't get through after 5 tries, this should fail + assert resp.code == 200 + + # cleanup + os.remove(os.path.join(extra_config_dir, "extra.toml")) + reload_component("proxy") diff --git a/tests/conftest.py b/tests/conftest.py index 77262e1..7d4ec37 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -22,4 +22,5 @@ def tljh_dir(tmpdir): assert tljh.config.INSTALL_PREFIX == tljh_dir os.makedirs(tljh.config.STATE_DIR) os.makedirs(tljh.config.CONFIG_DIR) + os.makedirs(os.path.join(tljh.config.CONFIG_DIR, "traefik_config.d")) yield tljh_dir diff --git a/tests/test_traefik.py b/tests/test_traefik.py index e15be9c..d22f68d 100644 --- a/tests/test_traefik.py +++ b/tests/test_traefik.py @@ -2,7 +2,8 @@ import os from unittest import mock -import pytoml as toml +import toml +import pytest from tljh import config from tljh import traefik @@ -124,3 +125,47 @@ def test_manual_ssl_config(tljh_dir): "whiteList": {"sourceRange": ["127.0.0.1"]} }, } + +def test_extra_config(tmpdir, tljh_dir): + extra_config_dir = os.path.join(tljh_dir, config.CONFIG_DIR, "traefik_config.d") + state_dir = tmpdir.mkdir("state") + traefik_toml = os.path.join(state_dir, "traefik.toml") + + # Generate default config + traefik.ensure_traefik_config(str(state_dir)) + + # Read the default config + toml_cfg = toml.load(traefik_toml) + + # Make sure the defaults are what we expect + assert toml_cfg["logLevel"] == "INFO" + with pytest.raises(KeyError): + toml_cfg["checkNewVersion"] + assert toml_cfg["entryPoints"]["auth_api"]["address"] == "127.0.0.1:8099" + + extra_config = { + # modify existing value + "logLevel": "ERROR", + # modify existing value with multiple levels + "entryPoints": { + "auth_api": { + "address": "127.0.0.1:9999" + } + }, + # add new setting + "checkNewVersion": False + } + + with open(os.path.join(extra_config_dir, "extra.toml"), "w+") as extra_config_file: + toml.dump(extra_config, extra_config_file) + + # Merge the extra config with the defaults + traefik.ensure_traefik_config(str(state_dir)) + + # Read back the merged config + toml_cfg = toml.load(traefik_toml) + + # Check that the defaults were updated by the extra config + assert toml_cfg["logLevel"] == "ERROR" + assert toml_cfg["checkNewVersion"] == False + assert toml_cfg["entryPoints"]["auth_api"]["address"] == "127.0.0.1:9999" diff --git a/tljh/traefik.py b/tljh/traefik.py index 3595778..2cbb312 100644 --- a/tljh/traefik.py +++ b/tljh/traefik.py @@ -1,13 +1,16 @@ """Traefik installation and setup""" import hashlib import os +from glob import glob from jinja2 import Template from passlib.apache import HtpasswdFile import backoff import requests +import toml -from tljh.configurer import load_config +from .config import CONFIG_DIR +from tljh.configurer import load_config, _merge_dictionaries # FIXME: support more than one platform here plat = "linux-amd64" @@ -18,7 +21,6 @@ checksums = { "linux-amd64": "3c2d153d80890b6fc8875af9f8ced32c4d684e1eb5a46d9815337cb343dfd92e" } - def checksum_file(path): """Compute the sha256 checksum of a path""" hasher = hashlib.sha256() @@ -79,8 +81,18 @@ def compute_basic_auth(username, password): return username + ":" + hashed_password +def load_extra_config(extra_config_dir): + extra_configs = sorted(glob(os.path.join(extra_config_dir, '*.toml'))) + # Load the toml list of files into dicts and merge them + config = toml.load(extra_configs) + return config + + def ensure_traefik_config(state_dir): """Render the traefik.toml config file""" + traefik_std_config_file = os.path.join(state_dir, "traefik.toml") + traefik_extra_config_dir = os.path.join(CONFIG_DIR, "traefik_config.d") + config = load_config() config['traefik_api']['basic_auth'] = compute_basic_auth( config['traefik_api']['username'], @@ -89,7 +101,7 @@ def ensure_traefik_config(state_dir): with open(os.path.join(os.path.dirname(__file__), "traefik.toml.tpl")) as f: template = Template(f.read()) - new_toml = template.render(config) + std_config = template.render(config) https = config["https"] letsencrypt = https["letsencrypt"] tls = https["tls"] @@ -103,9 +115,21 @@ def ensure_traefik_config(state_dir): letsencrypt["domains"] and not letsencrypt["email"] ): raise ValueError("Both email and domains must be set for letsencrypt") - with open(os.path.join(state_dir, "traefik.toml"), "w") as f: + + # Ensure extra config dir exists and is private + os.makedirs(traefik_extra_config_dir, mode=0o700, exist_ok=True) + + try: + # Load standard config file merge it with the extra config files into a dict + extra_config = load_extra_config(traefik_extra_config_dir) + new_toml = _merge_dictionaries(toml.loads(std_config), extra_config) + except FileNotFoundError: + new_toml = toml.loads(std_config) + + # Dump the dict into a toml-formatted string and write it to file + with open(traefik_std_config_file, "w") as f: os.fchmod(f.fileno(), 0o600) - f.write(new_toml) + toml.dump(new_toml, f) with open(os.path.join(state_dir, "rules.toml"), "w") as f: os.fchmod(f.fileno(), 0o600)