Source code for jupyterhub_traefik_proxy.kv_proxy

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

from traitlets import Any, Unicode

from . import traefik_utils
from jupyterhub_traefik_proxy import TraefikProxy


[docs]class TKvProxy(TraefikProxy): """ JupyterHub Proxy implementation using traefik and a key-value store. Custom proxy implementations based on trafik and a key-value store can sublass :class:`TKvProxy`. """ kv_client = Any() # Key-value store client kv_name = Unicode(config=False, help="""The name of the key value store""") kv_username = Unicode( config=True, help="""The username for key value store login""" ) kv_password = Unicode( config=True, help="""The password for key value store login""" ) kv_url = Unicode(config=True, help="""The URL of the key value store server""") kv_traefik_prefix = traefik_utils.KVStorePrefix( config=True, help="""The key value store key prefix for traefik static configuration""", ) kv_jupyterhub_prefix = Unicode( config=True, help="""The key value store key prefix for traefik dynamic configuration""", ) def _define_kv_specific_static_config(self): """Define the traefik static configuration that configures traefik's communication with the key-value store. 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. In order to be picked up by the proxy, the static configuration must be stored into `proxy.static_config` dict under the `kv_name` key. """ raise NotImplementedError() async def _kv_atomic_add_route_parts( self, jupyterhub_routespec, target, data, route_keys, rule ): """Add the key-value pairs associated with a route within a key-value store transaction. **Subclasses must define this method** Will be called during add_route. When retrieving or deleting a route, the parts of a route are expected to have the following structure: [ key: jupyterhub_routespec , value: target ] [ key: target , value: data ] [ key: route_keys.backend_url_path , value: target ] [ key: route_keys.frontend_rule_path , value: rule ] [ key: route_keys.frontend_backend_path, value: route_keys.backend_alias] [ key: route_keys.backend_weight_path , value: w(int) ] (where `w` is the weight of the backend to be used during load balancing) Returns: result (tuple): 'status'(int): The transaction status (0: failure, positive: success) 'response'(str): The transaction response """ raise NotImplementedError() async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): """Delete the key-value pairs associated with a route within a key-value store transaction (if the route exists). **Subclasses must define this method** Will be called during delete_route. The keys associated with a route are: jupyterhub_routespec, target, route_keys.backend_url_path, route_keys.frontend_rule_path, route_keys.frontend_backend_path, route_keys.backend_weight_path, Returns: result (tuple): 'status'(int): The transaction status (0: failure, positive: success). 'response'(str): The transaction response. """ raise NotImplementedError() async def _kv_get_target(self, jupyterhub_routespec): """Retrive the target from the key-value store. The target is the value associated with `jupyterhub_routespec` key. **Subclasses must define this method** Returns: target (str): The full URL associated with this route. """ raise NotImplementedError() async def _kv_get_data(self, target): """Retrive the data associated with the `target` from the key-value store. **Subclasses must define this method** Returns: data (dict): A JSONable dict that holds extra info about the route """ raise NotImplementedError() async def _kv_get_route_parts(self, kv_entry): """Retrive all the parts that make up a route (i.e. routespec, target, data) from the key-value store given a `kv_entry`. A `kv_entry` is a key-value store entry where the key starts with `proxy.jupyterhub_prefix`. It is expected that only the routespecs will be prefixed with `proxy.jupyterhub_prefix` when added to the kv store. **Subclasses must define this method** Returns: '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. """ raise NotImplementedError() async def _kv_get_jupyterhub_prefixed_entries(self): """Retrive from the kv store all the key-value pairs where the key starts with `proxy.jupyterhub_prefix`. It is expected that only the routespecs will be prefixed with `proxy.jupyterhub_prefix` when added to the kv store. **Subclasses must define this method** Returns: 'routes': A list of key-value store entries where the keys start with `proxy.jupyterhub_prefix`. """ raise NotImplementedError() def _clean_resources(self): try: if self.should_start: os.remove(self.toml_static_config_file) except: self.log.error("Failed to remove traefik's configuration files") raise def _start_traefik(self): self.log.info("Starting traefik...") try: self._launch_traefik(config_type=self.kv_name) 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 async def _setup_traefik_static_config(self): await super()._setup_traefik_static_config() self._define_kv_specific_static_config() try: traefik_utils.persist_static_conf( self.toml_static_config_file, self.static_config ) 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
[docs] async def start(self): """Start the proxy. Will be called during startup if should_start is True. """ await super().start() await self._wait_for_static_config(provider=self.kv_name)
[docs] async def stop(self): """Stop the proxy. Will be called during teardown if should_start is True. """ await super().stop() self._clean_resources()
[docs] async def add_route(self, routespec, target, data): """Add a route to the proxy. 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. This proxy implementation prefixes the routespec with `proxy.jupyterhub_prefix` when adding it to the kv store in orde to associate the fact that the route came from JupyterHub. Everything traefik related is prefixed with `proxy.traefik_prefix`. """ self.log.info("Adding route for %s to %s.", routespec, target) routespec = self._routespec_to_traefik_path(routespec) route_keys = traefik_utils.generate_route_keys(self, routespec) # Store the data dict passed in by JupyterHub data = json.dumps(data) # Generate the routing rule rule = traefik_utils.generate_rule(routespec) # To be able to delete the route when only routespec is provided jupyterhub_routespec = self.kv_jupyterhub_prefix + routespec status, response = await self._kv_atomic_add_route_parts( jupyterhub_routespec, target, data, route_keys, rule ) 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 if status: self.log.info( "Added backend %s with the alias %s.", target, route_keys.backend_alias ) self.log.info( "Added frontend %s for backend %s with the following routing rule %s.", route_keys.frontend_alias, route_keys.backend_alias, routespec, ) else: self.log.error( "Couldn't add route for %s. Response: %s", routespec, response ) await self._wait_for_route(routespec, provider=self.kv_name)
[docs] async def delete_route(self, routespec): """Delete a route and all the traefik related info associated given a routespec, (if it exists). """ routespec = self._routespec_to_traefik_path(routespec) jupyterhub_routespec = self.kv_jupyterhub_prefix + routespec route_keys = traefik_utils.generate_route_keys(self, routespec) status, response = await self._kv_atomic_delete_route_parts( jupyterhub_routespec, route_keys ) if status: self.log.info("Routespec %s was deleted.", routespec) else: self.log.error( "Couldn't delete route %s. Response: %s", routespec, response )
[docs] async def get_all_routes(self): """Fetch and return all the routes associated by JupyterHub from the proxy. Returns 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 = {} routes = await self._kv_get_jupyterhub_prefixed_entries() for kv_entry in routes: traefik_routespec, target, data = await self._kv_get_route_parts(kv_entry) routespec = self._routespec_from_traefik_path(traefik_routespec) all_routes[routespec] = { "routespec": routespec, "target": target, "data": None if data is None else json.loads(data), } 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.validate_routespec(routespec) traefik_routespec = self._routespec_to_traefik_path(routespec) jupyterhub_routespec = self.kv_jupyterhub_prefix + traefik_routespec target = await self._kv_get_target(jupyterhub_routespec) if target is None: return None data = await self._kv_get_data(target) return { "routespec": routespec, "target": target, "data": None if data is None else json.loads(data), }