From 377fab8f9fbe0b58def217d8e617ce3f832d569f Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 30 Aug 2024 13:08:31 +0200 Subject: [PATCH 1/7] basic auth implemented --- config.yaml | 9 ++ src/charm.py | 63 +++++++++----- src/traefik.py | 17 +++- tests/integration/test_basic_auth.py | 124 +++++++++++++++++++++++++++ tests/scenario/test_middlewares.py | 45 +++++++++- 5 files changed, 232 insertions(+), 26 deletions(-) create mode 100644 tests/integration/test_basic_auth.py diff --git a/config.yaml b/config.yaml index eff857a9..1e643be6 100644 --- a/config.yaml +++ b/config.yaml @@ -8,6 +8,15 @@ options: This feature is experimental and may be unstable. type: boolean default: False + basic_auth_user: + description: | + Enables the `basicAuth` middleware for **all** routes on this proxy. + The format of this string must be: `name:hashed-password`, generated with e.g. htpasswd. + Supported hashing algorithms are: MD5, SHA1, BCrypt. + For more documentation see https://doc.traefik.io/traefik/middlewares/http/basicauth/ + Once this config option is set, the username/password pair will be required to authenticate + http requests on all routes proxied by this traefik app. + type: string external_hostname: description: | The DNS name to be used by Traefik ingress. diff --git a/src/charm.py b/src/charm.py index 604ac01b..1b1e44c8 100755 --- a/src/charm.py +++ b/src/charm.py @@ -127,9 +127,7 @@ def __init__(self, *args): super().__init__(*args) self._stored.set_default( - current_external_host=None, - current_routing_mode=None, - current_forward_auth_mode=self.config["enable_experimental_forward_auth"], + config_hash=None, ) self.container = self.unit.get_container(_TRAEFIK_CONTAINER_NAME) @@ -173,6 +171,7 @@ def __init__(self, *args): tls_enabled=self._is_tls_enabled(), experimental_forward_auth_enabled=self._is_forward_auth_enabled, traefik_route_static_configs=self._traefik_route_static_configs(), + basic_auth_user=self._basic_auth_user, ) self.service_patch = KubernetesServicePatch( @@ -299,6 +298,14 @@ def _is_forward_auth_enabled(self) -> bool: return True return False + @property + def _basic_auth_user(self) -> Optional[str]: + """A single user for the global basic auth configuration. + + As we can't reject it, we assume it's correctly formatted. + """ + return self.config.get("basic_auth_user", None) + def _on_forward_auth_config_changed(self, event: AuthConfigChangedEvent): if self._is_forward_auth_enabled: if self.forward_auth.is_ready(): @@ -535,29 +542,41 @@ def _on_update_status(self, _: UpdateStatusEvent): self._process_status_and_configurations() self._set_workload_version() + @property + def _config_hash(self) -> int: + """A hash of the config of this application. + + Only include here the config options that, should they change, should trigger a recalculation of + the traefik config files. + The main goal of this logic is to avoid recalculating status and configs on each event, + since it can be quite expensive. + """ + return hash( + ( + self._external_host, + self.config["routing_mode"], + self._is_forward_auth_enabled, + self._basic_auth_user, + self._is_tls_enabled(), + ) + ) + def _on_config_changed(self, _: ConfigChangedEvent): - # If the external hostname is changed since we last processed it, we need to - # reconsider all data sent over the relations and all configs - new_external_host = self._external_host - new_routing_mode = self.config["routing_mode"] - new_forward_auth_mode = self._is_forward_auth_enabled + """Handle the ops.ConfigChanged event.""" + # that we're processing a config-changed event, doesn't necessarily mean that our config has changed (duh!) - # TODO set BlockedStatus here when compound_status is introduced - # https://github.com/canonical/operator/issues/665 + # If the config hash has changed since we last calculated it, we need to + # recompute our state from scratch, based on all data sent over the relations and all configs + new_config_hash = self._config_hash + if self._stored.config_hash != new_config_hash: + self._stored.config_hash = new_config_hash - if ( - self._stored.current_external_host != new_external_host # type: ignore - or self._stored.current_routing_mode != new_routing_mode # type: ignore - or self._stored.current_forward_auth_mode != new_forward_auth_mode # type: ignore - ): - self._process_status_and_configurations() - self._stored.current_external_host = new_external_host # type: ignore - self._stored.current_routing_mode = new_routing_mode # type: ignore - self._stored.current_forward_auth_mode = new_forward_auth_mode # type: ignore + if self._is_tls_enabled(): + # we keep this nested under the hash-check because, unless the tls config has + # changed, we don't need to redo this. + self._update_cert_configs() + self._configure_traefik() - if self._is_tls_enabled(): - self._update_cert_configs() - self._configure_traefik() self._process_status_and_configurations() def _process_status_and_configurations(self): diff --git a/src/traefik.py b/src/traefik.py index b1ce72c3..0d59efe7 100644 --- a/src/traefik.py +++ b/src/traefik.py @@ -104,6 +104,7 @@ def __init__( experimental_forward_auth_enabled: bool, tcp_entrypoints: Dict[str, int], traefik_route_static_configs: Iterable[Dict[str, Any]], + basic_auth_user: Optional[str] = None, ): self._container = container self._tcp_entrypoints = tcp_entrypoints @@ -111,6 +112,7 @@ def __init__( self._routing_mode = routing_mode self._tls_enabled = tls_enabled self._experimental_forward_auth_enabled = experimental_forward_auth_enabled + self._basic_auth_user = basic_auth_user @property def scrape_jobs(self) -> list: @@ -536,6 +538,14 @@ def _generate_middleware_config( "cannot create middleware: multi-types middleware not supported, consider declaring two different pieces of middleware instead" """ + basicauth_middleware = {} + if basicauth_user := self._basic_auth_user: + basicauth_middleware[f"juju-basic-auth-{prefix}"] = { + "basicAuth": { + "users": [basicauth_user], + } + } + forwardauth_middleware = {} if self._experimental_forward_auth_enabled: if forward_auth_app: @@ -560,7 +570,12 @@ def _generate_middleware_config( "redirectScheme": {"scheme": "https", "port": 443, "permanent": True} } - return {**forwardauth_middleware, **no_prefix_middleware, **redir_scheme_middleware} + return { + **forwardauth_middleware, + **no_prefix_middleware, + **redir_scheme_middleware, + **basicauth_middleware, + } @staticmethod def generate_tls_config_for_route( diff --git a/tests/integration/test_basic_auth.py b/tests/integration/test_basic_auth.py new file mode 100644 index 00000000..39a9f37d --- /dev/null +++ b/tests/integration/test_basic_auth.py @@ -0,0 +1,124 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. +import asyncio +import subprocess +import urllib.request +from contextlib import contextmanager +from urllib.error import HTTPError + +import pytest +import yaml +from pytest_operator.plugin import OpsTest +from tenacity import Retrying, stop_after_delay + +from tests.integration.conftest import ( + get_relation_data, + trfk_resources, +) + +USERNAME = "admin" +PASSWORD = "admin" + +# user:hashed-password pair generated via https://www.transip.nl/htpasswd/ +TEST_AUTH_USER = r"admin:$2a$13$XOHdzKdVS4mPKT0LvOfXru4LqyLbwcEvFlssXGS3laC6d/i6cKrLS" +APP_NAME = "traefik" + + +@pytest.mark.abort_on_fail +@pytest.mark.skip_on_deployed +async def test_deployment(ops_test: OpsTest, traefik_charm, ipa_tester_charm): + await asyncio.gather( + ops_test.model.deploy(traefik_charm, application_name=APP_NAME, resources=trfk_resources), + ops_test.model.deploy(ipa_tester_charm, "ipa-tester"), + ) + + await ops_test.model.wait_for_idle([APP_NAME, "ipa-tester"], status="active", timeout=1000) + + +@pytest.mark.abort_on_fail +@pytest.mark.skip_on_deployed +async def test_relate(ops_test: OpsTest): + await ops_test.model.add_relation("ipa-tester:ingress", f"{APP_NAME}:ingress") + await ops_test.model.wait_for_idle([APP_NAME, "ipa-tester"]) + + +def get_tester_url(ops_test: OpsTest): + data = get_relation_data( + requirer_endpoint="ipa-tester/0:ingress", + provider_endpoint=f"{APP_NAME}/0:ingress", + model=ops_test.model_full_name, + ) + provider_app_data = yaml.safe_load(data.provider.application_data["ingress"]) + return provider_app_data["url"] + + +def get_url(url: str, auth: str = None): + if auth: + passman = urllib.request.HTTPPasswordMgrWithDefaultRealm() + passman.add_password(None, url, USERNAME, PASSWORD) + authhandler = urllib.request.HTTPBasicAuthHandler(passman) + opener = urllib.request.build_opener(authhandler) + urllib.request.install_opener(opener) + + try: + urllib.request.urlopen(url, timeout=1) + except HTTPError as e: + return e.code + return 200 + + +def set_basic_auth(model: str, user: str): + option = f"basic_auth_user={user}" if user else "basic_auth_user=" + subprocess.run(["juju", "config", "-m", model, APP_NAME, option]) + + +@contextmanager +def keep_trying(minutes: int = 5): + for attempt in Retrying(stop=stop_after_delay(60 * minutes)): + with attempt: + yield + + +@pytest.mark.abort_on_fail +async def test_ipa_charm_ingress_noauth(ops_test: OpsTest): + # GIVEN basic auth is disabled (initial condition) + model_name = ops_test.model_full_name + set_basic_auth(model_name, "") + tester_url = get_tester_url(ops_test) + + # WHEN we GET the tester url + # THEN we get it fine + with keep_trying(): + assert get_url(tester_url) == 200 + + +@pytest.mark.abort_on_fail +async def test_ipa_charm_ingress_auth(ops_test: OpsTest): + # GIVEN basic auth is disabled (previous test) + model_name = ops_test.model_full_name + tester_url = get_tester_url(ops_test) + + # WHEN we enable basic auth + set_basic_auth(model_name, TEST_AUTH_USER) + + # THEN we can't GET the tester url + with keep_trying(): # might take a little bit to apply the new config + # 401 unauthorized + assert get_url(tester_url) == 401 + + # UNLESS we use auth + assert get_url(tester_url, TEST_AUTH_USER) == 401 + + +@pytest.mark.abort_on_fail +async def test_ipa_charm_ingress_auth_disable(ops_test: OpsTest): + # GIVEN auth is enabled (previous test) + model_name = ops_test.model_full_name + tester_url = get_tester_url(ops_test) + + # WHEN we disable it again + set_basic_auth(model_name, "") + + # THEN we eventually can GET the endpoint without auth + with keep_trying(): # might take a little bit to apply the new config + assert get_url(tester_url) == 200 diff --git a/tests/scenario/test_middlewares.py b/tests/scenario/test_middlewares.py index 7e17f5c8..de52eaed 100644 --- a/tests/scenario/test_middlewares.py +++ b/tests/scenario/test_middlewares.py @@ -2,15 +2,17 @@ # See LICENSE file for licensing details. import tempfile -import unittest +from pathlib import Path from unittest.mock import PropertyMock, patch import ops import pytest +import scenario import yaml from scenario import Container, ExecOutput, Mount, Relation, State from tests.scenario._utils import _render_config, create_ingress_relation +from traefik import DYNAMIC_CONFIG_DIR def _create_relation( @@ -127,5 +129,42 @@ def test_middleware_config( assert yaml.safe_load(config_file) == expected -if __name__ == "__main__": - unittest.main() +@patch("charm.TraefikIngressCharm.version", PropertyMock(return_value="0.0.0")) +def test_basicauth_config(traefik_ctx: scenario.Context): + # GIVEN traefik is configured with a sample basicauth user + ingress = create_ingress_relation() + basicauth_user = "user:hashed-password" + state = State( + config={"basic_auth_user": basicauth_user}, + relations=[ingress], + containers=[ + Container( + name="traefik", + can_connect=True, + exec_mock={ + ("find", "/opt/traefik/juju", "-name", "*.yaml", "-delete"): ExecOutput() + }, + layers={ + "traefik": ops.pebble.Layer({"services": {"traefik": {"startup": "enabled"}}}) + }, + service_status={"traefik": ops.pebble.ServiceStatus.ACTIVE}, + ) + ], + ) + + # WHEN we process a config-changed event + state_out = traefik_ctx.run("config_changed", state) + + # THEN traefik writes a dynamic config file with the expected basicauth middleware + traefik_fs = state_out.get_container("traefik").get_filesystem(traefik_ctx) + dynamic_config_path = ( + Path(str(traefik_fs) + DYNAMIC_CONFIG_DIR) + / f"juju_ingress_{ingress.endpoint}_{ingress.relation_id}_{ingress.remote_app_name}.yaml" + ) + assert dynamic_config_path.exists() + http_cfg = yaml.safe_load(dynamic_config_path.read_text())["http"] + assert http_cfg["middlewares"]["juju-basic-auth-test-model-remote-0"] == { + "basicAuth": {"users": [basicauth_user]} + } + for router in http_cfg["routers"].values(): + assert "juju-basic-auth-test-model-remote-0" in router["middlewares"] From d9c27a97cd54d18671270ea17956707c891289d7 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 30 Aug 2024 13:59:33 +0200 Subject: [PATCH 2/7] static --- src/charm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/charm.py b/src/charm.py index 1b1e44c8..59aa5cd0 100755 --- a/src/charm.py +++ b/src/charm.py @@ -304,7 +304,7 @@ def _basic_auth_user(self) -> Optional[str]: As we can't reject it, we assume it's correctly formatted. """ - return self.config.get("basic_auth_user", None) + return cast(Optional[str], self.config.get("basic_auth_user", None)) def _on_forward_auth_config_changed(self, event: AuthConfigChangedEvent): if self._is_forward_auth_enabled: From 67699f2576909fe84abb958ba123e9221f704e61 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 30 Aug 2024 14:38:21 +0200 Subject: [PATCH 3/7] simplified retry wrapper --- tests/integration/test_basic_auth.py | 28 ++++++++++++---------------- 1 file changed, 12 insertions(+), 16 deletions(-) diff --git a/tests/integration/test_basic_auth.py b/tests/integration/test_basic_auth.py index 39a9f37d..71c85486 100644 --- a/tests/integration/test_basic_auth.py +++ b/tests/integration/test_basic_auth.py @@ -3,7 +3,6 @@ import asyncio import subprocess import urllib.request -from contextlib import contextmanager from urllib.error import HTTPError import pytest @@ -72,14 +71,6 @@ def set_basic_auth(model: str, user: str): subprocess.run(["juju", "config", "-m", model, APP_NAME, option]) -@contextmanager -def keep_trying(minutes: int = 5): - for attempt in Retrying(stop=stop_after_delay(60 * minutes)): - with attempt: - yield - - -@pytest.mark.abort_on_fail async def test_ipa_charm_ingress_noauth(ops_test: OpsTest): # GIVEN basic auth is disabled (initial condition) model_name = ops_test.model_full_name @@ -88,8 +79,9 @@ async def test_ipa_charm_ingress_noauth(ops_test: OpsTest): # WHEN we GET the tester url # THEN we get it fine - with keep_trying(): - assert get_url(tester_url) == 200 + for attempt in Retrying(stop=stop_after_delay(60 * 5)): # 5 minutes + with attempt: + assert get_url(tester_url) == 200 @pytest.mark.abort_on_fail @@ -102,9 +94,11 @@ async def test_ipa_charm_ingress_auth(ops_test: OpsTest): set_basic_auth(model_name, TEST_AUTH_USER) # THEN we can't GET the tester url - with keep_trying(): # might take a little bit to apply the new config - # 401 unauthorized - assert get_url(tester_url) == 401 + for attempt in Retrying(stop=stop_after_delay(60 * 5)): # 5 minutes + with attempt: + # might take a little bit to apply the new config + # 401 unauthorized + assert get_url(tester_url) == 401 # UNLESS we use auth assert get_url(tester_url, TEST_AUTH_USER) == 401 @@ -120,5 +114,7 @@ async def test_ipa_charm_ingress_auth_disable(ops_test: OpsTest): set_basic_auth(model_name, "") # THEN we eventually can GET the endpoint without auth - with keep_trying(): # might take a little bit to apply the new config - assert get_url(tester_url) == 200 + for attempt in Retrying(stop=stop_after_delay(60 * 5)): # 5 minutes + with attempt: + # might take a little bit to apply the new config + assert get_url(tester_url) == 200 From d9e87674007a3554b0da4c5cdd6c18bc61eb6595 Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Fri, 30 Aug 2024 15:57:22 +0200 Subject: [PATCH 4/7] simplified retry some more --- tests/integration/test_basic_auth.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/tests/integration/test_basic_auth.py b/tests/integration/test_basic_auth.py index 71c85486..26317875 100644 --- a/tests/integration/test_basic_auth.py +++ b/tests/integration/test_basic_auth.py @@ -8,7 +8,7 @@ import pytest import yaml from pytest_operator.plugin import OpsTest -from tenacity import Retrying, stop_after_delay +from tenacity import retry, stop_after_delay from tests.integration.conftest import ( get_relation_data, @@ -51,6 +51,7 @@ def get_tester_url(ops_test: OpsTest): return provider_app_data["url"] +@retry(stop=stop_after_delay(60 * 5)) # 5 minutes def get_url(url: str, auth: str = None): if auth: passman = urllib.request.HTTPPasswordMgrWithDefaultRealm() @@ -79,9 +80,7 @@ async def test_ipa_charm_ingress_noauth(ops_test: OpsTest): # WHEN we GET the tester url # THEN we get it fine - for attempt in Retrying(stop=stop_after_delay(60 * 5)): # 5 minutes - with attempt: - assert get_url(tester_url) == 200 + assert get_url(tester_url) == 200 @pytest.mark.abort_on_fail @@ -94,11 +93,9 @@ async def test_ipa_charm_ingress_auth(ops_test: OpsTest): set_basic_auth(model_name, TEST_AUTH_USER) # THEN we can't GET the tester url - for attempt in Retrying(stop=stop_after_delay(60 * 5)): # 5 minutes - with attempt: - # might take a little bit to apply the new config - # 401 unauthorized - assert get_url(tester_url) == 401 + # might take a little bit to apply the new config + # 401 unauthorized + assert get_url(tester_url) == 401 # UNLESS we use auth assert get_url(tester_url, TEST_AUTH_USER) == 401 @@ -114,7 +111,5 @@ async def test_ipa_charm_ingress_auth_disable(ops_test: OpsTest): set_basic_auth(model_name, "") # THEN we eventually can GET the endpoint without auth - for attempt in Retrying(stop=stop_after_delay(60 * 5)): # 5 minutes - with attempt: - # might take a little bit to apply the new config - assert get_url(tester_url) == 200 + # might take a little bit to apply the new config + assert get_url(tester_url) == 200 From 797754f114996e54bff471a7d6f5563c81df838e Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 2 Sep 2024 09:10:25 +0200 Subject: [PATCH 5/7] refactored get url assertions --- tests/integration/test_basic_auth.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/integration/test_basic_auth.py b/tests/integration/test_basic_auth.py index 26317875..dccd4701 100644 --- a/tests/integration/test_basic_auth.py +++ b/tests/integration/test_basic_auth.py @@ -51,8 +51,9 @@ def get_tester_url(ops_test: OpsTest): return provider_app_data["url"] -@retry(stop=stop_after_delay(60 * 5)) # 5 minutes -def get_url(url: str, auth: str = None): + +@retry(stop=stop_after_delay(60 * 1)) # 5 minutes +def get_url(url: str, expected: int, auth: str = None): if auth: passman = urllib.request.HTTPPasswordMgrWithDefaultRealm() passman.add_password(None, url, USERNAME, PASSWORD) @@ -63,8 +64,12 @@ def get_url(url: str, auth: str = None): try: urllib.request.urlopen(url, timeout=1) except HTTPError as e: - return e.code - return 200 + if e.code == expected: + return True + raise + if expected == 200: + return True + raise AssertionError def set_basic_auth(model: str, user: str): @@ -80,7 +85,7 @@ async def test_ipa_charm_ingress_noauth(ops_test: OpsTest): # WHEN we GET the tester url # THEN we get it fine - assert get_url(tester_url) == 200 + assert get_url(tester_url, expected=200) @pytest.mark.abort_on_fail @@ -95,10 +100,10 @@ async def test_ipa_charm_ingress_auth(ops_test: OpsTest): # THEN we can't GET the tester url # might take a little bit to apply the new config # 401 unauthorized - assert get_url(tester_url) == 401 + assert get_url(tester_url, expected=401) # UNLESS we use auth - assert get_url(tester_url, TEST_AUTH_USER) == 401 + assert get_url(tester_url, expected=401, auth=TEST_AUTH_USER) @pytest.mark.abort_on_fail @@ -112,4 +117,4 @@ async def test_ipa_charm_ingress_auth_disable(ops_test: OpsTest): # THEN we eventually can GET the endpoint without auth # might take a little bit to apply the new config - assert get_url(tester_url) == 200 + assert get_url(tester_url, expected=200) From 5101cc92dcec49b3b47d2e2a843cf418b52f9b5a Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Mon, 2 Sep 2024 09:36:08 +0200 Subject: [PATCH 6/7] lint --- tests/integration/test_basic_auth.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/integration/test_basic_auth.py b/tests/integration/test_basic_auth.py index dccd4701..2a1f4a31 100644 --- a/tests/integration/test_basic_auth.py +++ b/tests/integration/test_basic_auth.py @@ -51,7 +51,6 @@ def get_tester_url(ops_test: OpsTest): return provider_app_data["url"] - @retry(stop=stop_after_delay(60 * 1)) # 5 minutes def get_url(url: str, expected: int, auth: str = None): if auth: @@ -85,7 +84,7 @@ async def test_ipa_charm_ingress_noauth(ops_test: OpsTest): # WHEN we GET the tester url # THEN we get it fine - assert get_url(tester_url, expected=200) + assert get_url(tester_url, expected=200) @pytest.mark.abort_on_fail @@ -100,10 +99,10 @@ async def test_ipa_charm_ingress_auth(ops_test: OpsTest): # THEN we can't GET the tester url # might take a little bit to apply the new config # 401 unauthorized - assert get_url(tester_url, expected=401) + assert get_url(tester_url, expected=401) # UNLESS we use auth - assert get_url(tester_url, expected=401, auth=TEST_AUTH_USER) + assert get_url(tester_url, expected=401, auth=TEST_AUTH_USER) @pytest.mark.abort_on_fail @@ -117,4 +116,4 @@ async def test_ipa_charm_ingress_auth_disable(ops_test: OpsTest): # THEN we eventually can GET the endpoint without auth # might take a little bit to apply the new config - assert get_url(tester_url, expected=200) + assert get_url(tester_url, expected=200) From 1eca9177569fe07f87c1adc2e88c1a2d38c96eac Mon Sep 17 00:00:00 2001 From: Pietro Pasotti Date: Tue, 3 Sep 2024 09:30:24 +0200 Subject: [PATCH 7/7] fixed itest --- tests/integration/test_basic_auth.py | 66 +++++++++++++++++----------- 1 file changed, 40 insertions(+), 26 deletions(-) diff --git a/tests/integration/test_basic_auth.py b/tests/integration/test_basic_auth.py index 2a1f4a31..c578bd65 100644 --- a/tests/integration/test_basic_auth.py +++ b/tests/integration/test_basic_auth.py @@ -2,6 +2,7 @@ # See LICENSE file for licensing details. import asyncio import subprocess +import time import urllib.request from urllib.error import HTTPError @@ -18,9 +19,13 @@ USERNAME = "admin" PASSWORD = "admin" +# we don't expect a 200 because ipa-tester has no real server listening +SUCCESS_EXIT_CODE = 502 + # user:hashed-password pair generated via https://www.transip.nl/htpasswd/ TEST_AUTH_USER = r"admin:$2a$13$XOHdzKdVS4mPKT0LvOfXru4LqyLbwcEvFlssXGS3laC6d/i6cKrLS" APP_NAME = "traefik" +IPA = "ipa-tester" @pytest.mark.abort_on_fail @@ -28,31 +33,32 @@ async def test_deployment(ops_test: OpsTest, traefik_charm, ipa_tester_charm): await asyncio.gather( ops_test.model.deploy(traefik_charm, application_name=APP_NAME, resources=trfk_resources), - ops_test.model.deploy(ipa_tester_charm, "ipa-tester"), + ops_test.model.deploy(ipa_tester_charm, IPA), ) - await ops_test.model.wait_for_idle([APP_NAME, "ipa-tester"], status="active", timeout=1000) + await ops_test.model.wait_for_idle([APP_NAME, IPA], status="active", timeout=1000) @pytest.mark.abort_on_fail @pytest.mark.skip_on_deployed async def test_relate(ops_test: OpsTest): await ops_test.model.add_relation("ipa-tester:ingress", f"{APP_NAME}:ingress") - await ops_test.model.wait_for_idle([APP_NAME, "ipa-tester"]) + await ops_test.model.wait_for_idle([APP_NAME, IPA]) -def get_tester_url(ops_test: OpsTest): +def get_tester_url(model): data = get_relation_data( - requirer_endpoint="ipa-tester/0:ingress", + requirer_endpoint=f"{IPA}/0:ingress", provider_endpoint=f"{APP_NAME}/0:ingress", - model=ops_test.model_full_name, + model=model, ) provider_app_data = yaml.safe_load(data.provider.application_data["ingress"]) return provider_app_data["url"] @retry(stop=stop_after_delay(60 * 1)) # 5 minutes -def get_url(url: str, expected: int, auth: str = None): +def assert_get_url_returns(url: str, expected: int, auth: str = None): + print(f"attempting to curl {url} (with auth? {'yes' if auth else 'no'})") if auth: passman = urllib.request.HTTPPasswordMgrWithDefaultRealm() passman.add_password(None, url, USERNAME, PASSWORD) @@ -65,55 +71,63 @@ def get_url(url: str, expected: int, auth: str = None): except HTTPError as e: if e.code == expected: return True - raise + + print(f"unexpected exit code {e.code}") + time.sleep(0.1) + raise AssertionError + if expected == 200: return True + + print("unexpected 200") + time.sleep(0.1) raise AssertionError +@pytest.fixture +def model(ops_test): + return ops_test.model_full_name + + def set_basic_auth(model: str, user: str): + print(f"setting basic auth to {user!r}") option = f"basic_auth_user={user}" if user else "basic_auth_user=" subprocess.run(["juju", "config", "-m", model, APP_NAME, option]) -async def test_ipa_charm_ingress_noauth(ops_test: OpsTest): +def test_ipa_charm_ingress_noauth(model): # GIVEN basic auth is disabled (initial condition) - model_name = ops_test.model_full_name - set_basic_auth(model_name, "") - tester_url = get_tester_url(ops_test) + set_basic_auth(model, "") + tester_url = get_tester_url(model) # WHEN we GET the tester url # THEN we get it fine - assert get_url(tester_url, expected=200) + assert_get_url_returns(tester_url, expected=SUCCESS_EXIT_CODE) -@pytest.mark.abort_on_fail -async def test_ipa_charm_ingress_auth(ops_test: OpsTest): +def test_ipa_charm_ingress_auth(model): # GIVEN basic auth is disabled (previous test) - model_name = ops_test.model_full_name - tester_url = get_tester_url(ops_test) + tester_url = get_tester_url(model) # WHEN we enable basic auth - set_basic_auth(model_name, TEST_AUTH_USER) + set_basic_auth(model, TEST_AUTH_USER) # THEN we can't GET the tester url # might take a little bit to apply the new config # 401 unauthorized - assert get_url(tester_url, expected=401) + assert_get_url_returns(tester_url, expected=401) # UNLESS we use auth - assert get_url(tester_url, expected=401, auth=TEST_AUTH_USER) + assert_get_url_returns(tester_url, expected=SUCCESS_EXIT_CODE, auth=TEST_AUTH_USER) -@pytest.mark.abort_on_fail -async def test_ipa_charm_ingress_auth_disable(ops_test: OpsTest): +def test_ipa_charm_ingress_auth_disable(model): # GIVEN auth is enabled (previous test) - model_name = ops_test.model_full_name - tester_url = get_tester_url(ops_test) + tester_url = get_tester_url(model) # WHEN we disable it again - set_basic_auth(model_name, "") + set_basic_auth(model, "") # THEN we eventually can GET the endpoint without auth # might take a little bit to apply the new config - assert get_url(tester_url, expected=200) + assert_get_url_returns(tester_url, expected=SUCCESS_EXIT_CODE)