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 \
|
||||
--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:
|
||||
|
||||
@@ -2,4 +2,3 @@ pytest
|
||||
pytest-cov
|
||||
pytest-mock
|
||||
codecov
|
||||
pytoml
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user