Merge pull request #582 from GeorgianaElena/allow-extending-traefik-config

This commit is contained in:
Yuvi Panda
2020-06-09 16:25:03 +05:30
committed by GitHub
7 changed files with 202 additions and 29 deletions

View File

@@ -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:

View File

@@ -2,4 +2,3 @@ pytest
pytest-cov
pytest-mock
codecov
pytoml

View File

@@ -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

View File

@@ -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")

View File

@@ -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

View File

@@ -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"

View File

@@ -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)