2018-06-27 01:18:07 -07:00
|
|
|
"""
|
|
|
|
|
Parse YAML config file & update JupyterHub config.
|
|
|
|
|
|
|
|
|
|
Config should never append or mutate, only set. Functions here could
|
|
|
|
|
be called many times per lifetime of a jupyterhub.
|
|
|
|
|
|
|
|
|
|
Traitlets that modify the startup of JupyterHub should not be here.
|
|
|
|
|
FIXME: A strong feeling that JSON Schema should be involved somehow.
|
|
|
|
|
"""
|
2018-07-21 00:18:58 -07:00
|
|
|
|
|
|
|
|
import os
|
2019-06-06 16:52:04 +03:00
|
|
|
import sys
|
2018-07-21 00:18:58 -07:00
|
|
|
|
2019-02-13 14:10:28 +02:00
|
|
|
from .config import CONFIG_FILE, STATE_DIR
|
2019-02-11 13:27:08 +01:00
|
|
|
from .yaml import yaml
|
2018-07-21 00:18:58 -07:00
|
|
|
|
2018-06-27 01:18:07 -07:00
|
|
|
# Default configuration for tljh
|
|
|
|
|
# User provided config is merged into this
|
|
|
|
|
default = {
|
2021-11-03 23:55:34 +01:00
|
|
|
"base_url": "/",
|
|
|
|
|
"auth": {
|
|
|
|
|
"type": "firstuseauthenticator.FirstUseAuthenticator",
|
|
|
|
|
"FirstUseAuthenticator": {"create_users": False},
|
2018-06-27 01:30:34 -07:00
|
|
|
},
|
2021-11-03 23:55:34 +01:00
|
|
|
"users": {"allowed": [], "banned": [], "admin": [], "extra_user_groups": {}},
|
|
|
|
|
"limits": {
|
|
|
|
|
"memory": None,
|
|
|
|
|
"cpu": None,
|
2018-07-21 00:20:29 -07:00
|
|
|
},
|
2021-11-03 23:55:34 +01:00
|
|
|
"http": {
|
|
|
|
|
"port": 80,
|
2018-07-21 00:20:29 -07:00
|
|
|
},
|
2021-11-03 23:55:34 +01:00
|
|
|
"https": {
|
|
|
|
|
"enabled": False,
|
|
|
|
|
"port": 443,
|
|
|
|
|
"tls": {
|
|
|
|
|
"cert": "",
|
|
|
|
|
"key": "",
|
2018-07-21 00:20:29 -07:00
|
|
|
},
|
2021-11-03 23:55:34 +01:00
|
|
|
"letsencrypt": {
|
|
|
|
|
"email": "",
|
|
|
|
|
"domains": [],
|
2018-07-21 00:20:29 -07:00
|
|
|
},
|
2018-06-29 00:47:08 -07:00
|
|
|
},
|
2021-11-03 23:55:34 +01:00
|
|
|
"traefik_api": {
|
|
|
|
|
"ip": "127.0.0.1",
|
|
|
|
|
"port": 8099,
|
|
|
|
|
"username": "api_admin",
|
|
|
|
|
"password": "",
|
2019-02-11 09:24:16 +02:00
|
|
|
},
|
2021-11-03 23:55:34 +01:00
|
|
|
"user_environment": {
|
|
|
|
|
"default_app": "classic",
|
2018-07-21 00:20:29 -07:00
|
|
|
},
|
2021-11-03 23:55:34 +01:00
|
|
|
"services": {
|
|
|
|
|
"cull": {
|
|
|
|
|
"enabled": True,
|
|
|
|
|
"timeout": 600,
|
|
|
|
|
"every": 60,
|
|
|
|
|
"concurrency": 5,
|
|
|
|
|
"users": False,
|
|
|
|
|
"max_age": 0,
|
2023-02-13 11:59:30 +00:00
|
|
|
"remove_named_servers": False
|
2021-04-01 14:26:54 +03:00
|
|
|
},
|
2021-11-03 23:55:34 +01:00
|
|
|
"configurator": {"enabled": False},
|
2021-11-01 09:42:45 +01:00
|
|
|
},
|
2018-06-27 01:18:07 -07:00
|
|
|
}
|
|
|
|
|
|
2020-10-19 19:48:23 +02:00
|
|
|
|
2018-07-21 00:18:58 -07:00
|
|
|
def load_config(config_file=CONFIG_FILE):
|
|
|
|
|
"""Load the current config as a dictionary
|
|
|
|
|
|
|
|
|
|
merges overrides from config.yaml with default config
|
|
|
|
|
"""
|
|
|
|
|
if os.path.exists(config_file):
|
|
|
|
|
with open(config_file) as f:
|
2019-02-11 13:27:08 +01:00
|
|
|
config_overrides = yaml.load(f)
|
2018-07-21 00:18:58 -07:00
|
|
|
else:
|
|
|
|
|
config_overrides = {}
|
2019-02-11 09:24:16 +02:00
|
|
|
|
2019-02-22 11:41:50 +01:00
|
|
|
secrets = load_secrets()
|
|
|
|
|
config = _merge_dictionaries(dict(default), secrets)
|
|
|
|
|
config = _merge_dictionaries(config, config_overrides)
|
|
|
|
|
return config
|
2018-07-21 00:18:58 -07:00
|
|
|
|
|
|
|
|
|
2018-07-15 13:40:17 -07:00
|
|
|
def apply_config(config_overrides, c):
|
|
|
|
|
"""
|
|
|
|
|
Merge config_overrides with config defaults & apply to JupyterHub config c
|
|
|
|
|
"""
|
|
|
|
|
tljh_config = _merge_dictionaries(dict(default), config_overrides)
|
2018-06-27 01:18:07 -07:00
|
|
|
|
2020-10-19 19:48:23 +02:00
|
|
|
update_base_url(c, tljh_config)
|
2018-06-27 01:30:34 -07:00
|
|
|
update_auth(c, tljh_config)
|
|
|
|
|
update_userlists(c, tljh_config)
|
2019-06-20 21:54:51 +03:00
|
|
|
update_usergroups(c, tljh_config)
|
2018-06-27 01:30:34 -07:00
|
|
|
update_limits(c, tljh_config)
|
2018-06-29 00:47:08 -07:00
|
|
|
update_user_environment(c, tljh_config)
|
2018-07-12 13:33:24 -07:00
|
|
|
update_user_account_config(c, tljh_config)
|
2019-02-22 10:53:36 +01:00
|
|
|
update_traefik_api(c, tljh_config)
|
2019-06-06 16:52:04 +03:00
|
|
|
update_services(c, tljh_config)
|
2018-06-27 01:18:07 -07:00
|
|
|
|
|
|
|
|
|
2018-07-15 13:40:17 -07:00
|
|
|
def set_if_not_none(parent, key, value):
|
|
|
|
|
"""
|
|
|
|
|
Set attribute 'key' on parent if value is not None
|
|
|
|
|
"""
|
|
|
|
|
if value is not None:
|
|
|
|
|
setattr(parent, key, value)
|
|
|
|
|
|
2019-02-22 11:41:50 +01:00
|
|
|
|
|
|
|
|
def load_traefik_api_credentials():
|
|
|
|
|
"""Load traefik api secret from a file"""
|
2021-11-03 23:55:34 +01:00
|
|
|
proxy_secret_path = os.path.join(STATE_DIR, "traefik-api.secret")
|
2019-02-22 11:41:50 +01:00
|
|
|
if not os.path.exists(proxy_secret_path):
|
|
|
|
|
return {}
|
2021-10-31 11:26:40 +01:00
|
|
|
with open(proxy_secret_path) as f:
|
2019-02-13 14:10:28 +02:00
|
|
|
password = f.read()
|
2019-02-22 11:41:50 +01:00
|
|
|
return {
|
2021-11-03 23:55:34 +01:00
|
|
|
"traefik_api": {
|
|
|
|
|
"password": password,
|
2019-02-22 11:41:50 +01:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2019-02-11 09:24:16 +02:00
|
|
|
|
2019-02-22 11:41:50 +01:00
|
|
|
def load_secrets():
|
|
|
|
|
"""Load any secret values stored on disk
|
|
|
|
|
|
|
|
|
|
Returns dict to be merged into config during load
|
|
|
|
|
"""
|
|
|
|
|
config = {}
|
|
|
|
|
config = _merge_dictionaries(config, load_traefik_api_credentials())
|
|
|
|
|
return config
|
2019-02-11 09:24:16 +02:00
|
|
|
|
2018-07-15 13:40:17 -07:00
|
|
|
|
2020-10-19 19:48:23 +02:00
|
|
|
def update_base_url(c, config):
|
|
|
|
|
"""
|
|
|
|
|
Update base_url of JupyterHub through tljh config
|
|
|
|
|
"""
|
2021-11-03 23:55:34 +01:00
|
|
|
c.JupyterHub.base_url = config["base_url"]
|
2020-10-19 19:48:23 +02:00
|
|
|
|
|
|
|
|
|
2018-06-27 01:18:07 -07:00
|
|
|
def update_auth(c, config):
|
|
|
|
|
"""
|
2021-10-20 20:40:33 +02:00
|
|
|
Set auth related configuration from YAML config file.
|
|
|
|
|
|
|
|
|
|
As an example, this function should update the following TLJH auth
|
|
|
|
|
configuration:
|
|
|
|
|
|
|
|
|
|
```yaml
|
|
|
|
|
auth:
|
|
|
|
|
type: oauthenticator.github.GitHubOAuthenticator
|
|
|
|
|
GitHubOAuthenticator:
|
|
|
|
|
client_id: "..."
|
|
|
|
|
client_secret: "..."
|
|
|
|
|
oauth_callback_url: "..."
|
2021-10-27 09:11:44 +02:00
|
|
|
ArbitraryClass:
|
2021-10-20 20:40:33 +02:00
|
|
|
arbitrary_key: "..."
|
|
|
|
|
arbitrary_key_with_none_value:
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
by applying the following configuration:
|
|
|
|
|
|
|
|
|
|
```python
|
|
|
|
|
c.JupyterHub.authenticator_class = "oauthenticator.github.GitHubOAuthenticator"
|
|
|
|
|
c.GitHubOAuthenticator.client_id = "..."
|
|
|
|
|
c.GitHubOAuthenticator.client_secret = "..."
|
|
|
|
|
c.GitHubOAuthenticator.oauth_callback_url = "..."
|
2021-10-27 09:11:44 +02:00
|
|
|
c.ArbitraryClass.arbitrary_key = "..."
|
2021-10-20 20:40:33 +02:00
|
|
|
```
|
|
|
|
|
|
2021-10-27 09:11:44 +02:00
|
|
|
Note that "auth.type" and "auth.ArbitraryClass.arbitrary_key_with_none_value"
|
2021-10-20 20:40:33 +02:00
|
|
|
are treated a bit differently. auth.type will always map to
|
|
|
|
|
c.JupyterHub.authenticator_class and any configured value being None won't
|
|
|
|
|
be set.
|
2018-06-27 01:18:07 -07:00
|
|
|
"""
|
2021-11-03 23:55:34 +01:00
|
|
|
tljh_auth_config = config["auth"]
|
2021-10-27 00:15:26 +02:00
|
|
|
|
2021-11-03 23:55:34 +01:00
|
|
|
c.JupyterHub.authenticator_class = tljh_auth_config["type"]
|
2021-10-20 20:40:33 +02:00
|
|
|
|
|
|
|
|
for auth_key, auth_value in tljh_auth_config.items():
|
2021-10-22 15:32:45 +02:00
|
|
|
if not (auth_key[0] == auth_key[0].upper() and isinstance(auth_value, dict)):
|
2021-11-03 23:55:34 +01:00
|
|
|
if auth_key == "type":
|
2021-10-27 08:46:46 +02:00
|
|
|
continue
|
2021-11-01 09:42:45 +01:00
|
|
|
raise ValueError(
|
|
|
|
|
f"Error: auth.{auth_key} was ignored, it didn't look like a valid configuration"
|
|
|
|
|
)
|
2021-10-22 15:32:45 +02:00
|
|
|
class_name = auth_key
|
|
|
|
|
class_config_to_set = auth_value
|
|
|
|
|
class_config = c[class_name]
|
|
|
|
|
for config_name, config_value in class_config_to_set.items():
|
|
|
|
|
set_if_not_none(class_config, config_name, config_value)
|
2018-06-27 01:18:07 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def update_userlists(c, config):
|
|
|
|
|
"""
|
|
|
|
|
Set user whitelists & admin lists
|
|
|
|
|
"""
|
2021-11-03 23:55:34 +01:00
|
|
|
users = config["users"]
|
2018-06-27 01:18:07 -07:00
|
|
|
|
2021-11-03 23:55:34 +01:00
|
|
|
c.Authenticator.allowed_users = set(users["allowed"])
|
|
|
|
|
c.Authenticator.blocked_users = set(users["banned"])
|
|
|
|
|
c.Authenticator.admin_users = set(users["admin"])
|
2018-06-27 01:18:07 -07:00
|
|
|
|
|
|
|
|
|
2019-06-20 21:54:51 +03:00
|
|
|
def update_usergroups(c, config):
|
|
|
|
|
"""
|
|
|
|
|
Set user groups
|
|
|
|
|
"""
|
2021-11-03 23:55:34 +01:00
|
|
|
users = config["users"]
|
|
|
|
|
c.UserCreatingSpawner.user_groups = users["extra_user_groups"]
|
2019-06-20 21:54:51 +03:00
|
|
|
|
|
|
|
|
|
2018-06-27 01:30:34 -07:00
|
|
|
def update_limits(c, config):
|
|
|
|
|
"""
|
|
|
|
|
Set user server limits
|
|
|
|
|
"""
|
2021-11-03 23:55:34 +01:00
|
|
|
limits = config["limits"]
|
2018-06-27 01:30:34 -07:00
|
|
|
|
2021-11-03 23:55:34 +01:00
|
|
|
c.Spawner.mem_limit = limits["memory"]
|
|
|
|
|
c.Spawner.cpu_limit = limits["cpu"]
|
2018-06-27 01:30:34 -07:00
|
|
|
|
|
|
|
|
|
2018-06-29 00:47:08 -07:00
|
|
|
def update_user_environment(c, config):
|
|
|
|
|
"""
|
|
|
|
|
Set user environment configuration
|
|
|
|
|
"""
|
2021-11-03 23:55:34 +01:00
|
|
|
user_env = config["user_environment"]
|
2018-06-29 00:47:08 -07:00
|
|
|
|
|
|
|
|
# Set default application users are launched into
|
2021-11-03 23:55:34 +01:00
|
|
|
if user_env["default_app"] == "jupyterlab":
|
|
|
|
|
c.Spawner.default_url = "/lab"
|
2018-06-29 00:47:08 -07:00
|
|
|
|
|
|
|
|
|
2018-07-12 13:33:24 -07:00
|
|
|
def update_user_account_config(c, config):
|
2021-11-03 23:55:34 +01:00
|
|
|
c.SystemdSpawner.username_template = "jupyter-{USERNAME}"
|
2018-07-12 13:33:24 -07:00
|
|
|
|
|
|
|
|
|
2019-02-22 10:53:36 +01:00
|
|
|
def update_traefik_api(c, config):
|
2019-02-11 09:24:16 +02:00
|
|
|
"""
|
|
|
|
|
Set traefik api endpoint credentials
|
|
|
|
|
"""
|
2021-11-03 23:55:34 +01:00
|
|
|
c.TraefikTomlProxy.traefik_api_username = config["traefik_api"]["username"]
|
|
|
|
|
c.TraefikTomlProxy.traefik_api_password = config["traefik_api"]["password"]
|
2019-02-11 09:24:16 +02:00
|
|
|
|
|
|
|
|
|
2019-06-12 17:05:01 +03:00
|
|
|
def set_cull_idle_service(config):
|
2019-06-06 16:52:04 +03:00
|
|
|
"""
|
|
|
|
|
Set Idle Culler service
|
|
|
|
|
"""
|
2021-11-03 23:55:34 +01:00
|
|
|
cull_cmd = [sys.executable, "-m", "jupyterhub_idle_culler"]
|
|
|
|
|
cull_config = config["services"]["cull"]
|
2019-06-12 17:05:01 +03:00
|
|
|
print()
|
2019-06-06 16:52:04 +03:00
|
|
|
|
2021-11-03 23:55:34 +01:00
|
|
|
cull_cmd += ["--timeout=%d" % cull_config["timeout"]]
|
|
|
|
|
cull_cmd += ["--cull-every=%d" % cull_config["every"]]
|
|
|
|
|
cull_cmd += ["--concurrency=%d" % cull_config["concurrency"]]
|
|
|
|
|
cull_cmd += ["--max-age=%d" % cull_config["max_age"]]
|
|
|
|
|
if cull_config["users"]:
|
|
|
|
|
cull_cmd += ["--cull-users"]
|
2023-02-13 11:59:30 +00:00
|
|
|
if cull_config["remove_named_servers"]:
|
|
|
|
|
cull_cmd += ["--remove-named-servers"]
|
2019-06-06 16:52:04 +03:00
|
|
|
|
|
|
|
|
cull_service = {
|
2021-11-03 23:55:34 +01:00
|
|
|
"name": "cull-idle",
|
|
|
|
|
"admin": True,
|
|
|
|
|
"command": cull_cmd,
|
2019-06-06 16:52:04 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return cull_service
|
|
|
|
|
|
|
|
|
|
|
2021-04-01 14:26:54 +03:00
|
|
|
def set_configurator(config):
|
|
|
|
|
"""
|
|
|
|
|
Set the JupyterHub Configurator service
|
|
|
|
|
"""
|
|
|
|
|
HERE = os.path.abspath(os.path.dirname(__file__))
|
|
|
|
|
configurator_cmd = [
|
2021-11-01 09:42:45 +01:00
|
|
|
sys.executable,
|
|
|
|
|
"-m",
|
|
|
|
|
"jupyterhub_configurator.app",
|
|
|
|
|
f"--Configurator.config_file={HERE}/jupyterhub_configurator_config.py",
|
2021-04-01 14:26:54 +03:00
|
|
|
]
|
|
|
|
|
configurator_service = {
|
2021-11-03 23:55:34 +01:00
|
|
|
"name": "configurator",
|
|
|
|
|
"url": "http://127.0.0.1:10101",
|
|
|
|
|
"command": configurator_cmd,
|
2021-04-01 14:26:54 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return configurator_service
|
|
|
|
|
|
|
|
|
|
|
2019-06-06 16:52:04 +03:00
|
|
|
def update_services(c, config):
|
2019-06-06 17:42:10 +03:00
|
|
|
c.JupyterHub.services = []
|
2021-04-01 14:26:54 +03:00
|
|
|
|
2021-11-03 23:55:34 +01:00
|
|
|
if config["services"]["cull"]["enabled"]:
|
2019-06-12 17:05:01 +03:00
|
|
|
c.JupyterHub.services.append(set_cull_idle_service(config))
|
2021-11-03 23:55:34 +01:00
|
|
|
if config["services"]["configurator"]["enabled"]:
|
2021-04-01 14:26:54 +03:00
|
|
|
c.JupyterHub.services.append(set_configurator(config))
|
2019-06-06 16:52:04 +03:00
|
|
|
|
|
|
|
|
|
2018-06-27 01:18:07 -07:00
|
|
|
def _merge_dictionaries(a, b, path=None, update=True):
|
|
|
|
|
"""
|
|
|
|
|
Merge two dictionaries recursively.
|
|
|
|
|
|
2018-07-10 11:45:07 -07:00
|
|
|
From https://stackoverflow.com/a/7205107
|
2018-06-27 01:18:07 -07:00
|
|
|
"""
|
|
|
|
|
if path is None:
|
|
|
|
|
path = []
|
|
|
|
|
for key in b:
|
|
|
|
|
if key in a:
|
|
|
|
|
if isinstance(a[key], dict) and isinstance(b[key], dict):
|
|
|
|
|
_merge_dictionaries(a[key], b[key], path + [str(key)])
|
|
|
|
|
elif a[key] == b[key]:
|
|
|
|
|
pass # same leaf value
|
|
|
|
|
elif update:
|
|
|
|
|
a[key] = b[key]
|
|
|
|
|
else:
|
2021-11-03 23:55:34 +01:00
|
|
|
raise Exception("Conflict at %s" % ".".join(path + [str(key)]))
|
2018-06-27 01:18:07 -07:00
|
|
|
else:
|
|
|
|
|
a[key] = b[key]
|
|
|
|
|
return a
|