mirror of
https://github.com/jupyterhub/the-littlest-jupyterhub.git
synced 2025-12-18 21:54:05 +08:00
Merge pull request #582 from GeorgianaElena/allow-extending-traefik-config
This commit is contained in:
@@ -58,8 +58,10 @@ commands:
|
|||||||
|
|
||||||
.circleci/integration-test.py run-test \
|
.circleci/integration-test.py run-test \
|
||||||
--bootstrap-pip-spec "$BOOTSTRAP_PIP_SPEC" \
|
--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 >>
|
<< parameters.upgrade >>
|
||||||
|
|
||||||
admin_tests:
|
admin_tests:
|
||||||
parameters:
|
parameters:
|
||||||
upgrade:
|
upgrade:
|
||||||
|
|||||||
@@ -2,4 +2,3 @@ pytest
|
|||||||
pytest-cov
|
pytest-cov
|
||||||
pytest-mock
|
pytest-mock
|
||||||
codecov
|
codecov
|
||||||
pytoml
|
|
||||||
@@ -1,17 +1,67 @@
|
|||||||
.. _topic/escape-hatch:
|
.. _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``
|
Custom configuration snippets
|
||||||
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.
|
|
||||||
|
|
||||||
Any files in ``/opt/tljh/config/jupyterhub_config.d`` that end in ``.py`` will be
|
The two main TLJH components are **JupyterHub** and **Traefik**.
|
||||||
loaded in alphabetical order as python files to provide configuration for
|
|
||||||
JupyterHub. Any config that can go in a regular ``jupyterhub_config.py``
|
* JupyterHub takes its configuration from the ``jupyterhub_config.py`` file.
|
||||||
file is valid in these files. They will be loaded *after* any of the config
|
* Traefik takes its configuration from the ``traefik.toml`` file.
|
||||||
options specified with ``tljh-config`` are loaded.
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import ssl
|
|||||||
from subprocess import check_call
|
from subprocess import check_call
|
||||||
import time
|
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):
|
def test_manual_https(preserve_config):
|
||||||
@@ -53,14 +55,64 @@ 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):
|
for i in range(10):
|
||||||
time.sleep(i)
|
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)
|
try:
|
||||||
if r.status_code == 200:
|
req = HTTPRequest(
|
||||||
break;
|
"https://127.0.0.1/hub/api", method="GET", validate_cert=False
|
||||||
|
)
|
||||||
r.raise_for_status()
|
resp = HTTPClient().fetch(req)
|
||||||
|
break
|
||||||
|
except Exception as e:
|
||||||
|
pass
|
||||||
|
assert resp.code == 200
|
||||||
|
|
||||||
# cleanup
|
# cleanup
|
||||||
shutil.rmtree(ssl_dir)
|
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")
|
||||||
|
|||||||
@@ -22,4 +22,5 @@ def tljh_dir(tmpdir):
|
|||||||
assert tljh.config.INSTALL_PREFIX == tljh_dir
|
assert tljh.config.INSTALL_PREFIX == tljh_dir
|
||||||
os.makedirs(tljh.config.STATE_DIR)
|
os.makedirs(tljh.config.STATE_DIR)
|
||||||
os.makedirs(tljh.config.CONFIG_DIR)
|
os.makedirs(tljh.config.CONFIG_DIR)
|
||||||
|
os.makedirs(os.path.join(tljh.config.CONFIG_DIR, "traefik_config.d"))
|
||||||
yield tljh_dir
|
yield tljh_dir
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
import os
|
import os
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
import pytoml as toml
|
import toml
|
||||||
|
import pytest
|
||||||
|
|
||||||
from tljh import config
|
from tljh import config
|
||||||
from tljh import traefik
|
from tljh import traefik
|
||||||
@@ -124,3 +125,47 @@ def test_manual_ssl_config(tljh_dir):
|
|||||||
"whiteList": {"sourceRange": ["127.0.0.1"]}
|
"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"
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
"""Traefik installation and setup"""
|
"""Traefik installation and setup"""
|
||||||
import hashlib
|
import hashlib
|
||||||
import os
|
import os
|
||||||
|
from glob import glob
|
||||||
|
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from passlib.apache import HtpasswdFile
|
from passlib.apache import HtpasswdFile
|
||||||
import backoff
|
import backoff
|
||||||
import requests
|
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
|
# FIXME: support more than one platform here
|
||||||
plat = "linux-amd64"
|
plat = "linux-amd64"
|
||||||
@@ -18,7 +21,6 @@ checksums = {
|
|||||||
"linux-amd64": "3c2d153d80890b6fc8875af9f8ced32c4d684e1eb5a46d9815337cb343dfd92e"
|
"linux-amd64": "3c2d153d80890b6fc8875af9f8ced32c4d684e1eb5a46d9815337cb343dfd92e"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def checksum_file(path):
|
def checksum_file(path):
|
||||||
"""Compute the sha256 checksum of a path"""
|
"""Compute the sha256 checksum of a path"""
|
||||||
hasher = hashlib.sha256()
|
hasher = hashlib.sha256()
|
||||||
@@ -79,8 +81,18 @@ def compute_basic_auth(username, password):
|
|||||||
return username + ":" + hashed_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):
|
def ensure_traefik_config(state_dir):
|
||||||
"""Render the traefik.toml config file"""
|
"""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 = load_config()
|
||||||
config['traefik_api']['basic_auth'] = compute_basic_auth(
|
config['traefik_api']['basic_auth'] = compute_basic_auth(
|
||||||
config['traefik_api']['username'],
|
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:
|
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)
|
std_config = template.render(config)
|
||||||
https = config["https"]
|
https = config["https"]
|
||||||
letsencrypt = https["letsencrypt"]
|
letsencrypt = https["letsencrypt"]
|
||||||
tls = https["tls"]
|
tls = https["tls"]
|
||||||
@@ -103,9 +115,21 @@ def ensure_traefik_config(state_dir):
|
|||||||
letsencrypt["domains"] and not letsencrypt["email"]
|
letsencrypt["domains"] and not letsencrypt["email"]
|
||||||
):
|
):
|
||||||
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:
|
|
||||||
|
# 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)
|
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:
|
with open(os.path.join(state_dir, "rules.toml"), "w") as f:
|
||||||
os.fchmod(f.fileno(), 0o600)
|
os.fchmod(f.fileno(), 0o600)
|
||||||
|
|||||||
Reference in New Issue
Block a user