Source code for jupyterhub_traefik_proxy.fileprovider

"""Traefik implementation

Custom proxy implementations can subclass :class:`Proxy`
and register in JupyterHub config:

.. sourcecode:: python

    from mymodule import MyProxy
    c.JupyterHub.proxy_class = MyProxy

Route Specification:

- A routespec is a URL prefix ([host]/path/), e.g.
  'host.tld/path/' for host-based routing or '/path/' for default routing.
- Route paths should be normalized to always start and end with '/'
"""

# Copyright (c) Jupyter Development Team.
# Distributed under the terms of the Modified BSD License.

import asyncio
import os
from itertools import chain

from traitlets import Any, Unicode, default, observe

from . import traefik_utils
from .proxy import TraefikProxy


[docs]class TraefikFileProviderProxy(TraefikProxy): """JupyterHub Proxy implementation using traefik and toml or yaml config file""" provider_name = "file" mutex = Any() @default("mutex") def _default_mutex(self): return asyncio.Lock() dynamic_config_file = Unicode( "rules.toml", config=True, help="""traefik's dynamic configuration file""" ) dynamic_config_handler = Any() @default("dynamic_config_handler") def _default_handler(self): return traefik_utils.TraefikConfigFileHandler(self.dynamic_config_file) # If dynamic_config_file is changed, then update the dynamic config file handler @observe("dynamic_config_file") def _set_dynamic_config_file(self, change): self.dynamic_config_handler = traefik_utils.TraefikConfigFileHandler( self.dynamic_config_file ) @default("dynamic_config") def _load_dynamic_config(self): try: # Load initial dynamic config from disk dynamic_config = self.dynamic_config_handler.load() except FileNotFoundError: dynamic_config = {} # fill in default keys # use setdefault to ensure these are always fully defined # and never _partially_ defined http = dynamic_config.setdefault("http", {}) http.setdefault("services", {}) http.setdefault("routers", {}) jupyterhub = dynamic_config.setdefault("jupyterhub", {}) jupyterhub.setdefault("routes", {}) return dynamic_config def _persist_dynamic_config(self): """Save the dynamic config file with the current dynamic_config""" # avoid writing empty dicts, which traefik doesn't handle for some reason dynamic_config = self.dynamic_config if ( not dynamic_config["http"]["routers"] or not dynamic_config["http"]["services"] ): # traefik can't handle empty dicts, so don't persist them. # But don't _remove_ them from our own config # I think this is a bug in traefik - empty dicts satisfy the spec # use shallow copy, which is cheap but most be done at every level where we modify keys dynamic_config = dynamic_config.copy() dynamic_config["http"] = http = dynamic_config["http"].copy() for key in ("routers", "services"): if not http[key]: http.pop(key) if not http: dynamic_config.pop("http") self.log.debug("Writing dynamic config %s", dynamic_config) self.dynamic_config_handler.atomic_dump(dynamic_config) async def _setup_traefik_dynamic_config(self): self.log.info( f"Creating the dynamic configuration file: {self.dynamic_config_file}" ) await super()._setup_traefik_dynamic_config() async def _setup_traefik_static_config(self): self.static_config["providers"] = { "file": {"filename": self.dynamic_config_file, "watch": True} } await super()._setup_traefik_static_config() def _cleanup(self): """Cleanup dynamic config file as well""" super()._cleanup() try: os.remove(self.dynamic_config_file) except Exception as e: self.log.error( f"Failed to remove traefik configuration file {self.dynamic_config_file}: {e}" ) async def _get_jupyterhub_dynamic_config(self): return self.dynamic_config["jupyterhub"] async def _apply_dynamic_config(self, traefik_config, jupyterhub_config=None): dynamic_config = {} dynamic_config.update(traefik_config) # file provider stores jupyterhub info in "jupyterhub" key if jupyterhub_config is not None: dynamic_config["jupyterhub"] = jupyterhub_config async with self.mutex: self.dynamic_config = traefik_utils.deep_merge( self.dynamic_config, dynamic_config ) self._persist_dynamic_config() async def _delete_dynamic_config(self, traefik_keys, jupyterhub_keys): """Delete keys from dynamic configuration jupyterhub dynamic config is _inside_ traefik dynamic config, under the 'jupyterhub' key """ jupyterhub_keys = (["jupyterhub"] + key_path for key_path in jupyterhub_keys) async with self.mutex: for key_path in chain(traefik_keys, jupyterhub_keys): parent = self.dynamic_config for key in key_path[:-1]: if key in parent: parent = parent[key] else: parent = {} break # final key, time to delete key = key_path[-1] if key in parent: parent.pop(key) else: self.log.warning( f"Missing dynamic config, nothing to delete: {'.'.join(key_path)}" ) self._persist_dynamic_config()
[docs] async def get_route(self, routespec): """Return the route info for a given routespec. Args: routespec (str): A URI that was used to add this route, e.g. `host.tld/path/` Returns: result (dict): dict with the following keys:: 'routespec': The normalized route specification passed in to add_route ([host]/path/) 'target': The target host for this route (proto://host) 'data': The arbitrary data dict that was passed in by JupyterHub when adding this route. None: if there are no routes matching the given routespec """ routespec = self.validate_routespec(routespec) router_alias = traefik_utils.generate_alias(routespec, "router") async with self.mutex: route = self.dynamic_config["jupyterhub"]["routes"].get(router_alias) if not route: return None return { "routespec": route["routespec"], "data": route["data"], "target": route["target"], }