Skip to content

Commit

Permalink
Upgrade the jupyterhub_traefik_proxy to work with the Traefik v2 API.
Browse files Browse the repository at this point in the history
  - Renamed frontends and backedns to routers and services, respectively.
  - Traefik API paths at /api/providers/{provider_name} no longer work, so
    search /api/http/{services|routers} instead
  - Traefik file provider doesn't like arbitrary data (any more?), so have put
    JupyterHub's 'data' object into the dynamic configuration file in separate
    root keys.

To Do:
  - Haven't touched the consul or etcd providers, other than to rename
    'frontends' and 'backends', as above.
  - Test, test, test.

Additionally, have renamed TraefikTomlProxy to TraefikFileProviderProxy, and
edited relevant traefik_utils functions so the proxy provider should now work
with either yaml or toml files. (need to test).

  - jupyterhub_config.py will now need to reference the proxy class
    'traefik_file', e.g.:-

      c.JupyterHub.proxy_class = 'traefik_file'
      c.TraefikFileProviderProxy = '/path/to/rules.toml'
  • Loading branch information
alexleach committed Jun 11, 2021
1 parent e7e52c9 commit 41a5ef3
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 140 deletions.
2 changes: 1 addition & 1 deletion jupyterhub_traefik_proxy/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from .kv_proxy import TKvProxy # noqa
from .etcd import TraefikEtcdProxy
from .consul import TraefikConsulProxy
from .toml import TraefikTomlProxy
from .fileprovider import TraefikFileProviderProxy

from ._version import get_versions

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,47 +31,51 @@
from jupyterhub_traefik_proxy import TraefikProxy


class TraefikTomlProxy(TraefikProxy):
"""JupyterHub Proxy implementation using traefik and toml config file"""
class TraefikFileProviderProxy(TraefikProxy):
"""JupyterHub Proxy implementation using traefik and toml or yaml config file"""

mutex = Any()

@default("mutex")
def _default_mutex(self):
return asyncio.Lock()

toml_dynamic_config_file = Unicode(
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)
self.routes_cache = traefik_utils.load_routes(self.dynamic_config_file)
except FileNotFoundError:
self.routes_cache = {}

if not self.routes_cache:
self.routes_cache = {"backends": {}, "frontends": {}}
self.routes_cache = {
"http" : {"services": {}, "routers": {}},
"jupyter": {"routers" : {} }
}

async def _setup_traefik_static_config(self):
await super()._setup_traefik_static_config()

# Is this not the same as the dynamic config file?
self.static_config["file"] = {"filename": "rules.toml", "watch": True}

