From 077a6258a950843368b59959bfc762a98fc37b3d Mon Sep 17 00:00:00 2001 From: Marc Richter Date: Wed, 8 Apr 2020 22:58:34 +0200 Subject: [PATCH 01/10] Updated installer with latest versions. --- jupyterhub_traefik_proxy/install.py | 221 +++++++++++++++------------- 1 file changed, 119 insertions(+), 102 deletions(-) diff --git a/jupyterhub_traefik_proxy/install.py b/jupyterhub_traefik_proxy/install.py index fdcbb999..81cc3c7a 100644 --- a/jupyterhub_traefik_proxy/install.py +++ b/jupyterhub_traefik_proxy/install.py @@ -10,24 +10,30 @@ import warnings checksums_traefik = { - "https://github.com/containous/traefik/releases/download/v1.7.18/traefik_linux-amd64": "3c2d153d80890b6fc8875af9f8ced32c4d684e1eb5a46d9815337cb343dfd92e", - "https://github.com/containous/traefik/releases/download/v1.7.18/traefik_darwin-amd64": "84e07a184c31b7fb86417ba3a237ad334a26bcb1ed53fd56f0774afaa34074d9", - "https://github.com/containous/traefik/releases/download/v1.7.5/traefik_linux-amd64": "4417a9d83753e1ad6bdd64bbbeaeb4b279bcc71542e779b7bcb3b027c6e3356e", - "https://github.com/containous/traefik/releases/download/v1.7.5/traefik_darwin-amd64": "379d4af242743a3fe44b44a1ee6df68ea8332578d85de35f264e062c19fd20a0", - "https://github.com/containous/traefik/releases/download/v1.7.0/traefik_linux-amd64": "b84cb03e8a175b8b7d1a30246d19705f607c6ae5ee89f2dca7a1adccab919135", - "https://github.com/containous/traefik/releases/download/v1.7.0/traefik_darwin-amd64": "3000cb9f8ed567e9bc567cce33107f6877f2017c69fae8ac235b51a7a94229bf", + "https://github.com/containous/traefik/releases/download/v2.2.0/traefik_v2.2.0_linux_amd64.tar.gz": + "eddea0507ad715c723662e7c10fdab554eb64379748278cd2d09403063e3e32f", + "https://github.com/containous/traefik/releases/download/v2.2.0/traefik_v2.2.0_darwin_amd64.tar.gz": + "8bfa2393b265ef01aca12be94d67080961299968bd602f3708480eed273b95e0", + "https://github.com/containous/traefik/releases/download/v2.2.0/traefik_v2.2.0_windows_amd64.zip": + "9a794e395b7eba8d44118c4a1fb358fbf14abff3f5f5d264f46b1d6c243b9a5e", } checksums_etcd = { - "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.10-linux-amd64.tar.gz": "8a509ffb1443088d501f19e339a0d9c0058ce20599752c3baef83c1c68790ef7", - "https://github.com/etcd-io/etcd/releases/download/v3.2.25/etcd-v3.3.10-darwin-amd64.tar.gz": "9950684a01d7431bc12c3dba014f222d55a862c6f8af64c09c42d7a59ed6790d", + "https://github.com/etcd-io/etcd/releases/download/v3.4.7/etcd-v3.4.7-linux-amd64.tar.gz": + "4ad86e663b63feb4855e1f3a647e719d6d79cf6306410c52b7f280fa56f8eb6b", + "https://github.com/etcd-io/etcd/releases/download/v3.4.7/etcd-v3.4.7-darwin-amd64.zip": + "ffe3237fcb70b7ce91c16518c2f62f3fa9ff74ddc10f7b6ca83a3b5b29ade19a", + "https://github.com/etcd-io/etcd/releases/download/v3.4.7/etcd-v3.4.7-windows-amd64.zip": + "3863ea59bcb407113524b51406810e33d58daff11ca10d1192f289185ae94ffe", } checksums_consul = { - "https://releases.hashicorp.com/consul/1.5.0/consul_1.5.0_linux_amd64.zip": "1399064050019db05d3378f757e058ec4426a917dd2d240336b51532065880b6", - "https://releases.hashicorp.com/consul/1.5.0/consul_1.5.0_darwin_amd64.zip": "b4033ea6871fe6136ee5d940c834be2248463c3ec248dc22370e6d5360931325", + "https://releases.hashicorp.com/consul/1.7.2/consul_1.7.2_linux_amd64.zip": + "5ab689cad175c08a226a5c41d16392bc7dd30ceaaf90788411542a756773e698", + "https://releases.hashicorp.com/consul/1.7.2/consul_1.7.2_darwin_amd64.zip": + "c474f00b022cae38acae2d19b2a707a4fcb08dfdd22875efeefdf052ce19c90b", + "https://releases.hashicorp.com/consul/1.7.2/consul_1.7.2_windows_amd64.zip": + "e9b9355f77f80b2c0940888cb0d27c44a5879c31e379ef21ffcfd36c91d202c1", } @@ -41,54 +47,75 @@ def checksum_file(path): def install_traefik(prefix, plat, traefik_version): - traefik_bin = os.path.join(prefix, "traefik") + plat = plat.replace("-", "_") + if "windows" in plat: + traefik_archive_extension = "zip" + traefik_bin = os.path.join(prefix, "traefik.exe") + else: + traefik_archive_extension = "tar.gz" + traefik_bin = os.path.join(prefix, "traefik") + + traefik_archive = "traefik_v" + traefik_version + "_" + plat + "." + traefik_archive_extension + traefik_archive_path = os.path.join(prefix, traefik_archive) + + traefik_archive = "traefik_v" + traefik_version + "_" + plat + "." + traefik_archive_extension + traefik_archive_path = os.path.join(prefix, traefik_archive) + + traefik_archive = "traefik_v" + traefik_version + "_" + plat + "." + traefik_archive_extension + traefik_archive_path = os.path.join(prefix, traefik_archive) traefik_url = ( "https://github.com/containous/traefik/releases" - f"/download/v{traefik_version}/traefik_{plat}" + f"/download/v{traefik_version}/{traefik_archive}" ) - if os.path.exists(traefik_bin): + if os.path.exists(traefik_bin) and os.path.exists(traefik_archive_path): print(f"Traefik already exists") if traefik_url not in checksums_traefik: warnings.warn( - f"Couldn't verify checksum for traefik-v{traefik_version}-{plat}", + f"Traefik {traefik_version} not supported !", stacklevel=2, ) os.chmod(traefik_bin, 0o755) print("--- Done ---") return else: - checksum = checksum_file(traefik_bin) - if checksum == checksums_traefik[traefik_url]: + if checksum_file(traefik_archive_path) == checksums_traefik[traefik_url]: os.chmod(traefik_bin, 0o755) print("--- Done ---") return else: - print(f"checksum mismatch on {traefik_bin}") + print(f"checksum mismatch on {traefik_archive_path}") + os.remove(traefik_archive_path) os.remove(traefik_bin) - print(f"Downloading traefik {traefik_version}...") - urlretrieve(traefik_url, traefik_bin) - if traefik_url in checksums_traefik: - checksum = checksum_file(traefik_bin) - if checksum != checksums_traefik[traefik_url]: + print(f"Downloading traefik {traefik_version}...") + urlretrieve(traefik_url, traefik_archive_path) + + 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"Couldn't verify checksum for traefik-v{traefik_version}-{plat}", + f"Traefik {traefik_version} not supported !", stacklevel=2, ) - os.chmod(traefik_bin, 0o755) - print("--- Done ---") def install_etcd(prefix, plat, etcd_version): etcd_downloaded_dir_name = f"etcd-v{etcd_version}-{plat}" - etcd_archive_extension = ".tar.gz" if "linux" in plat: etcd_archive_extension = "tar.gz" else: @@ -102,7 +129,7 @@ def install_etcd(prefix, plat, etcd_version): etcdctl_bin = os.path.join(prefix, "etcdctl") etcd_url = ( - "https://github.com/etcd-io/etcd/releases/" + "https://github.com/etcd-io/etcd/releases" f"/download/v{etcd_version}/etcd-v{etcd_version}-{plat}.{etcd_archive_extension}" ) @@ -110,7 +137,7 @@ def install_etcd(prefix, plat, etcd_version): print(f"Etcd and etcdctl already exist") if etcd_url not in checksums_etcd: warnings.warn( - f"Couldn't verify checksum for etcd-v{etcd_version}-{plat}", + f"Etcd {etcd_version} not supported !", stacklevel=2, ) os.chmod(etcd_bin, 0o755) @@ -118,68 +145,65 @@ def install_etcd(prefix, plat, etcd_version): print("--- Done ---") return else: - checksum_etcd_archive = checksum_file(etcd_downloaded_archive) - if checksum_etcd_archive == checksums_etcd[etcd_url]: + if checksum_file(etcd_downloaded_archive) == checksums_etcd[etcd_url]: os.chmod(etcd_bin, 0o755) os.chmod(etcdctl_bin, 0o755) print("--- Done ---") return else: - print(f"checksum mismatch on etcd") + print(f"checksum mismatch on {etcd_downloaded_archive}") os.remove(etcd_bin) os.remove(etcdctl_bin) os.remove(etcd_downloaded_archive) - if not os.path.exists(etcd_downloaded_archive): - print(f"Downloading {etcd_downloaded_dir_name} archive...") - urlretrieve(etcd_url, etcd_downloaded_archive) - else: - print(f"Archive {etcd_downloaded_dir_name} already exists") - - if etcd_archive_extension == "zip": - with zipfile.ZipFile(etcd_downloaded_archive, "r") as zip_ref: - zip_ref.extract(etcd_downloaded_dir_name + "/etcd", etcd_binaries) - zip_ref.extract(etcd_downloaded_dir_name + "/etcdctl", etcd_binaries) - else: - with (tarfile.open(etcd_downloaded_archive, "r")) as tar_ref: - print("Extracting the archive...") - tar_ref.extract(etcd_downloaded_dir_name + "/etcd", etcd_binaries) - tar_ref.extract(etcd_downloaded_dir_name + "/etcdctl", etcd_binaries) - - shutil.copy(os.path.join(etcd_binaries, etcd_downloaded_dir_name, "etcd"), etcd_bin) - shutil.copy( - os.path.join(etcd_binaries, etcd_downloaded_dir_name, "etcdctl"), etcdctl_bin - ) - if etcd_url in checksums_etcd: - checksum_etcd_archive = checksum_file(etcd_downloaded_archive) - if checksum_etcd_archive != checksums_etcd[etcd_url]: + if not os.path.exists(etcd_downloaded_archive): + print(f"Downloading {etcd_downloaded_dir_name} archive...") + urlretrieve(etcd_url, etcd_downloaded_archive) + else: + print(f"Archive {etcd_downloaded_dir_name} already exists") + + if checksum_file(etcd_downloaded_archive) != checksums_etcd[etcd_url]: raise IOError("Checksum failed") + + print("Extracting the archive...") + + if etcd_archive_extension == "zip": + with zipfile.ZipFile(etcd_downloaded_archive, "r") as zip_ref: + zip_ref.extract(etcd_downloaded_dir_name + "/etcd", etcd_binaries) + zip_ref.extract(etcd_downloaded_dir_name + "/etcdctl", etcd_binaries) + else: + with (tarfile.open(etcd_downloaded_archive, "r")) as tar_ref: + tar_ref.extract(etcd_downloaded_dir_name + "/etcd", etcd_binaries) + tar_ref.extract(etcd_downloaded_dir_name + "/etcdctl", etcd_binaries) + + shutil.copy(os.path.join(etcd_binaries, etcd_downloaded_dir_name, "etcd"), etcd_bin) + shutil.copy( + os.path.join(etcd_binaries, etcd_downloaded_dir_name, "etcdctl"), etcdctl_bin) + + os.chmod(etcd_bin, 0o755) + os.chmod(etcdctl_bin, 0o755) + + # Cleanup + shutil.rmtree(etcd_binaries) else: warnings.warn( - f"Couldn't verify checksum for etcd-v{etcd_version}-{plat}", stacklevel=2 + f"Etcd {etcd_version} not supported !", + stacklevel=2 ) - os.chmod(etcd_bin, 0o755) - os.chmod(etcdctl_bin, 0o755) - - # Cleanup - shutil.rmtree(etcd_binaries) - print("--- Done ---") def install_consul(prefix, plat, consul_version): plat = plat.replace("-", "_") consul_downloaded_dir_name = f"consul_v{consul_version}_{plat}" - consul_archive_extension = ".tar.gz" consul_archive_extension = "zip" consul_downloaded_archive = os.path.join( prefix, consul_downloaded_dir_name + "." + consul_archive_extension ) consul_binaries = os.path.join(prefix, "consul_binaries") - consul_bin = os.path.join(prefix, "consul") consul_url = ( @@ -191,49 +215,45 @@ def install_consul(prefix, plat, consul_version): print(f"Consul already exists") if consul_url not in checksums_consul: warnings.warn( - f"Couldn't verify checksum for consul_v{consul_version}_{plat}", + f"Consul {consul_version} not supported !", stacklevel=2, ) os.chmod(consul_bin, 0o755) print("--- Done ---") return else: - checksum_consul_archive = checksum_file(consul_downloaded_archive) - if checksum_consul_archive == checksums_consul[consul_url]: + if checksum_file(consul_downloaded_archive) == checksums_consul[consul_url]: os.chmod(consul_bin, 0o755) print("--- Done ---") return else: - print(f"checksum mismatch on consul") + print(f"checksum mismatch on {consul_downloaded_archive}") os.remove(consul_bin) os.remove(consul_downloaded_archive) - if not os.path.exists(consul_downloaded_archive): - print(f"Downloading {consul_downloaded_dir_name} archive...") - urlretrieve(consul_url, consul_downloaded_archive) - else: - print(f"Archive {consul_downloaded_dir_name} already exists") + if consul_url in checksums_consul: + if not os.path.exists(consul_downloaded_archive): + print(f"Downloading {consul_downloaded_dir_name} archive...") + urlretrieve(consul_url, consul_downloaded_archive) + else: + print(f"Archive {consul_downloaded_dir_name} already exists") - with zipfile.ZipFile(consul_downloaded_archive, "r") as zip_ref: - zip_ref.extract("consul", consul_binaries) + if checksum_file(consul_downloaded_archive) != checksums_consul[consul_url]: + raise IOError("Checksum failed") - shutil.copy(os.path.join(consul_binaries, "consul"), consul_bin) + with zipfile.ZipFile(consul_downloaded_archive, "r") as zip_ref: + zip_ref.extract("consul", consul_binaries) - if consul_url in checksums_consul: - checksum_consul_archive = checksum_file(consul_downloaded_archive) - if checksum_consul_archive != checksums_consul[consul_url]: - raise IOError("Checksum failed") + shutil.copy(os.path.join(consul_binaries, "consul"), consul_bin) + os.chmod(consul_bin, 0o755) + # Cleanup + shutil.rmtree(consul_binaries) else: warnings.warn( - f"Couldn't verify checksum for consul_v{consul_version}_{plat}", + f"Consul {consul_version} not supported !", stacklevel=2, ) - os.chmod(consul_bin, 0o755) - - # Cleanup - shutil.rmtree(consul_binaries) - print("--- Done ---") @@ -245,20 +265,17 @@ def main(): """\ Checksums available for: - traefik: - - v1.7.18-linux-amd64 - - v1.7.18-darwin-amd64 - - v1.7.5-linux-amd64 - - v1.7.5-darwin-amd64 - - v1.7.0-linux-amd64 - - v1.7.0-darwin-amd64 + - v2.2.0-linux-amd64 + - v2.2.0-darwin-amd64 + - v2.2.0-windows-amd64 - etcd: - - v3.3.10-linux-amd64 - - v3.3.10-darwin-amd64 - - v3.2.25-linux-amd64 - - v3.2.25-darwin-amd64 + - v3.4.7-linux-amd64 + - v3.4.7-darwin-amd64 + - v3.4.7-windows-amd64 - consul: - - v1.5.0_linux_amd64 - - v1.5.0_darwin_amd64 + - v1.7.2_linux_amd64 + - v1.7.2_darwin_amd64 + - v1.7.2_windows_amd64 """ ), formatter_class=argparse.RawTextHelpFormatter, @@ -307,7 +324,7 @@ def main(): parser.add_argument( "--traefik-version", dest="traefik_version", - default="1.7.18", + default="2.2.0", help=textwrap.dedent( """\ The version of traefik to download. @@ -331,7 +348,7 @@ def main(): parser.add_argument( "--etcd-version", dest="etcd_version", - default="3.3.10", + default="3.4.7", help=textwrap.dedent( """\ The version of etcd to download. @@ -355,7 +372,7 @@ def main(): parser.add_argument( "--consul-version", dest="consul_version", - default="1.5.0", + default="1.7.2", help=textwrap.dedent( """\ The version of consul to download. From 41a5ef31aa6304e12d7b74adda77118c32a9a44b Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Fri, 11 Jun 2021 15:02:45 +0000 Subject: [PATCH 02/10] Upgrade the jupyterhub_traefik_proxy to work with the Traefik v2 API. - Renamed frontends and backedns to routers and services, respectively. - Traefik API paths at /api/providers/{provider_name} no longer work, so search /api/http/{services|routers} instead - Traefik file provider doesn't like arbitrary data (any more?), so have put JupyterHub's 'data' object into the dynamic configuration file in separate root keys. To Do: - Haven't touched the consul or etcd providers, other than to rename 'frontends' and 'backends', as above. - Test, test, test. Additionally, have renamed TraefikTomlProxy to TraefikFileProviderProxy, and edited relevant traefik_utils functions so the proxy provider should now work with either yaml or toml files. (need to test). - jupyterhub_config.py will now need to reference the proxy class 'traefik_file', e.g.:- c.JupyterHub.proxy_class = 'traefik_file' c.TraefikFileProviderProxy = '/path/to/rules.toml' --- jupyterhub_traefik_proxy/__init__.py | 2 +- .../{toml.py => fileprovider.py} | 127 +++++++++------ jupyterhub_traefik_proxy/kv_proxy.py | 34 ++-- jupyterhub_traefik_proxy/proxy.py | 35 +++-- jupyterhub_traefik_proxy/traefik_utils.py | 147 ++++++++++-------- setup.py | 2 +- 6 files changed, 207 insertions(+), 140 deletions(-) rename jupyterhub_traefik_proxy/{toml.py => fileprovider.py} (60%) diff --git a/jupyterhub_traefik_proxy/__init__.py b/jupyterhub_traefik_proxy/__init__.py index 8e9ca837..15472138 100644 --- a/jupyterhub_traefik_proxy/__init__.py +++ b/jupyterhub_traefik_proxy/__init__.py @@ -4,7 +4,7 @@ from .kv_proxy import TKvProxy # noqa from .etcd import TraefikEtcdProxy from .consul import TraefikConsulProxy -from .toml import TraefikTomlProxy +from .fileprovider import TraefikFileProviderProxy from ._version import get_versions diff --git a/jupyterhub_traefik_proxy/toml.py b/jupyterhub_traefik_proxy/fileprovider.py similarity index 60% rename from jupyterhub_traefik_proxy/toml.py rename to jupyterhub_traefik_proxy/fileprovider.py index e20e8a38..ca383823 100644 --- a/jupyterhub_traefik_proxy/toml.py +++ b/jupyterhub_traefik_proxy/fileprovider.py @@ -31,8 +31,8 @@ from jupyterhub_traefik_proxy import TraefikProxy -class TraefikTomlProxy(TraefikProxy): - """JupyterHub Proxy implementation using traefik and toml config file""" +class TraefikFileProviderProxy(TraefikProxy): + """JupyterHub Proxy implementation using traefik and toml or yaml config file""" mutex = Any() @@ -40,7 +40,7 @@ class TraefikTomlProxy(TraefikProxy): def _default_mutex(self): return asyncio.Lock() - toml_dynamic_config_file = Unicode( + dynamic_config_file = Unicode( "rules.toml", config=True, help="""traefik's dynamic configuration file""" ) @@ -48,30 +48,34 @@ def __init__(self, **kwargs): super().__init__(**kwargs) try: # Load initial routing table from disk - self.routes_cache = traefik_utils.load_routes(self.toml_dynamic_config_file) + self.routes_cache = traefik_utils.load_routes(self.dynamic_config_file) except FileNotFoundError: self.routes_cache = {} if not self.routes_cache: - self.routes_cache = {"backends": {}, "frontends": {}} + self.routes_cache = { + "http" : {"services": {}, "routers": {}}, + "jupyter": {"routers" : {} } + } async def _setup_traefik_static_config(self): await super()._setup_traefik_static_config() + # Is this not the same as the dynamic config file? self.static_config["file"] = {"filename": "rules.toml", "watch": True} try: traefik_utils.persist_static_conf( - self.toml_static_config_file, self.static_config + self.static_config_file, self.static_config ) try: - os.stat(self.toml_dynamic_config_file) + os.stat(self.dynamic_config_file) except FileNotFoundError: # Make sure that the dynamic configuration file exists self.log.info( - f"Creating the toml dynamic configuration file: {self.toml_dynamic_config_file}" + f"Creating the dynamic configuration file: {self.dynamic_config_file}" ) - open(self.toml_dynamic_config_file, "a").close() + open(self.dynamic_config_file, "a").close() except IOError: self.log.exception("Couldn't set up traefik's static config.") raise @@ -82,7 +86,7 @@ async def _setup_traefik_static_config(self): def _start_traefik(self): self.log.info("Starting traefik...") try: - self._launch_traefik(config_type="toml") + self._launch_traefik(config_type="fileprovider") except FileNotFoundError as e: self.log.error( "Failed to find traefik \n" @@ -93,24 +97,24 @@ def _start_traefik(self): def _clean_resources(self): try: if self.should_start: - os.remove(self.toml_static_config_file) - os.remove(self.toml_dynamic_config_file) + os.remove(self.static_config_file) + os.remove(self.dynamic_config_file) except: self.log.error("Failed to remove traefik's configuration files") raise def _get_route_unsafe(self, traefik_routespec): - backend_alias = traefik_utils.generate_alias(traefik_routespec, "backend") - frontend_alias = traefik_utils.generate_alias(traefik_routespec, "frontend") + service_alias = traefik_utils.generate_alias(traefik_routespec, "service") + router_alias = traefik_utils.generate_alias(traefik_routespec, "router") routespec = self._routespec_from_traefik_path(traefik_routespec) - result = {"data": "", "target": "", "routespec": routespec} + result = {"data": None, "target": None, "routespec": routespec} def get_target_data(d, to_find): if to_find == "url": key = "target" else: key = to_find - if result[key]: + if result[key] is not None: return for k, v in d.items(): if k == to_find: @@ -118,17 +122,35 @@ def get_target_data(d, to_find): if isinstance(v, dict): get_target_data(v, to_find) - if backend_alias in self.routes_cache["backends"]: - get_target_data(self.routes_cache["backends"][backend_alias], "url") + service_node = self.routes_cache["http"]["services"].get(service_alias, None) + if service_node is not None: + get_target_data(service_node, "url") - if frontend_alias in self.routes_cache["frontends"]: - get_target_data(self.routes_cache["frontends"][frontend_alias], "data") + router_node = self.routes_cache["jupyter"]["routers"].get(router_alias, None) + if router_node is not None: + get_target_data(router_node, "data") - if not result["data"] and not result["target"]: + if result["data"] is None and result["target"] is None: self.log.info("No route for {} found!".format(routespec)) result = None - else: - result["data"] = json.loads(result["data"]) + self.log.debug("treefik routespec: {0}".format(traefik_routespec)) + self.log.debug("result for routespec {0}:-\n{1}".format(routespec, result)) + + # No longer bother converting `data` to/from JSON + #else: + # result["data"] = json.loads(result["data"]) + + #if service_alias in self.routes_cache["services"]: + # get_target_data(self.routes_cache["services"][service_alias], "url") + + #if router_alias in self.routes_cache["routers"]: + # get_target_data(self.routes_cache["routers"][router_alias], "data") + + #if not result["data"] and not result["target"]: + # self.log.info("No route for {} found!".format(routespec)) + # result = None + #else: + # result["data"] = json.loads(result["data"]) return result async def start(self): @@ -164,6 +186,8 @@ async def add_route(self, routespec, target, data): target (str): A full URL that will be the target of this route. data (dict): A JSONable dict that will be associated with this route, and will be returned when retrieving information about this route. + FIXME: Why do we need to pass data back and forth to traefik? + Traefik v2 doesn't seem to allow a data key... Will raise an appropriate Exception (FIXME: find what?) if the route could not be added. @@ -171,26 +195,38 @@ 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. """ - routespec = self._routespec_to_traefik_path(routespec) - backend_alias = traefik_utils.generate_alias(routespec, "backend") - frontend_alias = traefik_utils.generate_alias(routespec, "frontend") - data = json.dumps(data) - rule = traefik_utils.generate_rule(routespec) + traefik_routespec = self._routespec_to_traefik_path(routespec) + service_alias = traefik_utils.generate_alias(traefik_routespec, "service") + router_alias = traefik_utils.generate_alias(traefik_routespec, "router") + #data = json.dumps(data) + rule = traefik_utils.generate_rule(traefik_routespec) async with self.mutex: - self.routes_cache["frontends"][frontend_alias] = { - "backend": backend_alias, - "passHostHeader": True, - "routes": {"test": {"rule": rule, "data": data}}, + self.routes_cache["http"]["routers"][router_alias] = { + "service": service_alias, + "rule": rule, + # The data node is passed by JupyterHub. We can store its data in our routes_cache, + # but giving it to Traefik causes issues... + #"data" : data + #"routes": {"test": {"rule": rule, "data": data}}, } - self.routes_cache["backends"][backend_alias] = { - "servers": {"server1": {"url": target, "weight": 1}} + # Add the data node to a separate top-level node + self.routes_cache["jupyter"]["routers"][router_alias] = {"data": data} + + self.routes_cache["http"]["services"][service_alias] = { + "loadBalancer" : { + "servers": {"server1": {"url": target} }, + "passHostHeader": True + } } traefik_utils.persist_routes( - self.toml_dynamic_config_file, self.routes_cache + self.dynamic_config_file, self.routes_cache ) + self.log.debug("treefik routespec: {0}".format(traefik_routespec)) + self.log.debug("data for routespec {0}:-\n{1}".format(routespec, data)) + if self.should_start: try: # Check if traefik was launched @@ -201,10 +237,10 @@ async def add_route(self, routespec, target, data): ) raise try: - await self._wait_for_route(routespec, provider="file") + await self._wait_for_route(traefik_routespec) except TimeoutError: self.log.error( - f"Is Traefik configured to watch {self.toml_dynamic_config_file}?" + f"Is Traefik configured to watch {self.dynamic_config_file}?" ) raise @@ -214,14 +250,14 @@ async def delete_route(self, routespec): **Subclasses must define this method** """ routespec = self._routespec_to_traefik_path(routespec) - backend_alias = traefik_utils.generate_alias(routespec, "backend") - frontend_alias = traefik_utils.generate_alias(routespec, "frontend") + service_alias = traefik_utils.generate_alias(routespec, "service") + router_alias = traefik_utils.generate_alias(routespec, "router") async with self.mutex: - self.routes_cache["frontends"].pop(frontend_alias, None) - self.routes_cache["backends"].pop(backend_alias, None) + self.routes_cache["http"]["routers"].pop(router_alias, None) + self.routes_cache["http"]["services"].pop(service_alias, None) - traefik_utils.persist_routes(self.toml_dynamic_config_file, self.routes_cache) + traefik_utils.persist_routes(self.dynamic_config_file, self.routes_cache) async def get_all_routes(self): """Fetch and return all the routes associated by JupyterHub from the @@ -241,11 +277,13 @@ async def get_all_routes(self): all_routes = {} async with self.mutex: - for key, value in self.routes_cache["frontends"].items(): + for key, value in self.routes_cache["http"]["routers"].items(): escaped_routespec = "".join(key.split("_", 1)[1:]) traefik_routespec = escapism.unescape(escaped_routespec) routespec = self._routespec_from_traefik_path(traefik_routespec) - all_routes[routespec] = self._get_route_unsafe(traefik_routespec) + all_routes.update({ + routespec : self._get_route_unsafe(traefik_routespec) + }) return all_routes @@ -272,3 +310,4 @@ async def get_route(self, routespec): routespec = self._routespec_to_traefik_path(routespec) async with self.mutex: return self._get_route_unsafe(routespec) + diff --git a/jupyterhub_traefik_proxy/kv_proxy.py b/jupyterhub_traefik_proxy/kv_proxy.py index b1dc4fb3..32f56b3c 100644 --- a/jupyterhub_traefik_proxy/kv_proxy.py +++ b/jupyterhub_traefik_proxy/kv_proxy.py @@ -87,12 +87,12 @@ async def _kv_atomic_add_route_parts( are expected to have the following structure: [ key: jupyterhub_routespec , value: target ] [ key: target , value: data ] - [ key: route_keys.backend_url_path , value: target ] - [ key: route_keys.frontend_rule_path , value: rule ] - [ key: route_keys.frontend_backend_path, value: - route_keys.backend_alias] - [ key: route_keys.backend_weight_path , value: w(int) ] - (where `w` is the weight of the backend to be used during load balancing) + [ key: route_keys.service_url_path , value: target ] + [ key: route_keys.router_rule_path , value: rule ] + [ key: route_keys.router_service_path, value: + route_keys.service_alias] + [ key: route_keys.service_weight_path , value: w(int) ] + (where `w` is the weight of the service to be used during load balancing) Returns: result (tuple): @@ -113,10 +113,10 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): The keys associated with a route are: jupyterhub_routespec, target, - route_keys.backend_url_path, - route_keys.frontend_rule_path, - route_keys.frontend_backend_path, - route_keys.backend_weight_path, + route_keys.service_url_path, + route_keys.router_rule_path, + route_keys.router_service_path, + route_keys.service_weight_path, Returns: result (tuple): @@ -184,7 +184,7 @@ async def _kv_get_jupyterhub_prefixed_entries(self): def _clean_resources(self): try: if self.should_start: - os.remove(self.toml_static_config_file) + os.remove(self.static_config_file) except: self.log.error("Failed to remove traefik's configuration files") raise @@ -205,7 +205,7 @@ async def _setup_traefik_static_config(self): self._define_kv_specific_static_config() try: traefik_utils.persist_static_conf( - self.toml_static_config_file, self.static_config + self.static_config_file, self.static_config ) except IOError: self.log.exception("Couldn't set up traefik's static config.") @@ -273,12 +273,12 @@ async def add_route(self, routespec, target, data): raise if status: self.log.info( - "Added backend %s with the alias %s.", target, route_keys.backend_alias + "Added service %s with the alias %s.", target, route_keys.service_alias ) self.log.info( - "Added frontend %s for backend %s with the following routing rule %s.", - route_keys.frontend_alias, - route_keys.backend_alias, + "Added router %s for service %s with the following routing rule %s.", + route_keys.router_alias, + route_keys.service_alias, routespec, ) else: @@ -286,7 +286,7 @@ async def add_route(self, routespec, target, data): "Couldn't add route for %s. Response: %s", routespec, response ) - await self._wait_for_route(routespec, provider=self.kv_name) + await self._wait_for_route(routespec) async def delete_route(self, routespec): """Delete a route and all the traefik related info associated given a routespec, diff --git a/jupyterhub_traefik_proxy/proxy.py b/jupyterhub_traefik_proxy/proxy.py index d2421c8f..ecb1fd77 100644 --- a/jupyterhub_traefik_proxy/proxy.py +++ b/jupyterhub_traefik_proxy/proxy.py @@ -36,7 +36,7 @@ class TraefikProxy(Proxy): traefik_process = Any() - toml_static_config_file = Unicode( + static_config_file = Unicode( "traefik.toml", config=True, help="""traefik's static configuration file""" ) @@ -108,40 +108,42 @@ def _generate_htpassword(self): ht.set_password(self.traefik_api_username, self.traefik_api_password) self.traefik_api_hashed_password = str(ht.to_string()).split(":")[1][:-3] - async def _check_for_traefik_endpoint(self, routespec, kind, provider): - """Check for an expected frontend or backend + async def _check_for_traefik_service(self, routespec, kind): + """Check for an expected router or service This is used to wait for traefik to load configuration from a provider """ - expected = traefik_utils.generate_alias(routespec, kind) - path = "/api/providers/{}/{}s".format(provider, kind) + # expected e.g. 'service' + '_' + routespec @ file + expected = traefik_utils.generate_alias(routespec, kind) + "@file" + path = "/api/http/{0}s".format(kind) try: resp = await self._traefik_api_request(path) - data = json.loads(resp.body) + json_data = json.loads(resp.body) except Exception: self.log.exception("Error checking traefik api for %s %s", kind, routespec) return False - if expected not in data: - self.log.debug("traefik %s not yet in %ss", expected, kind) - self.log.debug("Current traefik %ss: %s", kind, data) + 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) return False # found the expected endpoint return True - async def _wait_for_route(self, routespec, provider): + async def _wait_for_route(self, routespec): self.log.info("Waiting for %s to register with traefik", routespec) async def _check_traefik_dynamic_conf_ready(): """Check if traefik loaded its dynamic configuration yet""" - if not await self._check_for_traefik_endpoint( - routespec, "backend", provider + if not await self._check_for_traefik_service( + routespec, "service" ): return False - if not await self._check_for_traefik_endpoint( - routespec, "frontend", provider + if not await self._check_for_traefik_service( + routespec, "router" ): return False @@ -173,7 +175,8 @@ async def _wait_for_static_config(self, provider): async def _check_traefik_static_conf_ready(): """Check if traefik loaded its static configuration yet""" try: - resp = await self._traefik_api_request("/api/providers/" + provider) + #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") return False @@ -199,7 +202,7 @@ def _stop_traefik(self): self.traefik_process.wait() def _launch_traefik(self, config_type): - if config_type == "toml" or config_type == "etcdv3" or config_type == "consul": + 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 diff --git a/jupyterhub_traefik_proxy/traefik_utils.py b/jupyterhub_traefik_proxy/traefik_utils.py index 76484f3e..c4cb7794 100644 --- a/jupyterhub_traefik_proxy/traefik_utils.py +++ b/jupyterhub_traefik_proxy/traefik_utils.py @@ -5,7 +5,6 @@ from urllib.parse import unquote import escapism -import toml from contextlib import contextmanager from collections import namedtuple @@ -28,12 +27,12 @@ def generate_rule(routespec): routespec = unquote(routespec) if routespec.startswith("/"): # Path-based route, e.g. /proxy/path/ - rule = "PathPrefix:" + routespec + rule = "PathPrefix(`{0}`)".format(routespec) else: # Host-based routing, e.g. host.tld/proxy/path/ host, path_prefix = routespec.split("/", 1) path_prefix = "/" + path_prefix - rule = "Host:" + host + ";PathPrefix:" + path_prefix + rule = "Host(`{0}`) && PathPrefix(`{1}`)".format(host, path_prefix) return rule @@ -42,75 +41,74 @@ def generate_alias(routespec, server_type=""): return server_type + "_" + escapism.escape(routespec, safe=safe) -def generate_backend_entry( - proxy, backend_alias, separator="/", url=False, weight=False -): - backend_entry = "" +def generate_service_entry( proxy, service_alias, separator="/", url=False, + weight=False): + service_entry = "" if separator == "/": - backend_entry = proxy.kv_traefik_prefix - backend_entry += separator.join(["backends", backend_alias, "servers", "server1"]) + service_entry = proxy.kv_traefik_prefix + service_entry += separator.join(["services", service_alias, "servers", "server1"]) if url: - backend_entry += separator + "url" + service_entry += separator + "url" elif weight: - backend_entry += separator + "weight" + service_entry += separator + "weight" - return backend_entry + return service_entry -def generate_frontend_backend_entry(proxy, frontend_alias): - return proxy.kv_traefik_prefix + "frontends/" + frontend_alias + "/backend" +def generate_router_service_entry(proxy, router_alias): + return proxy.kv_traefik_prefix + "routers/" + router_alias + "/service" -def generate_frontend_rule_entry(proxy, frontend_alias, separator="/"): - frontend_rule_entry = separator.join( - ["frontends", frontend_alias, "routes", "test"] +def generate_router_rule_entry(proxy, router_alias, separator="/"): + router_rule_entry = separator.join( + ["routers", router_alias, "routes", "test"] ) if separator == "/": - frontend_rule_entry = ( - proxy.kv_traefik_prefix + frontend_rule_entry + separator + "rule" + router_rule_entry = ( + proxy.kv_traefik_prefix + router_rule_entry + separator + "rule" ) - return frontend_rule_entry + return router_rule_entry def generate_route_keys(proxy, routespec, separator="/"): - backend_alias = generate_alias(routespec, "backend") - frontend_alias = generate_alias(routespec, "frontend") + service_alias = generate_alias(routespec, "service") + router_alias = generate_alias(routespec, "router") RouteKeys = namedtuple( "RouteKeys", [ - "backend_alias", - "backend_url_path", - "backend_weight_path", - "frontend_alias", - "frontend_backend_path", - "frontend_rule_path", + "service_alias", + "service_url_path", + "service_weight_path", + "router_alias", + "router_service_path", + "router_rule_path", ], ) if separator != ".": - backend_url_path = generate_backend_entry(proxy, backend_alias, url=True) - frontend_rule_path = generate_frontend_rule_entry(proxy, frontend_alias) - backend_weight_path = generate_backend_entry(proxy, backend_alias, weight=True) - frontend_backend_path = generate_frontend_backend_entry(proxy, frontend_alias) + 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) + router_service_path = generate_router_service_entry(proxy, router_alias) else: - backend_url_path = generate_backend_entry( - proxy, backend_alias, separator=separator + service_url_path = generate_service_entry( + proxy, service_alias, separator=separator ) - frontend_rule_path = generate_frontend_rule_entry( - proxy, frontend_alias, separator=separator + router_rule_path = generate_router_rule_entry( + proxy, router_alias, separator=separator ) - backend_weight_path = "" - frontend_backend_path = "" + service_weight_path = "" + router_service_path = "" return RouteKeys( - backend_alias, - backend_url_path, - backend_weight_path, - frontend_alias, - frontend_backend_path, - frontend_rule_path, + service_alias, + service_url_path, + service_weight_path, + router_alias, + router_service_path, + router_rule_path, ) @@ -142,20 +140,47 @@ def atomic_writing(path): # already deleted by os.replace above pass - -def persist_static_conf(file, static_conf_dict): - with open(file, "w") as f: - toml.dump(static_conf_dict, f) - - -def persist_routes(file, routes_dict): - with atomic_writing(file) as config_fd: - toml.dump(routes_dict, config_fd) - - -def load_routes(file): - try: - with open(file, "r") as config_fd: - return toml.load(config_fd) - except: - raise +class TraefikConfigFileHandler(object): + """Handles reading and writing Traefik config files. Can operate + on both toml and yaml files""" + def __init__(self, file_path): + file_ext = file_path.rsplit('.', 1)[-1] + if file_ext == 'yaml': + import yaml as config_handler + elif file_ext == 'toml': + import toml as config_handler + else: + raise TypeError("type should be either 'toml' or 'yaml'") + + self.file_path = file_path + # Redefined to either yaml.dump or toml.dump + self._dump = config_handler.dump + #self._dumps = config_handler.dumps + # Redefined by __init__, to either yaml.load or toml.load + self._load = config_handler.load + + def load(self): + """Depending on self.file_path, call either yaml.load or toml.load""" + return self._load(self.file_path) + + def dump(self, data): + with open(self.file_path, "w") as f: + self._dump(data, f) + + def atomic_dump(self, data): + """Save data to self.file_path after opening self.file_path with + :func:`atomic_writing`""" + with atomic_writing(self.file_path) as f: + self._dump(data, f) + +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): + handler = TraefikConfigFileHandler(file_path) + handler.atomic_dump(routes_dict) + +def load_routes(file_path): + handler = TraefikConfigFileHandler(file_path) + return handler.load() diff --git a/setup.py b/setup.py index a3f55f1e..118ce5b7 100644 --- a/setup.py +++ b/setup.py @@ -75,7 +75,7 @@ def run(self): "jupyterhub.proxies": [ "traefik_consul = jupyterhub_traefik_proxy:TraefikConsulProxy", "traefik_etcd = jupyterhub_traefik_proxy:TraefikEtcdProxy", - "traefik_toml = jupyterhub_traefik_proxy:TraefikTomlProxy", + "traefik_file = jupyterhub_traefik_proxy:TraefikFileProviderProxy", ] }, ) From faa2832437bf9a2bcd0023a24c34fea150d5d4c1 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Fri, 18 Jun 2021 10:28:17 +0000 Subject: [PATCH 03/10] 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 From 8459b13ab3ea9ad1fb4b7ab3f09b044e7767a4c8 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Fri, 18 Jun 2021 13:32:53 +0000 Subject: [PATCH 04/10] Remove any refererence to `traefik_toml` or `TraefikTomlProxy` from the documentation, examples and performance test suite. These are replaced by `traefik_file` and `TraefikFileProviderProxy`, respectively. --- README.md | 2 +- docs/source/api/index.rst | 4 +- docs/source/index.rst | 4 +- docs/source/install.md | 14 +++--- ...nfig_toml.py => jupyterhub_config_file.py} | 10 ++--- performance/ProxyPerformance.ipynb | 44 +++++++++---------- performance/perf_utils.py | 16 +++---- performance/run_benchmark.sh | 10 ++--- performance/run_benchmark_sequential.sh | 4 +- 9 files changed, 54 insertions(+), 54 deletions(-) rename examples/{jupyterhub_config_toml.py => jupyterhub_config_file.py} (55%) diff --git a/README.md b/README.md index 6dbc2782..5d0fadf7 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ 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 TraefikFileProviderProxy](https://jupyterhub-traefik-proxy.readthedocs.io/en/latest/toml.html#example-setup) +* [For TraefikFileProviderProxy](https://jupyterhub-traefik-proxy.readthedocs.io/en/latest/file.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) diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 8579deac..08896f54 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -15,10 +15,10 @@ Module: :mod:`jupyterhub_traefik_proxy` .. autoconfigurable:: TraefikProxy :members: -:class:`TraefikTomlProxy` +:class:`TraefikFileProviderProxy` ------------------------- -.. autoconfigurable:: TraefikTomlProxy +.. autoconfigurable:: TraefikFileProviderProxy :members: :class:`TKvProxy` diff --git a/docs/source/index.rst b/docs/source/index.rst index d4750855..0f73a31e 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -20,7 +20,7 @@ Moreover it offers *HTTPS* support through a straight-forward `ACME (Let's Encry There are three versions for the proxy, depending on how traefik stores the routes: * *for* **smaller**, *single-node deployments*: - * TraefikTomlProxy + * TraefikFileProviderProxy * *for* **distributed** *setups*: * TraefikEtcdProxy * TraefikConsulProxy @@ -39,7 +39,7 @@ Getting Started .. toctree:: :maxdepth: 1 - toml + fileprovider etcd consul diff --git a/docs/source/install.md b/docs/source/install.md index af1dd013..d5ea9858 100644 --- a/docs/source/install.md +++ b/docs/source/install.md @@ -69,7 +69,7 @@ ## Enabling traefik-proxy in JupyterHub -[TraefikTomlProxy](https://github.com/jupyterhub/traefik-proxy/blob/master/jupyterhub_traefik_proxy/toml.py), [TraefikEtcdProxy](https://github.com/jupyterhub/traefik-proxy/blob/master/jupyterhub_traefik_proxy/etcd.py) and [TraefikConsulProxy](https://github.com/jupyterhub/traefik-proxy/blob/master/jupyterhub_traefik_proxy/consul.py) are custom proxy implementations that subclass [Proxy](https://github.com/jupyterhub/jupyterhub/blob/master/jupyterhub/proxy.py) and can register in JupyterHub config using `c.JupyterHub.proxy_class` entrypoint. +[TraefikFileProviderProxy](https://github.com/jupyterhub/traefik-proxy/blob/Traefik_v2/jupyterhub_traefik_proxy/fileprovider.py), [TraefikEtcdProxy](https://github.com/jupyterhub/traefik-proxy/blob/Traefik_v2/jupyterhub_traefik_proxy/etcd.py) and [TraefikConsulProxy](https://github.com/jupyterhub/traefik-proxy/blob/Traefik_v2/jupyterhub_traefik_proxy/consul.py) are custom proxy implementations that subclass [Proxy](https://github.com/jupyterhub/jupyterhub/blob/Traefik_v2/jupyterhub/proxy.py) and can register in JupyterHub config using `c.JupyterHub.proxy_class` entrypoint. On startup, JupyterHub will look by default for a configuration file, *jupyterhub_config.py*, in the current working directory. If the configuration file is not in the current working directory, you can load a specific config file and start JupyterHub using: @@ -78,13 +78,13 @@ you can load a specific config file and start JupyterHub using: $ jupyterhub -f /path/to/jupyterhub_config.py ``` -There is an example configuration file [here](https://github.com/jupyterhub/traefik-proxy/blob/master/examples/jupyterhub_config.py) that configures JupyterHub to run with *TraefikEtcdProxy* as the proxy and uses dummyauthenticator and simplespawner to enable testing without administrative privileges. +There is an example configuration file [here](https://github.com/jupyterhub/traefik-proxy/blob/Traefik_v2/examples/jupyterhub_config.py) that configures JupyterHub to run with *TraefikEtcdProxy* as the proxy and uses dummyauthenticator and simplespawner to enable testing without administrative privileges. In *jupyterhub_config.py*: ``` -c.JupyterHub.proxy_class = "traefik_toml" -# will configure JupyterHub to run with TraefikTomlProxy +c.JupyterHub.proxy_class = "traefik_file" +# will configure JupyterHub to run with TraefikFileProviderProxy ``` ``` @@ -110,9 +110,9 @@ c.JupyterHub.proxy_class = "traefik_consul" The port on which traefik-proxy's api will run, as well as the username and password used for authenticating, can be passed to the proxy through `jupyterhub_config.py`, e.g.: ``` - c.TraefikTomlProxy.traefik_api_url = "http://127.0.0.1:8099" - c.TraefikTomlProxy.traefik_api_password = "admin" - c.TraefikTomlProxy.traefik_api_username = "admin" + c.TraefikFileProviderProxy.traefik_api_url = "http://127.0.0.1:8099" + c.TraefikFileProviderProxy.traefik_api_password = "admin" + c.TraefikFileProviderProxy.traefik_api_username = "admin" ``` Check out TraefikProxy's **API Reference** for more configuration options.

