From faa2832437bf9a2bcd0023a24c34fea150d5d4c1 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Fri, 18 Jun 2021 10:28:17 +0000 Subject: [PATCH] Major overhaul of the codebase to support the Traefik v2 API, following initial work done in previous commit:- https://github.com/alexleach/traefik-proxy/commit/41a5ef31aa6304e12d7b74adda77118c32a9a44b Also, see issue: https://github.com/jupyterhub/traefik-proxy/issues/97 - Relevant documentation has been updated in README.md, docs/source/file.md (renamed from toml.md) - KV providers (consul and etcd) have been updated to use new Traefik KV paths, as per:- https://doc.traefik.io/traefik/reference/dynamic-configuration/kv/ - requirements.txt now makes toml an optional dependency as well as ruamel.yaml. This latter module is required by jupyterhub anyway, so should already be present on a system running JupyterHub. - The (thoroughly excellent) test system has had a bit of an overhaul:- - There were issues with repeated launching and shutting down of external `consul` and `etcd` servers, causing test failures. Added code to gracefully shutdown consul and wait for servers to launch (within `tests/conftest.py`) before continuing with the tests. - `traefik` v2 no longer has a 'storeconfig' command. Therefore, to load a pre-baked configuration file into the KV stores, have had to resort to loading configurations with either `etcdctl txn` or `consul kv import` commands. - The external file provider now watches a directory instead of a file, so have added a pre-baked dynamic_config directory (and file), where the rules.toml file will be saved and managed by the TraefikFileProviderProxy. - Removed the previous traefik_{consul_config,etcd_config}.toml files, (which acted as traefik static configuration files), and instead applied the static KV configuration using the CLI. - Refactored some of the text fixtures to try and re-use fixtures and make them (hopefully) a bit easier to follow. - Have duplicated the file_proxy and external_file_proxy pytest fixtures to test both toml and yaml files. (Would have preferred to parametrize the existing fixtures to avoid duplicating them, but couldn't figure out how). - `tests/test_traefik_api_auth.py` - Have had to make the test give traefik more of a chance to read its dynamic config before running the test. Previously, in traefik v1, the api authentication would be configured in the static config. Now the api 'middleware' is configured in the dynamic config, the previous wait condition of just waiting for the port to come up didn't give traefik enough time to set up the API authentication on that entrypoint / router. - All tests now pass on my dev system, woohoo! --- README.md | 14 +- docs/source/file.md | 194 +++++++ docs/source/toml.md | 179 ------- jupyterhub_traefik_proxy/consul.py | 214 +++++--- jupyterhub_traefik_proxy/etcd.py | 65 ++- jupyterhub_traefik_proxy/fileprovider.py | 179 ++++--- jupyterhub_traefik_proxy/install.py | 53 +- jupyterhub_traefik_proxy/kv_proxy.py | 94 +++- jupyterhub_traefik_proxy/proxy.py | 208 ++++++-- jupyterhub_traefik_proxy/traefik_utils.py | 60 ++- requirements.txt | 4 +- .../dynamic_config/dynamic_conf.toml | 11 + tests/config_files/rules.toml | 0 tests/config_files/traefik.toml | 27 +- tests/config_files/traefik_consul_config.json | 27 + tests/config_files/traefik_consul_config.toml | 24 - tests/config_files/traefik_etcd_config.toml | 25 - tests/config_files/traefik_etcd_txns.txt | 8 + tests/conftest.py | 480 +++++++++++++----- tests/dummy_http_server.py | 11 +- tests/proxytest.py | 14 +- tests/test_installer.py | 4 +- tests/test_proxy.py | 6 +- tests/test_traefik_api_auth.py | 56 +- tests/test_traefik_utils.py | 2 +- 25 files changed, 1214 insertions(+), 745 deletions(-) create mode 100644 docs/source/file.md delete mode 100644 docs/source/toml.md create mode 100644 tests/config_files/dynamic_config/dynamic_conf.toml delete mode 100644 tests/config_files/rules.toml create mode 100644 tests/config_files/traefik_consul_config.json delete mode 100644 tests/config_files/traefik_consul_config.toml delete mode 100644 tests/config_files/traefik_etcd_config.toml create mode 100644 tests/config_files/traefik_etcd_txns.txt diff --git a/README.md b/README.md index bcf22e45..6dbc2782 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ depending on how traefik store its routing configuration. For **smaller**, single-node deployments: -* TraefikTomlProxy +* TraefikFileProviderProxy For **distributed** setups: @@ -32,21 +32,13 @@ The [documentation](https://jupyterhub-traefik-proxy.readthedocs.io) contains a guide](https://jupyterhub-traefik-proxy.readthedocs.io/en/latest/install.html) with examples for the three different implementations. -* [For TraefikTomlProxy](https://jupyterhub-traefik-proxy.readthedocs.io/en/latest/toml.html#example-setup) +* [For TraefikFileProviderProxy](https://jupyterhub-traefik-proxy.readthedocs.io/en/latest/toml.html#example-setup) * [For TraefikEtcdProxy](https://jupyterhub-traefik-proxy.readthedocs.io/en/latest/etcd.html#example-setup) * [For TraefikConsulProxy](https://jupyterhub-traefik-proxy.readthedocs.io/en/latest/consul.html#example-setup) ## Running tests -There are some tests that use *etcdctl* command line client for etcd. Make sure -to set environment variable `ETCDCTL_API=3` before running the tests, so that -the v3 API to be used, e.g.: - -``` -$ export ETCDCTL_API=3 -``` - -You can then run the all the test suite from the *traefik-proxy* directory with: +You can run the all the test suite from the *traefik-proxy* directory with: ``` $ pytest -v ./tests diff --git a/docs/source/file.md b/docs/source/file.md new file mode 100644 index 00000000..0f4178d9 --- /dev/null +++ b/docs/source/file.md @@ -0,0 +1,194 @@ +# Using TraefikFileProviderProxy + +**jupyterhub-traefik-proxy** can be used with simple toml or yaml configuration files, for smaller, single-node deployments such as +[The Littlest JupyterHub](https://tljh.jupyter.org). + +## How-To install TraefikFileProviderProxy + +1. Install **jupyterhub** +2. Install **jupyterhub-traefik-proxy** +3. Install **traefik** + +* You can find the full installation guide and examples in the [Introduction section](install.html#traefik-proxy-installation) + +## How-To enable TraefikFileProviderProxy + +You can enable JupyterHub to work with `TraefikFileProviderProxy` in jupyterhub_config.py, using the `proxy_class` configuration option. + +You can choose to: + +* use the `traefik_file` entrypoint, new in JupyterHub 1.0, e.g.: + + ```python + c.JupyterHub.proxy_class = "traefik_file" + ``` + +* use the TraefikFileProviderProxy object, in which case, you have to import the module, e.g.: + + ```python + from jupyterhub_traefik_proxy import TraefikFileProviderProxy + c.JupyterHub.proxy_class = TraefikFileProviderProxy + ``` + + +## Traefik configuration + +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), +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. + +```{note} + **TraefikFileProviderProxy**, uses two configuration files: one file for the routes (**rules.toml** or **rules.yaml**), and one for the static configuration (**traefik.toml** or **traefik.yaml**). +``` + + +By **default**, Traefik will search for `traefik.toml` and `rules.toml` in the following places: + +* /etc/traefik/ +* $HOME/.traefik/ +* . the working directory + +You can override this in TraefikFileProviderProxy, by modifying the **toml_static_config_file** argument: + +```python +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" +``` + +```{note} + +* **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 +JupyterHub is responsible exclusively for the routes. + +* **When JupyterHub does not start the proxy**, the user should tell `traefik` to get its dynamic configuration +from a directory. Then, one (or more) dynamic configuration file(s) can be managed externally, and `dynamic_config_file` +will be managed by JupyterHub. This allows e.g., the administrator to configure traefik's API outside of JupyterHub. + +``` + +## Externally managed TraefikFileProviderProxy + +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 import TraefikFileProviderProxy + c.JupyterHub.proxy_class = TraefikFileProviderProxy + ``` + +2. Configure `TraefikFileProviderProxy` in **jupyterhub_config.py** + + JupyterHub configuration file, *jupyterhub_config.py* must specify at least: + * That the proxy is externally managed + * The traefik api credentials + * The dynamic configuration file, + if different from *rules.toml* or if this file is located + in another place than traefik's default search directories (etc/traefik/, $HOME/.traefik/, the working directory) + + Example configuration: + ```python + # JupyterHub shouldn't start the proxy, it's already running + c.TraefikFileProviderProxy.should_start = False + + # if not the default: + c.TraefikFileProviderProxy.dynamic_config_file = "/path/to/somefile.toml" + + # traefik api credentials + c.TraefikFileProviderProxy.traefik_api_username = "abc" + c.TraefikFileProviderProxy.traefik_api_password = "xxx" + ``` + +3. Ensure **traefik.toml** / **traefik.yaml** + + The static configuration file, *traefik.toml* (or **traefik.yaml**) must configure at least: + * The default entrypoint + * The api entrypoint (*and authenticate it in a user-managed dynamic configuration file*) + * The websockets protocol + * The dynamic configuration directory to watch + (*make sure this configuration directory exists, even if empty before the proxy is launched*) + * 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 + + # traefik api endpoint login password + c.TraefikFileProviderProxy.traefik_api_password = "admin" + + # traefik api endpoint login username + c.TraefikFileProviderProxy.traefik_api_username = "api_admin" + + # traefik's dynamic configuration file, which will be managed by JupyterHub + c.TraefikFileProviderProxy.dynamic_config_file = "/var/run/traefik/rules.toml" + + # configure JupyterHub to use TraefikFileProviderProxy + c.JupyterHub.proxy_class = TraefikFileProviderProxy + ``` + +2. Create a traefik static configuration file, *traefik.toml*, e.g.: + + ``` + # the api entrypoint + [api] + dashboard = true + + # websockets protocol + [wss] + protocol = "http" + + # the port on localhost where traefik accepts http requests + [entryPoints.web] + address = ":8000" + + # the port on localhost where the traefik api and dashboard can be found + [entryPoints.enter_api] + address = ":8099" + + # the dynamic configuration directory + # This must match the directory provided in Step 1. above. + [providers.file] + directory = "/var/run/traefik" + watch = true + ``` + +3. Create a traefik dynamic configuration file in the directory provided in the dynamic configuration above, to provide the api authentication parameters, e.g. + + ``` + # Router configuration for the api service + [http.routers.router-api] + rule = "Host(`localhost`) && PathPrefix(`/api`)" + entryPoints = ["enter_api"] + service = "api@internal" + middlewares = ["auth_api"] + + # authenticate the traefik api entrypoint + [http.middlewares.auth_api.basicAuth] + users = [ "api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./",] + ``` + +4. Start traefik with the configuration specified above, e.g.: + ```bash + $ traefik --configfile traefik.toml + ``` diff --git a/docs/source/toml.md b/docs/source/toml.md deleted file mode 100644 index 7c88503f..00000000 --- a/docs/source/toml.md +++ /dev/null @@ -1,179 +0,0 @@ -# Using TraefikTomlProxy - -**jupyterhub-traefik-proxy** can be used with simple toml configuration files, for smaller, single-node deployments such as -[The Littlest JupyterHub](https://tljh.jupyter.org). - -## How-To install TraefikTomlProxy - -1. Install **jupyterhub** -2. Install **jupyterhub-traefik-proxy** -3. Install **traefik** - -* You can find the full installation guide and examples in the [Introduction section](install.html#traefik-proxy-installation) - -## How-To enable TraefikTomlProxy - -You can enable JupyterHub to work with `TraefikTomlProxy` in jupyterhub_config.py, using the `proxy_class` configuration option. - -You can choose to: - -* use the `traefik_toml` entrypoint, new in JupyterHub 1.0, e.g.: - - ```python - c.JupyterHub.proxy_class = "traefik_toml" - ``` - -* use the TraefikTomlProxy object, in which case, you have to import the module, e.g.: - - ```python - from jupyterhub_traefik_proxy import TraefikTomlProxy - c.JupyterHub.proxy_class = TraefikTomlProxy - ``` - - -## Traefik configuration - -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), -where the routing table will be updated continuously. - -Traefik allows us to have one file for the static configuration (the `traefik.toml`) and one or several files for the routes, that traefik would watch. - -```{note} - **TraefikTomlProxy**, uses two configuration files: one file for the routes (**rules.toml**), and one for the static configuration (**traefik.toml**). -``` - - -By **default**, Traefik will search for `traefik.toml` and `rules.toml` in the following places: - -* /etc/traefik/ -* $HOME/.traefik/ -* . the working directory - -You can override this in TraefikTomlProxy, by modifying the **toml_static_config_file** argument: - -```python -c.TraefikTomlProxy.toml_static_config_file="/path/to/static_config_filename.toml" -``` - -Similarly, you can override the dynamic configuration file by modifying the **toml_dynamic_config_file** argument: - -```python -c.TraefikTomlProxy.toml_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 routes config file. - -* **When JupyterHub does not start the proxy**, the user is totally responsible for the static config and -JupyterHub is responsible exclusively for the routes. -``` - -## Externally managed TraefikTomlProxy - -When TraefikTomlProxy 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 TraefikTomlProxy 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 TraefikTomlProxy, using the *proxy_class* configuration option: - ```python - c.JupyterHub.proxy_class = "traefik_toml" - ``` - -2. Configure `TraefikTomlProxy` in **jupyterhub_config.py** - - JupyterHub configuration file, *jupyterhub_config.py* must specify at least: - * That the proxy is externally managed - * The traefik api credentials - * The dynamic configuration file, - if different from *rules.toml* or if this file is located - in another place than traefik's default search directories (etc/traefik/, $HOME/.traefik/, the working directory) - - Example configuration: - ```python - # JupyterHub shouldn't start the proxy, it's already running - c.TraefikTomlProxy.should_start = False - - # if not the default: - c.TraefikTomlProxy.toml_dynamic_config_file = "somefile.toml" - - # traefik api credentials - c.TraefikTomlProxy.traefik_api_username = "abc" - c.TraefikTomlProxy.traefik_api_password = "xxx" - ``` - -3. Ensure **traefik.toml** - - The static configuration file, *traefik.toml* must configure at least: - * The default entrypoint - * The api entrypoint (*and authenticate it*) - * The websockets protocol - * The dynamic configuration file to watch - (*make sure this configuration file exists, even if empty before the proxy is launched*) - -## Example setup - -This is an example setup for using JupyterHub and TraefikTomlProxy 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 TraefikTomlProxy - - # mark the proxy as externally managed - c.TraefikTomlProxy.should_start = False - - # traefik api endpoint login password - c.TraefikTomlProxy.traefik_api_password = "admin" - - # traefik api endpoint login username - c.TraefikTomlProxy.traefik_api_username = "api_admin" - - # traefik's dynamic configuration file - c.TraefikTomlProxy.toml_dynamic_config_file = "path/to/rules.toml" - - # configure JupyterHub to use TraefikTomlProxy - c.JupyterHub.proxy_class = TraefikTomlProxy - ``` - -2. Create a traefik static configuration file, *traefik.toml*, e.g.: - - ``` - # the default entrypoint - defaultentrypoints = ["http"] - - # the api entrypoint - [api] - dashboard = true - entrypoint = "auth_api" - - # websockets protocol - [wss] - protocol = "http" - - # the port on localhost where traefik accepts http requests - [entryPoints.http] - address = ":8000" - - # 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 = [ "api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./",] - - # the dynamic configuration file - [file] - filename = "rules.toml" - watch = true - ``` - -3. Start traefik with the configuration specified above, e.g.: - ```bash - $ traefik -c traefik.toml - ``` diff --git a/jupyterhub_traefik_proxy/consul.py b/jupyterhub_traefik_proxy/consul.py index 3459aab0..6461b956 100644 --- a/jupyterhub_traefik_proxy/consul.py +++ b/jupyterhub_traefik_proxy/consul.py @@ -40,7 +40,11 @@ class TraefikConsulProxy(TKvProxy): # Consul doesn't accept keys containing // or starting with / so we have to escape them key_safe_chars = string.ascii_letters + string.digits + "!@#$%^&*();<>-.+?:" - kv_name = "consul" + #kv_name = "consul" + + @default("provider_name") + def _provider_name(self): + return "consul" consul_client_ca_cert = Unicode( config=True, @@ -60,41 +64,86 @@ def _default_client(self): except ImportError: raise ImportError("Please install python-consul2 package to use traefik-proxy with consul") consul_service = urlparse(self.kv_url) + kwargs = { + 'host': consul_service.hostname, + 'port': consul_service.port, + 'cert': self.consul_client_ca_cert + } if self.kv_password: - client = consul.aio.Consul( - host=str(consul_service.hostname), - port=consul_service.port, - token=self.kv_password, - cert=self.consul_client_ca_cert, - ) - return client - - return consul.aio.Consul( - host=str(consul_service.hostname), - port=consul_service.port, - cert=self.consul_client_ca_cert, - ) - - @default("kv_traefik_prefix") - def _default_kv_traefik_prefix(self): - return "traefik/" - - @default("kv_jupyterhub_prefix") - def _default_kv_jupyterhub_prefix(self): - return "jupyterhub/" + kwargs.update({'token': self.kv_password}) + return consul.aio.Consul(**kwargs) def _define_kv_specific_static_config(self): - self.static_config["consul"] = { - "endpoint": str(urlparse(self.kv_url).hostname) - + ":" - + str(urlparse(self.kv_url).port), - "prefix": self.kv_traefik_prefix, - "watch": True, + provider_config = { + "consul": { + "rootKey": self.kv_traefik_prefix, + #"watch": True, + "endpoints" : [ + urlparse(self.kv_url).netloc + ] + } } + # Q: Why weren't these in the Traefik v1 implementation? + # A: Although defined in the traefik docs, they appear to + # do nothing, and CONSUL_HTTP_TOKEN needs to be used instead. + # Ref: https://github.com/traefik/traefik/issues/767#issuecomment-270096663 + if self.kv_username: + provider_config["consul"].update({"username": self.kv_username}) + + if self.kv_password: + provider_config["consul"].update({"password": self.kv_password}) + + # FIXME: Same with the tls info + if self.consul_client_ca_cert: + provider_config["consul"]["tls"] = { + "ca" : self.consul_client_ca_cert + } - def _launch_traefik(self, config_type): + self.static_config.update({"providers": provider_config}) + + def _start_traefik(self): os.environ["CONSUL_HTTP_TOKEN"] = self.kv_password - super()._launch_traefik(config_type) + super()._start_traefik() + + def _stop_traefik(self): + super()._stop_traefik() + if "CONSUL_HTTP_TOKEN" in os.environ: + os.environ.pop("CONSUL_HTTP_TOKEN") + + async def persist_dynamic_config(self): + self.log.debug("Saving dynamic config to consul store") + data = self.flatten_dict_for_kv( + self.dynamic_config, prefix=self.kv_traefik_prefix + ) + payload = [] + def append_payload(key, val): + payload.append({ + "KV": { + "Verb": "set", + "Key": key, + "Value": base64.b64encode(val.encode()).decode(), + } + }) + for k,v in data.items(): + append_payload(k, v) + + try: + self.log.debug(f"Uploading payload to KV store. Payload: {repr(payload)}") + results = await self.kv_client.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") + + # Let's check if it's in there then... + #index, result = await self.kv_client.kv.get(k) + #self.log.debug(f"And the survey says, at {k} we have: {result}") + return status, response async def _kv_atomic_add_route_parts( self, jupyterhub_routespec, target, data, route_keys, rule @@ -105,54 +154,54 @@ async def _kv_atomic_add_route_parts( ) try: - results = await self.kv_client.txn.put( - payload=[ - { - "KV": { - "Verb": "set", - "Key": escaped_jupyterhub_routespec, - "Value": base64.b64encode(target.encode()).decode(), - } - }, - { - "KV": { - "Verb": "set", - "Key": escaped_target, - "Value": base64.b64encode(data.encode()).decode(), - } - }, - { - "KV": { - "Verb": "set", - "Key": route_keys.backend_url_path, - "Value": base64.b64encode(target.encode()).decode(), - } - }, - { - "KV": { - "Verb": "set", - "Key": route_keys.backend_weight_path, - "Value": base64.b64encode(b"1").decode(), - } - }, - { - "KV": { - "Verb": "set", - "Key": route_keys.frontend_backend_path, - "Value": base64.b64encode( - route_keys.backend_alias.encode() - ).decode(), - } - }, - { - "KV": { - "Verb": "set", - "Key": route_keys.frontend_rule_path, - "Value": base64.b64encode(rule.encode()).decode(), - } - }, - ] - ) + payload=[ + { + "KV": { + "Verb": "set", + "Key": escaped_jupyterhub_routespec, + "Value": base64.b64encode(target.encode()).decode(), + } + }, + { + "KV": { + "Verb": "set", + "Key": escaped_target, + "Value": base64.b64encode(data.encode()).decode(), + } + }, + { + "KV": { + "Verb": "set", + "Key": route_keys.service_url_path, + "Value": base64.b64encode(target.encode()).decode(), + } + }, + #{ + # "KV": { + # "Verb": "set", + # "Key": route_keys.service_weight_path, + # "Value": base64.b64encode(b"1").decode(), + # } + #}, + { + "KV": { + "Verb": "set", + "Key": route_keys.router_service_path, + "Value": base64.b64encode( + route_keys.service_alias.encode() + ).decode(), + } + }, + { + "KV": { + "Verb": "set", + "Key": route_keys.router_rule_path, + "Value": base64.b64encode(rule.encode()).decode(), + } + } + ] + self.log.debug(f"Uploading route to KV store. Payload: {repr(payload)}") + results = await self.kv_client.txn.put(payload=payload) status = 1 response = "" except Exception as e: @@ -180,10 +229,10 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): payload=[ {"KV": {"Verb": "delete", "Key": escaped_jupyterhub_routespec}}, {"KV": {"Verb": "delete", "Key": escaped_target}}, - {"KV": {"Verb": "delete", "Key": route_keys.backend_url_path}}, - {"KV": {"Verb": "delete", "Key": route_keys.backend_weight_path}}, - {"KV": {"Verb": "delete", "Key": route_keys.frontend_backend_path}}, - {"KV": {"Verb": "delete", "Key": route_keys.frontend_rule_path}}, + {"KV": {"Verb": "delete", "Key": route_keys.service_url_path}}, + #{"KV": {"Verb": "delete", "Key": route_keys.service_weight_path}}, + {"KV": {"Verb": "delete", "Key": route_keys.router_service_path}}, + {"KV": {"Verb": "delete", "Key": route_keys.router_rule_path}}, ] ) status = 1 @@ -240,3 +289,4 @@ async def _kv_get_jupyterhub_prefixed_entries(self): async def stop(self): await super().stop() + diff --git a/jupyterhub_traefik_proxy/etcd.py b/jupyterhub_traefik_proxy/etcd.py index 9b0e0d15..8afa9f7d 100644 --- a/jupyterhub_traefik_proxy/etcd.py +++ b/jupyterhub_traefik_proxy/etcd.py @@ -36,7 +36,9 @@ class TraefikEtcdProxy(TKvProxy): executor = Any() - kv_name = "etcdv3" + @default("provider_name") + def _provider_name(self): + return "etcd" etcd_client_ca_cert = Unicode( config=True, @@ -94,14 +96,6 @@ def _default_client(self): cert_key=self.etcd_client_cert_key, ) - @default("kv_traefik_prefix") - def _default_kv_traefik_prefix(self): - return "/traefik/" - - @default("kv_jupyterhub_prefix") - def _default_kv_jupyterhub_prefix(self): - return "/jupyterhub/" - @run_on_executor def _etcd_transaction(self, success_actions): status, response = self.kv_client.transaction( @@ -120,16 +114,19 @@ def _etcd_get_prefix(self, prefix): return routes def _define_kv_specific_static_config(self): - self.static_config["etcd"] = { - "username": self.kv_username, - "password": self.kv_password, - "endpoint": str(urlparse(self.kv_url).hostname) - + ":" - + str(urlparse(self.kv_url).port), - "prefix": self.kv_traefik_prefix, - "useapiv3": True, - "watch": True, - } + self.log.debug("Setting up the etcd provider in the static config") + url = urlparse(self.kv_url) + self.static_config.update({"providers" : { + "etcd" : { + "endpoints": [url.netloc], + "rootKey": self.kv_traefik_prefix, # Is rootKey the new prefix? + } + } }) + if self.kv_username and self.kv_password: + self.static_config["providers"]["etcd"].update({ + "username": self.kv_username, + "password": self.kv_password + }) async def _kv_atomic_add_route_parts( self, jupyterhub_routespec, target, data, route_keys, rule @@ -137,12 +134,15 @@ async def _kv_atomic_add_route_parts( success = [ self.kv_client.transactions.put(jupyterhub_routespec, target), self.kv_client.transactions.put(target, data), - self.kv_client.transactions.put(route_keys.backend_url_path, target), - self.kv_client.transactions.put(route_keys.backend_weight_path, "1"), + self.kv_client.transactions.put(route_keys.service_url_path, target), + # The weight is used to balance services, not servers. + # Traefik by default will use round-robin load-balancing anyway. + # See: https://doc.traefik.io/traefik/routing/services/#load-balancing + #self.kv_client.transactions.put(route_keys.service_weight_path, "1"), self.kv_client.transactions.put( - route_keys.frontend_backend_path, route_keys.backend_alias + route_keys.router_service_path, route_keys.service_alias ), - self.kv_client.transactions.put(route_keys.frontend_rule_path, rule), + self.kv_client.transactions.put(route_keys.router_rule_path, rule), ] status, response = await maybe_future(self._etcd_transaction(success)) return status, response @@ -151,7 +151,7 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): value = await maybe_future(self._etcd_get(jupyterhub_routespec)) if value is None: self.log.warning( - "Route %s doesn't exist. Nothing to delete", jupyterhub_routespec + "Route {jupyterhub_routespec} doesn't exist. Nothing to delete" ) return True, None @@ -160,10 +160,10 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): success = [ self.kv_client.transactions.delete(jupyterhub_routespec), self.kv_client.transactions.delete(target), - self.kv_client.transactions.delete(route_keys.backend_url_path), - self.kv_client.transactions.delete(route_keys.backend_weight_path), - self.kv_client.transactions.delete(route_keys.frontend_backend_path), - self.kv_client.transactions.delete(route_keys.frontend_rule_path), + self.kv_client.transactions.delete(route_keys.service_url_path), + #self.kv_client.transactions.delete(route_keys.service_weight_path), + self.kv_client.transactions.delete(route_keys.router_service_path), + self.kv_client.transactions.delete(route_keys.router_rule_path), ] status, response = await maybe_future(self._etcd_transaction(success)) return status, response @@ -194,3 +194,12 @@ async def _kv_get_route_parts(self, kv_entry): async def _kv_get_jupyterhub_prefixed_entries(self): routes = await maybe_future(self._etcd_get_prefix(self.kv_jupyterhub_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)) + return status, response + diff --git a/jupyterhub_traefik_proxy/fileprovider.py b/jupyterhub_traefik_proxy/fileprovider.py index ca383823..091fd7a4 100644 --- a/jupyterhub_traefik_proxy/fileprovider.py +++ b/jupyterhub_traefik_proxy/fileprovider.py @@ -24,7 +24,7 @@ import string import escapism -from traitlets import Any, default, Unicode +from traitlets import Any, default, Unicode, observe from . import traefik_utils from jupyterhub.proxy import Proxy @@ -40,59 +40,59 @@ class TraefikFileProviderProxy(TraefikProxy): def _default_mutex(self): return asyncio.Lock() + @default("provider_name") + def _provider_name(self): + return "file" + 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) + def __init__(self, **kwargs): super().__init__(**kwargs) + #self._set_dynamic_config_file(None) try: - # Load initial routing table from disk - self.routes_cache = traefik_utils.load_routes(self.dynamic_config_file) + # Load initial dynamic config from disk + self.dynamic_config = self.dynamic_config_handler.load() except FileNotFoundError: - self.routes_cache = {} + self.dynamic_config = {} - if not self.routes_cache: - self.routes_cache = { + if not self.dynamic_config: + self.dynamic_config = { "http" : {"services": {}, "routers": {}}, "jupyter": {"routers" : {} } } - async def _setup_traefik_static_config(self): - await super()._setup_traefik_static_config() + def persist_dynamic_config(self): + """Save the dynamic config file with the current dynamic_config""" + self.dynamic_config_handler.atomic_dump(self.dynamic_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.static_config_file, self.static_config - ) - try: - os.stat(self.dynamic_config_file) - except FileNotFoundError: - # Make sure that the dynamic configuration file exists - self.log.info( - f"Creating the dynamic configuration file: {self.dynamic_config_file}" - ) - open(self.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 + async def _setup_traefik_dynamic_config(self): + await super()._setup_traefik_dynamic_config() + self.log.info( + f"Creating the dynamic configuration file: {self.dynamic_config_file}" + ) + self.persist_dynamic_config() - def _start_traefik(self): - self.log.info("Starting traefik...") - try: - self._launch_traefik(config_type="fileprovider") - 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): + self.static_config["providers"] = { + "file" : { + "filename": self.dynamic_config_file, + "watch": True + } + } + await super()._setup_traefik_static_config() def _clean_resources(self): try: @@ -122,35 +122,19 @@ def get_target_data(d, to_find): if isinstance(v, dict): get_target_data(v, to_find) - service_node = self.routes_cache["http"]["services"].get(service_alias, None) + service_node = self.dynamic_config["http"]["services"].get(service_alias, None) if service_node is not None: get_target_data(service_node, "url") - router_node = self.routes_cache["jupyter"]["routers"].get(router_alias, None) - if router_node is not None: - get_target_data(router_node, "data") + jupyter_routers = self.dynamic_config["jupyter"]["routers"].get(router_alias, None) + if jupyter_routers is not None: + get_target_data(jupyter_routers, "data") if result["data"] is None and result["target"] is None: - self.log.info("No route for {} found!".format(routespec)) + self.log.info(f"No route for {routespec} found!") result = None - 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"]) + self.log.debug(f"traefik routespec: {traefik_routespec}") + self.log.debug(f"result for routespec {routespec}:-\n{result}") return result async def start(self): @@ -162,7 +146,7 @@ async def start(self): if the proxy is to be started by the Hub """ await super().start() - await self._wait_for_static_config(provider="file") + await self._wait_for_static_config() async def stop(self): """Stop the proxy. @@ -195,6 +179,7 @@ async def add_route(self, routespec, target, data): The proxy implementation should also have a way to associate the fact that a route came from JupyterHub. """ + self.log.debug(f"\tTraefikFileProviderProxy.add_route: Adding {routespec} for {target}") 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") @@ -202,30 +187,43 @@ async def add_route(self, routespec, target, data): rule = traefik_utils.generate_rule(traefik_routespec) async with self.mutex: - self.routes_cache["http"]["routers"][router_alias] = { + # If we've emptied the http and/or routers section, create it. + if "http" not in self.dynamic_config: + self.dynamic_config["http"] = { + "routers": {}, + } + self.dynamic_config["jupyter"] = {"routers": {}} + + elif "routers" not in self.dynamic_config["http"]: + self.dynamic_config["http"]["routers"] = {} + self.dynamic_config["jupyter"]["routers"] = {} + + # Is it worth querying the api for all entrypoints? + # Otherwise we just bind to all of them ... + #entrypoints = [ep for ep in self.static_config["entryPoints"] if ep != "enter_api" ] + self.dynamic_config["http"]["routers"][router_alias] = { + # "entryPoints": entrypoints, "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}}, + } + # Add the data node to a separate top-level node, so traefik doesn't complain. + self.dynamic_config["jupyter"]["routers"][router_alias] = { + "data": data } - # Add the data node to a separate top-level node - self.routes_cache["jupyter"]["routers"][router_alias] = {"data": data} + if "services" not in self.dynamic_config["http"]: + self.dynamic_config["http"]["services"] = {} - self.routes_cache["http"]["services"][service_alias] = { - "loadBalancer" : { + self.dynamic_config["http"]["services"][service_alias] = { + "loadBalancer": { "servers": {"server1": {"url": target} }, "passHostHeader": True } } - traefik_utils.persist_routes( - self.dynamic_config_file, self.routes_cache - ) + self.persist_dynamic_config() - self.log.debug("treefik routespec: {0}".format(traefik_routespec)) - self.log.debug("data for routespec {0}:-\n{1}".format(routespec, data)) + self.log.debug(f"traefik routespec: {traefik_routespec}") + self.log.debug(f"data for routespec {routespec}:-\n{data}") if self.should_start: try: @@ -254,10 +252,22 @@ async def delete_route(self, routespec): router_alias = traefik_utils.generate_alias(routespec, "router") async with self.mutex: - self.routes_cache["http"]["routers"].pop(router_alias, None) - self.routes_cache["http"]["services"].pop(service_alias, None) - - traefik_utils.persist_routes(self.dynamic_config_file, self.routes_cache) + + self.dynamic_config["http"]["routers"].pop(router_alias, None) + self.dynamic_config["http"]["services"].pop(service_alias, None) + self.dynamic_config["jupyter"]["routers"].pop(router_alias, None) + + # If empty, delete the keys + if not self.dynamic_config["http"]["routers"]: + self.dynamic_config["http"].pop("routers") + if not self.dynamic_config["http"]["services"]: + self.dynamic_config["http"].pop("services") + if not self.dynamic_config["http"]: + self.dynamic_config.pop("http") + if not self.dynamic_config["jupyter"]["routers"]: + self.dynamic_config["jupyter"].pop("routers") + + self.persist_dynamic_config() async def get_all_routes(self): """Fetch and return all the routes associated by JupyterHub from the @@ -277,8 +287,11 @@ async def get_all_routes(self): all_routes = {} async with self.mutex: - for key, value in self.routes_cache["http"]["routers"].items(): - escaped_routespec = "".join(key.split("_", 1)[1:]) + for router, value in self.dynamic_config["http"]["routers"].items(): + if router not in self.dynamic_config["jupyter"]["routers"]: + # Only check routers defined in jupyter node + continue + escaped_routespec = "".join(router.split("_", 1)[1:]) traefik_routespec = escapism.unescape(escaped_routespec) routespec = self._routespec_from_traefik_path(traefik_routespec) all_routes.update({ diff --git a/jupyterhub_traefik_proxy/install.py b/jupyterhub_traefik_proxy/install.py index 4846a1a3..ddc82824 100644 --- a/jupyterhub_traefik_proxy/install.py +++ b/jupyterhub_traefik_proxy/install.py @@ -10,6 +10,7 @@ import warnings checksums_traefik = { + "https://github.com/traefik/traefik/releases/download/v2.4.8/traefik_v2.4.8_linux_arm64.tar.gz": "0931fdd9c855fcafd38eba7568a1d287200fad5afd1aef7d112fb3a48d822fcc", "https://github.com/traefik/traefik/releases/download/v2.4.8/traefik_v2.4.8_linux_amd64.tar.gz": "de8d56f6777c5098834d4f8d9ed419b7353a3afe913393a55b6fd14779564129", "https://github.com/traefik/traefik/releases/download/v2.4.8/traefik_v2.4.8_darwin_amd64.tar.gz": "7d946baa422acfcf166e19779052c005722db03de3ab4d7aff586c4b4873a0f3", "https://github.com/traefik/traefik/releases/download/v2.4.8/traefik_v2.4.8_windows_amd64.zip": "4203443cb1e91d76f81d1e2a41fb70e66452d951b1ffd8964218a7bc211c377d", @@ -27,14 +28,14 @@ "https://github.com/etcd-io/etcd/releases/download/v3.4.15/etcd-v3.4.15-darwin-amd64.tar.gz": "c596709069193bffc639a22558bdea4d801128e635909ea01a6fd5b5c85da729", "https://github.com/etcd-io/etcd/releases/download/v3.3.10/etcd-v3.3.10-linux-amd64.tar.gz": "1620a59150ec0a0124a65540e23891243feb2d9a628092fb1edcc23974724a45", "https://github.com/etcd-io/etcd/releases/download/v3.3.10/etcd-v3.3.10-darwin-amd64.tar.gz": "fac4091c7ba6f032830fad7809a115909d0f0cae5cbf5b34044540def743577b", - "https://github.com/etcd-io/etcd/releases/download/v3.2.25/etcd-v3.3.25-linux-amd64.tar.gz": "8a509ffb1443088d501f19e339a0d9c0058ce20599752c3baef83c1c68790ef7", - "https://github.com/etcd-io/etcd/releases/download/v3.2.25/etcd-v3.3.25-darwin-amd64.tar.gz": "9950684a01d7431bc12c3dba014f222d55a862c6f8af64c09c42d7a59ed6790d", + "https://github.com/etcd-io/etcd/releases/download/v3.2.25/etcd-v3.2.25-linux-amd64.tar.gz": "8a509ffb1443088d501f19e339a0d9c0058ce20599752c3baef83c1c68790ef7", + "https://github.com/etcd-io/etcd/releases/download/v3.2.25/etcd-v3.2.25-darwin-amd64.tar.gz": "9950684a01d7431bc12c3dba014f222d55a862c6f8af64c09c42d7a59ed6790d", } checksums_consul = { - "https://releases.hashicorp.com/consul/1.9.4/consul_1.9.4_darwin.zip": "c168240d52f67c71b30ef51b3594673cad77d0dbbf38c412b2ee30b39ef30843", "https://releases.hashicorp.com/consul/1.9.4/consul_1.9.4_linux_amd64.zip": "da3919197ef33c4205bb7df3cc5992ccaae01d46753a72fe029778d7f52fb610", "https://releases.hashicorp.com/consul/1.9.4/consul_1.9.4_linux_arm64.zip": "012c552aff502f907416c9a119d2dfed88b92e981f9b160eb4fe292676afdaeb", + "https://releases.hashicorp.com/consul/1.9.4/consul_1.9.4_darwin.zip": "c168240d52f67c71b30ef51b3594673cad77d0dbbf38c412b2ee30b39ef30843", "https://releases.hashicorp.com/consul/1.6.1/consul_1.6.1_linux_amd64.zip": "a8568ca7b6797030b2c32615b4786d4cc75ce7aee2ed9025996fe92b07b31f7e", "https://releases.hashicorp.com/consul/1.6.1/consul_1.6.1_darwin_amd64.zip": "4bc205e06b2921f998cb6ddbe70de57f8e558e226e44aba3f337f2f245678b85", "https://releases.hashicorp.com/consul/1.5.0/consul_1.5.0_linux_amd64.zip": "1399064050019db05d3378f757e058ec4426a917dd2d240336b51532065880b6", @@ -70,7 +71,7 @@ def install_traefik(prefix, plat, traefik_version): traefik_archive_path = os.path.join(prefix, traefik_archive) traefik_url = ( - "https://github.com/containous/traefik/releases" + "https://github.com/traefik/traefik/releases" f"/download/v{traefik_version}/{traefik_archive}" ) @@ -78,7 +79,7 @@ def install_traefik(prefix, plat, traefik_version): print(f"Traefik already exists") if traefik_url not in checksums_traefik: warnings.warn( - f"Traefik {traefik_version} not supported !", + f"Traefik {traefik_version} not tested !", stacklevel=2, ) os.chmod(traefik_bin, 0o755) @@ -94,28 +95,27 @@ def install_traefik(prefix, plat, traefik_version): os.remove(traefik_archive_path) os.remove(traefik_bin) - if traefik_url in checksums_traefik: - print(f"Downloading traefik {traefik_version}...") - urlretrieve(traefik_url, traefik_archive_path) + print(f"Downloading traefik {traefik_version} from {traefik_url}...") + urlretrieve(traefik_url, traefik_archive_path) + if traefik_url in checksums_traefik: if checksum_file(traefik_archive_path) != checksums_traefik[traefik_url]: raise IOError("Checksum failed") - - print("Extracting the archive...") - if traefik_archive_extension == "tar.gz": - with tarfile.open(traefik_archive_path, "r") as tar_ref: - tar_ref.extract("traefik", prefix) - else: - with zipfile.ZipFile(traefik_archive_path, "r") as zip_ref: - zip_ref.extract("traefik.exe", prefix) - - os.chmod(traefik_bin, 0o755) else: warnings.warn( - f"Traefik {traefik_version} not supported !", + f"Traefik {traefik_version} not tested !", stacklevel=2, ) + print("Extracting the archive...") + if traefik_archive_extension == "tar.gz": + with tarfile.open(traefik_archive_path, "r") as tar_ref: + tar_ref.extract("traefik", prefix) + else: + with zipfile.ZipFile(traefik_archive_path, "r") as zip_ref: + zip_ref.extract("traefik.exe", prefix) + + os.chmod(traefik_bin, 0o755) print("--- Done ---") @@ -142,8 +142,8 @@ def install_etcd(prefix, plat, etcd_version): print(f"Etcd and etcdctl already exist") if etcd_url not in checksums_etcd: warnings.warn( - f"Etcd {etcd_version} not supported !", - stacklevel=2, + f"Etcd {etcd_version} not supported ! Or, at least, we don't " + f"recognise {etcd_url} in our checksums", stacklevel=2, ) os.chmod(etcd_bin, 0o755) os.chmod(etcdctl_bin, 0o755) @@ -193,7 +193,8 @@ def install_etcd(prefix, plat, etcd_version): shutil.rmtree(etcd_binaries) else: warnings.warn( - f"Etcd {etcd_version} not supported !", + f"Etcd {etcd_version} not supported ! Or, at least, we don't " + f"recognise {etcd_url} in our checksums", stacklevel=2 ) @@ -220,7 +221,8 @@ def install_consul(prefix, plat, consul_version): print(f"Consul already exists") if consul_url not in checksums_consul: warnings.warn( - f"Consul {consul_version} not supported !", + f"Consul {consul_version} not supported ! Or, at least we don't have " + f"it {consul_url} in our checksums", stacklevel=2, ) os.chmod(consul_bin, 0o755) @@ -255,7 +257,8 @@ def install_consul(prefix, plat, consul_version): shutil.rmtree(consul_binaries) else: warnings.warn( - f"Consul {consul_version} not supported !", + f"Consul {consul_version} not supported ! Or, at least we don't have " + f"it {consul_url} in our checksums", stacklevel=2, ) @@ -367,7 +370,7 @@ def main(): parser.add_argument( "--etcd-version", dest="etcd_version", - default="3.4.7", + default="3.4.15", help=textwrap.dedent( """\ The version of etcd to download. diff --git a/jupyterhub_traefik_proxy/kv_proxy.py b/jupyterhub_traefik_proxy/kv_proxy.py index 32f56b3c..cc69629e 100644 --- a/jupyterhub_traefik_proxy/kv_proxy.py +++ b/jupyterhub_traefik_proxy/kv_proxy.py @@ -21,7 +21,8 @@ import json import os -from traitlets import Any, Unicode +from traitlets import Any, Unicode, default +from collections import MutableMapping from . import traefik_utils from jupyterhub_traefik_proxy import TraefikProxy @@ -37,7 +38,7 @@ class TKvProxy(TraefikProxy): kv_client = Any() # Key-value store client - kv_name = Unicode(config=False, help="""The name of the key value store""") + #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""" @@ -59,6 +60,23 @@ class TKvProxy(TraefikProxy): 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""" + ) + + @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. @@ -69,7 +87,7 @@ def _define_kv_specific_static_config(self): 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. + must be stored into `proxy.static_config` dict under the `provider_name` key. """ raise NotImplementedError() @@ -189,37 +207,22 @@ def _clean_resources(self): 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.log.debug("Setup the KV-specific static config") self._define_kv_specific_static_config() - try: - traefik_utils.persist_static_conf( - self.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 + await super()._setup_traefik_static_config() + + async def _setup_traefik_dynamic_config(self): + self.log.info("Loading traefik dynamic config into kv store.") + await super()._setup_traefik_dynamic_config() + await self.persist_dynamic_config() 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) + await self._wait_for_static_config() async def stop(self): """Stop the proxy. @@ -248,7 +251,7 @@ async def add_route(self, routespec, target, data): 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) + route_keys = traefik_utils.generate_route_keys(self, routespec, separator=self.kv_separator) # Store the data dict passed in by JupyterHub data = json.dumps(data) @@ -294,7 +297,7 @@ async def delete_route(self, routespec): """ routespec = self._routespec_to_traefik_path(routespec) jupyterhub_routespec = self.kv_jupyterhub_prefix + routespec - route_keys = traefik_utils.generate_route_keys(self, routespec) + route_keys = traefik_utils.generate_route_keys(self, routespec, separator=self.kv_separator) status, response = await self._kv_atomic_delete_route_parts( jupyterhub_routespec, route_keys @@ -367,3 +370,38 @@ async def get_route(self, routespec): "target": target, "data": None if data is None else json.loads(data), } + + def flatten_dict_for_kv(self, data, prefix='traefik'): + """Flatten a dictionary of :arg:`data` for storage in the KV store, + prefixing each key with :arg:`prefix` and joining each key with + `self.kv_separator`. + + e.g. flatten_dict_for_kv( {'x' : {'y' : {'z': 'a'} }, {'foo': 'bar'} } ) + + Returns: + result (dict): + { + 'traefik.x.y.z' : 'a', + 'traefik.x.foo': 'bar' + } + + Ref: Taken from https://stackoverflow.com/a/6027615 + """ + sep = self.kv_separator + items = {} + for k, v in data.items(): + new_key = prefix + sep + k if prefix else k + if isinstance(v, MutableMapping): + items.update(self.flatten_dict_for_kv(v, prefix=new_key)) + #else: + #items.update({new_key: v}) + elif isinstance(v, str): + items.update({new_key: v}) + elif isinstance(v, list): + for n, item in enumerate(v): + items.update({ f"{new_key}{sep}{n}" : item }) + #items.update({new_key: ", ".join(v)}) + #transations.append(self.kv_client.transactions.put(k, ", ".join(v))) + else: + raise ValueError(f"Cannot upload {v} of type {type(v)} to etcd store") + return items diff --git a/jupyterhub_traefik_proxy/proxy.py b/jupyterhub_traefik_proxy/proxy.py index ecb1fd77..98e07ee9 100644 --- a/jupyterhub_traefik_proxy/proxy.py +++ b/jupyterhub_traefik_proxy/proxy.py @@ -20,7 +20,8 @@ import json from os.path import abspath, dirname, join -from subprocess import Popen +from subprocess import Popen, TimeoutExpired +import asyncio.subprocess from urllib.parse import urlparse from traitlets import Any, Bool, Dict, Integer, Unicode, default @@ -40,8 +41,11 @@ class TraefikProxy(Proxy): "traefik.toml", config=True, help="""traefik's static configuration file""" ) + static_config = Dict() + dynamic_config = Dict() + traefik_api_url = Unicode( - "http://127.0.0.1:8099", + "http://localhost:8099", config=True, help="""traefik authenticated api endpoint url""", ) @@ -52,12 +56,36 @@ class TraefikProxy(Proxy): help="""validate SSL certificate of traefik api endpoint""", ) - traefik_log_level = Unicode("ERROR", config=True, help="""traefik's log level""") + debug = Bool(False, config=True, help="""Debug the proxy class?""") + + traefik_log_level = Unicode("DEBUG", config=True, help="""traefik's log level""") traefik_api_password = Unicode( config=True, help="""The password for traefik api login""" ) + provider_name = Unicode( + config=True, help="""The provider name that Traefik expects, e.g. file, consul, etcd""" + ) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if kwargs.get('debug', self.debug) == True: + import sys, logging + # Check we don't already have a StreamHandler + addHandler = True + for handler in self.log.handlers: + if isinstance(handler, logging.StreamHandler): + addHandler = False + if addHandler: + self.log.setLevel("DEBUG") + handler = logging.StreamHandler(sys.stdout) + handler.setLevel("DEBUG") + self.log.addHandler(handler) + self.log.debug(f"Initialising {type(self).__name__}") + + #if kwargs.get('debug', self.debug) is True: + @default("traefik_api_password") def _warn_empty_password(self): self.log.warning("Traefik API password was not set.") @@ -99,42 +127,36 @@ def _warn_empty_username(self): help="""Timeout (in seconds) when waiting for traefik to register an updated route.""", ) - static_config = Dict() - def _generate_htpassword(self): - from passlib.apache import HtpasswdFile - - ht = HtpasswdFile() - ht.set_password(self.traefik_api_username, self.traefik_api_password) - self.traefik_api_hashed_password = str(ht.to_string()).split(":")[1][:-3] + from passlib.hash import apr_md5_crypt + self.traefik_api_hashed_password = apr_md5_crypt.hash(self.traefik_api_password) async def _check_for_traefik_service(self, routespec, kind): - """Check for an expected router or service + """Check for an expected router or service in the Traefik API. This is used to wait for traefik to load configuration from a provider """ # expected e.g. 'service' + '_' + routespec @ file - expected = traefik_utils.generate_alias(routespec, kind) + "@file" - path = "/api/http/{0}s".format(kind) + expected = traefik_utils.generate_alias(routespec, kind) + "@" + self.provider_name + path = f"/api/http/{kind}s" try: resp = await self._traefik_api_request(path) json_data = json.loads(resp.body) except Exception: - self.log.exception("Error checking traefik api for %s %s", kind, routespec) + self.log.exception(f"Error checking traefik api for {kind} {routespec}") return False service_names = [service['name'] for service in json_data] if expected not in service_names: - self.log.debug("traefik %s not yet in %s", expected, kind) - self.log.debug("Current traefik %ss: %s", kind, json_data) + self.log.debug(f"traefik {expected} not yet in {kind}") return False # found the expected endpoint return True async def _wait_for_route(self, routespec): - self.log.info("Waiting for %s to register with traefik", routespec) + self.log.info(f"Waiting for {routespec} to register with traefik") async def _check_traefik_dynamic_conf_ready(): """Check if traefik loaded its dynamic configuration yet""" @@ -151,7 +173,7 @@ async def _check_traefik_dynamic_conf_ready(): await exponential_backoff( _check_traefik_dynamic_conf_ready, - "Traefik route for %s configuration not available" % routespec, + f"Traefik route for {routespec} configuration not available", timeout=self.check_route_timeout, ) @@ -169,13 +191,14 @@ async def _traefik_api_request(self, path): self.log.warning("%s GET %s", resp.code, url) else: self.log.debug("%s GET %s", resp.code, url) + + self.log.debug(f"Succesfully received data from {path}: {resp.body}") return resp - async def _wait_for_static_config(self, provider): + async def _wait_for_static_config(self): async def _check_traefik_static_conf_ready(): """Check if traefik loaded its static configuration yet""" try: - #resp = await self._traefik_api_request("/api/overview/providers/" + provider) resp = await self._traefik_api_request("/api/overview") except Exception: self.log.exception("Error checking for traefik static configuration") @@ -198,59 +221,122 @@ async def _check_traefik_static_conf_ready(): def _stop_traefik(self): self.log.info("Cleaning up proxy[%i]...", self.traefik_process.pid) - self.traefik_process.kill() - self.traefik_process.wait() - - def _launch_traefik(self, config_type): - if config_type == "fileprovider" or config_type == "etcdv3" or config_type == "consul": - config_file_path = abspath(join(dirname(__file__), "traefik.toml")) - self.traefik_process = Popen( - ["traefik", "-c", config_file_path], stdout=None - ) - else: + self.traefik_process.terminate() + try: + self.traefik_process.communicate(timeout=10) + except TimeoutExpired: + self.traefik_process.kill() + self.traefik_process.communicate() + finally: + self.traefik_process.wait() + + def _start_traefik(self): + if self.provider_name not in ("file", "etcd", "consul"): raise ValueError( "Configuration mode not supported \n.\ - The proxy can only be configured through toml, etcd and consul" + The proxy can only be configured through fileprovider, etcd and consul" + ) + try: + self.traefik_process = Popen([ + "traefik", "--configfile", abspath(self.static_config_file) + ]) + 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 + except Exception as e: + self.log.exception(e) + raise async def _setup_traefik_static_config(self): + """When should_start=True, we are in control of traefik's static configuration + file. This sets up the entrypoints and api handler in self.static_config, and + then saves it to :attrib:`self.static_config_file`. + + Subclasses should specify any traefik providers themselves, in + :attrib:`self.static_config["providers"]` + """ self.log.info("Setting up traefik's static config...") - self._generate_htpassword() - self.static_config = {} - self.static_config["debug"] = True - self.static_config["logLevel"] = self.traefik_log_level + self.static_config["log"] = { "level": self.traefik_log_level } + entryPoints = {} if self.ssl_cert and self.ssl_key: - self.static_config["defaultentrypoints"] = ["https"] - entryPoints["https"] = { + entryPoints["websecure"] = { "address": ":" + str(urlparse(self.public_url).port), - "tls": { - "certificates": [ - {"certFile": self.ssl_cert, "keyFile": self.ssl_key} - ] - }, + "tls": {}, } else: - self.static_config["defaultentrypoints"] = ["http"] - entryPoints["http"] = {"address": ":" + str(urlparse(self.public_url).port)} - - auth = { - "basic": { - "users": [ - self.traefik_api_username + ":" + self.traefik_api_hashed_password - ] - } - } - entryPoints["auth_api"] = { + entryPoints["web"] = {"address": ":" + str(urlparse(self.public_url).port)} + + entryPoints["enter_api"] = { "address": ":" + str(urlparse(self.traefik_api_url).port), - "auth": auth, } self.static_config["entryPoints"] = entryPoints - self.static_config["api"] = {"dashboard": True, "entrypoint": "auth_api"} + self.static_config["api"] = {"dashboard": True} #, "entrypoints": "auth_api"} self.static_config["wss"] = {"protocol": "http"} + try: + self.log.debug(f"Persisting the static config: {self.static_config}") + traefik_utils.persist_static_conf( + self.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 + + async def _setup_traefik_dynamic_config(self): + self.log.info("Setting up traefik's dynamic config...") + self._generate_htpassword() + api_url = urlparse(self.traefik_api_url) + api_path = api_url.path if api_url.path else '/api' + api_credentials = "{0}:{1}".format( + self.traefik_api_username, + self.traefik_api_hashed_password + ) + self.dynamic_config.update({ + "http": { + "routers": { + "route_api": { + "rule": f"Host(`{api_url.hostname}`) && (PathPrefix(`{api_path}`) || PathPrefix(`/dashboard`))", + "entryPoints": ["enter_api"], + "service": "api@internal", + "middlewares": ["auth_api"] + }, + }, + "middlewares": { + "auth_api": { + "basicAuth": { + "users": [ + api_credentials + ] + } + } + } + } + }) + if self.ssl_cert and self.ssl_key: + self.dynamic_config.update({ + "tls": { + "stores": { + "default": { + "defaultCertificate": { + "certFile": self.ssl_cert, + "keyFile": self.ssl_key + } + } + } + } + }) + + def _routespec_to_traefik_path(self, routespec): path = self.validate_routespec(routespec) if path != "/" and path.endswith("/"): @@ -271,6 +357,7 @@ async def start(self): if the proxy is to be started by the Hub """ await self._setup_traefik_static_config() + await self._setup_traefik_dynamic_config() self._start_traefik() async def stop(self): @@ -348,3 +435,14 @@ async def get_route(self, routespec): None: if there are no routes matching the given routespec """ raise NotImplementedError() + + async def persist_dynamic_config(self): + """Update the Traefik dynamic configuration, depending on the backend + provider in use. This is used to e.g. set up the api endpoint's + authentication (username and password), as well as default tls + certificates to use. + + :arg:`settings` is a Dict containing the traefik settings, which will + be updated on the Traefik provider depending on the subclass in use. + """ + raise NotImplementedError() diff --git a/jupyterhub_traefik_proxy/traefik_utils.py b/jupyterhub_traefik_proxy/traefik_utils.py index c4cb7794..62d64335 100644 --- a/jupyterhub_traefik_proxy/traefik_utils.py +++ b/jupyterhub_traefik_proxy/traefik_utils.py @@ -13,8 +13,8 @@ class KVStorePrefix(Unicode): def validate(self, obj, value): u = super().validate(obj, value) - if not u.endswith("/"): - u = u + "/" + if u.endswith("/"): + u = u.rstrip("/") proxy_class = type(obj).__name__ if "Consul" in proxy_class and u.startswith("/"): @@ -41,31 +41,38 @@ def generate_alias(routespec, server_type=""): return server_type + "_" + escapism.escape(routespec, safe=safe) -def generate_service_entry( proxy, service_alias, separator="/", url=False, - weight=False): - service_entry = "" +def generate_service_entry( proxy, service_alias, separator="/", url=False): + service_entry = separator.join( + ["http", "services", service_alias, "loadBalancer", "servers", "server1"] + ) if separator == "/": - service_entry = proxy.kv_traefik_prefix - service_entry += separator.join(["services", service_alias, "servers", "server1"]) + service_entry = proxy.kv_traefik_prefix + separator + service_entry if url: service_entry += separator + "url" - elif weight: - service_entry += separator + "weight" - return service_entry +def generate_service_weight_entry( proxy, service_alias, separator="/"): + return separator.join( + [proxy.kv_traefik_prefix, "http", "services", service_alias, + "weighted", "services", "0", "weight"] + ) + def generate_router_service_entry(proxy, router_alias): - return proxy.kv_traefik_prefix + "routers/" + router_alias + "/service" + return "/".join( + [proxy.kv_traefik_prefix, "http", "routers", router_alias, "service"] + ) + #return proxy.kv_traefik_prefix + "routers/" + router_alias + "/service" def generate_router_rule_entry(proxy, router_alias, separator="/"): router_rule_entry = separator.join( - ["routers", router_alias, "routes", "test"] + ["http", "routers", router_alias] ) if separator == "/": - router_rule_entry = ( - proxy.kv_traefik_prefix + router_rule_entry + separator + "rule" + router_rule_entry = separator.join( + [proxy.kv_traefik_prefix, router_rule_entry, "rule"] + #proxy.kv_traefik_prefix + router_rule_entry + separator + "rule" ) return router_rule_entry @@ -80,7 +87,7 @@ def generate_route_keys(proxy, routespec, separator="/"): [ "service_alias", "service_url_path", - "service_weight_path", + #"service_weight_path", "router_alias", "router_service_path", "router_rule_path", @@ -90,7 +97,8 @@ def generate_route_keys(proxy, routespec, separator="/"): if separator != ".": service_url_path = generate_service_entry(proxy, service_alias, url=True) router_rule_path = generate_router_rule_entry(proxy, router_alias) - service_weight_path = generate_service_entry(proxy, service_alias, weight=True) + #service_weight_path = generate_service_entry(proxy, service_alias, weight=True) + #service_weight_path = generate_service_weight_entry(proxy, service_alias) router_service_path = generate_router_service_entry(proxy, router_alias) else: service_url_path = generate_service_entry( @@ -99,13 +107,13 @@ def generate_route_keys(proxy, routespec, separator="/"): router_rule_path = generate_router_rule_entry( proxy, router_alias, separator=separator ) - service_weight_path = "" + #service_weight_path = "" router_service_path = "" return RouteKeys( service_alias, service_url_path, - service_weight_path, + #service_weight_path, router_alias, router_service_path, router_rule_path, @@ -146,7 +154,8 @@ class TraefikConfigFileHandler(object): def __init__(self, file_path): file_ext = file_path.rsplit('.', 1)[-1] if file_ext == 'yaml': - import yaml as config_handler + from ruamel.yaml import YAML + config_handler = YAML(typ="safe") elif file_ext == 'toml': import toml as config_handler else: @@ -161,7 +170,8 @@ def __init__(self, file_path): def load(self): """Depending on self.file_path, call either yaml.load or toml.load""" - return self._load(self.file_path) + with open(self.file_path, "r") as fd: + return self._load(fd) def dump(self, data): with open(self.file_path, "w") as f: @@ -177,10 +187,16 @@ def persist_static_conf(file_path, static_conf_dict): handler = TraefikConfigFileHandler(file_path) handler.dump(static_conf_dict) -def persist_routes(file_path, routes_dict): +def persist_dynamic_conf(file_path, routes_dict): + # FIXME: Only used by fileprovider, remove? handler = TraefikConfigFileHandler(file_path) handler.atomic_dump(routes_dict) -def load_routes(file_path): +def load_dynamic_conf(file_path): + # FIXME: Only used by fileprovider, remove? handler = TraefikConfigFileHandler(file_path) return handler.load() + +# FIXME: Alias above functions for backwards compatibility? +persist_routes = persist_dynamic_conf +load_routes = load_dynamic_conf diff --git a/requirements.txt b/requirements.txt index ccb6dc38..711820e1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,6 @@ aiohttp escapism jupyterhub>=0.9 passlib -toml +# toml is now optional, as can use yaml configuration files instead now... +toml[toml_config] +ruamel.yaml[yaml_config] diff --git a/tests/config_files/dynamic_config/dynamic_conf.toml b/tests/config_files/dynamic_config/dynamic_conf.toml new file mode 100644 index 00000000..e309690e --- /dev/null +++ b/tests/config_files/dynamic_config/dynamic_conf.toml @@ -0,0 +1,11 @@ +# Example dynamic configuration file for an external file provider proxy. +# Defines the API listener and its authentication + +[http.routers.router-api] + rule = "Host(`localhost`) && (PathPrefix(`/api`) || PathPrefix(`/dashboard`))" + entryPoints = ["enter_api"] + service = "api@internal" + middlewares = ["auth_api"] + +[http.middlewares.auth_api.basicAuth] + users = [ "api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./",] diff --git a/tests/config_files/rules.toml b/tests/config_files/rules.toml deleted file mode 100644 index e69de29b..00000000 diff --git a/tests/config_files/traefik.toml b/tests/config_files/traefik.toml index 93013389..5f57109c 100644 --- a/tests/config_files/traefik.toml +++ b/tests/config_files/traefik.toml @@ -1,23 +1,20 @@ -defaultentrypoints = [ "http",] -debug = true -logLevel = "ERROR" +[log] + level = "debug" [api] -dashboard = true -entrypoint = "auth_api" + dashboard = true [wss] -protocol = "http" + protocol = "http" -[file] -filename = "./tests/config_files/rules.toml" -watch = true +[providers.file] + directory = "./tests/config_files/dynamic_config" + watch = true -[entryPoints.http] -address = "127.0.0.1:8000" +[entryPoints] + [entryPoints.my_web_api] + address = "127.0.0.1:8000" -[entryPoints.auth_api] -address = "127.0.0.1:8099" + [entryPoints.enter_api] + address = "127.0.0.1:8099" -[entryPoints.auth_api.auth.basic] -users = [ "api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./",] diff --git a/tests/config_files/traefik_consul_config.json b/tests/config_files/traefik_consul_config.json new file mode 100644 index 00000000..7d19f41d --- /dev/null +++ b/tests/config_files/traefik_consul_config.json @@ -0,0 +1,27 @@ +[ + { + "key": "traefik/http/middlewares/auth_api/basicAuth/users/0", + "flags": 0, + "value": "YXBpX2FkbWluOiRhcHIxJGVTL2oza3VtJHEvWDJraHNJRUcvYkJHc3RlUC54Li8=" + }, + { + "key": "traefik/http/routers/route_api/entryPoints/0", + "flags": 0, + "value": "ZW50ZXJfYXBp" + }, + { + "key": "traefik/http/routers/route_api/middlewares/0", + "flags": 0, + "value": "YXV0aF9hcGk=" + }, + { + "key": "traefik/http/routers/route_api/rule", + "flags": 0, + "value": "SG9zdChgbG9jYWxob3N0YCkgJiYgUGF0aFByZWZpeChgL2FwaWAp" + }, + { + "key": "traefik/http/routers/route_api/service", + "flags": 0, + "value": "YXBpQGludGVybmFs" + } +] diff --git a/tests/config_files/traefik_consul_config.toml b/tests/config_files/traefik_consul_config.toml deleted file mode 100644 index 71901d05..00000000 --- a/tests/config_files/traefik_consul_config.toml +++ /dev/null @@ -1,24 +0,0 @@ -defaultentrypoints = [ "http",] -debug = true -logLevel = "ERROR" - -[api] -dashboard = true -entrypoint = "auth_api" - -[wss] -protocol = "http" - -[entryPoints.http] -address = "127.0.0.1:8000" - -[entryPoints.auth_api] -address = "127.0.0.1:8099" - -[entryPoints.auth_api.auth.basic] -users = [ "api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./",] - -[consul] -endpoint = "127.0.0.1:8500" -prefix = "traefik/" -watch = true diff --git a/tests/config_files/traefik_etcd_config.toml b/tests/config_files/traefik_etcd_config.toml deleted file mode 100644 index 11245e4d..00000000 --- a/tests/config_files/traefik_etcd_config.toml +++ /dev/null @@ -1,25 +0,0 @@ -defaultentrypoints = [ "http",] -debug = true -logLevel = "ERROR" - -[api] -dashboard = true -entrypoint = "auth_api" - -[wss] -protocol = "http" - -[entryPoints.http] -address = "127.0.0.1:8000" - -[entryPoints.auth_api] -address = "127.0.0.1:8099" - -[entryPoints.auth_api.auth.basic] -users = [ "api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./",] - -[etcd] -endpoint = "127.0.0.1:2379" -prefix = "/traefik/" -useapiv3 = true -watch = true diff --git a/tests/config_files/traefik_etcd_txns.txt b/tests/config_files/traefik_etcd_txns.txt new file mode 100644 index 00000000..61e28e49 --- /dev/null +++ b/tests/config_files/traefik_etcd_txns.txt @@ -0,0 +1,8 @@ + +put traefik/http/middlewares/auth_api/basicAuth/users/0 "api_admin:$apr1$eS/j3kum$q/X2khsIEG/bBGsteP.x./" +put traefik/http/routers/route_api/entryPoints/0 "enter_api" +put traefik/http/routers/route_api/middlewares/0 "auth_api" +put traefik/http/routers/route_api/rule "Host(`localhost`) && PathPrefix(`/api`)" +put traefik/http/routers/route_api/service "api@internal" + + diff --git a/tests/conftest.py b/tests/conftest.py index 1b6c25ed..71ba58d2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -11,7 +11,28 @@ from jupyterhub_traefik_proxy import TraefikEtcdProxy from jupyterhub_traefik_proxy import TraefikConsulProxy -from jupyterhub_traefik_proxy import TraefikTomlProxy +from jupyterhub_traefik_proxy import TraefikFileProviderProxy + +from jupyterhub.utils import exponential_backoff + +from consul.aio import Consul + +class Config: + """Namespace for repeated variables. + + N.B. The user names and passwords are also stored in various configuration + files, saved in ./tests/config_files, both in plain text, and in the case + of the consul token, base64 encoded (so cannot be grep'ed).""" + # Force etcdctl to run with the v3 API. This gives us access to various + # commandss, e.g. txn + # Must be passed to the env parameter of any subprocess.Popen call that runs + # etcdctl + etcdctl_env = os.environ.copy().update({"ETCDCTL_API": "3"}) + + etcd_password = "secret" + etcd_user = "root" + + consul_token = "secret" # Define a "slow" test marker so that we can run the slow tests at the end @@ -38,7 +59,7 @@ def pytest_configure(config): @pytest.fixture -async def no_auth_consul_proxy(consul_no_acl): +async def no_auth_consul_proxy(launch_consul): """ Fixture returning a configured TraefikConsulProxy. Consul acl disabled. @@ -49,6 +70,7 @@ async def no_auth_consul_proxy(consul_no_acl): traefik_api_username="api_admin", check_route_timeout=45, should_start=True, + debug=True ) await proxy.start() yield proxy @@ -56,7 +78,7 @@ async def no_auth_consul_proxy(consul_no_acl): @pytest.fixture -async def auth_consul_proxy(consul_acl): +async def auth_consul_proxy(launch_consul_acl): """ Fixture returning a configured TraefikConsulProxy. Consul acl enabled. @@ -65,9 +87,10 @@ async def auth_consul_proxy(consul_acl): public_url="http://127.0.0.1:8000", traefik_api_password="admin", traefik_api_username="api_admin", - kv_password="secret", + kv_password=Config.consul_token, check_route_timeout=45, should_start=True, + debug=True ) await proxy.start() yield proxy @@ -75,7 +98,7 @@ async def auth_consul_proxy(consul_acl): @pytest.fixture -async def no_auth_etcd_proxy(): +async def no_auth_etcd_proxy(launch_etcd): """ Fixture returning a configured TraefikEtcdProxy. No etcd authentication. @@ -86,6 +109,7 @@ async def no_auth_etcd_proxy(): traefik_api_username="api_admin", check_route_timeout=45, should_start=True, + debug=True ) await proxy.start() yield proxy @@ -93,25 +117,24 @@ async def no_auth_etcd_proxy(): @pytest.fixture -async def auth_etcd_proxy(etcd): +async def auth_etcd_proxy(launch_etcd_auth): """ Fixture returning a configured TraefikEtcdProxy Etcd has credentials set up """ - enable_auth_in_etcd("secret") proxy = TraefikEtcdProxy( public_url="http://127.0.0.1:8000", traefik_api_password="admin", traefik_api_username="api_admin", - kv_password="secret", kv_username="root", + kv_password=Config.etcd_password, check_route_timeout=45, should_start=True, + debug=True ) await proxy.start() yield proxy await proxy.stop() - disable_auth_in_etcd("secret") @pytest.fixture(params=["no_auth_etcd_proxy", "auth_etcd_proxy"]) @@ -119,190 +142,377 @@ def etcd_proxy(request): return request.getfixturevalue(request.param) +# There must be a way to parameterise this to run on both yaml and toml files? @pytest.fixture -async def toml_proxy(): - """Fixture returning a configured TraefikTomlProxy""" - proxy = TraefikTomlProxy( +async def file_proxy_toml(): + """Fixture returning a configured TraefikFileProviderProxy""" + dynamic_config_file = os.path.join( + os.getcwd(), "tests", "config_files", "dynamic_config", "rules.toml" + ) + static_config_file = "traefik.toml" + proxy = _file_proxy(dynamic_config_file, + static_config_file=static_config_file, + should_start=True) + await proxy.start() + yield proxy + await proxy.stop() + +@pytest.fixture +async def file_proxy_yaml(): + dynamic_config_file = os.path.join( + os.getcwd(), "tests", "config_files", "dynamic_config", "rules.yaml" + ) + static_config_file = "traefik.yaml" + proxy = _file_proxy(dynamic_config_file, + static_config_file=static_config_file, + should_start=True) + await proxy.start() + yield proxy + await proxy.stop() + +def _file_proxy(dynamic_config_file, **kwargs): + ext = dynamic_config_file.rsplit('.', 1)[-1] + static_config_file = os.path.join( + os.getcwd(), f"traefik.{ext}" + ) + return TraefikFileProviderProxy( public_url="http://127.0.0.1:8000", traefik_api_password="admin", traefik_api_username="api_admin", - check_route_timeout=180, - should_start=True, + dynamic_config_file = dynamic_config_file, + check_route_timeout=60, + debug=True, + **kwargs ) - await proxy.start() +@pytest.fixture +async def external_file_proxy_yaml(launch_traefik_file): + dynamic_config_file = os.path.join( + os.getcwd(), "tests", "config_files", "dynamic_config", "rules.yaml" + ) + proxy = _file_proxy(dynamic_config_file, should_start=False) yield proxy - await proxy.stop() + os.remove(dynamic_config_file) + +@pytest.fixture +async def external_file_proxy_toml(launch_traefik_file): + dynamic_config_file = os.path.join( + os.getcwd(), "tests", "config_files", "dynamic_config", "rules.toml" + ) + proxy = _file_proxy(dynamic_config_file, should_start=False) + yield proxy + os.remove(dynamic_config_file) @pytest.fixture -def external_consul_proxy(consul_no_acl): +async def external_consul_proxy(launch_consul, configure_consul, launch_traefik_consul): proxy = TraefikConsulProxy( public_url="http://127.0.0.1:8000", traefik_api_password="admin", traefik_api_username="api_admin", check_route_timeout=45, should_start=False, + debug=True ) - traefik_process = configure_and_launch_traefik(kv_store="consul") yield proxy - traefik_process.kill() - traefik_process.wait() - @pytest.fixture -def auth_external_consul_proxy(consul_acl): +def auth_external_consul_proxy(launch_consul_acl, configure_consul_auth, launch_traefik_consul_auth): proxy = TraefikConsulProxy( public_url="http://127.0.0.1:8000", traefik_api_password="admin", traefik_api_username="api_admin", - kv_password="secret", + kv_password=Config.consul_token, check_route_timeout=45, should_start=False, + debug=True ) - traefik_process = configure_and_launch_traefik(kv_store="consul", password="secret") yield proxy - traefik_process.kill() - traefik_process.wait() - @pytest.fixture -def external_etcd_proxy(): +def external_etcd_proxy(launch_etcd, configure_etcd, launch_traefik_etcd): proxy = TraefikEtcdProxy( public_url="http://127.0.0.1:8000", traefik_api_password="admin", traefik_api_username="api_admin", check_route_timeout=45, should_start=False, + debug=True ) - traefik_process = configure_and_launch_traefik(kv_store="etcd") yield proxy - traefik_process.kill() - traefik_process.wait() - @pytest.fixture -def auth_external_etcd_proxy(): - enable_auth_in_etcd("secret") +def auth_external_etcd_proxy(launch_etcd_auth, configure_etcd_auth, launch_traefik_etcd_auth): proxy = TraefikEtcdProxy( public_url="http://127.0.0.1:8000", traefik_api_password="admin", traefik_api_username="api_admin", - kv_password="secret", + kv_password=Config.etcd_password, kv_username="root", check_route_timeout=45, should_start=False, + debug=True ) - traefik_process = configure_and_launch_traefik(kv_store="etcd", password="secret") + #traefik_process = configure_and_launch_traefik(provider="etcd", password=Config.etcd_password) yield proxy - traefik_process.kill() - traefik_process.wait() - disable_auth_in_etcd("secret") +######################################################################### +# Fixtures for launching traefik, with each backend and with or without # +# authentication # +######################################################################### + @pytest.fixture -def external_toml_proxy(): - proxy = TraefikTomlProxy( - public_url="http://127.0.0.1:8000", - traefik_api_password="admin", - traefik_api_username="api_admin", - check_route_timeout=45, +async def launch_traefik_file(): + args = ("--configfile", "./tests/config_files/traefik.toml") + print(f"\nLAUNCHING TRAEFIK with args: {args}\n") + proc = _launch_traefik(*args) + yield proc + shutdown_traefik(proc) + + +@pytest.fixture +async def launch_traefik_etcd(): + proc = _launch_traefik_cli("--providers.etcd") + yield proc + shutdown_traefik(proc) + + +@pytest.fixture +async def launch_traefik_etcd_auth(): + extra_args = ( + "--providers.etcd.username=" + Config.etcd_user, + "--providers.etcd.password=" + Config.etcd_password ) - proxy.should_start = False - proxy.toml_dynamic_config_file = "./tests/config_files/rules.toml" - # Start traefik manually - traefik_process = subprocess.Popen( - ["traefik", "-c", "./tests/config_files/traefik.toml"], stdout=None + proc = _launch_traefik_cli(*extra_args) + yield proc + shutdown_traefik(proc) + + +@pytest.fixture +async def launch_traefik_consul(): + proc = _launch_traefik_cli("--providers.consul") + yield proc + shutdown_traefik(proc) + +@pytest.fixture +async def launch_traefik_consul_auth(): + extra_args = ( + "--providers.consul.username=root", + "--providers.consul.password=" + Config.consul_token ) - yield proxy - open("./tests/config_files/rules.toml", "w").close() - traefik_process.kill() - traefik_process.wait() + traefik_env = os.environ.copy() + traefik_env.update({"CONSUL_HTTP_TOKEN": Config.consul_token}) + proc = _launch_traefik_cli(*extra_args, env=traefik_env) + yield proc + shutdown_traefik(proc) + +def _launch_traefik_cli(*extra_args, env=None): + default_args = ( + "--api", + "--log.level=debug", + "--entrypoints.web.address=:8000", + "--entrypoints.enter_api.address=:8099" + ) + args = default_args + extra_args + return _launch_traefik(*args, env=env) +def _launch_traefik(*extra_args, env=None): + traefik_launch_command = ( + "traefik", + ) + extra_args + proc = subprocess.Popen(traefik_launch_command, env=env) + return proc -@pytest.fixture(scope="session", autouse=True) -def etcd(): - etcd_proc = subprocess.Popen("etcd", stdout=None, stderr=None) - yield etcd_proc +######################################################################### +# Fixtures for configuring the traefik providers # +######################################################################### + +# Etcd Launchers and configurers # +################################## + +@pytest.fixture +def configure_etcd(): + """Load traefik api rules into the etcd kv store""" + yield _config_etcd() - etcd_proc.kill() - etcd_proc.wait() - shutil.rmtree(os.getcwd() + "/default.etcd/") +@pytest.fixture +def configure_etcd_auth(): + """Load traefik api rules into the etcd kv store, with authentication""" + yield _config_etcd( + "--user=" + Config.etcd_user + ":" + Config.etcd_password + ) + +def _config_etcd(*extra_args): + data_store_cmd = ("etcdctl", "txn") + extra_args + # Load a pre-baked dynamic configuration into the etcd store. + # This essentially puts authentication on the traefik api handler. + with open('tests/config_files/traefik_etcd_txns.txt', 'r') as fd: + txns = fd.read() + proc = subprocess.Popen(data_store_cmd, stdin=subprocess.PIPE, env=Config.etcdctl_env) + proc.communicate(txns.encode()) + proc.wait() +@pytest.fixture +def _enable_auth_in_etcd(): + user = Config.etcd_user + pw = Config.etcd_password + subprocess.call(["etcdctl", "user", "add", f"{user}:{pw}"], env=Config.etcdctl_env) + subprocess.call(["etcdctl", "user", "grant-role", "root", user], env=Config.etcdctl_env) + assert ( + subprocess.check_output(["etcdctl", "auth", "enable"], env=Config.etcdctl_env) + .decode(sys.stdout.encoding) + .strip() + == "Authentication Enabled" + ) + yield + + assert ( + subprocess.check_output( + ["etcdctl", "--user", f"{user}:{pw}", "auth", "disable"], env=Config.etcdctl_env + ).decode(sys.stdout.encoding) + .strip() == "Authentication Disabled" + ) + subprocess.call(["etcdctl", "user", "revoke-role", "root", user], env=Config.etcdctl_env) + subprocess.call(["etcdctl", "user", "delete", user], env=Config.etcdctl_env) -@pytest.fixture(scope="function", autouse=True) -def clean_etcd(): - subprocess.run(["etcdctl", "del", '""', "--from-key=true"]) +@pytest.fixture +def launch_etcd_auth(launch_etcd, _enable_auth_in_etcd): + yield @pytest.fixture() -def consul_no_acl(): +async def launch_etcd(): + etcd_proc = subprocess.Popen(["etcd", "--debug"]) + await _wait_for_etcd() + yield etcd_proc + + terminate_process(etcd_proc, timeout=15) + + # There have been cases where default.etcd didn't exist... + # Not sure why, but guess it doesn't really matter, just + # check to be safe. + default_etcd = os.path.join(os.getcwd(), "default.etcd") + if os.path.exists(default_etcd): + shutil.rmtree(default_etcd) + +async def _wait_for_etcd(user=None, pw=None): + """Etcd may not be ready if we jump straight into the tests. + Make sure it's running before we continue with configuring it or running + tests against it. + + In production, etcd would already be running, so don't put this in the + proxy classes. + """ + import etcd3 + async def _check_etcd(): + try: + cli = etcd3.client( + user=user, + password=pw + ) + routes = cli.get_prefix('/') + except Exception as e: + print(f"Etcd not up: {e}") + return False + + print( "Etcd is up!" ) + return True + + await exponential_backoff( + _check_etcd, + "Etcd not available", + timeout=20, + ) + +#@pytest.fixture(scope="function", autouse=True) +# Is this referenced anywhere?? +#@pytest.fixture +#def clean_etcd(): +# subprocess.run(["etcdctl", "del", '""', "--from-key=true"], env=Config.etcdctl_env) + + +# Consul Launchers and configurers # +#################################### + +@pytest.fixture +async def launch_consul(): consul_proc = subprocess.Popen( - ["consul", "agent", "-dev"], stdout=None, stderr=None + ["consul", "agent", "-dev"] ) + await _wait_for_consul() yield consul_proc - - consul_proc.kill() - consul_proc.wait() + await shutdown_consul(consul_proc) -@pytest.fixture() -def consul_acl(): - etcd_proc = subprocess.Popen( - [ +@pytest.fixture +async def launch_consul_acl(): + consul_proc = subprocess.Popen([ "consul", "agent", "-advertise=127.0.0.1", "-config-file=./tests/config_files/consul_config.json", "-bootstrap-expect=1", - ], - stdout=None, - stderr=None, + ] ) - yield etcd_proc - etcd_proc.kill() - etcd_proc.wait() + await _wait_for_consul(token=Config.consul_token) + yield consul_proc + await shutdown_consul(consul_proc, secret=Config.consul_token) shutil.rmtree(os.getcwd() + "/consul.data") -def configure_and_launch_traefik(kv_store, password=""): - if kv_store == "etcd": - storeconfig_command = [ - "traefik", - "storeconfig", - "-c", - "./tests/config_files/traefik_etcd_config.toml", - "--etcd", - "--etcd.endpoint=127.0.0.1:2379", - "--etcd.useapiv3=true", - ] +async def _wait_for_consul(token=None): + """Consul takes ages to shutdown and start. Make sure it's running before + we continue with configuring it or running tests against it. + + In production, consul would already be running, so don't put this in the + proxy classes. + """ + async def _check_consul(): + try: + cli = Consul(token=token) + index, data = await cli.kv.get('getting_any_nonexistent_key_will_do') + except Exception as e: + print(f"Consul not up: {e}") + return False + + print( "Consul is up!" ) + return True + + await exponential_backoff( + _check_consul, + "Consul not available", + timeout=20, + ) + + +@pytest.fixture +async def configure_consul(): + """Load an initial config into the consul KV store""" + yield _config_consul() - traefik_launch_command = ["traefik", "--etcd", "--etcd.useapiv3=true"] - - if password: - credentials = ["--etcd.username=root", "--etcd.password=" + password] - storeconfig_command += credentials - traefik_launch_command += credentials - - elif kv_store == "consul": - storeconfig_command = [ - "traefik", - "storeconfig", - "-c", - "./tests/config_files/traefik_consul_config.toml", - "--consul", - "--consul.endpoint=127.0.0.1:8500", - ] - traefik_launch_command = ["traefik", "--consul"] +@pytest.fixture +async def configure_consul_auth(): + """Load an initial config into the consul KV store, using authentication""" + yield _config_consul(secret=Config.consul_token) + - if password: - os.environ["CONSUL_HTTP_TOKEN"] = password +def _config_consul(secret=None): + proc_env = None + if secret is not None: + proc_env = os.environ.copy() + proc_env.update({"CONSUL_HTTP_TOKEN": secret}) + + consul_import_cmd = [ + "consul", "kv", "import", + "@tests/config_files/traefik_consul_config.json" + ] """ Try storing the static config to the kv store. @@ -314,30 +524,34 @@ def configure_and_launch_traefik(kv_store, password=""): raise Exception("KV not ready! 60s timeout expired!") try: # Put static config from file in kv store. - subprocess.check_call(storeconfig_command) + proc = subprocess.check_call(consul_import_cmd, env=proc_env) break except subprocess.CalledProcessError: - pass - - # Start traefik manually - traefik_process = subprocess.Popen(traefik_launch_command, stdout=None) - - return traefik_process - - -def enable_auth_in_etcd(password): - subprocess.call(["etcdctl", "user", "add", "root:" + password]) - subprocess.call(["etcdctl", "user", "grant-role", "root", "root"]) - assert ( - subprocess.check_output(["etcdctl", "auth", "enable"]) - .decode(sys.stdout.encoding) - .strip() - == "Authentication Enabled" - ) - + time.sleep(3) + +######################################################################### +# Teardown functions # +######################################################################### + +async def shutdown_consul(consul_proc, secret=None): + # For some reason, without running `consul leave`, subsequent consul tests fail + consul_env = None + if secret is not None: + consul_env = os.environ.copy() + consul_env.update({"CONSUL_HTTP_TOKEN" : secret}) + subprocess.call(["consul", "leave"], env=consul_env) + terminate_process(consul_proc, timeout=30) + +def shutdown_traefik(traefik_process): + terminate_process(traefik_process) + +def terminate_process(proc, timeout=5): + proc.terminate() + try: + proc.communicate(timeout=timeout) + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + finally: + proc.wait() -def disable_auth_in_etcd(password): - subprocess.call(["etcdctl", "user", "remove", "root"]) - subprocess.check_output( - ["etcdctl", "--user", "root:" + password, "auth", "disable"] - ).decode(sys.stdout.encoding).strip() == "Authentication Disabled" diff --git a/tests/dummy_http_server.py b/tests/dummy_http_server.py index 73941b9b..dc7b89b9 100644 --- a/tests/dummy_http_server.py +++ b/tests/dummy_http_server.py @@ -40,10 +40,17 @@ def run(port=80): proto = str(argv[2]) if proto == "http": run(port=int(argv[1])) - else: + elif proto == "ws": asyncio.get_event_loop().run_until_complete( - websockets.serve(send_port, "localhost", int(argv[1])) + # localhost can resolve to ::1. This causes issues on + # docker, which disables the IPv6 stack by default, + # resulting in the error 'Cannot assign requested address'. + websockets.serve(send_port, "127.0.0.1", int(argv[1])) ) asyncio.get_event_loop().run_forever() + else: + raise ValueError( + f"I know how to run 'http' or 'ws' servers, not {proto} servers" + ) else: run() diff --git a/tests/proxytest.py b/tests/proxytest.py index 90c8ab17..c6243697 100644 --- a/tests/proxytest.py +++ b/tests/proxytest.py @@ -81,14 +81,16 @@ def launch_backend(): def _launch_backend(port, proto="http"): backend = subprocess.Popen( - [sys.executable, dummy_server_path, str(port), proto], stdout=None + [sys.executable, dummy_server_path, str(port), proto] ) running_backends.append(backend) yield _launch_backend for proc in running_backends: - proc.kill() + proc.terminate() + for proc in running_backends: + proc.communicate() for proc in running_backends: proc.wait() @@ -189,7 +191,7 @@ async def test_route_exist(spec, backend): if not expect_value_error(spec): try: - del route["data"]["last_activity"] # CHP + del( route["data"]["last_activity"] ) # CHP except KeyError: pass @@ -321,7 +323,7 @@ async def test_get_all_routes(proxy, launch_backend): routes = await proxy.get_all_routes() try: for route_key in routes.keys(): - del routes[route_key]["data"]["last_activity"] # CHP + del( routes[route_key]["data"]["last_activity"] ) # CHP except KeyError: pass @@ -424,14 +426,14 @@ async def test_websockets(proxy, launch_backend): launch_backend(default_backend_port, "ws") await exponential_backoff( - utils.check_host_up, "Traefik not reacheable", ip="localhost", port=traefik_port + utils.check_host_up, "Traefik not reacheable", ip="127.0.0.1", port=traefik_port ) # Check if default backend is reacheable await exponential_backoff( utils.check_host_up, "Backend not reacheable", - ip="localhost", + ip="127.0.0.1", port=default_backend_port, ) # Add route to default_backend diff --git a/tests/test_installer.py b/tests/test_installer.py index 149a4ad5..157a336b 100644 --- a/tests/test_installer.py +++ b/tests/test_installer.py @@ -104,7 +104,7 @@ def test_version(tmpdir): installer_module, f"--output={deps_dir}", "--traefik", - "--traefik-version=1.7.0", + "--traefik-version=2.4.8", "--etcd", "--etcd-version=3.2.25", "--consul", @@ -176,7 +176,7 @@ def test_warning(tmpdir): installer_module, f"--output={deps_dir}", "--traefik", - "--traefik-version=1.6.6", + "--traefik-version=2.4.1", ], stderr=subprocess.STDOUT, ) diff --git a/tests/test_proxy.py b/tests/test_proxy.py index 2f6b6d29..11d712b1 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -14,12 +14,14 @@ "auth_consul_proxy", "no_auth_etcd_proxy", "auth_etcd_proxy", - "toml_proxy", + "file_proxy_toml", + "file_proxy_yaml", "external_consul_proxy", "auth_external_consul_proxy", "external_etcd_proxy", "auth_external_etcd_proxy", - "external_toml_proxy", + "external_file_proxy_toml", + "external_file_proxy_yaml", ] ) def proxy(request): diff --git a/tests/test_traefik_api_auth.py b/tests/test_traefik_api_auth.py index 0751a341..e82a8021 100644 --- a/tests/test_traefik_api_auth.py +++ b/tests/test_traefik_api_auth.py @@ -1,10 +1,8 @@ """Tests for the authentication to the traefik proxy api (dashboard)""" import pytest -import utils -from urllib.parse import urlparse from jupyterhub.utils import exponential_backoff -from tornado.httpclient import AsyncHTTPClient, HTTPRequest +from tornado.httpclient import AsyncHTTPClient # Mark all tests in this file as asyncio pytestmark = pytest.mark.asyncio @@ -12,7 +10,8 @@ @pytest.fixture( params=[ - "toml_proxy", + "file_proxy_toml", + "file_proxy_yaml", "no_auth_etcd_proxy", "auth_etcd_proxy", "no_auth_consul_proxy", @@ -28,25 +27,40 @@ def proxy(request): [("api_admin", "admin", 200), ("api_admin", "1234", 401), ("", "", 401)], ) async def test_traefik_api_auth(proxy, username, password, expected_rc): - traefik_port = urlparse(proxy.public_url).port + traefik_api_url = proxy.traefik_api_url + "/api" - await exponential_backoff( - utils.check_host_up, "Traefik not reacheable", ip="localhost", port=traefik_port - ) + # Must have a trailing slash! + dashboard_url = proxy.traefik_api_url + "/dashboard/" + + async def api_login(): + try: + if not username and not password: + resp = await AsyncHTTPClient().fetch(traefik_api_url) + else: + resp = await AsyncHTTPClient().fetch( + dashboard_url, + auth_username=username, + auth_password=password, + ) + except ConnectionRefusedError: + rc = None + except Exception as e: + rc = e.response.code + else: + rc = resp.code + return rc - try: - if not username and not password: - resp = await AsyncHTTPClient().fetch(proxy.traefik_api_url + "/dashboard") + async def cmp_api_login(): + rc = await api_login() + if rc == expected_rc: + return True else: - resp = await AsyncHTTPClient().fetch( - proxy.traefik_api_url + "/dashboard/", - auth_username=username, - auth_password=password, - ) - rc = resp.code - except ConnectionRefusedError: - rc = None - except Exception as e: - rc = e.response.code + return False + + await exponential_backoff( + cmp_api_login, "Traefik API not reacheable" + ) + rc = await api_login() assert rc == expected_rc + return diff --git a/tests/test_traefik_utils.py b/tests/test_traefik_utils.py index edfd4013..4288e5fc 100644 --- a/tests/test_traefik_utils.py +++ b/tests/test_traefik_utils.py @@ -29,7 +29,7 @@ def test_roundtrip_routes(): file = "test_roudtrip.toml" open(file, "a").close() traefik_utils.persist_routes(file, routes) - reloaded = traefik_utils.load_routes(file) + reloaded = traefik_utils.load_dynamic_conf(file) os.remove(file) assert reloaded == routes