Source code for jupyterhub_traefik_proxy.toml

"""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 json
import os
import asyncio
import string
import escapism

from traitlets import Any, default, Unicode

from . import traefik_utils
from jupyterhub.proxy import Proxy
from jupyterhub_traefik_proxy import TraefikProxy


[docs]class TraefikTomlProxy(TraefikProxy): """JupyterHub Proxy implementation using traefik and toml config file""" mutex = Any() @default("mutex") def _default_mutex(self): return asyncio.Lock() toml_dynamic_config_file = Unicode( "rules.toml", config=True, help="""traefik's dynamic configuration file""" ) def __init__(self, **kwargs): super().__init__(**kwargs) try: # Load initial routing table from disk self.routes_cache = traefik_utils.load_routes(self.toml_dynamic_config_file) except FileNotFoundError: self.routes_cache = {} finally: if not self.routes_cache: self.routes_cache = {"backends": {}, "frontends": {}} async def _setup_traefik_static_config(self): await super()._setup_traefik_static_config() self.static_config["file"] = {"filename": "rules.toml", "watch": True} try: traefik_utils.persist_static_conf( self.toml_static_config_file, self.static_config ) try: os.stat(self.toml_dynamic_config_file) except FileNotFoundError: # Make sure that the dynamic configuration file exists self.log.info( f"Creating the toml dynamic configuration file: {self.toml_dynamic_config_file}" ) open(self.toml_dynamic_config_file, "a").close() except IOError: self.log.exception("Couldn't set up traefik's static config.") raise except: self.log.error("Couldn't set up traefik's static config. Unexpected error:") raise def _start_traefik(self): self.log.info("Starting traefik...") try: self._launch_traefik(config_type="toml") except FileNotFoundError as e: self.log.error( "Failed to find traefik \n" "The proxy can be downloaded from https://github.com/containous/traefik/releases/download." ) raise def _clean_resources(self): try: if self.should_start: os.remove(self.toml_static_config_file) os.remove(self.toml_dynamic_config_file) except: self.log.error("Failed to remove traefik's configuration files") raise def _get_route_unsafe(self, traefik_routespec): safe = string.ascii_letters + string.digits + "_-" escaped_routespec = escapism.escape(traefik_routespec, safe=safe) routespec = self._routespec_from_traefik_path(traefik_routespec) result = {"data": "", "target": "", "routespec": routespec} def get_target_data(d, to_find): if to_find == "url": key = "target" else: key = to_find if result[key]: return for k, v in d.items(): if k == to_find: result[key] = v if isinstance(v, dict): get_target_data(v, to_find) for key, value in self.routes_cache["backends"].items(): if escaped_routespec in key: get_target_data(value, "url") for key, value in self.routes_cache["frontends"].items(): if escaped_routespec in key: get_target_data(value, "data") if not result["data"] and not result["target"]: self.log.info("No route for {} found!".format(routespec)) result = None else: result["data"] = json.loads(result["data"]) return result
[docs] async def start(self): """Start the proxy. Will be called during startup if should_start is True. **Subclasses must define this method** if the proxy is to be started by the Hub """ await super().start() await self._wait_for_static_config(provider="file")
[docs] async def stop(self): """Stop the proxy. Will be called during teardown if should_start is True. **Subclasses must define this method** if the proxy is to be started by the Hub """ await super().stop() self._clean_resources()
[docs] async def add_route(self, routespec, target, data): """Add a route to the proxy. **Subclasses must define this method** Args: routespec (str): A URL prefix ([host]/path/) for which this route will be matched, e.g. host.name/path/ target (str): A full URL that will be the target of this route. data (dict): A JSONable dict that will be associated with this route, and will be returned when retrieving information about this route. Will raise an appropriate Exception (FIXME: find what?) if the route could not be added. The proxy implementation should also have a way to associate the fact that a route came from JupyterHub. """ routespec = self._routespec_to_traefik_path(routespec) backend_alias = traefik_utils.generate_alias(routespec, "backend") frontend_alias = traefik_utils.generate_alias(routespec, "frontend") data = json.dumps(data) rule = traefik_utils.generate_rule(routespec) async with self.mutex: self.routes_cache["frontends"][frontend_alias] = { "backend": backend_alias, "passHostHeader": True, "routes": {"test": {"rule": rule, "data": data}}, } self.routes_cache["backends"][backend_alias] = { "servers": {"server1": {"url": target, "weight": 1}} } traefik_utils.persist_routes( self.toml_dynamic_config_file, self.routes_cache ) if self.should_start: try: # Check if traefik was launched pid = self.traefik_process.pid except AttributeError: self.log.error( "You cannot add routes if the proxy isn't running! Please start the proxy: proxy.start()" ) raise try: await self._wait_for_route(routespec, provider="file") except TimeoutError: self.log.error( f"Is Traefik configured to watch {self.toml_dynamic_config_file}?" ) raise
[docs] async def delete_route(self, routespec): """Delete a route with a given routespec if it exists. **Subclasses must define this method** """ routespec = self._routespec_to_traefik_path(routespec) safe = string.ascii_letters + string.digits + "_-" escaped_routespec = escapism.escape(routespec, safe=safe) async with self.mutex: for key, value in self.routes_cache["frontends"].items(): if escaped_routespec in key: del self.routes_cache["frontends"][key] break for key, value in self.routes_cache["backends"].items(): if escaped_routespec in key: del self.routes_cache["backends"][key] break traefik_utils.persist_routes(self.toml_dynamic_config_file, self.routes_cache)
[docs] async def get_all_routes(self): """Fetch and return all the routes associated by JupyterHub from the proxy. **Subclasses must define this method** Should return a dictionary of routes, where the keys are routespecs and each value is a dict of the form:: { 'routespec': the route specification ([host]/path/) 'target': the target host URL (proto://host) for this route 'data': the attached data dict for this route (as specified in add_route) } """ all_routes = {} async with self.mutex: for key, value in self.routes_cache["frontends"].items(): escaped_routespec = "".join(key.split("_", 1)[1:]) traefik_routespec = escapism.unescape(escaped_routespec) routespec = self._routespec_from_traefik_path(traefik_routespec) all_routes[routespec] = self._get_route_unsafe(traefik_routespec) return all_routes
[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._routespec_to_traefik_path(routespec) async with self.mutex: return self._get_route_unsafe(routespec)