diff --git a/examples/jupyterhub_config_toml.py b/examples/jupyterhub_config_file.py similarity index 55% rename from examples/jupyterhub_config_toml.py rename to examples/jupyterhub_config_file.py index b17ca1f3..927ea54d 100644 --- a/examples/jupyterhub_config_toml.py +++ b/examples/jupyterhub_config_file.py @@ -1,6 +1,6 @@ """sample jupyterhub config file for testing -configures jupyterhub to run with traefik_toml. +configures jupyterhub to run with traefik_file. configures jupyterhub with dummyauthenticator and simplespawner to enable testing without administrative privileges. @@ -8,10 +8,10 @@ requires jupyterhub 1.0.dev """ -c.JupyterHub.proxy_class = "traefik_toml" -c.TraefikTomlProxy.traefik_api_username = "admin" -c.TraefikTomlProxy.traefik_api_password = "admin" -c.TraefikTomlProxy.traefik_log_level = "INFO" +c.JupyterHub.proxy_class = "traefik_file" +c.TraefikFileProviderProxy.traefik_api_username = "admin" +c.TraefikFileProviderProxy.traefik_api_password = "admin" +c.TraefikFileProviderProxy.traefik_log_level = "INFO" # use dummy and simple auth/spawner for testing c.JupyterHub.authenticator_class = "dummy" diff --git a/performance/ProxyPerformance.ipynb b/performance/ProxyPerformance.ipynb index 24305ecf..7c09b475 100644 --- a/performance/ProxyPerformance.ipynb +++ b/performance/ProxyPerformance.ipynb @@ -31,14 +31,14 @@ "metadata": {}, "outputs": [], "source": [ - "toml_df_concurrent = pd.read_csv('~/results/toml_methods_concurrent.csv')\n", + "file_df_concurrent = pd.read_csv('~/results/file_methods_concurrent.csv')\n", "etcd_df_concurrent = pd.read_csv('~/results/etcd_methods_concurrent.csv')\n", "chp_df_concurrent = pd.read_csv('~/results/chp_methods_concurrent.csv')\n", "consul_df_concurrent = pd.read_csv('~/results/consul_methods_concurrent.csv')\n", "patched_consul_df_concurrent = pd.read_csv('~/results/patched_consul_methods_concurrent.csv')\n", "patched_consul_df_concurrent = patched_consul_df_concurrent.replace('ConsulProxy', 'PatchedConsulProxy')\n", "\n", - "frames = [toml_df_concurrent, etcd_df_concurrent, consul_df_concurrent,\n", + "frames = [file_df_concurrent, etcd_df_concurrent, consul_df_concurrent,\n", " patched_consul_df_concurrent, chp_df_concurrent]\n", "proxies_df_concurrent = pd.concat(frames)" ] @@ -179,7 +179,7 @@ ], "source": [ "fig, ax = plt.subplots()\n", - "plot_proxy_method_perf(ax, proxies_df_concurrent, \"TomlProxy\", \"add\", \"concurrent\")\n", + "plot_proxy_method_perf(ax, proxies_df_concurrent, \"FileProviderProxy\", \"add\", \"concurrent\")\n", "plt.gca().set_ylabel(\"Time (s)\")\n", "\n", "fig, ax = plt.subplots()\n", @@ -301,7 +301,7 @@ ], "source": [ "fig, ax = plt.subplots()\n", - "plot_proxy_method_perf(ax, proxies_df_concurrent, \"TomlProxy\", \"delete\", \"concurrent\")\n", + "plot_proxy_method_perf(ax, proxies_df_concurrent, \"FileProviderProxy\", \"delete\", \"concurrent\")\n", "plt.gca().set_ylabel(\"Time (s)\")\n", "\n", "fig, ax = plt.subplots()\n", @@ -421,7 +421,7 @@ ], "source": [ "fig, ax = plt.subplots()\n", - "plot_proxy_method_perf(ax, proxies_df_concurrent, \"TomlProxy\", \"get_all\", \"concurrent\")\n", + "plot_proxy_method_perf(ax, proxies_df_concurrent, \"FileProviderProxy\", \"get_all\", \"concurrent\")\n", "plt.gca().set_xlabel(\"Iteration no\")\n", "plt.gca().set_ylabel(\"Time (s)\")\n", "\n", @@ -459,7 +459,7 @@ "metadata": {}, "outputs": [], "source": [ - "toml_df_seq = pd.read_csv('~/results/toml_methods_sequential.csv')\n", + "file_df_seq = pd.read_csv('~/results/file_methods_sequential.csv')\n", "etcd_df_seq = pd.read_csv('~/results/etcd_methods_sequential.csv')\n", "chp_df_seq = pd.read_csv('~/results/chp_methods_sequential.csv')\n", "consul_df_seq = pd.read_csv('~/results/consul_methods_sequential.csv')\n", @@ -467,7 +467,7 @@ "patched_consul_df_seq = patched_consul_df_seq.replace('ConsulProxy', 'PatchedConsulProxy')\n", "\n", "\n", - "frames = [toml_df_seq, etcd_df_seq, consul_df_seq, patched_consul_df_seq, chp_df_seq]\n", + "frames = [file_df_seq, etcd_df_seq, consul_df_seq, patched_consul_df_seq, chp_df_seq]\n", "proxies_df_seq = pd.concat(frames)" ] }, @@ -579,7 +579,7 @@ ], "source": [ "fig, ax = plt.subplots()\n", - "plot_proxy_method_perf(ax, proxies_df_seq, \"TomlProxy\", \"add\", \"sequential\")\n", + "plot_proxy_method_perf(ax, proxies_df_seq, \"FileProviderProxy\", \"add\", \"sequential\")\n", "plt.gca().set_ylabel(\"Time (s)\")\n", "\n", "fig, ax = plt.subplots()\n", @@ -703,7 +703,7 @@ ], "source": [ "fig, ax = plt.subplots()\n", - "plot_proxy_method_perf(ax, proxies_df_seq, \"TomlProxy\", \"delete\", \"sequential\")\n", + "plot_proxy_method_perf(ax, proxies_df_seq, \"FileProviderProxy\", \"delete\", \"sequential\")\n", "plt.gca().invert_xaxis()\n", "plt.gca().set_ylabel(\"Time (s)\")\n", "\n", @@ -815,7 +815,7 @@ ], "source": [ "fig, ax = plt.subplots()\n", - "plot_proxy_method_perf(ax, proxies_df_seq, \"TomlProxy\", \"get_all\", \"sequential\")\n", + "plot_proxy_method_perf(ax, proxies_df_seq, \"FileProviderProxy\", \"get_all\", \"sequential\")\n", "plt.gca().set_ylabel(\"Time (s)\")\n", "\n", "fig, ax = plt.subplots()\n", @@ -1105,52 +1105,52 @@ "%matplotlib inline\n", "fig, ax = plt.subplots()\n", "plot_proxy_method_perf(ax, proxies_df_seq, \"CHP\", \"add\", \"sequential\", \"Proxies\", \"cpu_time\", \"blue\")\n", - "plot_proxy_method_perf(ax, proxies_df_seq, \"TomlProxy\", \"add\", \"sequential\", \"Proxies\", \"cpu_time\", \"orange\")\n", + "plot_proxy_method_perf(ax, proxies_df_seq, \"FileProviderProxy\", \"add\", \"sequential\", \"Proxies\", \"cpu_time\", \"orange\")\n", "plot_proxy_method_perf(ax, proxies_df_seq, \"EtcdProxy\", \"add\", \"sequential\", \"Proxies\", \"cpu_time\", \"green\")\n", "plot_proxy_method_perf(ax, proxies_df_seq, \"ConsulProxy\", \"add\", \"sequential\", \"Proxies\", \"cpu_time\", \"red\")\n", - "plt.gca().legend(('CHP','TomlProxy', 'EtcdProxy', 'ConsulProxy'))\n", + "plt.gca().legend(('CHP','FileProviderProxy', 'EtcdProxy', 'ConsulProxy'))\n", "plt.gca().set_ylabel(\"CPU Time (s)\")\n", "\n", "fig, ax = plt.subplots()\n", "plot_proxy_method_perf(ax, proxies_df_concurrent, \"CHP\", \"add\", \"concurrent\", \"Proxies\", \"cpu_time\", \"blue\")\n", - "plot_proxy_method_perf(ax, proxies_df_concurrent, \"TomlProxy\", \"add\", \"concurrent\", \"Proxies\", \"cpu_time\", \"orange\")\n", + "plot_proxy_method_perf(ax, proxies_df_concurrent, \"FileProviderProxy\", \"add\", \"concurrent\", \"Proxies\", \"cpu_time\", \"orange\")\n", "plot_proxy_method_perf(ax, proxies_df_concurrent, \"EtcdProxy\", \"add\", \"concurrent\", \"Proxies\", \"cpu_time\", \"green\")\n", "plot_proxy_method_perf(ax, proxies_df_concurrent, \"ConsulProxy\", \"add\", \"concurrent\", \"Proxies\", \"cpu_time\", \"red\")\n", - "plt.gca().legend(('CHP','TomlProxy', 'EtcdProxy', 'ConsulProxy'))\n", + "plt.gca().legend(('CHP','FileProviderProxy', 'EtcdProxy', 'ConsulProxy'))\n", "plt.gca().set_ylabel(\"CPU Time (s)\")\n", "\n", "#DELETE\n", "fig, ax = plt.subplots()\n", "plot_proxy_method_perf(ax, proxies_df_seq, \"CHP\", \"delete\", \"sequential\", \"Proxies\", \"cpu_time\", \"blue\")\n", - "plot_proxy_method_perf(ax, proxies_df_seq, \"TomlProxy\", \"delete\", \"sequential\", \"Proxies\", \"cpu_time\", \"orange\")\n", + "plot_proxy_method_perf(ax, proxies_df_seq, \"FileProviderProxy\", \"delete\", \"sequential\", \"Proxies\", \"cpu_time\", \"orange\")\n", "plot_proxy_method_perf(ax, proxies_df_seq, \"EtcdProxy\", \"delete\", \"sequential\", \"Proxies\", \"cpu_time\", \"green\")\n", "plot_proxy_method_perf(ax, proxies_df_seq, \"ConsulProxy\", \"delete\", \"sequential\", \"Proxies\", \"cpu_time\", \"red\")\n", - "plt.gca().legend(('CHP','TomlProxy', 'EtcdProxy', 'ConsulProxy'))\n", + "plt.gca().legend(('CHP','FileProviderProxy', 'EtcdProxy', 'ConsulProxy'))\n", "plt.gca().set_ylabel(\"CPU Time (s)\")\n", "\n", "fig, ax = plt.subplots()\n", "plot_proxy_method_perf(ax, proxies_df_concurrent, \"CHP\", \"delete\", \"concurrent\", \"Proxies\", \"cpu_time\", \"blue\")\n", - "plot_proxy_method_perf(ax, proxies_df_concurrent, \"TomlProxy\", \"delete\", \"concurrent\", \"Proxies\", \"cpu_time\", \"orange\")\n", + "plot_proxy_method_perf(ax, proxies_df_concurrent, \"FileProviderProxy\", \"delete\", \"concurrent\", \"Proxies\", \"cpu_time\", \"orange\")\n", "plot_proxy_method_perf(ax, proxies_df_concurrent, \"EtcdProxy\", \"delete\", \"concurrent\", \"Proxies\", \"cpu_time\", \"green\")\n", "plot_proxy_method_perf(ax, proxies_df_concurrent, \"ConsulProxy\", \"delete\", \"concurrent\", \"Proxies\", \"cpu_time\", \"red\")\n", - "plt.gca().legend(('CHP','TomlProxy', 'EtcdProxy', 'ConsulProxy'))\n", + "plt.gca().legend(('CHP','FileProviderProxy', 'EtcdProxy', 'ConsulProxy'))\n", "plt.gca().set_ylabel(\"CPU Time (s)\")\n", "\n", "#Get all\n", "fig, ax = plt.subplots()\n", "plot_proxy_method_perf(ax, proxies_df_seq, \"CHP\", \"get_all\", \"sequential\", \"Proxies\", \"cpu_time\", \"blue\")\n", - "plot_proxy_method_perf(ax, proxies_df_seq, \"TomlProxy\", \"get_all\", \"sequential\", \"Proxies\", \"cpu_time\", \"orange\")\n", + "plot_proxy_method_perf(ax, proxies_df_seq, \"FileProviderProxy\", \"get_all\", \"sequential\", \"Proxies\", \"cpu_time\", \"orange\")\n", "plot_proxy_method_perf(ax, proxies_df_seq, \"EtcdProxy\", \"get_all\", \"sequential\", \"Proxies\", \"cpu_time\", \"green\")\n", "plot_proxy_method_perf(ax, proxies_df_seq, \"ConsulProxy\", \"get_all\", \"sequential\", \"Proxies\", \"cpu_time\", \"red\")\n", - "plt.gca().legend(('CHP','TomlProxy', 'EtcdProxy', 'ConsulProxy'))\n", + "plt.gca().legend(('CHP','FileProviderProxy', 'EtcdProxy', 'ConsulProxy'))\n", "plt.gca().set_ylabel(\"CPU Time (s)\")\n", "\n", "fig, ax = plt.subplots()\n", "plot_proxy_method_perf(ax, proxies_df_concurrent, \"CHP\", \"get_all\", \"concurrent\", \"Proxies\", \"cpu_time\", \"blue\")\n", - "plot_proxy_method_perf(ax, proxies_df_concurrent, \"TomlProxy\", \"get_all\", \"concurrent\", \"Proxies\", \"cpu_time\", \"orange\")\n", + "plot_proxy_method_perf(ax, proxies_df_concurrent, \"FileProviderProxy\", \"get_all\", \"concurrent\", \"Proxies\", \"cpu_time\", \"orange\")\n", "plot_proxy_method_perf(ax, proxies_df_concurrent, \"EtcdProxy\", \"get_all\", \"concurrent\", \"Proxies\", \"cpu_time\", \"green\")\n", "plot_proxy_method_perf(ax, proxies_df_concurrent, \"ConsulProxy\", \"get_all\", \"concurrent\", \"Proxies\", \"cpu_time\", \"red\")\n", - "plt.gca().legend(('CHP','TomlProxy', 'EtcdProxy', \"ConsulProxy\"))\n", + "plt.gca().legend(('CHP','FileProviderProxy', 'EtcdProxy', \"ConsulProxy\"))\n", "plt.gca().set_ylabel(\"CPU Time (s)\")" ] }, diff --git a/performance/perf_utils.py b/performance/perf_utils.py index 12ff21d0..fa5f62bc 100644 --- a/performance/perf_utils.py +++ b/performance/perf_utils.py @@ -10,7 +10,7 @@ from jupyterhub.proxy import ConfigurableHTTPProxy from jupyterhub_traefik_proxy import TraefikConsulProxy from jupyterhub_traefik_proxy import TraefikEtcdProxy -from jupyterhub_traefik_proxy import TraefikTomlProxy +from jupyterhub_traefik_proxy import TraefikFileProviderProxy def configure_argument_parser(): @@ -73,12 +73,12 @@ def configure_argument_parser(): parser.add_argument( "--proxy", dest="proxy_class", - default="TomlProxy", + default="FileProxy", help=textwrap.dedent( """\ Proxy class to analyze. Available proxies: - -TomlProxy + -FileProxy -EtcdProxy -ConsulProxy -CHP @@ -216,9 +216,9 @@ async def no_auth_etcd_proxy(): return proxy -async def toml_proxy(): - """Function returning a configured TraefikTomlProxy""" - proxy = TraefikTomlProxy( +async def file_proxy(): + """Function returning a configured TraefikFileProviderProxy""" + proxy = TraefikFileProviderProxy( public_url="http://127.0.0.1:8000", traefik_api_password="admin", traefik_api_username="admin", @@ -248,8 +248,8 @@ async def configurable_http_proxy(): async def get_proxy(proxy_class): - if proxy_class == "TomlProxy": - proxy = await toml_proxy() + if proxy_class == "FileProxy": + proxy = await file_proxy() elif proxy_class == "EtcdProxy": proxy = await no_auth_etcd_proxy() elif proxy_class == "ConsulProxy": diff --git a/performance/run_benchmark.sh b/performance/run_benchmark.sh index 39c9a829..cd927264 100755 --- a/performance/run_benchmark.sh +++ b/performance/run_benchmark.sh @@ -1,8 +1,8 @@ #!/bin/sh #CHP methods performance python3 -m performance.check_perf --measure=methods --proxy=CHP --iterations=4 --routes_number=500 --concurrent --output=./results/chp_methods_concurrent.csv -# TomlProxy methods performance - throttle = 2s -python3 -m performance.check_perf --measure=methods --proxy=TomlProxy --iterations=4 --routes_number=500 --concurrent --output=./results/toml_methods_concurrent.csv +# FileProxy methods performance - throttle = 2s +python3 -m performance.check_perf --measure=methods --proxy=FileProxy --iterations=4 --routes_number=500 --concurrent --output=./results/file_methods_concurrent.csv # EtcdProxy methods performance - throttle = 2s #start etcd: etcd &>/dev/null & @@ -17,7 +17,7 @@ python3 ./performance/dummy_http_server.py 9001 & #port 9001 python3 ./performance/dummy_ws_server.py & #port 9000 python3 -m performance.check_perf --measure=http_throughput_small --proxy=CHP --concurrent_requests_number=10 --backend_port=9001 --output=./results/http_throughput_small.csv -python3 -m performance.check_perf --measure=http_throughput_small --proxy=TomlProxy --concurrent_requests_number=10 --backend_port=9001 --output=./results/http_throughput_small.csv +python3 -m performance.check_perf --measure=http_throughput_small --proxy=FileProxy --concurrent_requests_number=10 --backend_port=9001 --output=./results/http_throughput_small.csv #start etcd: etcd &>/dev/null & python3 -m performance.check_perf --measure=http_throughput_small --proxy=EtcdProxy --concurrent_requests_number=10 --backend_port=9001 --output=./results/http_throughput_small.csv @@ -26,7 +26,7 @@ pkill etcd -rf default.etcd/ python3 -m performance.check_perf --measure=http_throughput_large --proxy=CHP --concurrent_requests_number=10 --backend_port=9001 --output=./results/http_throughput_large.csv -python3 -m performance.check_perf --measure=http_throughput_large --proxy=TomlProxy --concurrent_requests_number=10 --backend_port=9001 --output=./results/http_throughput_large.csv +python3 -m performance.check_perf --measure=http_throughput_large --proxy=FileProxy --concurrent_requests_number=10 --backend_port=9001 --output=./results/http_throughput_large.csv #start etcd: etcd &>/dev/null & python3 -m performance.check_perf --measure=http_throughput_large --proxy=EtcdProxy --concurrent_requests_number=10 --backend_port=9001 --output=./results/http_throughput_large.csv @@ -35,7 +35,7 @@ pkill etcd -rf default.etcd/ python3 -m performance.check_perf --measure=ws_throughput --proxy=CHP --concurrent_requests_number=10 --output=./results/ws_throughput.csv -python3 -m performance.check_perf --measure=ws_throughput --proxy=TomlProxy --concurrent_requests_number=10 --output=./results/ws_throughput.csv +python3 -m performance.check_perf --measure=ws_throughput --proxy=FileProxy --concurrent_requests_number=10 --output=./results/ws_throughput.csv #start etcd: etcd &>/dev/null & python3 -m performance.check_perf --measure=ws_throughput --proxy=EtcdProxy --concurrent_requests_number=10 --output=./results/ws_throughput.csv diff --git a/performance/run_benchmark_sequential.sh b/performance/run_benchmark_sequential.sh index 09243916..202cedff 100755 --- a/performance/run_benchmark_sequential.sh +++ b/performance/run_benchmark_sequential.sh @@ -1,8 +1,8 @@ #!/bin/sh #CHP methods performance python3 -m performance.check_perf --measure=methods --proxy=CHP --iterations=2 --routes_number=500 --sequential --output=./results/chp_methods_sequential.csv -# TomlProxy methods performance - run with throttle = 0s -python3 -m performance.check_perf --measure=methods --proxy=TomlProxy --iterations=2 --routes_number=500 --sequential --output=./results/toml_methods_sequential.csv +# FileProxy methods performance - run with throttle = 0s +python3 -m performance.check_perf --measure=methods --proxy=FileProxy --iterations=2 --routes_number=500 --sequential --output=./results/toml_methods_sequential.csv # EtcdProxy methods performance - run with throttle = 0s #start etcd: etcd &> /dev/null & From c90686b8b31c73bb2dc2fc86ea070c65a423b728 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Wed, 23 Jun 2021 09:38:37 +0000 Subject: [PATCH 05/10] Further work to upgrade the proxy to support the Traefik v2 API, in relation to issue: https://github.com/jupyterhub/traefik-proxy/issues/97 Although all tests passed on my test system, the github workflow tests failed with the etcd tests, for the following reasons:- - Hadn't actually tested etcd-3.4.15, the default version installed by the install script. I'd used the default ubuntu version, 3.2.26. I'm not sure if this caused issues (maybe?, see next point), but some new warnings are emitted about the log parameters for instance. - The tests mainly failed due to what I thought was a nasty race-condition between successive TraefikEtcdProxy test fixture calls. The `proxy()` fixture in `tests/test_proxy.py` doesn't appear to fully destroy the dependent Proxy classes between test runs. In the case of TraefikEtcdProxy, this leaves the etcd3/grpc client open through the end of one test and into the next test. Then the next TraefikEtcdProxy test gets a connection error. I don't know why only one grpc client is allowed - is this related to the etcd version? - but regardless, the less than ideal solution is to manually call `TraefikEtcdProxy.kv_client.close()` during the teardown of the external_etcd* test runs. This is also manually called now by `TraefikEtcdProxy.stop()`, when `should_start=True`. (This took me days to figure out!!) Some further modifications to the code include:- - Changed the KV store structure for jupyterhub-specific information. All jupyterhub routespecs, targets and data are now stored as:- jupyterhub/ -/routes/{escaped_routespec} = {target} -/targets/{escaped_target} = {data} N.B. I think this can be condensed to one single request. Atm, to get the {data} for a routespec, two KV get requests are required: 1. to get the {target}, and 2. to get the {data} using that {target}. The {target} is also in the traefik configuration, so it seems like unnecessary duplication of information and redirection. - Added `log_level` and `traefik_log_level` config parameters to the base Proxy class. The first sets the log level for the logger (and handler) of the Proxy class. The latter sets traefik's log level, if `should_start=True`. - General tidy up, removing excessive debug statements and commented-out code. --- jupyterhub_traefik_proxy/consul.py | 64 ++++----- jupyterhub_traefik_proxy/etcd.py | 75 ++++++----- jupyterhub_traefik_proxy/fileprovider.py | 22 +--- jupyterhub_traefik_proxy/install.py | 4 +- jupyterhub_traefik_proxy/kv_proxy.py | 73 ++++++---- jupyterhub_traefik_proxy/proxy.py | 50 +++---- jupyterhub_traefik_proxy/traefik_utils.py | 16 +-- tests/conftest.py | 154 +++++++++++----------- tests/proxytest.py | 32 +++-- 9 files changed, 245 insertions(+), 245 deletions(-) diff --git a/jupyterhub_traefik_proxy/consul.py b/jupyterhub_traefik_proxy/consul.py index 6461b956..5865746c 100644 --- a/jupyterhub_traefik_proxy/consul.py +++ b/jupyterhub_traefik_proxy/consul.py @@ -111,7 +111,6 @@ def _stop_traefik(self): 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 ) @@ -128,7 +127,6 @@ def append_payload(key, val): 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 = "" @@ -140,17 +138,13 @@ def append_payload(key, val): 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 ): - escaped_target = escapism.escape(target, safe=self.key_safe_chars) - escaped_jupyterhub_routespec = escapism.escape( - jupyterhub_routespec, safe=self.key_safe_chars + jupyterhub_target = self.kv_separator.join( + [self.kv_jupyterhub_prefix, "targets", escapism.escape(target)] ) try: @@ -158,14 +152,14 @@ async def _kv_atomic_add_route_parts( { "KV": { "Verb": "set", - "Key": escaped_jupyterhub_routespec, + "Key": jupyterhub_routespec, "Value": base64.b64encode(target.encode()).decode(), } }, { "KV": { "Verb": "set", - "Key": escaped_target, + "Key": jupyterhub_target, "Value": base64.b64encode(data.encode()).decode(), } }, @@ -176,13 +170,6 @@ async def _kv_atomic_add_route_parts( "Value": base64.b64encode(target.encode()).decode(), } }, - #{ - # "KV": { - # "Verb": "set", - # "Key": route_keys.service_weight_path, - # "Value": base64.b64encode(b"1").decode(), - # } - #}, { "KV": { "Verb": "set", @@ -211,26 +198,24 @@ async def _kv_atomic_add_route_parts( return status, response async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): - escaped_jupyterhub_routespec = escapism.escape( - jupyterhub_routespec, safe=self.key_safe_chars - ) - index, v = await self.kv_client.kv.get(escaped_jupyterhub_routespec) + index, v = await self.kv_client.kv.get(jupyterhub_routespec) if v is None: self.log.warning( "Route %s doesn't exist. Nothing to delete", jupyterhub_routespec ) return True, None target = v["Value"] - escaped_target = escapism.escape(target, safe=self.key_safe_chars) + jupyterhub_target = self.kv_separator.join( + [self.kv_jupyterhub_prefix, "targets", escapism.escape(target)] + ) try: status, response = await self.kv_client.txn.put( payload=[ - {"KV": {"Verb": "delete", "Key": escaped_jupyterhub_routespec}}, - {"KV": {"Verb": "delete", "Key": escaped_target}}, + {"KV": {"Verb": "delete", "Key": jupyterhub_routespec}}, + {"KV": {"Verb": "delete", "Key": jupyterhub_target}}, {"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}}, ] @@ -244,17 +229,13 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): return status, response async def _kv_get_target(self, jupyterhub_routespec): - escaped_jupyterhub_routespec = escapism.escape( - jupyterhub_routespec, safe=self.key_safe_chars - ) - _, res = await self.kv_client.kv.get(escaped_jupyterhub_routespec) + _, res = await self.kv_client.kv.get(jupyterhub_routespec) if res is None: return None return res["Value"].decode() async def _kv_get_data(self, target): - escaped_target = escapism.escape(target, safe=self.key_safe_chars) - _, res = await self.kv_client.kv.get(escaped_target) + _, res = await self.kv_client.kv.get(target) if res is None: return None @@ -264,10 +245,18 @@ async def _kv_get_route_parts(self, kv_entry): key = escapism.unescape(kv_entry["KV"]["Key"]) value = kv_entry["KV"]["Value"] - # Strip the "/jupyterhub" prefix from the routespec - routespec = key.replace(self.kv_jupyterhub_prefix, "") + # Strip the "jupyterhub/routes/" prefix from the routespec + route_prefix = self.kv_separator.join( + [self.kv_jupyterhub_prefix, "routes/"] + ) + routespec = key.replace(route_prefix, "") + target = base64.b64decode(value.encode()).decode() - data = await self._kv_get_data(target) + jupyterhub_target = self.kv_separator.join( + [self.kv_jupyterhub_prefix, "targets", escapism.escape(target)] + ) + + data = await self._kv_get_data(jupyterhub_target) return routespec, target, data @@ -277,9 +266,10 @@ async def _kv_get_jupyterhub_prefixed_entries(self): { "KV": { "Verb": "get-tree", - "Key": escapism.escape( - self.kv_jupyterhub_prefix, safe=self.key_safe_chars - ), + "Key": f"{self.kv_jupyterhub_prefix}/routes" + #escapism.escape( + # self.kv_jupyterhub_prefix, safe=self.key_safe_chars + #)+ "/routes", } } ] diff --git a/jupyterhub_traefik_proxy/etcd.py b/jupyterhub_traefik_proxy/etcd.py index 8afa9f7d..63cf4ab0 100644 --- a/jupyterhub_traefik_proxy/etcd.py +++ b/jupyterhub_traefik_proxy/etcd.py @@ -19,15 +19,13 @@ # Distributed under the terms of the Modified BSD License. from concurrent.futures import ThreadPoolExecutor -import json -import os +import escapism from urllib.parse import urlparse from tornado.concurrent import run_on_executor from traitlets import Any, default, Unicode from jupyterhub.utils import maybe_future -from . import traefik_utils from jupyterhub_traefik_proxy import TKvProxy @@ -78,23 +76,23 @@ def _default_client(self): import etcd3 except ImportError: raise ImportError("Please install etcd3 package to use traefik-proxy with etcd3") + kwargs = { + 'host': etcd_service.hostname, + 'port': etcd_service.port, + 'ca_cert': self.etcd_client_ca_cert, + 'cert_cert': self.etcd_client_cert_crt, + 'cert_key': self.etcd_client_cert_key, + } if self.kv_password: - return etcd3.client( - host=str(etcd_service.hostname), - port=etcd_service.port, - user=self.kv_username, - password=self.kv_password, - ca_cert=self.etcd_client_ca_cert, - cert_cert=self.etcd_client_cert_crt, - cert_key=self.etcd_client_cert_key, - ) - return etcd3.client( - host=str(etcd_service.hostname), - port=etcd_service.port, - ca_cert=self.etcd_client_ca_cert, - cert_cert=self.etcd_client_cert_crt, - cert_key=self.etcd_client_cert_key, - ) + kwargs.update({ + 'user': self.kv_username, + 'password': self.kv_password + }) + return etcd3.client(**kwargs) + + def _clean_resources(self): + super()._clean_resources() + self.kv_client.close() @run_on_executor def _etcd_transaction(self, success_actions): @@ -119,7 +117,7 @@ def _define_kv_specific_static_config(self): self.static_config.update({"providers" : { "etcd" : { "endpoints": [url.netloc], - "rootKey": self.kv_traefik_prefix, # Is rootKey the new prefix? + "rootKey": self.kv_traefik_prefix, } } }) if self.kv_username and self.kv_password: @@ -131,14 +129,13 @@ def _define_kv_specific_static_config(self): async def _kv_atomic_add_route_parts( self, jupyterhub_routespec, target, data, route_keys, rule ): + jupyterhub_target = self.kv_separator.join( + [self.kv_jupyterhub_prefix, "targets", escapism.escape(target)] + ) success = [ self.kv_client.transactions.put(jupyterhub_routespec, target), - self.kv_client.transactions.put(target, data), + self.kv_client.transactions.put(jupyterhub_target, data), 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.router_service_path, route_keys.service_alias ), @@ -151,15 +148,17 @@ 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 {jupyterhub_routespec} doesn't exist. Nothing to delete" + f"Route {jupyterhub_routespec} doesn't exist. Nothing to delete" ) return True, None - target = value.decode() + jupyterhub_target = self.kv_separator.join( + [self.kv_jupyterhub_prefix, "targets", escapism.escape(value.decode())] + ) success = [ self.kv_client.transactions.delete(jupyterhub_routespec), - self.kv_client.transactions.delete(target), + self.kv_client.transactions.delete(jupyterhub_target), 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), @@ -170,7 +169,7 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): async def _kv_get_target(self, jupyterhub_routespec): value = await maybe_future(self._etcd_get(jupyterhub_routespec)) - if value == None: + if value is None: return None return value.decode() @@ -182,17 +181,23 @@ async def _kv_get_data(self, target): async def _kv_get_route_parts(self, kv_entry): key = kv_entry[1].key.decode() - value = kv_entry[0] + value = kv_entry[0].decode() - # Strip the "/jupyterhub" prefix from the routespec - routespec = key.replace(self.kv_jupyterhub_prefix, "") - target = value.decode() - data = await self._kv_get_data(target) + # Strip the "/jupyterhub/routes/" prefix from the routespec and unescape it + sep = self.kv_separator + route_prefix = sep.join([self.kv_jupyterhub_prefix, "routes" + sep]) + target_prefix = sep.join([self.kv_jupyterhub_prefix, "targets" + sep]) + routespec = escapism.unescape(key.replace(route_prefix, "", 1)) + etcd_target = sep.join([target_prefix, escapism.escape(value)]) + target = escapism.unescape(etcd_target.replace(target_prefix, "", 1)) + data = await self._kv_get_data(etcd_target) return routespec, target, data async def _kv_get_jupyterhub_prefixed_entries(self): - routes = await maybe_future(self._etcd_get_prefix(self.kv_jupyterhub_prefix)) + sep = self.kv_separator + routespecs_prefix = sep.join([self.kv_jupyterhub_prefix, "routes" + sep]) + routes = await maybe_future(self._etcd_get_prefix(routespecs_prefix)) return routes async def persist_dynamic_config(self): diff --git a/jupyterhub_traefik_proxy/fileprovider.py b/jupyterhub_traefik_proxy/fileprovider.py index 091fd7a4..1e3d5e33 100644 --- a/jupyterhub_traefik_proxy/fileprovider.py +++ b/jupyterhub_traefik_proxy/fileprovider.py @@ -18,10 +18,8 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. -import json import os import asyncio -import string import escapism from traitlets import Any, default, Unicode, observe @@ -61,7 +59,6 @@ def _set_dynamic_config_file(self, change): def __init__(self, **kwargs): super().__init__(**kwargs) - #self._set_dynamic_config_file(None) try: # Load initial dynamic config from disk self.dynamic_config = self.dynamic_config_handler.load() @@ -106,7 +103,7 @@ def _clean_resources(self): def _get_route_unsafe(self, traefik_routespec): service_alias = traefik_utils.generate_alias(traefik_routespec, "service") router_alias = traefik_utils.generate_alias(traefik_routespec, "router") - routespec = self._routespec_from_traefik_path(traefik_routespec) + routespec = self.validate_routespec(traefik_routespec) result = {"data": None, "target": None, "routespec": routespec} def get_target_data(d, to_find): @@ -133,8 +130,6 @@ def get_target_data(d, to_find): if result["data"] is None and result["target"] is None: self.log.info(f"No route for {routespec} found!") result = None - self.log.debug(f"traefik routespec: {traefik_routespec}") - self.log.debug(f"result for routespec {routespec}:-\n{result}") return result async def start(self): @@ -179,11 +174,9 @@ 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) + traefik_routespec = self.validate_routespec(routespec) service_alias = traefik_utils.generate_alias(traefik_routespec, "service") router_alias = traefik_utils.generate_alias(traefik_routespec, "router") - #data = json.dumps(data) rule = traefik_utils.generate_rule(traefik_routespec) async with self.mutex: @@ -222,9 +215,6 @@ async def add_route(self, routespec, target, data): } self.persist_dynamic_config() - self.log.debug(f"traefik routespec: {traefik_routespec}") - self.log.debug(f"data for routespec {routespec}:-\n{data}") - if self.should_start: try: # Check if traefik was launched @@ -247,17 +237,17 @@ async def delete_route(self, routespec): **Subclasses must define this method** """ - routespec = self._routespec_to_traefik_path(routespec) + routespec = self.validate_routespec(routespec) service_alias = traefik_utils.generate_alias(routespec, "service") router_alias = traefik_utils.generate_alias(routespec, "router") async with self.mutex: + # Pop each entry and if it's the last one, delete the key self.dynamic_config["http"]["routers"].pop(router_alias, None) self.dynamic_config["http"]["services"].pop(service_alias, None) 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"]: @@ -293,7 +283,7 @@ async def get_all_routes(self): continue escaped_routespec = "".join(router.split("_", 1)[1:]) traefik_routespec = escapism.unescape(escaped_routespec) - routespec = self._routespec_from_traefik_path(traefik_routespec) + routespec = self.validate_routespec(traefik_routespec) all_routes.update({ routespec : self._get_route_unsafe(traefik_routespec) }) @@ -320,7 +310,7 @@ async def get_route(self, routespec): None: if there are no routes matching the given routespec """ - routespec = self._routespec_to_traefik_path(routespec) + routespec = self.validate_routespec(routespec) async with self.mutex: return self._get_route_unsafe(routespec) diff --git a/jupyterhub_traefik_proxy/install.py b/jupyterhub_traefik_proxy/install.py index ddc82824..ac49a699 100644 --- a/jupyterhub_traefik_proxy/install.py +++ b/jupyterhub_traefik_proxy/install.py @@ -28,8 +28,8 @@ "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.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", + "https://github.com/etcd-io/etcd/releases/download/v3.2.26/etcd-v3.2.26-linux-amd64.tar.gz": "127d4f2097c09d929beb9d3784590cc11102f4b4d4d4da7ad82d5c9e856afd38", + "https://github.com/etcd-io/etcd/releases/download/v3.2.26/etcd-v3.2.26-darwin-amd64.zip": "0393e650ffa3e61b1fd07c61f8c78af1556896c300c9814545ff0e91f52c3513", } checksums_consul = { diff --git a/jupyterhub_traefik_proxy/kv_proxy.py b/jupyterhub_traefik_proxy/kv_proxy.py index cc69629e..17dd5f2d 100644 --- a/jupyterhub_traefik_proxy/kv_proxy.py +++ b/jupyterhub_traefik_proxy/kv_proxy.py @@ -18,6 +18,7 @@ # Copyright (c) Jupyter Development Team. # Distributed under the terms of the Modified BSD License. +import escapism import json import os @@ -38,8 +39,6 @@ class TKvProxy(TraefikProxy): kv_client = Any() # Key-value store client - #kv_name = Unicode(config=False, help="""The name of the key value store""") - kv_username = Unicode( config=True, help="""The username for key value store login""" ) @@ -170,8 +169,8 @@ async def _kv_get_route_parts(self, kv_entry): from the key-value store given a `kv_entry`. A `kv_entry` is a key-value store entry where the key starts with - `proxy.jupyterhub_prefix`. It is expected that only the routespecs - will be prefixed with `proxy.jupyterhub_prefix` when added to the kv store. + `proxy.kv_jupyterhub_prefix`. It is expected that only the routespecs + will be prefixed with `proxy.kv_jupyterhub_prefix` when added to the kv store. **Subclasses must define this method** @@ -186,15 +185,15 @@ async def _kv_get_route_parts(self, kv_entry): async def _kv_get_jupyterhub_prefixed_entries(self): """Retrive from the kv store all the key-value pairs where the key starts with - `proxy.jupyterhub_prefix`. - It is expected that only the routespecs will be prefixed with `proxy.jupyterhub_prefix` + `proxy.kv_jupyterhub_prefix`. + It is expected that only the routespecs will be prefixed with `proxy.kv_jupyterhub_prefix` when added to the kv store. **Subclasses must define this method** Returns: 'routes': A list of key-value store entries where the keys start - with `proxy.jupyterhub_prefix`. + with `proxy.kv_jupyterhub_prefix`. """ raise NotImplementedError() @@ -208,12 +207,10 @@ def _clean_resources(self): raise async def _setup_traefik_static_config(self): - self.log.debug("Setup the KV-specific static config") self._define_kv_specific_static_config() 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() @@ -244,13 +241,13 @@ async def add_route(self, routespec, target, data): Will raise an appropriate Exception (FIXME: find what?) if the route could not be added. - This proxy implementation prefixes the routespec with `proxy.jupyterhub_prefix` when + This proxy implementation prefixes the routespec with `proxy.kv_jupyterhub_prefix` when adding it to the kv store in orde to associate the fact that the route came from JupyterHub. Everything traefik related is prefixed with `proxy.traefik_prefix`. """ - self.log.info("Adding route for %s to %s.", routespec, target) + self.log.debug("Adding route for %s to %s.", routespec, target) - routespec = self._routespec_to_traefik_path(routespec) + routespec = self.validate_routespec(routespec) route_keys = traefik_utils.generate_route_keys(self, routespec, separator=self.kv_separator) # Store the data dict passed in by JupyterHub @@ -259,7 +256,9 @@ async def add_route(self, routespec, target, data): rule = traefik_utils.generate_rule(routespec) # To be able to delete the route when only routespec is provided - jupyterhub_routespec = self.kv_jupyterhub_prefix + routespec + jupyterhub_routespec = self.kv_separator.join( + [self.kv_jupyterhub_prefix, "routes", escapism.escape(routespec)] + ) status, response = await self._kv_atomic_add_route_parts( jupyterhub_routespec, target, data, route_keys, rule @@ -295,8 +294,10 @@ async def delete_route(self, routespec): """Delete a route and all the traefik related info associated given a routespec, (if it exists). """ - routespec = self._routespec_to_traefik_path(routespec) - jupyterhub_routespec = self.kv_jupyterhub_prefix + routespec + routespec = self.validate_routespec(routespec) + jupyterhub_routespec = self.kv_separator.join( + [self.kv_jupyterhub_prefix, "routes", escapism.escape(routespec)] + ) route_keys = traefik_utils.generate_route_keys(self, routespec, separator=self.kv_separator) status, response = await self._kv_atomic_delete_route_parts( @@ -327,7 +328,7 @@ async def get_all_routes(self): for kv_entry in routes: traefik_routespec, target, data = await self._kv_get_route_parts(kv_entry) - routespec = self._routespec_from_traefik_path(traefik_routespec) + routespec = self.validate_routespec(traefik_routespec) all_routes[routespec] = { "routespec": routespec, "target": target, @@ -357,13 +358,17 @@ async def get_route(self, routespec): None: if there are no routes matching the given routespec """ routespec = self.validate_routespec(routespec) - traefik_routespec = self._routespec_to_traefik_path(routespec) - jupyterhub_routespec = self.kv_jupyterhub_prefix + traefik_routespec + jupyterhub_routespec = self.kv_separator.join( + [self.kv_jupyterhub_prefix, "routes", escapism.escape(routespec)] + ) target = await self._kv_get_target(jupyterhub_routespec) if target is None: return None - data = await self._kv_get_data(target) + traefik_target = self.kv_separator.join( + [self.kv_jupyterhub_prefix, "targets", escapism.escape(target)] + ) + data = await self._kv_get_data(traefik_target) return { "routespec": routespec, @@ -376,16 +381,32 @@ def flatten_dict_for_kv(self, data, prefix='traefik'): 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'} } ) + If the final value is a `list`, then the provided bottom-level key + shall be appended with an incrementing numeric number, in the style + that is used by traefik's KV store, e.g. + + flatten_dict_for_kv({ + 'x' : { + 'y' : { + 'z': 'a' + } + }, { + 'foo': 'bar' + }, + 'baz': [ 'a', 'b', 'c' ] + }) Returns: result (dict): { - 'traefik.x.y.z' : 'a', - 'traefik.x.foo': 'bar' + 'traefik/x/y/z' : 'a', + 'traefik/x/foo': 'bar', + 'traefik/baz/0': 'a', + 'traefik/baz/1': 'b', + 'traefik/baz/2': 'c', } - - Ref: Taken from https://stackoverflow.com/a/6027615 + + Ref: Inspired by https://stackoverflow.com/a/6027615 """ sep = self.kv_separator items = {} @@ -393,15 +414,11 @@ def flatten_dict_for_kv(self, data, prefix='traefik'): 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 98e07ee9..c7e8df5a 100644 --- a/jupyterhub_traefik_proxy/proxy.py +++ b/jupyterhub_traefik_proxy/proxy.py @@ -58,7 +58,8 @@ class TraefikProxy(Proxy): debug = Bool(False, config=True, help="""Debug the proxy class?""") - traefik_log_level = Unicode("DEBUG", config=True, help="""traefik's log level""") + traefik_log_level = Unicode(config=True, help="""traefik's log level""") + log_level = Unicode(config=True, help="""The Proxy's log level""") traefik_api_password = Unicode( config=True, help="""The password for traefik api login""" @@ -70,21 +71,23 @@ class TraefikProxy(Proxy): 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: + if self.log_level: + self._set_log_level() + + def _set_log_level(self): + import sys, logging + # Check we don't already have a StreamHandler + # and add one if necessary + addHandler = True + for handler in self.log.handlers: + if isinstance(handler, logging.StreamHandler): + addHandler = False + level = self.log_level + if addHandler: + self.log.setLevel(level) + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(level) + self.log.addHandler(handler) @default("traefik_api_password") def _warn_empty_password(self): @@ -260,7 +263,8 @@ async def _setup_traefik_static_config(self): """ self.log.info("Setting up traefik's static config...") - self.static_config["log"] = { "level": self.traefik_log_level } + if self.traefik_log_level: + self.static_config["log"] = { "level": self.traefik_log_level } entryPoints = {} @@ -336,14 +340,10 @@ async def _setup_traefik_dynamic_config(self): } }) - - def _routespec_to_traefik_path(self, routespec): - path = self.validate_routespec(routespec) - if path != "/" and path.endswith("/"): - path = path.rstrip("/") - return path - - def _routespec_from_traefik_path(self, routespec): + def validate_routespec(self, routespec): + """Override jupyterhub's default Proxy.validate_routespec method, as traefik + can set router rule's on both Host and PathPrefix rules combined. + """ if not routespec.endswith("/"): routespec = routespec + "/" return routespec diff --git a/jupyterhub_traefik_proxy/traefik_utils.py b/jupyterhub_traefik_proxy/traefik_utils.py index 62d64335..9dca25ae 100644 --- a/jupyterhub_traefik_proxy/traefik_utils.py +++ b/jupyterhub_traefik_proxy/traefik_utils.py @@ -27,12 +27,11 @@ def generate_rule(routespec): routespec = unquote(routespec) if routespec.startswith("/"): # Path-based route, e.g. /proxy/path/ - rule = "PathPrefix(`{0}`)".format(routespec) + rule = f"PathPrefix(`{routespec}`)" else: # Host-based routing, e.g. host.tld/proxy/path/ host, path_prefix = routespec.split("/", 1) - path_prefix = "/" + path_prefix - rule = "Host(`{0}`) && PathPrefix(`{1}`)".format(host, path_prefix) + rule = f"Host(`{host}`) && PathPrefix(`/{path_prefix}`)" return rule @@ -51,12 +50,6 @@ def generate_service_entry( proxy, service_alias, separator="/", url=False): service_entry += separator + "url" 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 "/".join( @@ -87,7 +80,6 @@ def generate_route_keys(proxy, routespec, separator="/"): [ "service_alias", "service_url_path", - #"service_weight_path", "router_alias", "router_service_path", "router_rule_path", @@ -97,8 +89,6 @@ 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_weight_entry(proxy, service_alias) router_service_path = generate_router_service_entry(proxy, router_alias) else: service_url_path = generate_service_entry( @@ -107,13 +97,11 @@ def generate_route_keys(proxy, routespec, separator="/"): router_rule_path = generate_router_rule_entry( proxy, router_alias, separator=separator ) - #service_weight_path = "" router_service_path = "" return RouteKeys( service_alias, service_url_path, - #service_weight_path, router_alias, router_service_path, router_rule_path, diff --git a/tests/conftest.py b/tests/conftest.py index 71ba58d2..6dcd6379 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,13 +27,18 @@ class Config: # 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"}) + etcdctl_env = dict(os.environ, ETCDCTL_API="3") + # Etcd3 auth login credentials etcd_password = "secret" etcd_user = "root" + # Consol auth login credentials consul_token = "secret" + # Traefik api auth login credentials + traefik_api_user = "api_admin" + traefik_api_pass = "admin" # Define a "slow" test marker so that we can run the slow tests at the end # ref: https://docs.pytest.org/en/6.0.1/example/simple.html#control-skipping-of-tests-according-to-command-line-option @@ -66,11 +71,11 @@ async def no_auth_consul_proxy(launch_consul): """ proxy = TraefikConsulProxy( public_url="http://127.0.0.1:8000", - traefik_api_password="admin", - traefik_api_username="api_admin", + traefik_api_password=Config.traefik_api_pass, + traefik_api_username=Config.traefik_api_user, check_route_timeout=45, should_start=True, - debug=True + log_level='DEBUG' ) await proxy.start() yield proxy @@ -85,12 +90,12 @@ async def auth_consul_proxy(launch_consul_acl): """ proxy = TraefikConsulProxy( public_url="http://127.0.0.1:8000", - traefik_api_password="admin", - traefik_api_username="api_admin", + traefik_api_password=Config.traefik_api_pass, + traefik_api_username=Config.traefik_api_user, kv_password=Config.consul_token, check_route_timeout=45, should_start=True, - debug=True + log_level='DEBUG' ) await proxy.start() yield proxy @@ -98,18 +103,18 @@ async def auth_consul_proxy(launch_consul_acl): @pytest.fixture -async def no_auth_etcd_proxy(launch_etcd): +async def no_auth_etcd_proxy(launch_etcd, wait_for_etcd): """ Fixture returning a configured TraefikEtcdProxy. No etcd authentication. """ proxy = TraefikEtcdProxy( public_url="http://127.0.0.1:8000", - traefik_api_password="admin", - traefik_api_username="api_admin", + traefik_api_password=Config.traefik_api_pass, + traefik_api_username=Config.traefik_api_user, check_route_timeout=45, should_start=True, - debug=True + log_level='DEBUG' ) await proxy.start() yield proxy @@ -124,13 +129,13 @@ async def auth_etcd_proxy(launch_etcd_auth): """ proxy = TraefikEtcdProxy( public_url="http://127.0.0.1:8000", - traefik_api_password="admin", - traefik_api_username="api_admin", + traefik_api_password=Config.traefik_api_pass, + traefik_api_username=Config.traefik_api_user, kv_username="root", kv_password=Config.etcd_password, check_route_timeout=45, should_start=True, - debug=True + log_level='DEBUG' ) await proxy.start() yield proxy @@ -177,11 +182,11 @@ def _file_proxy(dynamic_config_file, **kwargs): ) return TraefikFileProviderProxy( public_url="http://127.0.0.1:8000", - traefik_api_password="admin", - traefik_api_username="api_admin", + traefik_api_password=Config.traefik_api_pass, + traefik_api_username=Config.traefik_api_user, dynamic_config_file = dynamic_config_file, check_route_timeout=60, - debug=True, + log_level='DEBUG', **kwargs ) @@ -208,8 +213,8 @@ async def external_file_proxy_toml(launch_traefik_file): async def external_consul_proxy(launch_consul, configure_consul, launch_traefik_consul): proxy = TraefikConsulProxy( public_url="http://127.0.0.1:8000", - traefik_api_password="admin", - traefik_api_username="api_admin", + traefik_api_password=Config.traefik_api_pass, + traefik_api_username=Config.traefik_api_user, check_route_timeout=45, should_start=False, debug=True @@ -218,11 +223,11 @@ async def external_consul_proxy(launch_consul, configure_consul, launch_traefik_ @pytest.fixture -def auth_external_consul_proxy(launch_consul_acl, configure_consul_auth, launch_traefik_consul_auth): +async 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", + traefik_api_password=Config.traefik_api_pass, + traefik_api_username=Config.traefik_api_user, kv_password=Config.consul_token, check_route_timeout=45, should_start=False, @@ -232,32 +237,33 @@ def auth_external_consul_proxy(launch_consul_acl, configure_consul_auth, launch_ @pytest.fixture -def external_etcd_proxy(launch_etcd, configure_etcd, launch_traefik_etcd): +async 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", + traefik_api_password=Config.traefik_api_pass, + traefik_api_username=Config.traefik_api_user, check_route_timeout=45, should_start=False, - debug=True + log_level="DEBUG" ) yield proxy + proxy.kv_client.close() @pytest.fixture 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", + traefik_api_password=Config.traefik_api_pass, + traefik_api_username=Config.traefik_api_user, kv_password=Config.etcd_password, kv_username="root", check_route_timeout=45, should_start=False, - debug=True + log_level="DEBUG" ) - #traefik_process = configure_and_launch_traefik(provider="etcd", password=Config.etcd_password) yield proxy + proxy.kv_client.close() @@ -267,7 +273,7 @@ def auth_external_etcd_proxy(launch_etcd_auth, configure_etcd_auth, launch_traef ######################################################################### @pytest.fixture -async def launch_traefik_file(): +def launch_traefik_file(): args = ("--configfile", "./tests/config_files/traefik.toml") print(f"\nLAUNCHING TRAEFIK with args: {args}\n") proc = _launch_traefik(*args) @@ -276,31 +282,32 @@ async def launch_traefik_file(): @pytest.fixture -async def launch_traefik_etcd(): - proc = _launch_traefik_cli("--providers.etcd") +def launch_traefik_etcd(): + env = Config.etcdctl_env + proc = _launch_traefik_cli("--providers.etcd", env=env) yield proc shutdown_traefik(proc) @pytest.fixture -async def launch_traefik_etcd_auth(): +def launch_traefik_etcd_auth(): extra_args = ( "--providers.etcd.username=" + Config.etcd_user, "--providers.etcd.password=" + Config.etcd_password ) - proc = _launch_traefik_cli(*extra_args) + proc = _launch_traefik_cli(*extra_args, env=Config.etcdctl_env) yield proc shutdown_traefik(proc) @pytest.fixture -async def launch_traefik_consul(): +def launch_traefik_consul(): proc = _launch_traefik_cli("--providers.consul") yield proc shutdown_traefik(proc) @pytest.fixture -async def launch_traefik_consul_auth(): +def launch_traefik_consul_auth(): extra_args = ( "--providers.consul.username=root", "--providers.consul.password=" + Config.consul_token @@ -336,7 +343,7 @@ def _launch_traefik(*extra_args, env=None): ################################## @pytest.fixture -def configure_etcd(): +def configure_etcd(wait_for_etcd): """Load traefik api rules into the etcd kv store""" yield _config_etcd() @@ -358,7 +365,7 @@ def _config_etcd(*extra_args): proc.wait() @pytest.fixture -def _enable_auth_in_etcd(): +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) @@ -382,25 +389,18 @@ def _enable_auth_in_etcd(): @pytest.fixture -def launch_etcd_auth(launch_etcd, _enable_auth_in_etcd): +def launch_etcd_auth(launch_etcd, wait_for_etcd, enable_auth_in_etcd): yield @pytest.fixture() -async def launch_etcd(): - etcd_proc = subprocess.Popen(["etcd", "--debug"]) - await _wait_for_etcd() +def launch_etcd(): + etcd_proc = subprocess.Popen(["etcd", "--log-level=debug"]) 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) + shutdown_etcd(etcd_proc) -async def _wait_for_etcd(user=None, pw=None): +@pytest.fixture +def wait_for_etcd(): """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. @@ -409,31 +409,20 @@ async def _wait_for_etcd(user=None, pw=None): 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, + assert ( + "is healthy" in + subprocess.check_output( + ["etcdctl", "endpoint", "health"], + env=Config.etcdctl_env, + stderr=subprocess.STDOUT + ).decode(sys.stdout.encoding) ) #@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) +def clean_etcd(): + subprocess.run(["etcdctl", "del", '""', "--from-key=true"], env=Config.etcdctl_env) # Consul Launchers and configurers # @@ -446,7 +435,7 @@ async def launch_consul(): ) await _wait_for_consul() yield consul_proc - await shutdown_consul(consul_proc) + shutdown_consul(consul_proc) @pytest.fixture @@ -462,7 +451,7 @@ async def launch_consul_acl(): await _wait_for_consul(token=Config.consul_token) yield consul_proc - await shutdown_consul(consul_proc, secret=Config.consul_token) + shutdown_consul(consul_proc, secret=Config.consul_token) shutil.rmtree(os.getcwd() + "/consul.data") @@ -492,13 +481,13 @@ async def _check_consul(): @pytest.fixture -async def configure_consul(): +def configure_consul(): """Load an initial config into the consul KV store""" yield _config_consul() @pytest.fixture -async def configure_consul_auth(): +def configure_consul_auth(): """Load an initial config into the consul KV store, using authentication""" yield _config_consul(secret=Config.consul_token) @@ -533,7 +522,7 @@ def _config_consul(secret=None): # Teardown functions # ######################################################################### -async def shutdown_consul(consul_proc, secret=None): +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: @@ -542,6 +531,17 @@ async def shutdown_consul(consul_proc, secret=None): subprocess.call(["consul", "leave"], env=consul_env) terminate_process(consul_proc, timeout=30) +def shutdown_etcd(etcd_proc): + clean_etcd() + terminate_process(etcd_proc, timeout=20) + + # 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 and remove it if there. + default_etcd = os.path.join(os.getcwd(), "default.etcd") + if os.path.exists(default_etcd): + shutil.rmtree(default_etcd) + def shutdown_traefik(traefik_process): terminate_process(traefik_process) diff --git a/tests/proxytest.py b/tests/proxytest.py index c6243697..8bfc17d7 100644 --- a/tests/proxytest.py +++ b/tests/proxytest.py @@ -20,6 +20,9 @@ import websockets +import pprint +pp = pprint.PrettyPrinter(indent=2) + class MockApp: def __init__(self): self.hub = Hub(routespec="/") @@ -73,6 +76,13 @@ def __init__(self, name): def _new_spawner(self, spawner_name, **kwargs): return MockSpawner(spawner_name, user=self, **kwargs) +def assert_equal(value, expected): + try: + assert value == expected + except AssertionError: + pp.pprint({'value': value}) + pp.pprint({"expected": expected}) + raise @pytest.fixture def launch_backend(): @@ -192,10 +202,12 @@ async def test_route_exist(spec, backend): if not expect_value_error(spec): try: del( route["data"]["last_activity"] ) # CHP + except TypeError as e: + raise TypeError(f"{e}\nRoute got:{route}") except KeyError: pass - assert route == expected_output(spec, backend.geturl()) + assert_equal(route, expected_output(spec, backend.geturl())) # Test the actual routing responding_backend1 = await utils.get_responding_backend_port( @@ -204,10 +216,8 @@ async def test_route_exist(spec, backend): responding_backend2 = await utils.get_responding_backend_port( proxy_url, normalize_spec(spec) + "something" ) - assert ( - responding_backend1 == backend.port - and responding_backend2 == backend.port - ) + assert_equal(responding_backend1, backend.port) + assert_equal(responding_backend2, backend.port) for i, spec in enumerate(existing_routes, start=1): backend = default_backend._replace( @@ -226,8 +236,8 @@ async def test_route_exist(spec, backend): for i, spec in enumerate(existing_routes): try: await proxy.add_route(spec, extra_backends[i].geturl(), copy.copy(data)) - except Exception: - pass + except Exception as e: + raise type(e)(f"{e}\nProblem adding Route {spec}") def finalizer(): async def cleanup(): @@ -258,7 +268,7 @@ async def cleanup(): # Test that deleted route does not exist anymore if not expect_value_error(routespec): - assert route == None + assert_equal(route, None) async def _wait_for_deletion(): deleted = 0 @@ -327,7 +337,7 @@ async def test_get_all_routes(proxy, launch_backend): except KeyError: pass - assert routes == expected_output + assert_equal(routes, expected_output) async def test_host_origin_headers(proxy, launch_backend): @@ -369,8 +379,8 @@ async def test_host_origin_headers(proxy, launch_backend): ) resp = await AsyncHTTPClient().fetch(req) - assert resp.headers["Host"] == expected_host_header - assert resp.headers["Origin"] == expected_origin_header + assert_equal(resp.headers["Host"], expected_host_header) + assert_equal(resp.headers["Origin"], expected_origin_header) @pytest.mark.parametrize("username", ["zoe", "50fia", "秀樹", "~TestJH", "has@"]) From 9685be6a0d651d0472bffc56c74b41c6de39ac83 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Wed, 23 Jun 2021 11:27:08 +0000 Subject: [PATCH 06/10] Reverted some breaking changes made in commit 077a6258a950843368b59959bfc762a98fc37b3d, and later. The change in this commit made `jupyterhub_traefik/install.py` not download any versions of traefik, consul or etcd where the checksums were not present. Further, (and this was a change made in a later commit faa2832437bf9a2bcd0023a24c34fea150d5d4c1), the default version of etcd was updated to 3.4.15 and although this was present in the checksums, for some reason it did not install properly in the github workflow. Finally, updated some assert statements in tests/proxytest.py, to instead run `assert_equal(val, cmp)`, which makes the pytest error message easier to follow. --- jupyterhub_traefik_proxy/install.py | 63 +++++++++++++++-------------- tests/proxytest.py | 4 +- 2 files changed, 34 insertions(+), 33 deletions(-) diff --git a/jupyterhub_traefik_proxy/install.py b/jupyterhub_traefik_proxy/install.py index ac49a699..59e62ff4 100644 --- a/jupyterhub_traefik_proxy/install.py +++ b/jupyterhub_traefik_proxy/install.py @@ -150,7 +150,8 @@ def install_etcd(prefix, plat, etcd_version): print("--- Done ---") return else: - if checksum_file(etcd_downloaded_archive) == checksums_etcd[etcd_url]: + checksum_etcd_archive = checksum_file(etcd_downloaded_archive) + if checksum_etcd_archive == checksums_etcd[etcd_url]: os.chmod(etcd_bin, 0o755) os.chmod(etcdctl_bin, 0o755) print("--- Done ---") @@ -161,36 +162,16 @@ def install_etcd(prefix, plat, etcd_version): os.remove(etcdctl_bin) os.remove(etcd_downloaded_archive) - if etcd_url in checksums_etcd: - if not os.path.exists(etcd_downloaded_archive): - print(f"Downloading {etcd_downloaded_dir_name} archive...") - urlretrieve(etcd_url, etcd_downloaded_archive) - else: - print(f"Archive {etcd_downloaded_dir_name} already exists") + if not os.path.exists(etcd_downloaded_archive): + print(f"Downloading {etcd_downloaded_dir_name} archive...") + urlretrieve(etcd_url, etcd_downloaded_archive) + else: + print(f"Archive {etcd_downloaded_dir_name} already exists") - if checksum_file(etcd_downloaded_archive) != checksums_etcd[etcd_url]: + if etcd_url in checksums_etcd: + checksum_etcd_archive = checksum_file(etcd_downloaded_archive) + if checksum_etcd_archive != checksums_etcd[etcd_url]: raise IOError("Checksum failed") - - print("Extracting the archive...") - - if etcd_archive_extension == "zip": - with zipfile.ZipFile(etcd_downloaded_archive, "r") as zip_ref: - zip_ref.extract(etcd_downloaded_dir_name + "/etcd", etcd_binaries) - zip_ref.extract(etcd_downloaded_dir_name + "/etcdctl", etcd_binaries) - else: - with (tarfile.open(etcd_downloaded_archive, "r")) as tar_ref: - tar_ref.extract(etcd_downloaded_dir_name + "/etcd", etcd_binaries) - tar_ref.extract(etcd_downloaded_dir_name + "/etcdctl", etcd_binaries) - - shutil.copy(os.path.join(etcd_binaries, etcd_downloaded_dir_name, "etcd"), etcd_bin) - shutil.copy( - os.path.join(etcd_binaries, etcd_downloaded_dir_name, "etcdctl"), etcdctl_bin) - - os.chmod(etcd_bin, 0o755) - os.chmod(etcdctl_bin, 0o755) - - # Cleanup - shutil.rmtree(etcd_binaries) else: warnings.warn( f"Etcd {etcd_version} not supported ! Or, at least, we don't " @@ -198,6 +179,26 @@ def install_etcd(prefix, plat, etcd_version): stacklevel=2 ) + print("Extracting the archive...") + if etcd_archive_extension == "zip": + with zipfile.ZipFile(etcd_downloaded_archive, "r") as zip_ref: + zip_ref.extract(etcd_downloaded_dir_name + "/etcd", etcd_binaries) + zip_ref.extract(etcd_downloaded_dir_name + "/etcdctl", etcd_binaries) + else: + with (tarfile.open(etcd_downloaded_archive, "r")) as tar_ref: + tar_ref.extract(etcd_downloaded_dir_name + "/etcd", etcd_binaries) + tar_ref.extract(etcd_downloaded_dir_name + "/etcdctl", etcd_binaries) + + shutil.copy(os.path.join(etcd_binaries, etcd_downloaded_dir_name, "etcd"), etcd_bin) + shutil.copy( + os.path.join(etcd_binaries, etcd_downloaded_dir_name, "etcdctl"), etcdctl_bin) + + os.chmod(etcd_bin, 0o755) + os.chmod(etcdctl_bin, 0o755) + + # Cleanup + shutil.rmtree(etcd_binaries) + print("--- Done ---") @@ -288,8 +289,8 @@ def main(): - v3.4.15-windows-amd64 - v3.3.10-linux-amd64 - v3.3.10-darwin-amd64 - - v3.2.25-linux-amd64 - - v3.2.25-darwin-amd64 + - v3.2.26-linux-amd64 + - v3.2.26-darwin-amd64 - consul: - v1.9.4_darwin - v1.9.4_linux_amd64 diff --git a/tests/proxytest.py b/tests/proxytest.py index 8bfc17d7..2c8caaa1 100644 --- a/tests/proxytest.py +++ b/tests/proxytest.py @@ -394,7 +394,7 @@ async def test_check_routes(proxy, username): # run initial check first, to ensure that `/` is in the routes await proxy.check_routes(users, services) routes = await proxy.get_all_routes() - assert sorted(routes) == ["/"] + assert_equal(sorted(routes), ["/"]) users[username] = test_user = MockUser(username) spawner = test_user.spawners[""] @@ -422,7 +422,7 @@ async def test_check_routes(proxy, username): assert test_user.proxy_spec in after # check that before and after state are the same - assert before == after + assert_equal(before, after) async def test_websockets(proxy, launch_backend): From 508987c1140daf6bb0d898950fdc327fca25eeb9 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Wed, 23 Jun 2021 14:54:24 +0000 Subject: [PATCH 07/10] Fix minor typo in jupyterhub_traefik_proxy/etcd.py, which put the `proxy.kv_separator` in the wrong place, causing faulty target lookups in the etcd database. --- jupyterhub_traefik_proxy/etcd.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/jupyterhub_traefik_proxy/etcd.py b/jupyterhub_traefik_proxy/etcd.py index 63cf4ab0..3f29680b 100644 --- a/jupyterhub_traefik_proxy/etcd.py +++ b/jupyterhub_traefik_proxy/etcd.py @@ -160,7 +160,6 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): self.kv_client.transactions.delete(jupyterhub_routespec), self.kv_client.transactions.delete(jupyterhub_target), 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), ] @@ -186,10 +185,10 @@ async def _kv_get_route_parts(self, kv_entry): # Strip the "/jupyterhub/routes/" prefix from the routespec and unescape it sep = self.kv_separator route_prefix = sep.join([self.kv_jupyterhub_prefix, "routes" + sep]) - target_prefix = sep.join([self.kv_jupyterhub_prefix, "targets" + sep]) + target_prefix = sep.join([self.kv_jupyterhub_prefix, "targets"]) routespec = escapism.unescape(key.replace(route_prefix, "", 1)) etcd_target = sep.join([target_prefix, escapism.escape(value)]) - target = escapism.unescape(etcd_target.replace(target_prefix, "", 1)) + target = escapism.unescape(etcd_target.replace(target_prefix + sep, "", 1)) data = await self._kv_get_data(etcd_target) return routespec, target, data From 3501d085a200dcbb2f9ca12f61b146890f4b5a57 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Fri, 25 Jun 2021 16:44:48 +0000 Subject: [PATCH 08/10] Enable TLS termination and use of specific entrypoints on the traefik routers. As mentioned in https://github.com/jupyterhub/traefik-proxy/pull/133#issuecomment-868431999 configurable options have been added to the base TraefikProxy class:- - TraefikProxy.traefik_tls Setting this to `True ` will set the traefik tls option on each jupyterhub-configured traefik router - TraefikProxy.default_entrypoint A string that should match the traefik entryPoint, for which each jupyterhub-configured service will be attached. - TraefikProxy.traefik_entrypoints A list of strings, where each entrypoint should match traefik-configured entrypoints. If empty, then just use the default_entrypoint I'm not sure if this is the best way to do this to be honest. It looks like jupyterhub's `bind_url` predicates use of a single port anyway. So, is only a default_entrypoint necessary? Maybe these configuration options aren't required and should just be figured out from a combination of the other jupyterhub configuration variables (e.g. is bind_url http or https), and pulled from existing jupyterhub configuration variables anyway? --- jupyterhub_traefik_proxy/consul.py | 151 ++++++++++++++-------- jupyterhub_traefik_proxy/etcd.py | 60 +++++++-- jupyterhub_traefik_proxy/fileprovider.py | 18 ++- jupyterhub_traefik_proxy/proxy.py | 47 ++++++- jupyterhub_traefik_proxy/traefik_utils.py | 1 - requirements.txt | 5 +- tests/config_files/traefik.toml | 2 +- tests/conftest.py | 13 +- 8 files changed, 211 insertions(+), 86 deletions(-) diff --git a/jupyterhub_traefik_proxy/consul.py b/jupyterhub_traefik_proxy/consul.py index 5865746c..874878b9 100644 --- a/jupyterhub_traefik_proxy/consul.py +++ b/jupyterhub_traefik_proxy/consul.py @@ -147,47 +147,71 @@ async def _kv_atomic_add_route_parts( [self.kv_jupyterhub_prefix, "targets", escapism.escape(target)] ) - try: - payload=[ - { - "KV": { - "Verb": "set", - "Key": jupyterhub_routespec, - "Value": base64.b64encode(target.encode()).decode(), - } - }, - { - "KV": { - "Verb": "set", - "Key": jupyterhub_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.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(), - } + payload=[ + { + "KV": { + "Verb": "set", + "Key": jupyterhub_routespec, + "Value": base64.b64encode(target.encode()).decode(), } - ] - self.log.debug(f"Uploading route to KV store. Payload: {repr(payload)}") + }, + { + "KV": { + "Verb": "set", + "Key": jupyterhub_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.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(), + } + } + ] + + if self.traefik_tls: + tls_path = self.kv_separator.join( + ["traefik", "http", "routers", route_keys.router_alias, "tls"] + ) + payload.append({ + "KV": { + "Verb": "set", + "Key": tls_path, + "Value": base64.b64encode("true".encode()).decode() + } + }) + for n, ep in enumerate(self.traefik_entrypoints): + ep_path = self.kv_separator.join( + ["traefik", "http", "routers", route_keys.router_alias, "entryPoints", str(n)] + ) + payload.append({ + "KV": { + "Verb": "set", + "Key": ep_path, + "Value": base64.b64encode(ep.encode()).decode() + } + }) + + self.log.debug(f"Uploading route to KV store. Payload: {repr(payload)}") + try: results = await self.kv_client.txn.put(payload=payload) status = 1 response = "" @@ -210,16 +234,29 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): [self.kv_jupyterhub_prefix, "targets", escapism.escape(target)] ) - try: - status, response = await self.kv_client.txn.put( - payload=[ - {"KV": {"Verb": "delete", "Key": jupyterhub_routespec}}, - {"KV": {"Verb": "delete", "Key": jupyterhub_target}}, - {"KV": {"Verb": "delete", "Key": route_keys.service_url_path}}, - {"KV": {"Verb": "delete", "Key": route_keys.router_service_path}}, - {"KV": {"Verb": "delete", "Key": route_keys.router_rule_path}}, - ] + payload=[ + {"KV": {"Verb": "delete", "Key": jupyterhub_routespec}}, + {"KV": {"Verb": "delete", "Key": jupyterhub_target}}, + {"KV": {"Verb": "delete", "Key": route_keys.service_url_path}}, + {"KV": {"Verb": "delete", "Key": route_keys.router_service_path}}, + {"KV": {"Verb": "delete", "Key": route_keys.router_rule_path}}, + ] + + if self.traefik_tls: + tls_path = self.kv_separator.join( + ["traefik", "http", "routers", route_keys.router_alias, "tls"] + ) + payload.append({"KV": {"Verb": "delete", "Key": tls_path}}) + + # delete any configured entrypoints + for n in range(len(self.traefik_entrypoints)): + ep_path = self.kv_separator.join( + ["traefik", "http", "routers", route_keys.router_alias, "entryPoints", str(n)] ) + payload.append({"KV": {"Verb": "delete", "Key": ep_path}}) + + try: + status, response = await self.kv_client.txn.put(payload=payload) status = 1 response = "" except Exception as e: @@ -246,13 +283,14 @@ async def _kv_get_route_parts(self, kv_entry): value = kv_entry["KV"]["Value"] # Strip the "jupyterhub/routes/" prefix from the routespec - route_prefix = self.kv_separator.join( - [self.kv_jupyterhub_prefix, "routes/"] + sep = self.kv_separator + route_prefix = sep.join( + [self.kv_jupyterhub_prefix, "routes"] ) - routespec = key.replace(route_prefix, "") + routespec = key.replace(route_prefix + sep, "") target = base64.b64decode(value.encode()).decode() - jupyterhub_target = self.kv_separator.join( + jupyterhub_target = sep.join( [self.kv_jupyterhub_prefix, "targets", escapism.escape(target)] ) @@ -266,10 +304,9 @@ async def _kv_get_jupyterhub_prefixed_entries(self): { "KV": { "Verb": "get-tree", - "Key": f"{self.kv_jupyterhub_prefix}/routes" - #escapism.escape( - # self.kv_jupyterhub_prefix, safe=self.key_safe_chars - #)+ "/routes", + "Key": self.kv_separator.join( + [self.kv_jupyterhub_prefix, "routes"] + ) } } ] diff --git a/jupyterhub_traefik_proxy/etcd.py b/jupyterhub_traefik_proxy/etcd.py index 3f29680b..5fa9438e 100644 --- a/jupyterhub_traefik_proxy/etcd.py +++ b/jupyterhub_traefik_proxy/etcd.py @@ -129,18 +129,40 @@ def _define_kv_specific_static_config(self): async def _kv_atomic_add_route_parts( self, jupyterhub_routespec, target, data, route_keys, rule ): + # e.g. jupyterhub_target = 'jupyter/targets/{http://foobar.com}' + # where {http://foobar.com} is escaped jupyterhub_target = self.kv_separator.join( [self.kv_jupyterhub_prefix, "targets", escapism.escape(target)] ) + put = self.kv_client.transactions.put success = [ - self.kv_client.transactions.put(jupyterhub_routespec, target), - self.kv_client.transactions.put(jupyterhub_target, data), - self.kv_client.transactions.put(route_keys.service_url_path, target), - self.kv_client.transactions.put( - route_keys.router_service_path, route_keys.service_alias - ), - self.kv_client.transactions.put(route_keys.router_rule_path, rule), + # e.g. jupyter/routers/router-1 = {target} + put(jupyterhub_routespec, target), + # e.g. jupyter/targets/{escaped_target} = {data} + put(jupyterhub_target, data), + # e.g. http/services/service-1/loadBalancer/servers/server1 = target + put(route_keys.service_url_path, target), + # e.g. http/routers/router-1/service = service-1 + put(route_keys.router_service_path, route_keys.service_alias), + # e.g. http/routers/router-1/rule = {rule} + put(route_keys.router_rule_path, rule), ] + # Optionally enable TLS on this router + if self.traefik_tls: + tls_path = self.kv_separator.join( + ["traefik", "http", "routers", route_keys.router_alias, "tls"] + ) + success.append(put(tls_path, None)) + + # If specified in the config, assign to specific entryPoints + # FIXME: Should we add a router for each entrypoint, enabling TLS + # on only select ones? + for n, ep in enumerate(self.traefik_entrypoints): + ep_path = self.kv_separator.join( + ["traefik", "http", "routers", route_keys.router_alias, "entryPoints", str(n)] + ) + success.append(put(ep_path, ep)) + status, response = await maybe_future(self._etcd_transaction(success)) return status, response @@ -156,13 +178,27 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): [self.kv_jupyterhub_prefix, "targets", escapism.escape(value.decode())] ) + delete = self.kv_client.transactions.delete success = [ - self.kv_client.transactions.delete(jupyterhub_routespec), - self.kv_client.transactions.delete(jupyterhub_target), - self.kv_client.transactions.delete(route_keys.service_url_path), - self.kv_client.transactions.delete(route_keys.router_service_path), - self.kv_client.transactions.delete(route_keys.router_rule_path), + delete(jupyterhub_routespec), + delete(jupyterhub_target), + delete(route_keys.service_url_path), + delete(route_keys.router_service_path), + delete(route_keys.router_rule_path), ] + # If it was enabled, delete TLS on the router too + if self.traefik_tls: + tls_path = self.kv_separator.join( + ["traefik", "http", "routers", route_keys.router_alias, "tls"] + ) + success.append(delete(tls_path)) + + # Delete entrypoints, if any were specified + for n in range(len(self.traefik_entrypoints)): + ep_path = self.kv_separator.join( + ["traefik", "http", "routers", route_keys.router_alias, "entryPoints", str(n)] + ) + success.append(delete(ep_path)) status, response = await maybe_future(self._etcd_transaction(success)) return status, response diff --git a/jupyterhub_traefik_proxy/fileprovider.py b/jupyterhub_traefik_proxy/fileprovider.py index 1e3d5e33..3acef5ef 100644 --- a/jupyterhub_traefik_proxy/fileprovider.py +++ b/jupyterhub_traefik_proxy/fileprovider.py @@ -191,14 +191,24 @@ async def add_route(self, routespec, target, data): 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, } + + # FIXME: Shoule we bind to just one traefik entry point? + # If not defined, then traefik will bind to all of them... + if self.traefik_entrypoints: + self.dynamic_config["http"]["routers"][router_alias].update({ + "entryPoints": self.traefik_entrypoints + }) + + # Enable TLS on this router if globally enabled + if self.traefik_tls: + self.dynamic_config["http"]["routers"][router_alias].update({ + "tls": {} + }) + # Add the data node to a separate top-level node, so traefik doesn't complain. self.dynamic_config["jupyter"]["routers"][router_alias] = { "data": data diff --git a/jupyterhub_traefik_proxy/proxy.py b/jupyterhub_traefik_proxy/proxy.py index c7e8df5a..6de0da49 100644 --- a/jupyterhub_traefik_proxy/proxy.py +++ b/jupyterhub_traefik_proxy/proxy.py @@ -24,7 +24,7 @@ import asyncio.subprocess from urllib.parse import urlparse -from traitlets import Any, Bool, Dict, Integer, Unicode, default +from traitlets import Any, Bool, Dict, Integer, List, Unicode, default, observe from tornado.httpclient import AsyncHTTPClient from jupyterhub.utils import exponential_backoff, url_path_join, new_token @@ -69,10 +69,37 @@ class TraefikProxy(Proxy): config=True, help="""The provider name that Traefik expects, e.g. file, consul, etcd""" ) + # FIXME: How best to enable TLS on routers assigned to only select + # entrypoints defined here? + traefik_entrypoints = List( + trait=Unicode(), config=True, + help="""A list of entrypoint names, to which each Traefik router is assigned""" + ) + + default_entrypoint = Unicode( + "web", config=True, + help="""Default entrypoint to apply to jupyterhub-configured traefik routers""" + ) + + @observe("default_entrypoint", type="change") + def _update_entrypoints(self, change): + """Update the list of traefik_entrypoints, should default_entrypoint be changed""" + if change["old"] in self.traefik_entrypoints: + self.traefik_entrypoints.remove(change["old"]) + if change["new"] not in self.traefik_entrypoints: + self.traefik_entrypoints.append(change["new"]) + + # FIXME: As above, can we enable TLS on only certain routers / entrypoints? + traefik_tls = Bool( + config=True, help="""Enable TLS on the jupyterhub-configured traefik routers.""" + ) + def __init__(self, **kwargs): super().__init__(**kwargs) if self.log_level: self._set_log_level() + if self.default_entrypoint not in self.traefik_entrypoints: + self.traefik_entrypoints.append(self.default_entrypoint) def _set_log_level(self): import sys, logging @@ -266,20 +293,26 @@ async def _setup_traefik_static_config(self): if self.traefik_log_level: self.static_config["log"] = { "level": self.traefik_log_level } - entryPoints = {} + entry_points = {} - if self.ssl_cert and self.ssl_key: - entryPoints["websecure"] = { + is_https = urlparse(self.public_url).scheme == "https" + + # FIXME: Do we only create a single entrypoint for jupyterhub? + # Why not have an http and https entrypoint? + if self.ssl_cert and self.ssl_key or is_https: + entry_points[self.default_entrypoint] = { "address": ":" + str(urlparse(self.public_url).port), "tls": {}, } else: - entryPoints["web"] = {"address": ":" + str(urlparse(self.public_url).port)} + entry_points[self.default_entrypoint] = { + "address": ":" + str(urlparse(self.public_url).port) + } - entryPoints["enter_api"] = { + entry_points["enter_api"] = { "address": ":" + str(urlparse(self.traefik_api_url).port), } - self.static_config["entryPoints"] = entryPoints + self.static_config["entryPoints"] = entry_points self.static_config["api"] = {"dashboard": True} #, "entrypoints": "auth_api"} self.static_config["wss"] = {"protocol": "http"} diff --git a/jupyterhub_traefik_proxy/traefik_utils.py b/jupyterhub_traefik_proxy/traefik_utils.py index 9dca25ae..185b4df9 100644 --- a/jupyterhub_traefik_proxy/traefik_utils.py +++ b/jupyterhub_traefik_proxy/traefik_utils.py @@ -65,7 +65,6 @@ def generate_router_rule_entry(proxy, router_alias, separator="/"): if separator == "/": 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 diff --git a/requirements.txt b/requirements.txt index 711820e1..3c6a5574 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,5 +3,6 @@ escapism jupyterhub>=0.9 passlib # toml is now optional, as can use yaml configuration files instead now... -toml[toml_config] -ruamel.yaml[yaml_config] +toml +# ruamel.yaml is required by jupyterhub... +ruamel.yaml diff --git a/tests/config_files/traefik.toml b/tests/config_files/traefik.toml index 5f57109c..c50c0948 100644 --- a/tests/config_files/traefik.toml +++ b/tests/config_files/traefik.toml @@ -12,7 +12,7 @@ watch = true [entryPoints] - [entryPoints.my_web_api] + [entryPoints.my_web_ep] address = "127.0.0.1:8000" [entryPoints.enter_api] diff --git a/tests/conftest.py b/tests/conftest.py index 6dcd6379..1710f58f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -195,7 +195,12 @@ 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) + proxy = _file_proxy( + dynamic_config_file, + should_start=False, + default_entrypoint='my_web_ep' + ) + yield proxy os.remove(dynamic_config_file) @@ -204,7 +209,11 @@ 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) + proxy = _file_proxy( + dynamic_config_file, + should_start=False, + default_entrypoint='my_web_ep' + ) yield proxy os.remove(dynamic_config_file) From 50e59d64bff5c0b30d6b3177f428b3fc0ffa57c0 Mon Sep 17 00:00:00 2001 From: Alex Leach Date: Fri, 2 Jul 2021 12:32:26 +0000 Subject: [PATCH 09/10] Fix the TLS implementation and consolidate the jupyterhub public_url with the traefik entrypoint. * Removed the import of all TraefikProxy derived classes in jupyterhub_traefik_proxy/__init__.py. Only one needed at a time, never all of them. * When using an external traefik proxy, the TraefikProxy classes will now query traefik's API for the entrypoint that matches the assigned public_url. See `TraefikProxy._get_traefik_entrypoint()`. * Only a single entrypoint is allowed by this manner (reverting suggested change in previous commit 3501d085a200dcbb2f9ca12f61b146890f4b5a57) to have multiple, configurable entrypoints. * Done away with configurable option `traefik_tls`, instead determining it from the scheme of `public_url`, i.e. http or https. * Fixed docstrings in kv_proxy.py, if sphinx complained. * Removed the `debug` Boolean trait from TraefikProxy, instead relying on `traefik_log_level` and `log_level`, for setting traefik's log level, and the TraefikProxy log level, respectively. * Documentation fixes: updated docs/sphinxext/autodoc_traits.py to match master, at https://github.com/jupyterhub/autodoc-traits/ * Implemented various suggested changes by @GeorgianaElena in https://github.com/jupyterhub/traefik-proxy/pull/133 * Removed the standalone configuration loading and dumping functions from traefik_utils, leaving them only in TraefikConfigFileHandler. * Updated test suite so works when testing https URLs (without validation, as traefik will self-sign certificates) as well as http URLs. Currently, proxytest.py will do either http or https. Should it run both? --- docs/source/api/index.rst | 12 ++- docs/source/file.md | 2 +- docs/source/index.rst | 2 +- docs/source/install.md | 4 +- docs/sphinxext/autodoc_traits.py | 37 ++++--- jupyterhub_traefik_proxy/__init__.py | 6 -- jupyterhub_traefik_proxy/consul.py | 45 ++++---- jupyterhub_traefik_proxy/etcd.py | 39 +++---- jupyterhub_traefik_proxy/fileprovider.py | 18 ++-- jupyterhub_traefik_proxy/install.py | 6 -- jupyterhub_traefik_proxy/kv_proxy.py | 49 +++++---- jupyterhub_traefik_proxy/proxy.py | 122 ++++++++++++---------- jupyterhub_traefik_proxy/traefik_utils.py | 18 ---- setup.py | 6 +- tests/conftest.py | 53 ++++++---- tests/proxytest.py | 13 ++- tests/test_traefik_utils.py | 11 +- tests/utils.py | 3 +- 18 files changed, 225 insertions(+), 221 deletions(-) diff --git a/docs/source/api/index.rst b/docs/source/api/index.rst index 08896f54..f303a6d6 100644 --- a/docs/source/api/index.rst +++ b/docs/source/api/index.rst @@ -7,7 +7,7 @@ Module: :mod:`jupyterhub_traefik_proxy` .. automodule:: jupyterhub_traefik_proxy -.. currentmodule:: jupyterhub_traefik_proxy +.. currentmodule:: jupyterhub_traefik_proxy.proxy :class:`TraefikProxy` --------------------- @@ -15,24 +15,32 @@ Module: :mod:`jupyterhub_traefik_proxy` .. autoconfigurable:: TraefikProxy :members: +.. currentmodule:: jupyterhub_traefik_proxy.fileprovider + :class:`TraefikFileProviderProxy` -------------------------- +--------------------------------- .. autoconfigurable:: TraefikFileProviderProxy :members: +.. currentmodule:: jupyterhub_traefik_proxy.kv_proxy + :class:`TKvProxy` ----------------- .. autoconfigurable:: TKvProxy :members: +.. currentmodule:: jupyterhub_traefik_proxy.etcd + :class:`TraefikEtcdProxy` ------------------------- .. autoconfigurable:: TraefikEtcdProxy :members: +.. currentmodule:: jupyterhub_traefik_proxy.consul + :class:`TraefikConsulProxy` --------------------------- diff --git a/docs/source/file.md b/docs/source/file.md index 0f4178d9..e422b0fb 100644 --- a/docs/source/file.md +++ b/docs/source/file.md @@ -52,7 +52,7 @@ By **default**, Traefik will search for `traefik.toml` and `rules.toml` in the f * $HOME/.traefik/ * . the working directory -You can override this in TraefikFileProviderProxy, by modifying the **toml_static_config_file** argument: +You can override this in TraefikFileProviderProxy, by modifying the **static_config_file** argument: ```python c.TraefikFileProviderProxy.static_config_file="/path/to/static_config_filename.toml" diff --git a/docs/source/index.rst b/docs/source/index.rst index 0f73a31e..1d3a705e 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -39,7 +39,7 @@ Getting Started .. toctree:: :maxdepth: 1 - fileprovider + file etcd consul diff --git a/docs/source/install.md b/docs/source/install.md index d5ea9858..7d9050b4 100644 --- a/docs/source/install.md +++ b/docs/source/install.md @@ -69,7 +69,7 @@ ## Enabling traefik-proxy in JupyterHub -[TraefikFileProviderProxy](https://github.com/jupyterhub/traefik-proxy/blob/Traefik_v2/jupyterhub_traefik_proxy/fileprovider.py), [TraefikEtcdProxy](https://github.com/jupyterhub/traefik-proxy/blob/Traefik_v2/jupyterhub_traefik_proxy/etcd.py) and [TraefikConsulProxy](https://github.com/jupyterhub/traefik-proxy/blob/Traefik_v2/jupyterhub_traefik_proxy/consul.py) are custom proxy implementations that subclass [Proxy](https://github.com/jupyterhub/jupyterhub/blob/Traefik_v2/jupyterhub/proxy.py) and can register in JupyterHub config using `c.JupyterHub.proxy_class` entrypoint. +[TraefikFileProviderProxy](https://github.com/jupyterhub/traefik-proxy/blob/traefik-v2/jupyterhub_traefik_proxy/fileprovider.py), [TraefikEtcdProxy](https://github.com/jupyterhub/traefik-proxy/blob/traefik-v2/jupyterhub_traefik_proxy/etcd.py) and [TraefikConsulProxy](https://github.com/jupyterhub/traefik-proxy/blob/traefik-v2/jupyterhub_traefik_proxy/consul.py) are custom proxy implementations that subclass [Proxy](https://github.com/jupyterhub/jupyterhub/blob/traefik-v2/jupyterhub/proxy.py) and can register in JupyterHub config using `c.JupyterHub.proxy_class` entrypoint. On startup, JupyterHub will look by default for a configuration file, *jupyterhub_config.py*, in the current working directory. If the configuration file is not in the current working directory, you can load a specific config file and start JupyterHub using: @@ -78,7 +78,7 @@ you can load a specific config file and start JupyterHub using: $ jupyterhub -f /path/to/jupyterhub_config.py ``` -There is an example configuration file [here](https://github.com/jupyterhub/traefik-proxy/blob/Traefik_v2/examples/jupyterhub_config.py) that configures JupyterHub to run with *TraefikEtcdProxy* as the proxy and uses dummyauthenticator and simplespawner to enable testing without administrative privileges. +There is an example configuration file [here](https://github.com/jupyterhub/traefik-proxy/blob/traefik-v2/examples/jupyterhub_config.py) that configures JupyterHub to run with *TraefikEtcdProxy* as the proxy and uses dummyauthenticator and simplespawner to enable testing without administrative privileges. In *jupyterhub_config.py*: diff --git a/docs/sphinxext/autodoc_traits.py b/docs/sphinxext/autodoc_traits.py index 516156cd..9bce6ead 100644 --- a/docs/sphinxext/autodoc_traits.py +++ b/docs/sphinxext/autodoc_traits.py @@ -1,15 +1,15 @@ """autodoc extension for configurable traits""" - -from traitlets import TraitType, Undefined -from sphinx.domains.python import PyClassmember -from sphinx.ext.autodoc import ClassDocumenter, AttributeDocumenter +from sphinx.ext.autodoc import AttributeDocumenter +from sphinx.ext.autodoc import ClassDocumenter +from traitlets import TraitType +from traitlets import Undefined class ConfigurableDocumenter(ClassDocumenter): """Specialized Documenter subclass for traits with config=True""" - objtype = 'configurable' - directivetype = 'class' + objtype = "configurable" + directivetype = "class" def get_object_members(self, want_all): """Add traits with .tag(config=True) to members list""" @@ -24,12 +24,18 @@ def get_object_members(self, want_all): # put help in __doc__ where autodoc will look for it trait.__doc__ = trait.help trait_members.append((name, trait)) - return check, trait_members + members + # Remove duplicates between members and trait_members. We + # can't use sets, because not all items are hashable. Modify + # trait_members in place for returning. + for item in members: + if item not in trait_members: + trait_members.append(item) + return check, trait_members class TraitDocumenter(AttributeDocumenter): - objtype = 'trait' - directivetype = 'attribute' + objtype = "trait" + directivetype = "attribute" member_order = 1 priority = 100 @@ -37,17 +43,18 @@ class TraitDocumenter(AttributeDocumenter): def can_document_member(cls, member, membername, isattr, parent): return isinstance(member, TraitType) - def format_name(self): - return 'config c.' + super().format_name() - def add_directive_header(self, sig): default = self.object.get_default_value() if default is Undefined: - default_s = '' + default_s = "" else: default_s = repr(default) - sig = ' = {}({})'.format(self.object.__class__.__name__, default_s) - return super().add_directive_header(sig) + self.options.annotation = "c.{name} = {trait}({default})".format( + name=self.format_name(), + trait=self.object.__class__.__name__, + default=default_s, + ) + super().add_directive_header(sig) def setup(app): diff --git a/jupyterhub_traefik_proxy/__init__.py b/jupyterhub_traefik_proxy/__init__.py index 15472138..1ce70b1a 100644 --- a/jupyterhub_traefik_proxy/__init__.py +++ b/jupyterhub_traefik_proxy/__init__.py @@ -1,11 +1,5 @@ """Traefik implementation of the JupyterHub proxy API""" -from .proxy import TraefikProxy # noqa -from .kv_proxy import TKvProxy # noqa -from .etcd import TraefikEtcdProxy -from .consul import TraefikConsulProxy -from .fileprovider import TraefikFileProviderProxy - from ._version import get_versions __version__ = get_versions()["version"] diff --git a/jupyterhub_traefik_proxy/consul.py b/jupyterhub_traefik_proxy/consul.py index 874878b9..bf46cef1 100644 --- a/jupyterhub_traefik_proxy/consul.py +++ b/jupyterhub_traefik_proxy/consul.py @@ -30,7 +30,7 @@ from traitlets import Any, default, Unicode from . import traefik_utils -from jupyterhub_traefik_proxy import TKvProxy +from .kv_proxy import TKvProxy import time @@ -77,7 +77,6 @@ def _define_kv_specific_static_config(self): provider_config = { "consul": { "rootKey": self.kv_traefik_prefix, - #"watch": True, "endpoints" : [ urlparse(self.kv_url).netloc ] @@ -187,28 +186,28 @@ async def _kv_atomic_add_route_parts( } ] - if self.traefik_tls: - tls_path = self.kv_separator.join( - ["traefik", "http", "routers", route_keys.router_alias, "tls"] - ) + if self.is_https: payload.append({ "KV": { "Verb": "set", - "Key": tls_path, + "Key": self.kv_separator.join( + ["traefik", "http", "routers", route_keys.router_alias, "tls"] + ), "Value": base64.b64encode("true".encode()).decode() } }) - for n, ep in enumerate(self.traefik_entrypoints): - ep_path = self.kv_separator.join( - ["traefik", "http", "routers", route_keys.router_alias, "entryPoints", str(n)] - ) - payload.append({ - "KV": { - "Verb": "set", - "Key": ep_path, - "Value": base64.b64encode(ep.encode()).decode() - } - }) + # Specify the router's entryPoint + if not self.traefik_entrypoint: + self.traefik_entrypoint = await self._get_traefik_entrypoint() + payload.append({ + "KV": { + "Verb": "set", + "Key": self.kv_separator.join( + ["traefik", "http", "routers", route_keys.router_alias, "entryPoints", "0"] + ), + "Value": base64.b64encode(self.traefik_entrypoint.encode()).decode() + } + }) self.log.debug(f"Uploading route to KV store. Payload: {repr(payload)}") try: @@ -242,18 +241,18 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): {"KV": {"Verb": "delete", "Key": route_keys.router_rule_path}}, ] - if self.traefik_tls: + if self.is_https: tls_path = self.kv_separator.join( ["traefik", "http", "routers", route_keys.router_alias, "tls"] ) payload.append({"KV": {"Verb": "delete", "Key": tls_path}}) # delete any configured entrypoints - for n in range(len(self.traefik_entrypoints)): - ep_path = self.kv_separator.join( - ["traefik", "http", "routers", route_keys.router_alias, "entryPoints", str(n)] + payload.append({"KV": {"Verb": "delete", "Key": + self.kv_separator.join( + ["traefik", "http", "routers", route_keys.router_alias, "entryPoints", "0"] ) - payload.append({"KV": {"Verb": "delete", "Key": ep_path}}) + }}) try: status, response = await self.kv_client.txn.put(payload=payload) diff --git a/jupyterhub_traefik_proxy/etcd.py b/jupyterhub_traefik_proxy/etcd.py index 5fa9438e..4575c12c 100644 --- a/jupyterhub_traefik_proxy/etcd.py +++ b/jupyterhub_traefik_proxy/etcd.py @@ -26,7 +26,7 @@ from traitlets import Any, default, Unicode from jupyterhub.utils import maybe_future -from jupyterhub_traefik_proxy import TKvProxy +from .kv_proxy import TKvProxy class TraefikEtcdProxy(TKvProxy): @@ -148,20 +148,19 @@ async def _kv_atomic_add_route_parts( put(route_keys.router_rule_path, rule), ] # Optionally enable TLS on this router - if self.traefik_tls: + if self.is_https: tls_path = self.kv_separator.join( ["traefik", "http", "routers", route_keys.router_alias, "tls"] ) - success.append(put(tls_path, None)) - - # If specified in the config, assign to specific entryPoints - # FIXME: Should we add a router for each entrypoint, enabling TLS - # on only select ones? - for n, ep in enumerate(self.traefik_entrypoints): - ep_path = self.kv_separator.join( - ["traefik", "http", "routers", route_keys.router_alias, "entryPoints", str(n)] - ) - success.append(put(ep_path, ep)) + success.append(put(tls_path, "")) + + # Specify the entrypoint that jupyterhub's router should bind to + ep_path = self.kv_separator.join( + ["traefik", "http", "routers", route_keys.router_alias, "entryPoints", "0"] + ) + if not self.traefik_entrypoint: + self.traefik_entrypoint = await self._get_traefik_entrypoint() + success.append(put(ep_path, self.traefik_entrypoint)) status, response = await maybe_future(self._etcd_transaction(success)) return status, response @@ -185,20 +184,16 @@ async def _kv_atomic_delete_route_parts(self, jupyterhub_routespec, route_keys): delete(route_keys.service_url_path), delete(route_keys.router_service_path), delete(route_keys.router_rule_path), + delete(self.kv_separator.join( + ["traefik", "http", "routers", route_keys.router_alias, "entryPoints", "0"] + )) ] # If it was enabled, delete TLS on the router too - if self.traefik_tls: - tls_path = self.kv_separator.join( + if self.is_https: + success.append(delete(self.kv_separator.join( ["traefik", "http", "routers", route_keys.router_alias, "tls"] - ) - success.append(delete(tls_path)) + ))) - # Delete entrypoints, if any were specified - for n in range(len(self.traefik_entrypoints)): - ep_path = self.kv_separator.join( - ["traefik", "http", "routers", route_keys.router_alias, "entryPoints", str(n)] - ) - success.append(delete(ep_path)) status, response = await maybe_future(self._etcd_transaction(success)) return status, response diff --git a/jupyterhub_traefik_proxy/fileprovider.py b/jupyterhub_traefik_proxy/fileprovider.py index 3acef5ef..a45a3c63 100644 --- a/jupyterhub_traefik_proxy/fileprovider.py +++ b/jupyterhub_traefik_proxy/fileprovider.py @@ -25,8 +25,7 @@ from traitlets import Any, default, Unicode, observe from . import traefik_utils -from jupyterhub.proxy import Proxy -from jupyterhub_traefik_proxy import TraefikProxy +from .proxy import TraefikProxy class TraefikFileProviderProxy(TraefikProxy): @@ -165,8 +164,6 @@ async def add_route(self, routespec, target, data): target (str): A full URL that will be the target of this route. data (dict): A JSONable dict that will be associated with this route, and will be returned when retrieving information about this route. - FIXME: Why do we need to pass data back and forth to traefik? - Traefik v2 doesn't seem to allow a data key... Will raise an appropriate Exception (FIXME: find what?) if the route could not be added. @@ -179,6 +176,9 @@ async def add_route(self, routespec, target, data): router_alias = traefik_utils.generate_alias(traefik_routespec, "router") rule = traefik_utils.generate_rule(traefik_routespec) + if not self.traefik_entrypoint: + self.traefik_entrypoint = await self._get_traefik_entrypoint() + async with self.mutex: # If we've emptied the http and/or routers section, create it. if "http" not in self.dynamic_config: @@ -194,17 +194,11 @@ async def add_route(self, routespec, target, data): self.dynamic_config["http"]["routers"][router_alias] = { "service": service_alias, "rule": rule, + "entryPoints": [self.traefik_entrypoint] } - # FIXME: Shoule we bind to just one traefik entry point? - # If not defined, then traefik will bind to all of them... - if self.traefik_entrypoints: - self.dynamic_config["http"]["routers"][router_alias].update({ - "entryPoints": self.traefik_entrypoints - }) - # Enable TLS on this router if globally enabled - if self.traefik_tls: + if self.is_https: self.dynamic_config["http"]["routers"][router_alias].update({ "tls": {} }) diff --git a/jupyterhub_traefik_proxy/install.py b/jupyterhub_traefik_proxy/install.py index 59e62ff4..c1602cd6 100644 --- a/jupyterhub_traefik_proxy/install.py +++ b/jupyterhub_traefik_proxy/install.py @@ -64,12 +64,6 @@ def install_traefik(prefix, plat, traefik_version): traefik_archive = "traefik_v" + traefik_version + "_" + plat + "." + traefik_archive_extension traefik_archive_path = os.path.join(prefix, traefik_archive) - traefik_archive = "traefik_v" + traefik_version + "_" + plat + "." + traefik_archive_extension - traefik_archive_path = os.path.join(prefix, traefik_archive) - - traefik_archive = "traefik_v" + traefik_version + "_" + plat + "." + traefik_archive_extension - traefik_archive_path = os.path.join(prefix, traefik_archive) - traefik_url = ( "https://github.com/traefik/traefik/releases" f"/download/v{traefik_version}/{traefik_archive}" diff --git a/jupyterhub_traefik_proxy/kv_proxy.py b/jupyterhub_traefik_proxy/kv_proxy.py index 17dd5f2d..031f9dd4 100644 --- a/jupyterhub_traefik_proxy/kv_proxy.py +++ b/jupyterhub_traefik_proxy/kv_proxy.py @@ -26,7 +26,7 @@ from collections import MutableMapping from . import traefik_utils -from jupyterhub_traefik_proxy import TraefikProxy +from .proxy import TraefikProxy class TKvProxy(TraefikProxy): @@ -85,8 +85,8 @@ def _define_kv_specific_static_config(self): **Subclasses must define this method** if the proxy is to be started by the Hub. - In order to be picked up by the proxy, the static configuration - must be stored into `proxy.static_config` dict under the `provider_name` key. + In order to be picked up by the proxy, the static configuration must be + stored into `proxy.static_config` dict under the `provider_name` key. """ raise NotImplementedError() @@ -377,36 +377,43 @@ async def get_route(self, routespec): } 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`. + """Flatten a dictionary of `data` for storage in the KV store, + prefixing each key with `prefix` and joining each key with + :attr:`TKvProxy.kv_separator`. - If the final value is a `list`, then the provided bottom-level key + If the final value is a :class:`list`, then the provided bottom-level key shall be appended with an incrementing numeric number, in the style that is used by traefik's KV store, e.g. - flatten_dict_for_kv({ - 'x' : { - 'y' : { - 'z': 'a' - } - }, { - 'foo': 'bar' - }, + .. code-block:: + + flatten_dict_for_kv({ + 'x' : { + 'y' : { + 'z': 'a' + } + }, { + 'foo': 'bar' + }, 'baz': [ 'a', 'b', 'c' ] - }) + }) - Returns: - result (dict): - { + :return: The flattened dictionary + :rtype: dict + + e.g. + + .. code-block:: + + { 'traefik/x/y/z' : 'a', 'traefik/x/foo': 'bar', 'traefik/baz/0': 'a', 'traefik/baz/1': 'b', 'traefik/baz/2': 'c', - } + } - Ref: Inspired by https://stackoverflow.com/a/6027615 + Inspired by `this answer on StackOverflow `_ """ sep = self.kv_separator items = {} diff --git a/jupyterhub_traefik_proxy/proxy.py b/jupyterhub_traefik_proxy/proxy.py index 6de0da49..270bea9e 100644 --- a/jupyterhub_traefik_proxy/proxy.py +++ b/jupyterhub_traefik_proxy/proxy.py @@ -21,10 +21,9 @@ import json from os.path import abspath, dirname, join from subprocess import Popen, TimeoutExpired -import asyncio.subprocess from urllib.parse import urlparse -from traitlets import Any, Bool, Dict, Integer, List, Unicode, default, observe +from traitlets import Any, Bool, Dict, Integer, Unicode, default from tornado.httpclient import AsyncHTTPClient from jupyterhub.utils import exponential_backoff, url_path_join, new_token @@ -56,8 +55,6 @@ class TraefikProxy(Proxy): help="""validate SSL certificate of traefik api endpoint""", ) - debug = Bool(False, config=True, help="""Debug the proxy class?""") - traefik_log_level = Unicode(config=True, help="""traefik's log level""") log_level = Unicode(config=True, help="""The Proxy's log level""") @@ -69,37 +66,60 @@ class TraefikProxy(Proxy): config=True, help="""The provider name that Traefik expects, e.g. file, consul, etcd""" ) - # FIXME: How best to enable TLS on routers assigned to only select - # entrypoints defined here? - traefik_entrypoints = List( - trait=Unicode(), config=True, - help="""A list of entrypoint names, to which each Traefik router is assigned""" + is_https = Bool( + help="""Whether :attr:`.public_url` specifies an https entrypoint""" ) - default_entrypoint = Unicode( - "web", config=True, - help="""Default entrypoint to apply to jupyterhub-configured traefik routers""" - ) + @default("is_https") + def get_is_https(self): + # Check if we set https + return urlparse(self.public_url).scheme == "https" + + # FIXME: How best to enable TLS on routers assigned to only select + # entrypoints defined here? + traefik_entrypoint = Unicode( + help="""The traefik entrypoint names, to which each """ + """jupyterhub-configred Traefik router is assigned""") - @observe("default_entrypoint", type="change") - def _update_entrypoints(self, change): - """Update the list of traefik_entrypoints, should default_entrypoint be changed""" - if change["old"] in self.traefik_entrypoints: - self.traefik_entrypoints.remove(change["old"]) - if change["new"] not in self.traefik_entrypoints: - self.traefik_entrypoints.append(change["new"]) - - # FIXME: As above, can we enable TLS on only certain routers / entrypoints? - traefik_tls = Bool( - config=True, help="""Enable TLS on the jupyterhub-configured traefik routers.""" - ) + async def _get_traefik_entrypoint(self): + """Find the traefik entrypoint that matches our :attrib:`self.public_url`""" + if self.should_start: + if self.is_https: + return "websecure" + else: + return "web" + import re + # FIXME: Adding '_wait_for_static_config' to get through 'external' + # tests. Would this be required in the 'real world'? + # Adding _wait_for_static_config to the 'external' conftests instead... + #await self._wait_for_static_config() + resp = await self._traefik_api_request("/api/entrypoints") + json_data = json.loads(resp.body) + public_url = urlparse(self.public_url) + hub_port = public_url.port + if not hub_port: + # If the port is not specified, then use the default port + # according to the scheme (http, or https) + if public_url.scheme == 'http': + hub_port = 80 + elif public_url.scheme == 'https': + hub_port = 443 + else: + raise ValueError(f"Cannot discern public_url port from {self.public_url}!") + # Traefik entrypoint format described at:- + # https://doc.traefik.io/traefik/routing/entrypoints/#address + entrypoint_re = re.compile('([^:]+)?:([0-9]+)/?(tcp|udp)?') + for entrypoint in json_data: + host, port, prot = entrypoint_re.match(entrypoint["address"]).groups() + if int(port) == hub_port: + return entrypoint["name"] + entrypoints = [entrypoint["address"] for entrypoint in json_data] + raise ValueError(f"No traefik entrypoint ports ({entrypoints}) match public_url: {self.public_url}!") def __init__(self, **kwargs): super().__init__(**kwargs) if self.log_level: self._set_log_level() - if self.default_entrypoint not in self.traefik_entrypoints: - self.traefik_entrypoints.append(self.default_entrypoint) def _set_log_level(self): import sys, logging @@ -293,35 +313,27 @@ async def _setup_traefik_static_config(self): if self.traefik_log_level: self.static_config["log"] = { "level": self.traefik_log_level } - entry_points = {} - - is_https = urlparse(self.public_url).scheme == "https" - # FIXME: Do we only create a single entrypoint for jupyterhub? # Why not have an http and https entrypoint? - if self.ssl_cert and self.ssl_key or is_https: - entry_points[self.default_entrypoint] = { - "address": ":" + str(urlparse(self.public_url).port), - "tls": {}, - } - else: - entry_points[self.default_entrypoint] = { - "address": ":" + str(urlparse(self.public_url).port) + if not self.traefik_entrypoint: + self.traefik_entrypoint = await self._get_traefik_entrypoint() + + entrypoints = { + self.traefik_entrypoint : { + "address": f":{urlparse(self.public_url).port}", + }, + "enter_api" : { + "address": f":{urlparse(self.traefik_api_url).port}", } - - entry_points["enter_api"] = { - "address": ":" + str(urlparse(self.traefik_api_url).port), } - self.static_config["entryPoints"] = entry_points - self.static_config["api"] = {"dashboard": True} #, "entrypoints": "auth_api"} - self.static_config["wss"] = {"protocol": "http"} + + self.static_config["entryPoints"] = entrypoints + self.static_config["api"] = {"dashboard": True} try: self.log.debug(f"Persisting the static config: {self.static_config}") - traefik_utils.persist_static_conf( - self.static_config_file, - self.static_config - ) + handler = traefik_utils.TraefikConfigFileHandler(self.static_config_file) + handler.dump(self.static_config) except IOError: self.log.exception("Couldn't set up traefik's static config.") raise @@ -334,10 +346,7 @@ async def _setup_traefik_dynamic_config(self): 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 - ) + api_credentials = f"{self.traefik_api_username}:{self.traefik_api_hashed_password}" self.dynamic_config.update({ "http": { "routers": { @@ -470,12 +479,9 @@ async def get_route(self, routespec): raise NotImplementedError() async def persist_dynamic_config(self): - """Update the Traefik dynamic configuration, depending on the backend + """Save 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. + certificates to use, when should_start is True. """ raise NotImplementedError() diff --git a/jupyterhub_traefik_proxy/traefik_utils.py b/jupyterhub_traefik_proxy/traefik_utils.py index 185b4df9..8c9c0e61 100644 --- a/jupyterhub_traefik_proxy/traefik_utils.py +++ b/jupyterhub_traefik_proxy/traefik_utils.py @@ -55,7 +55,6 @@ def generate_router_service_entry(proxy, router_alias): 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="/"): @@ -170,20 +169,3 @@ def atomic_dump(self, data): with atomic_writing(self.file_path) as f: self._dump(data, f) -def persist_static_conf(file_path, static_conf_dict): - handler = TraefikConfigFileHandler(file_path) - handler.dump(static_conf_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_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/setup.py b/setup.py index 118ce5b7..1e20bc91 100644 --- a/setup.py +++ b/setup.py @@ -73,9 +73,9 @@ def run(self): cmdclass=cmdclass, entry_points={ "jupyterhub.proxies": [ - "traefik_consul = jupyterhub_traefik_proxy:TraefikConsulProxy", - "traefik_etcd = jupyterhub_traefik_proxy:TraefikEtcdProxy", - "traefik_file = jupyterhub_traefik_proxy:TraefikFileProviderProxy", + "traefik_consul = jupyterhub_traefik_proxy.consul:TraefikConsulProxy", + "traefik_etcd = jupyterhub_traefik_proxy.etcd:TraefikEtcdProxy", + "traefik_file = jupyterhub_traefik_proxy.fileprovider:TraefikFileProviderProxy", ] }, ) diff --git a/tests/conftest.py b/tests/conftest.py index 1710f58f..bf822565 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,9 +9,9 @@ import pytest from _pytest.mark import Mark -from jupyterhub_traefik_proxy import TraefikEtcdProxy -from jupyterhub_traefik_proxy import TraefikConsulProxy -from jupyterhub_traefik_proxy import TraefikFileProviderProxy +from jupyterhub_traefik_proxy.etcd import TraefikEtcdProxy +from jupyterhub_traefik_proxy.consul import TraefikConsulProxy +from jupyterhub_traefik_proxy.fileprovider import TraefikFileProviderProxy from jupyterhub.utils import exponential_backoff @@ -40,6 +40,10 @@ class Config: traefik_api_user = "api_admin" traefik_api_pass = "admin" + # The URL that should be proxied to jupyterhub + # Putting here, can easily change between http and https + public_url = "https://localhost:8000" + # Define a "slow" test marker so that we can run the slow tests at the end # ref: https://docs.pytest.org/en/6.0.1/example/simple.html#control-skipping-of-tests-according-to-command-line-option # ref: https://stackoverflow.com/questions/61533694/run-slow-pytest-commands-at-the-end-of-the-test-suite @@ -64,18 +68,19 @@ def pytest_configure(config): @pytest.fixture -async def no_auth_consul_proxy(launch_consul): +async def no_auth_consul_proxy(request, launch_consul): """ Fixture returning a configured TraefikConsulProxy. Consul acl disabled. """ proxy = TraefikConsulProxy( - public_url="http://127.0.0.1:8000", + public_url=Config.public_url, traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, check_route_timeout=45, should_start=True, - log_level='DEBUG' + log_level='DEBUG', + traefik_log_level="DEBUG" ) await proxy.start() yield proxy @@ -89,7 +94,7 @@ async def auth_consul_proxy(launch_consul_acl): Consul acl enabled. """ proxy = TraefikConsulProxy( - public_url="http://127.0.0.1:8000", + public_url=Config.public_url, traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, kv_password=Config.consul_token, @@ -109,7 +114,7 @@ async def no_auth_etcd_proxy(launch_etcd, wait_for_etcd): No etcd authentication. """ proxy = TraefikEtcdProxy( - public_url="http://127.0.0.1:8000", + public_url=Config.public_url, traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, check_route_timeout=45, @@ -128,7 +133,7 @@ async def auth_etcd_proxy(launch_etcd_auth): Etcd has credentials set up """ proxy = TraefikEtcdProxy( - public_url="http://127.0.0.1:8000", + public_url=Config.public_url, traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, kv_username="root", @@ -181,7 +186,7 @@ def _file_proxy(dynamic_config_file, **kwargs): os.getcwd(), f"traefik.{ext}" ) return TraefikFileProviderProxy( - public_url="http://127.0.0.1:8000", + public_url=Config.public_url, traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, dynamic_config_file = dynamic_config_file, @@ -197,10 +202,9 @@ async def external_file_proxy_yaml(launch_traefik_file): ) proxy = _file_proxy( dynamic_config_file, - should_start=False, - default_entrypoint='my_web_ep' + should_start=False ) - + await proxy._wait_for_static_config() yield proxy os.remove(dynamic_config_file) @@ -211,9 +215,9 @@ async def external_file_proxy_toml(launch_traefik_file): ) proxy = _file_proxy( dynamic_config_file, - should_start=False, - default_entrypoint='my_web_ep' + should_start=False ) + await proxy._wait_for_static_config() yield proxy os.remove(dynamic_config_file) @@ -221,48 +225,51 @@ async def external_file_proxy_toml(launch_traefik_file): @pytest.fixture async def external_consul_proxy(launch_consul, configure_consul, launch_traefik_consul): proxy = TraefikConsulProxy( - public_url="http://127.0.0.1:8000", + public_url=Config.public_url, traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, check_route_timeout=45, should_start=False, - debug=True + log_level="DEBUG" ) + await proxy._wait_for_static_config() yield proxy @pytest.fixture async 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", + public_url=Config.public_url, traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, kv_password=Config.consul_token, check_route_timeout=45, should_start=False, - debug=True + log_level="DEBUG" ) + await proxy._wait_for_static_config() yield proxy @pytest.fixture async def external_etcd_proxy(launch_etcd, configure_etcd, launch_traefik_etcd): proxy = TraefikEtcdProxy( - public_url="http://127.0.0.1:8000", + public_url=Config.public_url, traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, check_route_timeout=45, should_start=False, log_level="DEBUG" ) + await proxy._wait_for_static_config() yield proxy proxy.kv_client.close() @pytest.fixture -def auth_external_etcd_proxy(launch_etcd_auth, configure_etcd_auth, launch_traefik_etcd_auth): +async 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", + public_url=Config.public_url, traefik_api_password=Config.traefik_api_pass, traefik_api_username=Config.traefik_api_user, kv_password=Config.etcd_password, @@ -271,11 +278,11 @@ def auth_external_etcd_proxy(launch_etcd_auth, configure_etcd_auth, launch_traef should_start=False, log_level="DEBUG" ) + await proxy._wait_for_static_config() yield proxy proxy.kv_client.close() - ######################################################################### # Fixtures for launching traefik, with each backend and with or without # # authentication # diff --git a/tests/proxytest.py b/tests/proxytest.py index 2c8caaa1..80483e49 100644 --- a/tests/proxytest.py +++ b/tests/proxytest.py @@ -376,6 +376,7 @@ async def test_host_origin_headers(proxy, launch_backend): req_url, method="GET", headers={"Host": expected_host_header, "Origin": expected_origin_header}, + validate_cert=False ) resp = await AsyncHTTPClient().fetch(req) @@ -426,6 +427,7 @@ async def test_check_routes(proxy, username): async def test_websockets(proxy, launch_backend): + import ssl routespec = "/user/username/" target = "http://127.0.0.1:9000" data = {} @@ -453,9 +455,16 @@ async def test_websockets(proxy, launch_backend): if proxy.public_url.endswith("/"): public_url = proxy.public_url[:-1] - req_url = "ws://" + urlparse(proxy.public_url).netloc + routespec + if proxy.is_https: + kwargs = {'ssl': ssl._create_unverified_context()} + scheme = "wss://" + else: + kwargs = {} + scheme = "ws://" + req_url = scheme + urlparse(proxy.public_url).netloc + routespec - async with websockets.connect(req_url) as websocket: + # Don't validate the ssl certificate, it's self-signed by traefik + async with websockets.connect(req_url, **kwargs) as websocket: port = await websocket.recv() assert port == str(default_backend_port) diff --git a/tests/test_traefik_utils.py b/tests/test_traefik_utils.py index 4288e5fc..2fc2c4e6 100644 --- a/tests/test_traefik_utils.py +++ b/tests/test_traefik_utils.py @@ -26,11 +26,12 @@ def test_roundtrip_routes(): }, } - file = "test_roudtrip.toml" - open(file, "a").close() - traefik_utils.persist_routes(file, routes) - reloaded = traefik_utils.load_dynamic_conf(file) - os.remove(file) + file_name = "test_roudtrip.toml" + config_handler = traefik_utils.TraefikConfigFileHandler(file_name) + config_handler.atomic_dump(routes) + reloader = traefik_utils.TraefikConfigFileHandler(file_name) + reloaded = reloader.load() + os.remove(file_name) assert reloaded == routes diff --git a/tests/utils.py b/tests/utils.py index c8d0f148..26420e57 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -43,9 +43,10 @@ async def get_responding_backend_port(traefik_url, path): traefik_url + "".join("/" + path.split("/", 1)[1]), method="GET", headers={"Host": path.split("/")[0]}, + validate_cert=False, ) else: - req = traefik_url + path + req = HTTPRequest(traefik_url + path, validate_cert=False) try: resp = await AsyncHTTPClient().fetch(req) From bad360134d0c07eb92e5fbf807d64b214652122a Mon Sep 17 00:00:00 2001 From: "ALB.Leach" Date: Fri, 2 Jul 2021 16:25:26 +0100 Subject: [PATCH 10/10] Update jupyterhub_traefik_proxy/consul.py Remove old, commented-out code, as per @GeorgianaElena's review Co-authored-by: Georgiana Elena --- jupyterhub_traefik_proxy/consul.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/jupyterhub_traefik_proxy/consul.py b/jupyterhub_traefik_proxy/consul.py index bf46cef1..a356ff03 100644 --- a/jupyterhub_traefik_proxy/consul.py +++ b/jupyterhub_traefik_proxy/consul.py @@ -40,8 +40,6 @@ 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" - @default("provider_name") def _provider_name(self): return "consul" @@ -315,4 +313,3 @@ async def _kv_get_jupyterhub_prefixed_entries(self): async def stop(self): await super().stop() -