diff --git a/requirements.txt b/requirements.txt index c598a98..14a3a8c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -ops == 1.5.3 +ops >= 2.0 Jinja2==3.0.3 typing_extensions diff --git a/run_tests b/run_tests deleted file mode 100755 index 31bf463..0000000 --- a/run_tests +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/sh -e -# Copyright 2022 pietro -# See LICENSE file for licensing details. - -if [ -z "$VIRTUAL_ENV" -a -d venv/ ]; then - . venv/bin/activate -fi - -if [ -z "$PYTHONPATH" ]; then - export PYTHONPATH="lib:src" -else - export PYTHONPATH="lib:src:$PYTHONPATH" -fi - -flake8 -coverage run --branch --source=src -m unittest -v "$@" -coverage report -m diff --git a/src/charm.py b/src/charm.py index d1319d0..0e604b7 100755 --- a/src/charm.py +++ b/src/charm.py @@ -8,7 +8,7 @@ import textwrap from dataclasses import dataclass from itertools import starmap -from typing import Iterable, Optional, Tuple +from typing import Iterable, Optional, Tuple, cast from urllib.parse import urlparse import jinja2 @@ -62,7 +62,8 @@ class RouteConfig: root_url: str rule: str - id_: str + service_name: str + strip_prefix: bool = False @dataclass @@ -107,7 +108,7 @@ def _check_var(obj: str, name): valid = starmap(_check_var, ((self.rule, "rule"), (self.root_url, "root_url"))) return all(valid) - def render(self, model_name: str, unit_name: str, app_name: str): + def render(self, model_name: str, unit_name: str, app_name: str, strip_prefix: bool = False): """Fills in the blanks in the templates.""" def _render(obj: str): @@ -129,8 +130,10 @@ def _render(obj: str): rule = _render(self.rule) # an easily recognizable id for the traefik services - id_ = "-".join((unit_name, model_name)) - return RouteConfig(rule=rule, root_url=url, id_=id_) + service_name = "-".join((unit_name, model_name)) + return RouteConfig( + rule=rule, root_url=url, service_name=service_name, strip_prefix=strip_prefix + ) @staticmethod def generate_rule_from_url(url) -> str: @@ -229,8 +232,15 @@ def root_url(self) -> Optional[str]: """The advertised url for the charm requesting ingress.""" return self._config.root_url - def _render_config(self, model_name: str, unit_name: str, app_name: str): - return self._config.render(model_name=model_name, unit_name=unit_name, app_name=app_name) + def _render_config( + self, model_name: str, unit_name: str, app_name: str, strip_prefix: bool = False + ): + return self._config.render( + model_name=model_name, + unit_name=unit_name, + app_name=app_name, + strip_prefix=strip_prefix, + ) def _on_config_changed(self, _): """Check the config; set an active status if all is good.""" @@ -297,6 +307,7 @@ def _config_for_unit(self, unit_data: RequirerData) -> RouteConfig: # if self._is_ready()... unit_name = unit_data["name"] # pyright: ignore model_name = unit_data["model"] # pyright: ignore + strip_prefix = bool(unit_data.get("strip-prefix", None)) # sanity checks assert unit_name is not None, "remote unit did not provide its name" @@ -304,6 +315,7 @@ def _config_for_unit(self, unit_data: RequirerData) -> RouteConfig: return self._render_config( model_name=model_name, + strip_prefix=strip_prefix, unit_name=unit_name.replace("/", "-"), app_name=unit_name.split("/")[0], ) @@ -339,12 +351,16 @@ def _update(self): @staticmethod def _generate_traefik_unit_config(route_config: RouteConfig) -> "UnitConfig": - rule, config_id, url = route_config.rule, route_config.id_, route_config.root_url + rule, service_name, url = ( + route_config.rule, + route_config.service_name, + route_config.root_url, + ) - traefik_router_name = f"juju-{config_id}-router" - traefik_service_name = f"juju-{config_id}-service" + traefik_router_name = f"juju-{service_name}-router" + traefik_service_name = f"juju-{service_name}-service" - config: "UnitConfig" = { + config = { "router": { "rule": rule, "service": traefik_service_name, @@ -354,16 +370,30 @@ def _generate_traefik_unit_config(route_config: RouteConfig) -> "UnitConfig": "service": {"loadBalancer": {"servers": [{"url": url}]}}, "service_name": traefik_service_name, } - return config + + if route_config.strip_prefix: + traefik_middleware_name = f"juju-sidecar-noprefix-{service_name}-service" + config["middleware_name"] = traefik_middleware_name + config["middleware"] = {"forceSlash": False, "prefixes": [f"/{service_name}"]} + + return cast("UnitConfig", config) @staticmethod def _merge_traefik_configs(configs: Iterable["UnitConfig"]) -> "TraefikConfig": + middlewares = { + config.get("middleware_name"): config.get("middleware") + for config in configs + if config.get("middleware") + } traefik_config = { "http": { "routers": {config["router_name"]: config["router"] for config in configs}, "services": {config["service_name"]: config["service"] for config in configs}, } } + if middlewares: + traefik_config["http"]["middlewares"] = middlewares + return traefik_config # type: ignore diff --git a/src/types_.py b/src/types_.py index 33d6300..f055c7c 100644 --- a/src/types_.py +++ b/src/types_.py @@ -4,7 +4,7 @@ """Types for TraefikRoute charm.""" -from typing import List, Mapping +from typing import List, Mapping, Optional try: from typing import TypedDict @@ -36,11 +36,22 @@ class Service(TypedDict): # noqa: D101 loadBalancer: Servers # noqa N815 +class StripPrefixMiddleware(TypedDict): # noqa: D101 + forceSlash: bool # noqa N815 + prefixes: List[str] + + +class Middleware(TypedDict): # noqa: D101 + stripPrefix: StripPrefixMiddleware # noqa N815 + + class UnitConfig(TypedDict): # noqa: D101 router_name: str router: Router service_name: str service: Service + middleware_name: Optional[str] + middleware: Optional[Middleware] class Http(TypedDict): # noqa: D101 diff --git a/tests/scenario/test_route_interface.py b/tests/scenario/test_route_interface.py new file mode 100644 index 0000000..a4a1aa8 --- /dev/null +++ b/tests/scenario/test_route_interface.py @@ -0,0 +1,70 @@ +import pytest +import yaml +from charm import TraefikRouteK8SCharm +from scenario import Context, Relation, State + + +@pytest.fixture +def ctx(): + return Context(TraefikRouteK8SCharm) + + +@pytest.mark.parametrize("strip_prefix", (True, False)) +def test_config_generation(ctx, strip_prefix): + ipu = Relation( + "ingress-per-unit", + remote_units_data={ + 0: { + "port": "81", + "host": "foo.com", + "model": "mymodel", + "name": "MimirMcPromFace/0", + "mode": "http", + "strip-prefix": "true" if strip_prefix else "false", + "redirect-https": "true", + "scheme": "http", + } + }, + remote_app_name="prometheus", + ) + + route = Relation("traefik-route", remote_app_name="traefik") + + state = State( + leader=True, + config={ + "root_url": "{{juju_model}}-{{juju_unit}}.foo.bar/baz", + "rule": "Host(`{{juju_unit}}.bar.baz`)", + }, + relations=[ipu, route], + ) + + state_out = ctx.run(ipu.changed_event, state) + route_out = state_out.get_relations("traefik-route")[0] + strip_prefix_cfg = { + "middlewares": { + "juju-sidecar-noprefix-MimirMcPromFace-0-mymodel-service": { + "forceSlash": False, + "prefixes": ["/MimirMcPromFace-0-mymodel"], + } + } + } + raw_expected_cfg = { + "http": { + "routers": { + "juju-MimirMcPromFace-0-mymodel-router": { + "entryPoints": ["web"], + "rule": "Host(`MimirMcPromFace-0.bar.baz`)", + "service": "juju-MimirMcPromFace-0-mymodel-service", + } + }, + "services": { + "juju-MimirMcPromFace-0-mymodel-service": { + "loadBalancer": {"servers": [{"url": "mymodel-MimirMcPromFace-0.foo.bar/baz"}]} + } + }, + **(strip_prefix_cfg if strip_prefix else {}), + } + } + + assert route_out.local_app_data == {"config": yaml.safe_dump(raw_expected_cfg)} diff --git a/tox.ini b/tox.ini index b0527f7..0a55bb5 100644 --- a/tox.ini +++ b/tox.ini @@ -70,10 +70,18 @@ deps = -r{toxinidir}/requirements.txt commands = coverage run --source={[vars]src_path} \ - -m pytest --ignore={[vars]tst_path}integration -v --tb native -s {posargs} + -m pytest -v --tb native -s {[vars]tst_path}unit {posargs} coverage report [testenv:scenario] +description = Run integration tests +deps = + pytest + jsonschema + ops-scenario>=5.0 + -r{toxinidir}/requirements.txt +commands = + pytest -v --tb native --log-cli-level=INFO -s {[vars]tst_path}scenario {posargs} [testenv:integration] description = Run integration tests @@ -84,4 +92,4 @@ deps = pytest-operator -r{toxinidir}/requirements.txt commands = - pytest -v --tb native --ignore={[vars]tst_path}unit --log-cli-level=INFO -s {posargs} + pytest -v --tb native --log-cli-level=INFO -s {[vars]tst_path}integration {posargs}