diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9071a066..958c9d3c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,6 +33,7 @@ jobs: # Run "pytest tests" for various Python versions pytest: runs-on: ubuntu-20.04 + timeout-minutes: 30 strategy: # Keep running even if one variation of the job fail fail-fast: false diff --git a/docs/source/consul.md b/docs/source/consul.md index 0e505e1c..92f21e2e 100644 --- a/docs/source/consul.md +++ b/docs/source/consul.md @@ -69,30 +69,30 @@ You can choose to: 3. By **default**, TraefikConsulProxy assumes consul accepts client requests on the official **default** consul port `8500` for client requests. ```python - c.TraefikConsulProxy.kv_url = "http://127.0.0.1:8500" + c.TraefikConsulProxy.consul_url = "http://127.0.0.1:8500" ``` - If the consul cluster is deployed differently than using the consul defaults, then you **must** pass the consul url to the proxy using - the `kv_url` option in *jupyterhub_config.py*: + If the consul cluster is deployed differently than using the consul defaults, then you **must** pass the consul url to the proxy using + the `consul_url` option in *jupyterhub_config.py*: ```python - c.TraefikConsulProxy.kv_url = "scheme://hostname:port" + c.TraefikConsulProxy.consul_url = "scheme://hostname:port" ``` ```{note} **TraefikConsulProxy does not manage the consul cluster** and assumes it is up and running before the proxy itself starts. - However, based on how consul is configured and started, TraefikConsulProxy needs to be told about + However, based on how consul is configured and started, TraefikConsulProxy needs to be told about some consul configuration details, such as: * consul **address** where it accepts client requests - ``` - c.TraefikConsulProxy.kv_url="scheme://hostname:port" + ```python + c.TraefikConsulProxy.consul_url = "scheme://hostname:port" ``` * consul **credentials** (if consul has acl enabled) - ``` - c.TraefikConsulProxy.kv_password="123" + ```python + c.TraefikConsulProxy.consul_password = "123" ``` - Checkout the [consul documentation](https://learn.hashicorp.com/consul/) + Checkout the [consul documentation](https://learn.hashicorp.com/consul/) to find out more about possible consul configuration options. ``` @@ -118,14 +118,14 @@ If TraefikConsulProxy is used as an externally managed service, then make sure y c.TraefikConsulProxy.should_start = False # if not the default: - c.TraefikConsulProxy.kv_url = "http://consul-host:2379" + c.TraefikConsulProxy.consul_url = "http://consul-host:2379" # traefik api credentials c.TraefikConsulProxy.traefik_api_username = "abc" c.TraefikConsulProxy.traefik_api_password = "123" # consul acl token - c.TraefikConsulProxy.kv_password = "456" + c.TraefikConsulProxy.consul_password = "456" ``` 3. Create a *toml* file with traefik's desired static configuration @@ -198,17 +198,17 @@ This is an example setup for using JupyterHub and TraefikConsulProxy managed by c.TraefikConsulProxy.traefik_api_username = "123" # consul url where it accepts client requests - c.TraefikConsulProxy.kv_url = "path/to/rules.toml" + c.TraefikConsulProxy.consul_url = "path/to/rules.toml" # configure JupyterHub to use TraefikConsulProxy c.JupyterHub.proxy_class = TraefikConsulProxy ``` ```{note} - If you intend to enable consul acl, add the acl token to *jupyterhub_config.py* under *kv_password*: + If you intend to enable consul acl, add the acl token to *jupyterhub_config.py* under *consul_password*: # consul token - c.TraefikConsulProxy.kv_password = "456" + c.TraefikConsulProxy.consul_password = "456" ``` 2. Starts the agent in development mode on the default port on localhost. e.g.: @@ -242,7 +242,7 @@ This is an example setup for using JupyterHub and TraefikConsulProxy managed by # the port on localhost where the traefik api and dashboard can be found [entryPoints.auth_api] address = ":8099" - + # authenticate the traefik api entrypoint [entryPoints.auth_api.auth.basic] users = [ "abc:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./",] diff --git a/docs/source/etcd.md b/docs/source/etcd.md index a708fe32..b8952b91 100644 --- a/docs/source/etcd.md +++ b/docs/source/etcd.md @@ -69,36 +69,36 @@ You can choose to: 3. By **default**, TraefikEtcdProxy assumes etcd accepts client requests on the official **default** etcd port `2379` for client requests. ```python - c.TraefikEtcdProxy.kv_url = "http://127.0.0.1:2379" + c.TraefikEtcdProxy.etcd_url = "http://127.0.0.1:2379" ``` - If the etcd cluster is deployed differently than using the etcd defaults, then you **must** pass the etcd url to the proxy using - the `kv_url` option in *jupyterhub_config.py*: + If the etcd cluster is deployed differently than using the etcd defaults, then you **must** pass the etcd url to the proxy using + the `etcd_url` option in *jupyterhub_config.py*: ```python - c.TraefikEtcdProxy.kv_url = "scheme://hostname:port" + c.TraefikEtcdProxy.etcd_url = "scheme://hostname:port" ``` ```{note} 1. **TraefikEtcdProxy does not manage the etcd cluster** and assumes it is up and running before the proxy itself starts. - However, based on how etcd is configured and started, TraefikEtcdProxy needs to be told about + However, based on how etcd is configured and started, TraefikEtcdProxy needs to be told about some etcd configuration details, such as: * etcd **address** where it accepts client requests ``` - c.TraefikEtcdProxy.kv_url="scheme://hostname:port" + c.TraefikEtcdProxy.etcd_url="scheme://hostname:port" ``` * etcd **credentials** (if etcd has authentication enabled) ``` - c.TraefikEtcdProxy.kv_username="abc" - c.TraefikEtcdProxy.kv_password="123" + c.TraefikEtcdProxy.etcd_username="abc" + c.TraefikEtcdProxy.etcd_password="123" ``` -2. Etcd has two API versions: the API V3 and the API V2. Traefik suggests using Etcd API V3, +2. Etcd has two API versions: the API V3 and the API V2. Traefik suggests using Etcd API V3, because the API V2 won't be supported in the future. - Checkout the [etcd documentation](https://coreos.com/etcd/docs/latest/op-guide/configuration.html) + Checkout the [etcd documentation](https://coreos.com/etcd/docs/latest/op-guide/configuration.html) to find out more about possible etcd configuration options. ``` @@ -124,15 +124,15 @@ If TraefikEtcdProxy is used as an externally managed service, then make sure you c.TraefikEtcdProxy.should_start = False # if not the default: - c.TraefikEtcdProxy.kv_url = "http://etcd-host:2379" + c.TraefikEtcdProxy.etcd_url = "http://etcd-host:2379" # traefik api credentials c.TraefikEtcdProxy.traefik_api_username = "abc" c.TraefikEtcdProxy.traefik_api_password = "123" # etcd credentials - c.TraefikEtcdProxy.kv_username = "def" - c.TraefikEtcdProxy.kv_password = "456" + c.TraefikEtcdProxy.etcd_username = "def" + c.TraefikEtcdProxy.etcd_password = "456" ``` 3. Create a *toml* file with traefik's desired static configuration @@ -207,7 +207,7 @@ This is an example setup for using JupyterHub and TraefikEtcdProxy managed by an c.TraefikEtcdProxy.traefik_api_username = "123" # etcd url where it accepts client requests - c.TraefikEtcdProxy.kv_url = "path/to/rules.toml" + c.TraefikEtcdProxy.etcd_url = "http://127.0.0.1:2379" # configure JupyterHub to use TraefikEtcdProxy c.JupyterHub.proxy_class = TraefikEtcdProxy @@ -218,9 +218,9 @@ This is an example setup for using JupyterHub and TraefikEtcdProxy managed by an If you intend to enable authentication on etcd, add the etcd credentials to *jupyterhub_config.py*: # etcd username - c.TraefikEtcdProxy.kv_username = "def" + c.TraefikEtcdProxy.etcd_username = "def" # etcd password - c.TraefikEtcdProxy.kv_password = "456" + c.TraefikEtcdProxy.etcd_password = "456" ``` 2. Start a single-note etcd cluster on the default port on localhost. e.g.: @@ -255,7 +255,7 @@ This is an example setup for using JupyterHub and TraefikEtcdProxy managed by an # the port on localhost where the traefik api and dashboard can be found [entryPoints.auth_api] address = ":8099" - + # authenticate the traefik api entrypoint [entryPoints.auth_api.auth.basic] users = [ "abc:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./",] diff --git a/docs/source/file.md b/docs/source/file.md index 47f0e02c..6d601aae 100644 --- a/docs/source/file.md +++ b/docs/source/file.md @@ -36,7 +36,7 @@ You can choose to: Traefik's configuration is divided into two parts: * The **static** configuration (loaded only at the beginning) -* The **dynamic** configuration (can be hot-reloaded, without restarting the proxy), +* The **dynamic** configuration (can be hot-reloaded, without restarting the proxy), where the routing table will be updated continuously. Traefik allows us to have one file for the static configuration file (`traefik.toml` or `traefik.yaml`) and one or several files for the routes, that traefik would watch. @@ -55,20 +55,20 @@ By **default**, Traefik will search for `traefik.toml` and `rules.toml` in the f You can override this in TraefikFileProviderProxy, by modifying the **static_config_file** argument: ```python -c.TraefikFileProviderProxy.static_config_file="/path/to/static_config_filename.toml" +c.TraefikFileProviderProxy.static_config_file = "/path/to/static_config_filename.toml" ``` Similarly, you can override the dynamic configuration file by modifying the **dynamic_config_file** argument: ```python -c.TraefikFileProviderProxy.dynamic_config_file="/path/to/dynamic_config_filename.toml" +c.TraefikFileProviderProxy.dynamic_config_file = "/path/to/dynamic_config_filename.toml" ``` ```{note} -* **When JupyterHub starts the proxy**, it writes the static config once, then only edits the dynamic config file. +* **When JupyterHub starts the proxy**, it writes the static config once, then only edits the dynamic config file. -* **When JupyterHub does not start the proxy**, the user is totally responsible for the static config and +* **When JupyterHub does not start the proxy**, the user is totally responsible for the static config and JupyterHub is responsible exclusively for the routes. * **When JupyterHub does not start the proxy**, the user should tell `traefik` to get its dynamic configuration @@ -79,15 +79,14 @@ will be managed by JupyterHub. This allows e.g., the administrator to configure ## Externally managed TraefikFileProviderProxy -When TraefikFileProviderProxy is externally managed, service managers like [systemd](https://www.freedesktop.org/wiki/Software/systemd/) +When TraefikFileProviderProxy is externally managed, service managers like [systemd](https://www.freedesktop.org/wiki/Software/systemd/) or [docker](https://www.docker.com/) will be responsible for starting and stopping the proxy. If TraefikFileProviderProxy is used as an externally managed service, then make sure you follow the steps enumerated below: 1. Let JupyterHub know that the proxy being used is TraefikFileProviderProxy, using the *proxy_class* configuration option: ```python - from jupyterhub_traefik_proxy.fileprovider import TraefikFileProviderProxy - c.JupyterHub.proxy_class = TraefikFileProviderProxy + c.JupyterHub.proxy_class = "traefik_file" ``` 2. Configure `TraefikFileProviderProxy` in **jupyterhub_config.py** @@ -152,13 +151,12 @@ If TraefikFileProviderProxy is used as an externally managed service, then make * Check `tests/config_files/traefik.toml` for an example. ## Example setup - + This is an example setup for using JupyterHub and TraefikFileProviderProxy managed by another service than JupyterHub. 1. Configure the proxy through the JupyterHub configuration file, *jupyterhub_config.py*, e.g.: ```python - from jupyterhub_traefik_proxy import TraefikFileProviderProxy # mark the proxy as externally managed c.TraefikFileProviderProxy.should_start = False @@ -173,7 +171,7 @@ This is an example setup for using JupyterHub and TraefikFileProviderProxy manag c.TraefikFileProviderProxy.dynamic_config_file = "/var/run/traefik/rules.toml" # configure JupyterHub to use TraefikFileProviderProxy - c.JupyterHub.proxy_class = TraefikFileProviderProxy + c.JupyterHub.proxy_class = "traefik_file" ``` 2. Create a traefik static configuration file, *traefik.toml*, e.g.: diff --git a/jupyterhub_traefik_proxy/consul.py b/jupyterhub_traefik_proxy/consul.py index ada37b6e..955bfed8 100644 --- a/jupyterhub_traefik_proxy/consul.py +++ b/jupyterhub_traefik_proxy/consul.py @@ -18,20 +18,14 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -import json -import os from urllib.parse import urlparse -import string import base64 +import string -import asyncio import escapism -from tornado.concurrent import run_on_executor -from traitlets import Any, default, Unicode +from traitlets import default, Any, Unicode -from . import traefik_utils from .kv_proxy import TKvProxy -import time class TraefikConsulProxy(TKvProxy): @@ -51,52 +45,75 @@ def _provider_name(self): help="""Consul client root certificates""", ) - @default("kv_url") - def _default_kv_url(self): - return "http://127.0.0.1:8500" + consul_url = Unicode( + "http://127.0.0.1:8500", + config=True, + help="URL for the consul endpoint.", + ) + consul_username = Unicode( + "", + config=True, + help="Usrname for accessing consul.", + ) + consul_password = Unicode( + "", + config=True, + help="Password or token for accessing consul.", + ) + + kv_url = Unicode("DEPRECATED", config=True).tag( + deprecated_in="0.4", + deprecated_for="consul_url", + ) + kv_username = Unicode("DEPRECATED", config=True).tag( + deprecated_in="0.4", + deprecated_for="consul_username", + ) + kv_password = Unicode("DEPRECATED", config=True).tag( + deprecated_in="0.4", + deprecated_for="consul_password", + ) + + consul = Any() - @default("kv_client") + @default("consul") def _default_client(self): try: import consul.aio except ImportError: raise ImportError("Please install python-consul2 package to use traefik-proxy with consul") - consul_service = urlparse(self.kv_url) + consul_service = urlparse(self.consul_url) kwargs = { - 'host': consul_service.hostname, - 'port': consul_service.port, - 'cert': self.consul_client_ca_cert + "host": consul_service.hostname, + "port": consul_service.port, + "cert": self.consul_client_ca_cert, } - if self.kv_password: - kwargs.update({'token': self.kv_password}) + if self.consul_password: + kwargs.update({"token": self.consul_password}) return consul.aio.Consul(**kwargs) def _define_kv_specific_static_config(self): provider_config = { "consul": { "rootKey": self.kv_traefik_prefix, - "endpoints" : [ - urlparse(self.kv_url).netloc - ] + "endpoints": [urlparse(self.consul_url).netloc], } } # FIXME: Same with the tls info if self.consul_client_ca_cert: - provider_config["consul"]["tls"] = { - "ca" : self.consul_client_ca_cert - } + provider_config["consul"]["tls"] = {"ca": self.consul_client_ca_cert} self.static_config.update({"providers": provider_config}) def _start_traefik(self): - if self.kv_password: - if self.kv_username: + if self.consul_password: + if self.consul_username: self.traefik_env.setdefault( - "CONSUL_HTTP_AUTH", f"{self.kv_username}:{self.kv_password}" + "CONSUL_HTTP_AUTH", f"{self.consul_username}:{self.consul_password}" ) else: - self.traefik_env.setdefault("CONSUL_HTTP_TOKEN", self.kv_password) + self.traefik_env.setdefault("CONSUL_HTTP_TOKEN", self.consul_password) super()._start_traefik() def _stop_traefik(self): @@ -119,14 +136,13 @@ def append_payload(key, val): append_payload(k, v) try: - results = await self.kv_client.txn.put(payload=payload) + results = await self.consul.txn.put(payload=payload) status = 1 response = "" except Exception as e: status = 0 response = str(e) self.log.exception(f"Error uploading payload to KV store!\n{response}") - self.log.exception(f"Are you missing a token? {self.kv_client.token}") else: self.log.debug("Successfully uploaded payload to KV store") @@ -170,9 +186,9 @@ def append_payload(key, value): entrypoint_path = self.kv_separator.join([router_path, "entryPoints", "0"]) append_payload(entrypoint_path, self.traefik_entrypoint) - self.log.debug(f"Uploading route to KV store. Payload: {repr(payload)}") + self.log.debug("Uploading route to KV store. Payload: %r", payload) try: - results = await self.kv_client.txn.put(payload=payload) + results = await self.consul.txn.put(payload=payload) status = 1 response = "" except Exception as e: @@ -183,7 +199,7 @@ def append_payload(key, value): async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): - index, v = await self.kv_client.kv.get(jupyterhub_routespec) + index, v = await self.consul.kv.get(jupyterhub_routespec) if v is None: self.log.warning( "Route %s doesn't exist. Nothing to delete", jupyterhub_routespec @@ -216,7 +232,7 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): }}) try: - status, response = await self.kv_client.txn.put(payload=payload) + status, response = await self.consul.txn.put(payload=payload) status = 1 response = "" except Exception as e: @@ -226,13 +242,13 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): return status, response async def _kv_get_target(self, jupyterhub_routespec): - _, res = await self.kv_client.kv.get(jupyterhub_routespec) + _, res = await self.consul.kv.get(jupyterhub_routespec) if res is None: return None return res["Value"].decode() async def _kv_get_data(self, target): - _, res = await self.kv_client.kv.get(target) + _, res = await self.consul.kv.get(target) if res is None: return None @@ -259,7 +275,7 @@ async def _kv_get_route_parts(self, kv_entry): return routespec, target, data async def _kv_get_jupyterhub_prefixed_entries(self): - routes = await self.kv_client.txn.put( + routes = await self.consul.txn.put( payload=[ { "KV": { diff --git a/jupyterhub_traefik_proxy/etcd.py b/jupyterhub_traefik_proxy/etcd.py index a096a828..96b504d5 100644 --- a/jupyterhub_traefik_proxy/etcd.py +++ b/jupyterhub_traefik_proxy/etcd.py @@ -19,13 +19,12 @@ # Distributed under the terms of the Modified BSD License. from concurrent.futures import ThreadPoolExecutor -import escapism from urllib.parse import urlparse +import escapism from tornado.concurrent import run_on_executor from traitlets import Any, default, Bool, List, Unicode -from jupyterhub.utils import maybe_future from .kv_proxy import TKvProxy @@ -73,20 +72,48 @@ def _provider_name(self): config=True, allow_none=True, default_value=None, - help="""Any grpc options that need to be passed to the etcd client""" + help="""Any grpc options that need to be passed to the etcd client""", ) @default("executor") def _default_executor(self): return ThreadPoolExecutor(1) - @default("kv_url") - def _default_kv_url(self): - return "http://127.0.0.1:2379" + etcd_url = Unicode( + "http://127.0.0.1:2379", + config=True, + help="URL for the etcd endpoint.", + ) + + etcd_username = Unicode( + "", + config=True, + help="Username for accessing etcd.", + ) + etcd_password = Unicode( + "", + config=True, + help="Password for accessing etcd.", + ) + + kv_url = Unicode("DEPRECATED", config=True).tag( + deprecated_in="0.4", + deprecated_for="etcd_url", + ) + kv_username = Unicode("DEPRECATED", config=True).tag( + deprecated_in="0.4", + deprecated_for="etcd_username", + ) + kv_password = Unicode("DEPRECATED", config=True).tag( + deprecated_in="0.4", + deprecated_for="etcd_password", + ) + + etcd = Any() - @default("kv_client") + @default("etcd") def _default_client(self): - etcd_service = urlparse(self.kv_url) + etcd_service = urlparse(self.etcd_url) try: import etcd3 except ImportError: @@ -99,37 +126,39 @@ def _default_client(self): 'cert_key': self.etcd_client_cert_key, 'grpc_options': self.grpc_options } - if self.kv_password: - kwargs.update({ - 'user': self.kv_username, - 'password': self.kv_password - }) + if self.etcd_password: + kwargs.update( + { + "user": self.etcd_username, + "password": self.etcd_password, + } + ) return etcd3.client(**kwargs) def _clean_resources(self): super()._clean_resources() - self.kv_client.close() + self.etcd.close() @run_on_executor def _etcd_transaction(self, success_actions): - status, response = self.kv_client.transaction( + status, response = self.etcd.transaction( compare=[], success=success_actions, failure=[] ) return status, response @run_on_executor def _etcd_get(self, key): - value, _ = self.kv_client.get(key) + value, _ = self.etcd.get(key) return value @run_on_executor def _etcd_get_prefix(self, prefix): - routes = self.kv_client.get_prefix(prefix) + routes = self.etcd.get_prefix(prefix) return routes def _define_kv_specific_static_config(self): self.log.debug("Setting up the etcd provider in the static config") - url = urlparse(self.kv_url) + url = urlparse(self.etcd_url) self.static_config.update({"providers" : { "etcd" : { "endpoints": [url.netloc], @@ -144,11 +173,13 @@ def _define_kv_specific_static_config(self): tls_conf["insecureSkipVerify"] = self.etcd_insecure_skip_verify self.static_config["providers"]["etcd"]["tls"] = tls_conf - if self.kv_username and self.kv_password: - self.static_config["providers"]["etcd"].update({ - "username": self.kv_username, - "password": self.kv_password - }) + if self.etcd_username and self.etcd_password: + self.static_config["providers"]["etcd"].update( + { + "username": self.etcd_username, + "password": self.etcd_password, + } + ) async def _kv_atomic_add_route_parts( self, jupyterhub_routespec, target, data, route_keys, rule @@ -158,7 +189,7 @@ async def _kv_atomic_add_route_parts( jupyterhub_target = self.kv_separator.join( [self.kv_jupyterhub_prefix, "targets", escapism.escape(target)] ) - put = self.kv_client.transactions.put + put = self.etcd.transactions.put success = [ # e.g. jupyter/routers/router-1 = {target} put(jupyterhub_routespec, target), @@ -192,11 +223,11 @@ async def _kv_atomic_add_route_parts( self.traefik_entrypoint = await self._get_traefik_entrypoint() success.append(put(ep_path, self.traefik_entrypoint)) - status, response = await maybe_future(self._etcd_transaction(success)) + status, response = await self._etcd_transaction(success) return status, response async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): - value = await maybe_future(self._etcd_get(jupyterhub_routespec)) + value = await self._etcd_get(jupyterhub_routespec) if value is None: self.log.warning( f"Route {jupyterhub_routespec} doesn't exist. Nothing to delete" @@ -210,7 +241,7 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): router_path = self.kv_separator.join( ["traefik", "http", "routers", route_keys.router_alias] ) - delete = self.kv_client.transactions.delete + delete = self.etcd.transactions.delete success = [ delete(jupyterhub_routespec), delete(jupyterhub_target), @@ -228,17 +259,17 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): tls_path = self.kv_separator.join([tls_path, "certResolver"]) success.append(delete(tls_path)) - status, response = await maybe_future(self._etcd_transaction(success)) + status, response = await self._etcd_transaction(success) return status, response async def _kv_get_target(self, jupyterhub_routespec): - value = await maybe_future(self._etcd_get(jupyterhub_routespec)) + value = await self._etcd_get(jupyterhub_routespec) if value is None: return None return value.decode() async def _kv_get_data(self, target): - value = await maybe_future(self._etcd_get(target)) + value = await self._etcd_get(target) if value is None: return None return value @@ -261,13 +292,13 @@ async def _kv_get_route_parts(self, kv_entry): async def _kv_get_jupyterhub_prefixed_entries(self): sep = self.kv_separator routespecs_prefix = sep.join([self.kv_jupyterhub_prefix, "routes" + sep]) - routes = await maybe_future(self._etcd_get_prefix(routespecs_prefix)) + routes = await self._etcd_get_prefix(routespecs_prefix) return routes async def persist_dynamic_config(self): data = self.flatten_dict_for_kv(self.dynamic_config, prefix=self.kv_traefik_prefix) transactions = [] for k, v in data.items(): - transactions.append(self.kv_client.transactions.put(k, v)) - status, response = await maybe_future(self._etcd_transaction(transactions)) + transactions.append(self.etcd.transactions.put(k, v)) + status, response = await self._etcd_transaction(transactions) return status, response diff --git a/jupyterhub_traefik_proxy/fileprovider.py b/jupyterhub_traefik_proxy/fileprovider.py index a186d9a8..a266e655 100644 --- a/jupyterhub_traefik_proxy/fileprovider.py +++ b/jupyterhub_traefik_proxy/fileprovider.py @@ -56,19 +56,20 @@ def _default_handler(self): def _set_dynamic_config_file(self, change): self.dynamic_config_handler = traefik_utils.TraefikConfigFileHandler(self.dynamic_config_file) - def __init__(self, **kwargs): - super().__init__(**kwargs) + @default("dynamic_config") + def _load_dynamic_config(self): try: # Load initial dynamic config from disk - self.dynamic_config = self.dynamic_config_handler.load() + dynamic_config = self.dynamic_config_handler.load() except FileNotFoundError: - self.dynamic_config = {} + dynamic_config = {} - if not self.dynamic_config: - self.dynamic_config = { + if not dynamic_config: + dynamic_config = { "http" : {"services": {}, "routers": {}}, "jupyter": {"routers" : {} } } + return dynamic_config def persist_dynamic_config(self): """Save the dynamic config file with the current dynamic_config""" @@ -238,7 +239,6 @@ async def delete_route(self, routespec): router_alias = traefik_utils.generate_alias(routespec, "router") async with self.mutex: - # Pop each entry and if it's the last one, delete the key self.dynamic_config["http"]["routers"].pop(router_alias, None) self.dynamic_config["http"]["services"].pop(service_alias, None) @@ -309,4 +309,3 @@ async def get_route(self, routespec): routespec = self.validate_routespec(routespec) async with self.mutex: return self._get_route_unsafe(routespec) - diff --git a/jupyterhub_traefik_proxy/kv_proxy.py b/jupyterhub_traefik_proxy/kv_proxy.py index 84d5e5ec..17ea52ff 100644 --- a/jupyterhub_traefik_proxy/kv_proxy.py +++ b/jupyterhub_traefik_proxy/kv_proxy.py @@ -22,7 +22,7 @@ import json import os -from traitlets import Any, Unicode, default +from traitlets import Unicode from collections.abc import MutableMapping from . import traefik_utils @@ -32,50 +32,29 @@ class TKvProxy(TraefikProxy): """ JupyterHub Proxy implementation using traefik and a key-value store. - Custom proxy implementations based on trafik and a key-value store + + Custom proxy implementations based on traefik and a key-value store can sublass :class:`TKvProxy`. """ - kv_client = Any() - # Key-value store client - - 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( + "traefik", config=True, help="""The key value store key prefix for traefik static configuration""", ) - kv_jupyterhub_prefix = Unicode( + kv_jupyterhub_prefix = traefik_utils.KVStorePrefix( + "jupyterhub", config=True, help="""The key value store key prefix for traefik dynamic configuration""", ) kv_separator = Unicode( + "/", config=True, - help="""The separator used for the path in the KV store""" + help="""The separator used for the path in the KV store""", ) - @default("kv_traefik_prefix") - def _default_kv_traefik_prefix(self): - return "traefik" - - @default("kv_jupyterhub_prefix") - def _default_kv_jupyterhub_prefix(self): - return "jupyterhub" - - @default("kv_separator") - def _default_kv_separator(self): - return "/" - def _define_kv_specific_static_config(self): """Define the traefik static configuration that configures traefik's communication with the key-value store. @@ -427,5 +406,5 @@ def flatten_dict_for_kv(self, data, prefix='traefik'): for n, item in enumerate(v): items.update({ f"{new_key}{sep}{n}" : item }) else: - raise ValueError(f"Cannot upload {v} of type {type(v)} to etcd store") + raise ValueError(f"Cannot upload {v} of type {type(v)} to kv store") return items diff --git a/jupyterhub_traefik_proxy/proxy.py b/jupyterhub_traefik_proxy/proxy.py index 79eeda84..9ad4a9ce 100644 --- a/jupyterhub_traefik_proxy/proxy.py +++ b/jupyterhub_traefik_proxy/proxy.py @@ -24,7 +24,7 @@ from subprocess import Popen, TimeoutExpired from urllib.parse import urlparse -from traitlets import Any, Bool, Dict, Integer, Unicode, default +from traitlets import Any, Bool, Dict, Integer, Unicode, default, observe from tornado.httpclient import AsyncHTTPClient from jupyterhub.utils import exponential_backoff, url_path_join, new_token @@ -41,6 +41,48 @@ class TraefikProxy(Proxy): "traefik.toml", config=True, help="""traefik's static configuration file""" ) + toml_static_config_file = Unicode( + config=True, + help="Deprecated. Use static_config_file", + ).tag( + deprecated_in="0.4", + deprecated_for="static_config_file", + ) + + def _deprecated_trait(self, change): + """observer for deprecated traits""" + trait = change.owner.traits()[change.name] + old_attr = change.name + new_attr = trait.metadata["deprecated_for"] + version = trait.metadata["deprecated_in"] + if "." in new_attr: + new_cls_attr = new_attr + new_attr = new_attr.rsplit(".", 1)[1] + else: + new_cls_attr = f"{self.__class__.__name__}.{new_attr}" + + new_value = getattr(self, new_attr) + if new_value != change.new: + # only warn if different + # protects backward-compatible config from warnings + # if they set the same value under both names + message = "{cls}.{old} is deprecated in {cls} {version}, use {new} instead".format( + cls=self.__class__.__name__, + old=old_attr, + new=new_cls_attr, + version=version, + ) + self.log.warning(message) + + setattr(self, new_attr, change.new) + + def __init__(self, **kwargs): + # observe deprecated config names in oauthenticator + for name, trait in self.class_traits().items(): + if trait.metadata.get("deprecated_in"): + self.observe(self._deprecated_trait, name) + super().__init__(**kwargs) + static_config = Dict() dynamic_config = Dict() @@ -71,7 +113,7 @@ class TraefikProxy(Proxy): ) provider_name = Unicode( - config=True, help="""The provider name that Traefik expects, e.g. file, consul, etcd""" + help="""The provider name that Traefik expects, e.g. file, consul, etcd""" ) is_https = Bool( @@ -273,7 +315,7 @@ def _stop_traefik(self): self.traefik_process.wait() def _start_traefik(self): - if self.provider_name not in ("file", "etcd", "consul"): + if self.provider_name not in {"file", "etcd", "consul"}: raise ValueError( "Configuration mode not supported \n.\ The proxy can only be configured through fileprovider, etcd and consul" diff --git a/jupyterhub_traefik_proxy/toml.py b/jupyterhub_traefik_proxy/toml.py new file mode 100644 index 00000000..802b317c --- /dev/null +++ b/jupyterhub_traefik_proxy/toml.py @@ -0,0 +1,16 @@ + +from traitlets import Unicode +from .fileprovider import TraefikFileProviderProxy + + +class TraefikTomlProxy(TraefikFileProviderProxy): + """Deprecated alias for file provider""" + toml_dynamic_config_file = Unicode( + config=True, + ).tag( + deprecated_in="0.4", + deprecated_for="TraefikFileProvider.dynamic_config_file", + ) + def __init__(self, **kwargs): + super().__init__(**kwargs) + self.log.warning("TraefikTomlProxy is deprecated in jupyterhub-traefik-proxy 0.4. Use `c.JupyterHub.proxy_class = 'traefik_file'") diff --git a/setup.py b/setup.py index 1e20bc91..eb7e6be3 100644 --- a/setup.py +++ b/setup.py @@ -76,6 +76,7 @@ def run(self): "traefik_consul = jupyterhub_traefik_proxy.consul:TraefikConsulProxy", "traefik_etcd = jupyterhub_traefik_proxy.etcd:TraefikEtcdProxy", "traefik_file = jupyterhub_traefik_proxy.fileprovider:TraefikFileProviderProxy", + "traefik_toml = jupyterhub_traefik_proxy.toml:TraefikTomlProxy", ] }, ) diff --git a/tests/conftest.py b/tests/conftest.py index a9e9e679..a5c3b741 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -87,7 +87,7 @@ async def no_auth_consul_proxy(launch_consul): """ proxy = TraefikConsulProxy( public_url=Config.public_url, - kv_url=f"http://127.0.0.1:{Config.consul_port}", + consul_url=f"http://127.0.0.1:{Config.consul_port}", traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, check_route_timeout=45, @@ -106,10 +106,10 @@ async def auth_consul_proxy(launch_consul_acl): """ proxy = TraefikConsulProxy( public_url=Config.public_url, - kv_url=f"http://127.0.0.1:{Config.consul_port}", + consul_url=f"http://127.0.0.1:{Config.consul_port}", traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, - kv_password=Config.consul_token, + consul_password=Config.consul_token, check_route_timeout=45, should_start=True, ) @@ -154,14 +154,14 @@ async def launch_etcd_proxy(): public_url=Config.public_url, traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, - kv_url="https://localhost:2379", - kv_username="root", - kv_password=Config.etcd_password, + etcd_url="https://localhost:2379", + etcd_username=Config.etcd_user, + etcd_password=Config.etcd_password, etcd_client_ca_cert=f"{config_files}/fake-ca-cert.crt", etcd_insecure_skip_verify=True, check_route_timeout=45, should_start=True, - grpc_options=grpc_options + grpc_options=grpc_options, ) await proxy.start() @@ -254,7 +254,7 @@ async def external_file_proxy_toml(launch_traefik_file): async def external_consul_proxy(launch_consul, configure_consul, launch_traefik_consul): proxy = TraefikConsulProxy( public_url=Config.public_url, - kv_url=f"http://127.0.0.1:{Config.consul_port}", + consul_url=f"http://127.0.0.1:{Config.consul_port}", traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, check_route_timeout=45, @@ -271,10 +271,10 @@ async def auth_external_consul_proxy( print("creating proxy") proxy = TraefikConsulProxy( public_url=Config.public_url, - kv_url=f"http://127.0.0.1:{Config.consul_auth_port}", + consul_url=f"http://127.0.0.1:{Config.consul_auth_port}", traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, - kv_password=Config.consul_token, + consul_password=Config.consul_token, check_route_timeout=45, should_start=False, ) @@ -293,7 +293,7 @@ async def external_etcd_proxy(launch_etcd, configure_etcd, launch_traefik_etcd): ) await proxy._wait_for_static_config() yield proxy - proxy.kv_client.close() + proxy.etcd.close() @pytest.fixture @@ -450,25 +450,28 @@ async def launch_etcd_auth(): "--listen-client-urls=https://localhost:2379", "--advertise-client-urls=https://localhost:2379", "--log-level=debug"], - close_fds=True ) - await _wait_for_etcd( + try: + await _wait_for_etcd( "--insecure-skip-tls-verify=true", "--insecure-transport=false", "--debug") - yield etcd_proc - shutdown_etcd(etcd_proc) + yield etcd_proc + finally: + shutdown_etcd(etcd_proc) @pytest.fixture async def launch_etcd(): with TemporaryDirectory() as etcd_path: etcd_proc = subprocess.Popen( ["etcd", "--log-level=debug"], - cwd=etcd_path, close_fds=True + cwd=etcd_path, ) - await _wait_for_etcd("--debug=true") - yield etcd_proc - shutdown_etcd(etcd_proc) + try: + await _wait_for_etcd("--debug=true") + yield etcd_proc + finally: + shutdown_etcd(etcd_proc) async def _wait_for_etcd(*etcd_args): """Etcd may not be ready if we jump straight into the tests. @@ -478,11 +481,20 @@ async def _wait_for_etcd(*etcd_args): In production, etcd would already be running, so don't put this in the proxy classes. """ - assert "is healthy" in subprocess.check_output( - ["etcdctl", "endpoint", "health", *etcd_args], - env=Config.etcdctl_env, - stderr=subprocess.STDOUT, - ).decode(sys.stdout.encoding) + + def check(): + p = subprocess.run( + ["etcdctl", "endpoint", "health", *etcd_args], + env=Config.etcdctl_env, + check=False, + capture_output=True, + text=True, + ) + sys.stdout.write(p.stdout) + sys.stderr.write(p.stderr) + return "is healthy" in p.stdout + p.stderr + + await exponential_backoff(check, "etcd health check", timeout=10) # @pytest.fixture(scope="function", autouse=True) @@ -537,7 +549,7 @@ def launch_consul_acl(): f"-config-file={config_files}/consul_config.json", "-bootstrap-expect=1", ], - cwd=consul_path, close_fds=True + cwd=consul_path, ) asyncio.run( _wait_for_consul(token=Config.consul_token, port=Config.consul_auth_port) diff --git a/tests/test_deprecations.py b/tests/test_deprecations.py new file mode 100644 index 00000000..1721125b --- /dev/null +++ b/tests/test_deprecations.py @@ -0,0 +1,60 @@ +from traitlets.config import Config + +from jupyterhub_traefik_proxy.toml import TraefikTomlProxy +from jupyterhub_traefik_proxy.etcd import TraefikEtcdProxy +from jupyterhub_traefik_proxy.consul import TraefikConsulProxy + +def test_toml_deprecation(caplog): + cfg = Config() + cfg.TraefikTomlProxy.toml_static_config_file = 'deprecated-static.toml' + cfg.TraefikTomlProxy.toml_dynamic_config_file = 'deprecated-dynamic.toml' + p = TraefikTomlProxy(config=cfg) + assert p.static_config_file == 'deprecated-static.toml' + + assert p.dynamic_config_file == 'deprecated-dynamic.toml' + + log = '\n'.join([ + record.msg + for record in caplog.records + ]) + assert 'TraefikFileProvider.dynamic_config_file instead' in log + assert 'static_config_file instead' in log + +def test_etcd_deprecation(caplog): + cfg = Config() + cfg.TraefikEtcdProxy.kv_url = "http://1.2.3.4:12345" + cfg.TraefikEtcdProxy.kv_username = "user" + cfg.TraefikEtcdProxy.kv_password = "pass" + + p = TraefikEtcdProxy(config=cfg) + assert p.etcd_url=="http://1.2.3.4:12345" + assert p.etcd_username == "user" + assert p.etcd_password == "pass" + + log = '\n'.join([ + record.msg + for record in caplog.records + ]) + assert 'TraefikEtcdProxy.etcd_url instead' in log + assert 'TraefikEtcdProxy.etcd_username instead' in log + assert 'TraefikEtcdProxy.etcd_password instead' in log + + +def test_consul_deprecation(caplog): + cfg = Config() + cfg.TraefikConsulProxy.kv_url = "http://1.2.3.4:12345" + cfg.TraefikConsulProxy.kv_username = "user" + cfg.TraefikConsulProxy.kv_password = "pass" + + p = TraefikConsulProxy(config=cfg) + assert p.consul_url=="http://1.2.3.4:12345" + assert p.consul_username == "user" + assert p.consul_password == "pass" + + log = '\n'.join([ + record.msg + for record in caplog.records + ]) + assert 'TraefikConsulProxy.consul_url instead' in log + assert 'TraefikConsulProxy.consul_username instead' in log + assert 'TraefikConsulProxy.consul_password instead' in log