diff --git a/actions.yaml b/actions.yaml new file mode 100644 index 00000000..0babed10 --- /dev/null +++ b/actions.yaml @@ -0,0 +1,3 @@ +show-proxied-endpoints: + description: | + Returns a list of endpoints proxied by this Traefik proxy. diff --git a/lib/charms/traefik_k8s/v0/ingress.py b/lib/charms/traefik_k8s/v0/ingress.py index fd11703f..e413d268 100644 --- a/lib/charms/traefik_k8s/v0/ingress.py +++ b/lib/charms/traefik_k8s/v0/ingress.py @@ -61,7 +61,7 @@ def _handle_ingress(self, event): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 1 +LIBPATCH = 2 log = logging.getLogger(__name__) @@ -163,6 +163,29 @@ def is_failed(self, relation: Relation = None): raise RelationDataMismatchError(relation, other_app) return False + @property + def proxied_endpoints(self): + """Returns the ingress settings provided to applications by this IngressPerAppProvider. + + For example, when this IngressPerAppProvider has provided the + `http://foo.bar/my-model.my-app` URL to the my-app application, the returned dictionary + will be: + + ``` + { + "my-app": { + "url": "http://foo.bar/my-model.my-app" + } + } + ``` + """ + return { + ingress_relation.app.name: self.unwrap(ingress_relation)[self.charm.app].get( + "ingress", {} + ) + for ingress_relation in self.charm.model.relations[self.endpoint] + } + class IngressPerAppRequest: """A request for per-application ingress.""" diff --git a/lib/charms/traefik_k8s/v0/ingress_per_unit.py b/lib/charms/traefik_k8s/v0/ingress_per_unit.py index c95b7295..b05c8836 100644 --- a/lib/charms/traefik_k8s/v0/ingress_per_unit.py +++ b/lib/charms/traefik_k8s/v0/ingress_per_unit.py @@ -68,7 +68,7 @@ def _handle_ingress_per_unit(self, event): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 2 +LIBPATCH = 3 log = logging.getLogger(__name__) @@ -169,6 +169,32 @@ def is_failed(self, relation: Relation = None): raise RelationDataMismatchError(relation, unit) return False + @property + def proxied_endpoints(self): + """Returns the ingress settings provided to units by this IngressPerUnitProvider. + + For example, when this IngressPerUnitProvider has provided the + `http://foo.bar/my-model.my-app-1` and `http://foo.bar/my-model.my-app-2` URLs to + the two units of the my-app application, the returned dictionary will be: + + ``` + { + "my-app/1": { + "url": "http://foo.bar/my-model.my-app-1" + }, + "my-app/2": { + "url": "http://foo.bar/my-model.my-app-2" + } + } + ``` + """ + results = {} + + for ingress_relation in self.charm.model.relations[self.endpoint]: + results.update(self.unwrap(ingress_relation)[self.charm.app].get("ingress", {})) + + return results + class IngressRequest: """A request for per-unit ingress.""" diff --git a/src/charm.py b/src/charm.py index c69a3ff6..959ec0b0 100755 --- a/src/charm.py +++ b/src/charm.py @@ -5,6 +5,7 @@ """Charm Traefik.""" import enum +import json import logging from typing import Optional, Tuple @@ -16,6 +17,7 @@ from lightkube import Client from lightkube.resources.core_v1 import Service from ops.charm import ( + ActionEvent, CharmBase, ConfigChangedEvent, PebbleReadyEvent, @@ -99,6 +101,22 @@ def __init__(self, *args): self.framework.observe(self.ingress_per_unit.on.failed, self._handle_ingress_failure) self.framework.observe(self.ingress_per_unit.on.broken, self._handle_ingress_broken) + # Action handlers + self.framework.observe( + self.on.show_proxied_endpoints_action, self._on_show_proxied_endpoints + ) + + def _on_show_proxied_endpoints(self, event: ActionEvent): + try: + result = {} + result.update(self.ingress_per_unit.proxied_endpoints) + result.update(self.ingress_per_app.proxied_endpoints) + + event.set_results({"proxied-endpoints": json.dumps(result)}) + except Exception as e: + logger.exception("Action 'show-proxied-endpoints' failed") + event.fail(str(e)) + def _on_traefik_pebble_ready(self, _: PebbleReadyEvent): # The the Traefik container comes up, e.g., after a pod churn, we # ignore the unit status and start fresh. diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 8953db6d..bf200ef5 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -1,10 +1,12 @@ # Copyright 2022 Canonical Ltd. # See LICENSE file for licensing details. +import json import unittest -from unittest.mock import patch +from unittest.mock import Mock, patch import yaml +from ops.charm import ActionEvent from ops.model import ActiveStatus, BlockedStatus, WaitingStatus from ops.testing import Harness from test_lib_helpers import MockIPARequirer, MockIPURequirer @@ -373,3 +375,62 @@ def test_relation_broken(self): raise Exception("The line above should fail") except FileNotFoundError: pass + + @patch("charm._get_loadbalancer_status", lambda **unused: None) + @patch("charm.KubernetesServicePatch", lambda **unused: None) + def test_show_proxied_endpoints_action_no_relations(self): + self.harness.begin_with_initial_hooks() + + action_event = Mock(spec=ActionEvent) + self.harness.charm._on_show_proxied_endpoints(action_event) + action_event.set_results.assert_called_once_with({"proxied-endpoints": "{}"}) + + @patch("charm._get_loadbalancer_status", lambda **unused: None) + @patch("charm.KubernetesServicePatch", lambda **unused: None) + def test_show_proxied_endpoints_action_only_ingress_per_app_relations(self): + self.harness.set_leader(True) + self.harness.update_config({"external_hostname": "testhostname"}) + self.harness.begin_with_initial_hooks() + + requirer = MockIPARequirer(self.harness) + requirer.relate() + requirer.request(host="10.0.0.1", port=3000) + + self.harness.container_pebble_ready("traefik") + + action_event = Mock(spec=ActionEvent) + self.harness.charm._on_show_proxied_endpoints(action_event) + action_event.set_results.assert_called_once_with( + { + "proxied-endpoints": json.dumps( + {"ingress-remote": {"url": "http://testhostname:80/test-model-ingress-remote"}} + ) + } + ) + + @patch("charm._get_loadbalancer_status", lambda **unused: None) + @patch("charm.KubernetesServicePatch", lambda **unused: None) + def test_show_proxied_endpoints_action_only_ingress_per_unit_relations(self): + self.harness.set_leader(True) + self.harness.update_config({"external_hostname": "testhostname"}) + self.harness.begin_with_initial_hooks() + + requirer = MockIPURequirer(self.harness) + requirer.relate() + requirer.request(host="10.0.0.1", port=3000) + + self.harness.container_pebble_ready("traefik") + + action_event = Mock(spec=ActionEvent) + self.harness.charm._on_show_proxied_endpoints(action_event) + action_event.set_results.assert_called_once_with( + { + "proxied-endpoints": json.dumps( + { + "ingress-per-unit-remote/0": { + "url": "http://testhostname:80/test-model-ingress-per-unit-remote-0" + } + } + ) + } + )