try:
traefik_utils.persist_static_conf(
self.toml_static_config_file, self.static_config
self.static_config_file, self.static_config
)
try:
os.stat(self.toml_dynamic_config_file)
os.stat(self.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}"
f"Creating the dynamic configuration file: {self.dynamic_config_file}"
)
open(self.toml_dynamic_config_file, "a").close()
open(self.dynamic_config_file, "a").close()
except IOError:
self.log.exception("Couldn't set up traefik's static config.")
raise
Expand All @@ -82,7 +86,7 @@ async def _setup_traefik_static_config(self):
def _start_traefik(self):
self.log.info("Starting traefik...")
try:
self._launch_traefik(config_type="toml")
self._launch_traefik(config_type="fileprovider")
except FileNotFoundError as e:
self.log.error(
"Failed to find traefik \n"
Expand All @@ -93,42 +97,60 @@ def _start_traefik(self):
def _clean_resources(self):
try:
if self.should_start:
os.remove(self.toml_static_config_file)
os.remove(self.toml_dynamic_config_file)
os.remove(self.static_config_file)
os.remove(self.dynamic_config_file)
except:
self.log.error("Failed to remove traefik's configuration files")
raise

def _get_route_unsafe(self, traefik_routespec):
backend_alias = traefik_utils.generate_alias(traefik_routespec, "backend")
frontend_alias = traefik_utils.generate_alias(traefik_routespec, "frontend")
service_alias = traefik_utils.generate_alias(traefik_routespec, "service")
router_alias = traefik_utils.generate_alias(traefik_routespec, "router")
routespec = self._routespec_from_traefik_path(traefik_routespec)
result = {"data": "", "target": "", "routespec": routespec}
result = {"data": None, "target": None, "routespec": routespec}

def get_target_data(d, to_find):
if to_find == "url":
key = "target"
else:
key = to_find
if result[key]:
if result[key] is not None:
return
for k, v in d.items():
if k == to_find:
result[key] = v
if isinstance(v, dict):
get_target_data(v, to_find)

if backend_alias in self.routes_cache["backends"]:
get_target_data(self.routes_cache["backends"][backend_alias], "url")
service_node = self.routes_cache["http"]["services"].get(service_alias, None)
if service_node is not None:
get_target_data(service_node, "url")

if frontend_alias in self.routes_cache["frontends"]:
get_target_data(self.routes_cache["frontends"][frontend_alias], "data")
router_node = self.routes_cache["jupyter"]["routers"].get(router_alias, None)
if router_node is not None:
get_target_data(router_node, "data")

if not result["data"] and not result["target"]:
if result["data"] is None and result["target"] is None:
self.log.info("No route for {} found!".format(routespec))
result = None
else:
result["data"] = json.loads(result["data"])
self.log.debug("treefik routespec: {0}".format(traefik_routespec))
self.log.debug("result for routespec {0}:-\n{1}".format(routespec, result))

# No longer bother converting `data` to/from JSON
#else:
# result["data"] = json.loads(result["data"])

#if service_alias in self.routes_cache["services"]:
# get_target_data(self.routes_cache["services"][service_alias], "url")

#if router_alias in self.routes_cache["routers"]:
# get_target_data(self.routes_cache["routers"][router_alias], "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

async def start(self):
Expand Down Expand Up @@ -164,33 +186,47 @@ async def add_route(self, routespec, target, data):
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.
FIXME: Why do we need to pass data back and forth to traefik?
Traefik v2 doesn't seem to allow a data key...
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)
traefik_routespec = self._routespec_to_traefik_path(routespec)
service_alias = traefik_utils.generate_alias(traefik_routespec, "service")
router_alias = traefik_utils.generate_alias(traefik_routespec, "router")
#data = json.dumps(data)
rule = traefik_utils.generate_rule(traefik_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["http"]["routers"][router_alias] = {
"service": service_alias,
"rule": rule,
# The data node is passed by JupyterHub. We can store its data in our routes_cache,
# but giving it to Traefik causes issues...
#"data" : data
#"routes": {"test": {"rule": rule, "data": data}},
}

self.routes_cache["backends"][backend_alias] = {
"servers": {"server1": {"url": target, "weight": 1}}
# Add the data node to a separate top-level node
self.routes_cache["jupyter"]["routers"][router_alias] = {"data": data}

self.routes_cache["http"]["services"][service_alias] = {
"loadBalancer" : {
"servers": {"server1": {"url": target} },
"passHostHeader": True
}
}
traefik_utils.persist_routes(
self.toml_dynamic_config_file, self.routes_cache
self.dynamic_config_file, self.routes_cache
)

self.log.debug("treefik routespec: {0}".format(traefik_routespec))
self.log.debug("data for routespec {0}:-\n{1}".format(routespec, data))

if self.should_start:
try:
# Check if traefik was launched
Expand All @@ -201,10 +237,10 @@ async def add_route(self, routespec, target, data):
)
raise
try:
await self._wait_for_route(routespec, provider="file")
await self._wait_for_route(traefik_routespec)
except TimeoutError:
self.log.error(
f"Is Traefik configured to watch {self.toml_dynamic_config_file}?"
f"Is Traefik configured to watch {self.dynamic_config_file}?"
)
raise

Expand All @@ -214,14 +250,14 @@ async def delete_route(self, routespec):
**Subclasses must define this method**
"""
routespec = self._routespec_to_traefik_path(routespec)
backend_alias = traefik_utils.generate_alias(routespec, "backend")
frontend_alias = traefik_utils.generate_alias(routespec, "frontend")
service_alias = traefik_utils.generate_alias(routespec, "service")
router_alias = traefik_utils.generate_alias(routespec, "router")

async with self.mutex:
self.routes_cache["frontends"].pop(frontend_alias, None)
self.routes_cache["backends"].pop(backend_alias, None)
self.routes_cache["http"]["routers"].pop(router_alias, None)
self.routes_cache["http"]["services"].pop(service_alias, None)

traefik_utils.persist_routes(self.toml_dynamic_config_file, self.routes_cache)
traefik_utils.persist_routes(self.dynamic_config_file, self.routes_cache)

async def get_all_routes(self):
"""Fetch and return all the routes associated by JupyterHub from the
Expand All @@ -241,11 +277,13 @@ async def get_all_routes(self):
all_routes = {}

async with self.mutex:
for key, value in self.routes_cache["frontends"].items():
for key, value in self.routes_cache["http"]["routers"].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)
all_routes.update({
routespec : self._get_route_unsafe(traefik_routespec)
})

return all_routes

Expand All @@ -272,3 +310,4 @@ async def get_route(self, routespec):
routespec = self._routespec_to_traefik_path(routespec)
async with self.mutex:
return self._get_route_unsafe(routespec)

34 changes: 17 additions & 17 deletions jupyterhub_traefik_proxy/kv_proxy.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,12 @@ async def _kv_atomic_add_route_parts(
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)
[ key: route_keys.service_url_path , value: target ]
[ key: route_keys.router_rule_path , value: rule ]
[ key: route_keys.router_service_path, value:
route_keys.service_alias]
[ key: route_keys.service_weight_path , value: w(int) ]
(where `w` is the weight of the service to be used during load balancing)
Returns:
result (tuple):
Expand All @@ -113,10 +113,10 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys):
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,
route_keys.service_url_path,
route_keys.router_rule_path,
route_keys.router_service_path,
route_keys.service_weight_path,
Returns:
result (tuple):
Expand Down Expand Up @@ -184,7 +184,7 @@ async def _kv_get_jupyterhub_prefixed_entries(self):
def _clean_resources(self):
try:
if self.should_start:
os.remove(self.toml_static_config_file)
os.remove(self.static_config_file)
except:
self.log.error("Failed to remove traefik's configuration files")
raise
Expand All @@ -205,7 +205,7 @@ async def _setup_traefik_static_config(self):
self._define_kv_specific_static_config()
try:
traefik_utils.persist_static_conf(
self.toml_static_config_file, self.static_config
self.static_config_file, self.static_config
)
except IOError:
self.log.exception("Couldn't set up traefik's static config.")
Expand Down Expand Up @@ -273,20 +273,20 @@ async def add_route(self, routespec, target, data):
raise
if status:
self.log.info(
"Added backend %s with the alias %s.", target, route_keys.backend_alias
"Added service %s with the alias %s.", target, route_keys.service_alias
)
self.log.info(
"Added frontend %s for backend %s with the following routing rule %s.",
route_keys.frontend_alias,
route_keys.backend_alias,
"Added router %s for service %s with the following routing rule %s.",
route_keys.router_alias,
route_keys.service_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)
await self._wait_for_route(routespec)

async def delete_route(self, routespec):
"""Delete a route and all the traefik related info associated given a routespec,
Expand Down
Loading

0 comments on commit 41a5ef3

Please sign in to comment.