From 7e976ac126cd2b30b96e9c7a5e9fb49ffc00f07a Mon Sep 17 00:00:00 2001 From: Nikos Date: Mon, 30 Oct 2023 16:55:16 +0200 Subject: [PATCH 1/3] feat: update openfga lib --- lib/charms/openfga_k8s/v0/openfga.py | 334 +++++++++++++++--- requirements.txt | 1 + src/charm.py | 54 ++- src/constants.py | 1 + .../lib/charms/openfga_k8s/v0/.gitignore | 4 + .../lib/charms/openfga_k8s/v0/openfga.py | 163 --------- tests/charms/openfga_requires/src/charm.py | 49 ++- tests/integration/conftest.py | 10 + tests/unit/test_openfga_provider.py | 122 +++++++ tests/unit/test_openfga_requirer.py | 123 +++++++ tox.ini | 4 +- 11 files changed, 601 insertions(+), 264 deletions(-) create mode 100644 tests/charms/openfga_requires/lib/charms/openfga_k8s/v0/.gitignore delete mode 100644 tests/charms/openfga_requires/lib/charms/openfga_k8s/v0/openfga.py create mode 100644 tests/unit/test_openfga_provider.py create mode 100644 tests/unit/test_openfga_requirer.py diff --git a/lib/charms/openfga_k8s/v0/openfga.py b/lib/charms/openfga_k8s/v0/openfga.py index eadd3ca..a558c09 100644 --- a/lib/charms/openfga_k8s/v0/openfga.py +++ b/lib/charms/openfga_k8s/v0/openfga.py @@ -1,7 +1,7 @@ -"""# Interface Library for OpenFGA +"""# Interface Library for OpenFGA. This library wraps relation endpoints using the `openfga` interface -and provides a Python API for requesting OpenFGA authorization model +and provides a Python API for requesting OpenFGA authorization model stores to be created. ## Getting Started @@ -63,15 +63,23 @@ def _on_openfga_store_created(self, event: OpenFGAStoreCreateEvent): fall back to passing plaintext token via relation fata. """ +import json import logging - -from ops.charm import ( - CharmEvents, - RelationChangedEvent, - RelationEvent, - RelationJoinedEvent, +from typing import Dict, Literal, MutableMapping, Optional, Union + +import pydantic +from ops import ( + CharmBase, + Handle, + HookEvent, + Relation, + RelationCreatedEvent, + RelationDepartedEvent, + TooManyRelatedAppsError, ) +from ops.charm import CharmEvents, RelationChangedEvent, RelationEvent from ops.framework import EventSource, Object +from pydantic import BaseModel, Field, validator # The unique Charmhub library identifier, never change it LIBID = "216f28cfeea4447b8a576f01bfbecdf5" @@ -81,89 +89,305 @@ def _on_openfga_store_created(self, event: OpenFGAStoreCreateEvent): # Increment this PATCH version before using `charmcraft publish-lib` or reset # to 0 if you are raising the major API version -LIBPATCH = 5 +LIBPATCH = 6 +PYDEPS = ["pydantic<2.0"] logger = logging.getLogger(__name__) - +BUILTIN_JUJU_KEYS = {"ingress-address", "private-address", "egress-subnets"} RELATION_NAME = "openfga" +OPENFGA_TOKEN_FIELD = "token" -class OpenFGAEvent(RelationEvent): - """Base class for OpenFGA events.""" +class OpenfgaError(RuntimeError): + """Base class for custom errors raised by this library.""" - @property - def store_id(self): - return self.relation.data[self.relation.app].get("store_id", "") - @property - def token_secret_id(self): - return self.relation.data[self.relation.app].get("token_secret_id", "") - - @property - def token(self): - return self.relation.data[self.relation.app].get("token", "") +class DataValidationError(OpenfgaError): + """Raised when data validation fails on relation data.""" - @property - def address(self): - return self.relation.data[self.relation.app].get("address", "") - @property - def scheme(self): - return self.relation.data[self.relation.app].get("scheme", "") +class DatabagModel(BaseModel): + """Base databag model.""" - @property - def port(self): - return self.relation.data[self.relation.app].get("port", "") + class Config: + """Pydantic config.""" + allow_population_by_field_name = True + """Allow instantiating this class by field name (instead of forcing alias).""" + + @classmethod + def _load_value(cls, v: str): + try: + return json.loads(v) + except json.JSONDecodeError: + return v + + @classmethod + def load(cls, databag: MutableMapping): + """Load this model from a Juju databag.""" + try: + data = { + k: cls._load_value(v) for k, v in databag.items() if k not in BUILTIN_JUJU_KEYS + } + except json.JSONDecodeError: + logger.error(f"invalid databag contents: expecting json. {databag}") + raise + + return cls.parse_raw(json.dumps(data)) # type: ignore + + def dump(self, databag: Optional[MutableMapping] = None): + """Write the contents of this model to Juju databag.""" + if databag is None: + databag = {} + + dct = self.dict() + for key, field in self.__fields__.items(): # type: ignore + value = dct[key] + if value is None: + continue + databag[field.alias or key] = ( + json.dumps(value) if not isinstance(value, (str)) else value + ) + + return databag + + +class OpenfgaRequirerAppData(DatabagModel): + """Openfga requirer application databag model.""" + + store_name: str = Field(description="The store name the application requires") + + +class OpenfgaProviderAppData(DatabagModel): + """Openfga requirer application databag model.""" + + store_id: Optional[str] = Field(description="The store_id", default=None) + token: Optional[str] = Field(description="The token", default=None) + token_secret_id: Optional[str] = Field( + description="The juju secret_id which can be used to retrieve the token", + default=None, + ) + address: str = Field(description="The openfga server address") + scheme: Literal["http", "https"] = Field(description="The openfga server allowed schemes") + port: str = Field(description="The openfga server port") + dns_name: str = Field(description="The openfga server dns name") + + @validator("token_secret_id", pre=True) + def validate_token(cls, v, values): # noqa: N805 # pydantic wants 'cls' as first arg + """Validate token_secret_id arg.""" + if not v and not values["token"]: + raise ValueError("invalid scheme: neither of token and token_secret_id were defined") + return v -class OpenFGAStoreCreateEvent(OpenFGAEvent): - """ - Event emitted when a new OpenFGA store is created - for use on this relation. - """ + +class OpenFGAStoreCreateEvent(HookEvent): + """Event emitted when a new OpenFGA store is created.""" + + def __init__(self, handle: Handle, store_id: str): + super().__init__(handle) + self.store_id = store_id + + def snapshot(self) -> Dict: + """Save event.""" + return { + "store_id": self.store_id, + } + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + self.store_id = snapshot["store_id"] + + +class OpenFGAStoreRemovedEvent(HookEvent): + """Event emitted when a new OpenFGA store is removed.""" class OpenFGAEvents(CharmEvents): """Custom charm events.""" openfga_store_created = EventSource(OpenFGAStoreCreateEvent) + openfga_store_removed = EventSource(OpenFGAStoreRemovedEvent) class OpenFGARequires(Object): """This class defines the functionality for the 'requires' side of the 'openfga' relation. Hook events observed: - - relation-joined + - relation-created - relation-changed + - relation-departed """ on = OpenFGAEvents() - def __init__(self, charm, store_name: str): - super().__init__(charm, RELATION_NAME) + def __init__(self, charm, store_name: str, relation_name: str = RELATION_NAME): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.store_name = store_name + self.framework.observe(charm.on[relation_name].relation_created, self._on_relation_created) self.framework.observe( - charm.on[RELATION_NAME].relation_joined, self._on_relation_joined + charm.on[relation_name].relation_changed, + self._on_relation_changed, ) self.framework.observe( - charm.on[RELATION_NAME].relation_changed, - self._on_relation_changed, + charm.on[relation_name].relation_departed, + self._on_relation_departed, ) - self.data = {} + def _on_relation_created(self, event: RelationCreatedEvent) -> None: + """Handle the relation-joined event.""" + databag = event.relation.data[self.model.app] + OpenfgaRequirerAppData(store_name=self.store_name).dump(databag) + + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + """Handle the relation-changed event.""" + if not (app := event.relation.app): + return + databag = event.relation.data[app] + try: + data = OpenfgaProviderAppData.load(databag) + except pydantic.ValidationError: + return + + self.on.openfga_store_created.emit(store_id=data.store_id) + + def _on_relation_departed(self, event: RelationDepartedEvent) -> None: + """Handle the relation-departed event.""" + self.on.openfga_store_removed.emit() + + def _get_relation(self, relation_id: Optional[int] = None) -> Optional[Relation]: + try: + relation = self.model.get_relation(self.relation_name, relation_id=relation_id) + except TooManyRelatedAppsError: + raise RuntimeError("More than one relations are defined. Please provide a relation_id") + if not relation or not relation.app: + return None + return relation + + def get_store_info(self) -> Optional[OpenfgaProviderAppData]: + """Get the OpenFGA store and server info.""" + if not (relation := self._get_relation()): + return None + if not relation.app: + return None + + databag = relation.data[relation.app] + try: + data = OpenfgaProviderAppData.load(databag) + except pydantic.ValidationError: + return None + + if data.token_secret_id: + token_secret = self.model.get_secret(id=data.token_secret_id) + token = token_secret.get_content()["token"] + data.token = token + + return data + + +class OpenFGAStoreRequestEvent(RelationEvent): + """Event emitted when a new OpenFGA store is requested.""" + + def __init__(self, handle: Handle, relation: Relation, store_name: str): + super().__init__(handle, relation) self.store_name = store_name - def _on_relation_joined(self, event: RelationJoinedEvent): - """Handle the relation-joined event.""" - # `self.unit` isn't available here, so use `self.model.unit`. - if self.model.unit.is_leader(): - event.relation.data[self.model.app]["store_name"] = self.store_name + def snapshot(self) -> Dict: + """Save event.""" + dct = super().snapshot() + dct["store_name"] = self.store_name + return dct + + def restore(self, snapshot: Dict) -> None: + """Restore event.""" + super().restore(snapshot) + self.store_name = snapshot["store_name"] + + +class OpenFGAEvents(CharmEvents): + """Custom charm events.""" + + openfga_store_requested = EventSource(OpenFGAStoreRequestEvent) + + +class OpenFGAProvider(Object): + """Requirer side of the openfga relation.""" + + on = OpenFGAEvents() + + def __init__(self, charm: CharmBase, relation_name: str = RELATION_NAME): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + + self.framework.observe( + charm.on[relation_name].relation_changed, + self._on_relation_changed, + ) def _on_relation_changed(self, event: RelationChangedEvent): - """Handle the relation-changed event.""" - if self.model.unit.is_leader(): - self.on.openfga_store_created.emit( - event.relation, - app=event.app, - unit=event.unit, - ) + data = event.relation.data[event.app] + if not data: + logger.info("No relation data available.") + return + + try: + data = OpenfgaRequirerAppData.load(data) + except pydantic.ValidationError: + return + + self.on.openfga_store_requested.emit(event.relation, store_name=data.store_name) + + def update_relation_info( + self, + store_id: str, + address: str, + scheme: str, + port: str, + dns_name: str, + token: Optional[str] = None, + token_secret_id: Optional[str] = None, + relation_id: Optional[int] = None, + ): + """Update a relation databag.""" + if not self.model.unit.is_leader(): + return + + relation = self.model.get_relation(self.relation_name, relation_id) + if not relation or not relation.app: + return + + data = OpenfgaProviderAppData( + store_id=store_id, + address=address, + scheme=scheme, + port=port, + dns_name=dns_name, + token_secret_id=token_secret_id, + token=token, + ) + databag = relation.data[self.charm.app] + + try: + data.dump(databag) + except pydantic.ValidationError as e: + msg = "failed to validate app data" + logger.info(msg, exc_info=True) + raise DataValidationError(msg) from e + + def update_server_info(self, address: str, scheme: str, port: str, dns_name: str): + """Update all the relations databags with the server info.""" + if not self.model.unit.is_leader(): + return + + data = OpenfgaProviderAppData(address=address, scheme=scheme, port=port, dns_name=dns_name) + + for relation in self.model.relations[self.relation_name]: + try: + data.dump(relation.data[self.model.app]) + except pydantic.ValidationError as e: + msg = "failed to validate app data" + logger.info(msg, exc_info=True) + raise DataValidationError(msg) from e diff --git a/requirements.txt b/requirements.txt index 9b2a4db..95ba29e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ jsonschema >= 3.2.0 cryptography >= 3.4.8 lightkube lightkube-models +pydantic<2.0 diff --git a/src/charm.py b/src/charm.py index af14ed8..c57b3a4 100755 --- a/src/charm.py +++ b/src/charm.py @@ -30,6 +30,7 @@ from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider from charms.loki_k8s.v0.loki_push_api import LogProxyConsumer from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch +from charms.openfga_k8s.v0.openfga import OpenFGAProvider, OpenFGAStoreRequestEvent from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointProvider from charms.traefik_k8s.v1.ingress import ( IngressPerAppReadyEvent, @@ -61,6 +62,7 @@ LOG_FILE, LOG_PROXY_RELATION_NAME, METRIC_RELATION_NAME, + OPENFGA_RELATION_NAME, OPENFGA_SERVER_GRPC_PORT, OPENFGA_SERVER_HTTP_PORT, PEER_KEY_DB_MIGRATE_VERSION, @@ -86,6 +88,7 @@ def __init__(self, *args: Any) -> None: self._state = State(self.app, lambda: self.model.get_relation("peer")) self._container = self.unit.get_container(WORKLOAD_CONTAINER) self.openfga = OpenFGA(f"http://127.0.0.1:{OPENFGA_SERVER_HTTP_PORT}", self._container) + self.openfga_relation = OpenFGAProvider(self, relation_name=OPENFGA_RELATION_NAME) self.framework.observe(self.on.openfga_pebble_ready, self._on_openfga_pebble_ready) self.framework.observe(self.on.config_changed, self._on_config_changed) @@ -121,7 +124,9 @@ def __init__(self, *args: Any) -> None: ) # OpenFGA relation - self.framework.observe(self.on.openfga_relation_changed, self._on_openfga_relation_changed) + self.framework.observe( + self.openfga_relation.on.openfga_store_requested, self._on_openfga_store_requested + ) # Ingress relation self.ingress = IngressPerAppRequirer( @@ -335,7 +340,7 @@ def _update_workload(self, event: HookEvent) -> None: openfga_relation.data[self.app].update( { "address": self._get_address(openfga_relation), - "dns-name": self._domain_name, + "dns_name": self._domain_name, } ) @@ -460,13 +465,13 @@ def _is_openfga_server_running(self) -> bool: return True @requires_state_setter - def _on_openfga_relation_changed(self, event: RelationChangedEvent) -> None: + def _on_openfga_store_requested(self, event: OpenFGAStoreRequestEvent) -> None: """Open FGA relation changed.""" # the requires side will put the store_name in its # application bucket - if not event.app: + if not event.relation.app: return - store_name = event.relation.data[event.app].get("store_name", "") + store_name = event.store_name if not store_name: return @@ -487,23 +492,26 @@ def _on_openfga_relation_changed(self, event: RelationChangedEvent) -> None: # update the relation data with information needed # to connect to OpenFga - data = { - "store_id": store_id, - "address": self._get_address(event.relation), - "scheme": "http", - "port": str(OPENFGA_SERVER_HTTP_PORT), - "dns_name": self._domain_name, - } - if JujuVersion.from_environ().has_secrets: secret = self.model.get_secret(id=self._state.token_secret_id) secret.grant(event.relation) - data["token_secret_id"] = self._state.token_secret_id + token_secret_id = self._state.token_secret_id + token = None else: - data["token"] = self._state.token - - event.relation.data[self.app].update(data) + token_secret_id = None + token = self._state.token + + self.openfga_relation.update_relation_info( + store_id=store_id, + address=self._get_address(event.relation), + scheme="http", + port=str(OPENFGA_SERVER_HTTP_PORT), + dns_name=self._domain_name, + token=token, + token_secret_id=token_secret_id, + relation_id=event.relation.id, + ) def _get_address(self, relation: Relation) -> str: """Returns the ip address to be used with the specified relation.""" @@ -575,9 +583,21 @@ def _on_schema_upgrade_action(self, event: ActionEvent) -> None: def _on_ingress_ready(self, event: IngressPerAppReadyEvent) -> None: self._update_workload(event) + self.openfga_relation.update_server_info( + address=self._get_address(event.relation), + scheme="http", + port=str(OPENFGA_SERVER_HTTP_PORT), + dns_name=self._domain_name, + ) def _on_ingress_revoked(self, event: IngressPerAppRevokedEvent) -> None: self._update_workload(event) + self.openfga_relation.update_server_info( + address=self._get_address(event.relation), + scheme="http", + port=str(OPENFGA_SERVER_HTTP_PORT), + dns_name=self._domain_name, + ) def map_config_to_env_vars(charm: CharmBase, **additional_env: str) -> Dict: diff --git a/src/constants.py b/src/constants.py index f4dbe0d..57a8369 100644 --- a/src/constants.py +++ b/src/constants.py @@ -25,3 +25,4 @@ GRAFANA_RELATION_NAME = "grafana-dashboard" LOG_PROXY_RELATION_NAME = "log-proxy" METRIC_RELATION_NAME = "metrics-endpoint" +OPENFGA_RELATION_NAME = "openfga" diff --git a/tests/charms/openfga_requires/lib/charms/openfga_k8s/v0/.gitignore b/tests/charms/openfga_requires/lib/charms/openfga_k8s/v0/.gitignore new file mode 100644 index 0000000..5e7d273 --- /dev/null +++ b/tests/charms/openfga_requires/lib/charms/openfga_k8s/v0/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore diff --git a/tests/charms/openfga_requires/lib/charms/openfga_k8s/v0/openfga.py b/tests/charms/openfga_requires/lib/charms/openfga_k8s/v0/openfga.py deleted file mode 100644 index 460f4c8..0000000 --- a/tests/charms/openfga_requires/lib/charms/openfga_k8s/v0/openfga.py +++ /dev/null @@ -1,163 +0,0 @@ -# flake8: noqa -"""# Interface Library for OpenFGA - -This library wraps relation endpoints using the `openfga` interface -and provides a Python API for requesting OpenFGA authorization model -stores to be created. - -## Getting Started - -To get started using the library, you just need to fetch the library using `charmcraft`. - -```shell -cd some-charm -charmcraft fetch-lib charms.openfga_k8s.v0.openfga -``` - -In the `metadata.yaml` of the charm, add the following: - -```yaml -requires: - openfga: - interface: openfga -``` - -Then, to initialise the library: -```python -from charms.openfga_k8s.v0.openfga import ( - OpenFGARequires, - OpenFGAStoreCreateEvent, -) - -class SomeCharm(CharmBase): - def __init__(self, *args): - # ... - self.openfga = OpenFGARequires(self, "test-openfga-store") - self.framework.observe( - self.openfga.on.openfga_store_created, - self._on_openfga_store_created, - ) - - def _on_openfga_store_created(self, event: OpenFGAStoreCreateEvent): - if not self.unit.is_leader(): - return - - if not event.store_id: - return - - logger.info("store id {}".format(event.store_id)) - logger.info("token {}".format(event.token)) - logger.info("address {}".format(event.address)) - logger.info("port {}".format(event.port)) - logger.info("scheme {}".format(event.scheme)) - - if event.token_secret_id: - secret = self.model.get_secret(id=event.token_secret_id) - content = secret.get_content() - # and get the token with content["token"] - if event.token: - # get the token from event.token -``` - -As you can see the OpenFGA charm will attempt to use Juju secrets to pass the token -to the requiring charm. However if the Juju version does not support secrets it will -fall back to passing plaintext token via relation fata. -""" - -import logging - -from ops.charm import CharmEvents, RelationChangedEvent, RelationEvent, RelationJoinedEvent -from ops.framework import EventSource, Object - -# The unique Charmhub library identifier, never change it -LIBID = "216f28cfeea4447b8a576f01bfbecdf5" - -# Increment this major API version when introducing breaking changes -LIBAPI = 0 - -# Increment this PATCH version before using `charmcraft publish-lib` or reset -# to 0 if you are raising the major API version -LIBPATCH = 5 - -logger = logging.getLogger(__name__) - -RELATION_NAME = "openfga" - - -class OpenFGAEvent(RelationEvent): - """Base class for OpenFGA events.""" - - @property - def store_id(self): - return self.relation.data[self.relation.app].get("store_id", "") - - @property - def token_secret_id(self): - return self.relation.data[self.relation.app].get("token_secret_id", "") - - @property - def token(self): - return self.relation.data[self.relation.app].get("token", "") - - @property - def address(self): - return self.relation.data[self.relation.app].get("address", "") - - @property - def scheme(self): - return self.relation.data[self.relation.app].get("scheme", "") - - @property - def port(self): - return self.relation.data[self.relation.app].get("port", "") - - -class OpenFGAStoreCreateEvent(OpenFGAEvent): - """ - Event emitted when a new OpenFGA store is created - for use on this relation. - """ - - -class OpenFGAEvents(CharmEvents): - """Custom charm events.""" - - openfga_store_created = EventSource(OpenFGAStoreCreateEvent) - - -class OpenFGARequires(Object): - """This class defines the functionality for the 'requires' side of the 'openfga' relation. - - Hook events observed: - - relation-joined - - relation-changed - """ - - on = OpenFGAEvents() - - def __init__(self, charm, store_name: str): - super().__init__(charm, RELATION_NAME) - - self.framework.observe(charm.on[RELATION_NAME].relation_joined, self._on_relation_joined) - self.framework.observe( - charm.on[RELATION_NAME].relation_changed, - self._on_relation_changed, - ) - - self.data = {} - self.store_name = store_name - - def _on_relation_joined(self, event: RelationJoinedEvent): - """Handle the relation-joined event.""" - # `self.unit` isn't available here, so use `self.model.unit`. - if self.model.unit.is_leader(): - event.relation.data[self.model.app]["store_name"] = self.store_name - - def _on_relation_changed(self, event: RelationChangedEvent): - """Handle the relation-changed event.""" - if self.model.unit.is_leader(): - self.on.openfga_store_created.emit( - event.relation, - app=event.app, - unit=event.unit, - ) diff --git a/tests/charms/openfga_requires/src/charm.py b/tests/charms/openfga_requires/src/charm.py index 590ff0c..085c0ec 100755 --- a/tests/charms/openfga_requires/src/charm.py +++ b/tests/charms/openfga_requires/src/charm.py @@ -16,7 +16,7 @@ from typing import Any from charms.openfga_k8s.v0.openfga import OpenFGARequires, OpenFGAStoreCreateEvent -from ops import UpdateStatusEvent +from ops import EventBase from ops.charm import CharmBase from ops.main import main from ops.model import ActiveStatus, WaitingStatus @@ -46,16 +46,21 @@ def __init__(self, *args: Any) -> None: self.openfga.on.openfga_store_created, self._on_openfga_store_created, ) + self.framework.observe( + self.openfga.on.openfga_store_created, + self._on_openfga_store_created, + ) - def _on_update_status(self, event: UpdateStatusEvent) -> None: - if not self._state.is_ready(): + def _on_update_status(self, event: EventBase) -> None: + info = self.openfga.get_store_info() + if not info: event.defer() return - if self._state.store_id: + if info.store_id: self.unit.status = ActiveStatus( "running with store {}".format( - self._state.store_id, + info.store_id, ) ) else: @@ -65,32 +70,22 @@ def _on_openfga_store_created(self, event: OpenFGAStoreCreateEvent) -> None: if not self.unit.is_leader(): return - if not self._state.is_ready(): - event.defer() + if not event.store_id: return - if not event.store_id: + info = self.openfga.get_store_info() + if not info: + event.defer() return - logger.info("store id {}".format(event.store_id)) - logger.info("token_secret_id {}".format(event.token_secret_id)) - logger.info("token {}".format(event.token)) - logger.info("address {}".format(event.address)) - logger.info("port {}".format(event.port)) - logger.info("scheme {}".format(event.scheme)) - - self._state.store_id = event.store_id - self._state.address = event.address - self._state.port = event.port - self._state.scheme = event.scheme - - if event.token_secret_id: - secret = self.model.get_secret(id=event.token_secret_id) - content = secret.get_content() - logger.info("secret content {}".format(content)) - self._state.token_secret_id = event.token_secret_id - if event.token: - self._state.token = event.token + logger.info("store id {}".format(info.store_id)) + logger.info("token_secret_id {}".format(info.token_secret_id)) + logger.info("token {}".format(info.token)) + logger.info("address {}".format(info.address)) + logger.info("port {}".format(info.port)) + logger.info("scheme {}".format(info.scheme)) + + self._on_update_status(event) if __name__ == "__main__": # pragma: nocover diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 9b04b7d..2462086 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,6 +1,8 @@ import logging +import shutil from pathlib import Path +import pytest import pytest_asyncio from pytest_operator.plugin import OpsTest from utils import fetch_charm @@ -20,3 +22,11 @@ async def test_charm(ops_test: OpsTest) -> Path: logger.info("Building local test charm") test_charm = await fetch_charm(ops_test, "*.charm", "./tests/charms/openfga_requires/") return test_charm + + +@pytest.fixture(scope="module", autouse=True) +def copy_libraries_into_tester_charm(ops_test: OpsTest) -> None: + """Ensure that the tester charm uses the current libraries.""" + lib = Path("lib/charms/openfga_k8s/v0/openfga.py") + Path("tests/integration/openfga_requires", lib.parent).mkdir(parents=True, exist_ok=True) + shutil.copyfile(lib.as_posix(), "tests/charms/openfga_requires/{}".format(lib.as_posix())) diff --git a/tests/unit/test_openfga_provider.py b/tests/unit/test_openfga_provider.py new file mode 100644 index 0000000..7efa2c9 --- /dev/null +++ b/tests/unit/test_openfga_provider.py @@ -0,0 +1,122 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +from typing import Any, Dict, Generator, List + +import pytest +from charms.openfga_k8s.v0.openfga import OpenFGAProvider, OpenFGAStoreRequestEvent +from ops.charm import CharmBase +from ops.framework import EventBase +from ops.testing import Harness + +METADATA = """ +name: provider-tester +provides: + openfga: + interface: openfga +""" + + +PROVIDER_DATABAG = { + "store_id": "store_id", + "token_secret_id": "token_secret_id", + "address": "127.0.0.1", + "scheme": "http", + "port": "8080", + "dns_name": "example.domain.test.com/1234", +} + + +class OpenFGAProviderCharm(CharmBase): + def __init__(self, *args: Any) -> None: + super().__init__(*args) + self.openfga = OpenFGAProvider(self) + self.events: List = [] + + self.framework.observe( + self.openfga.on.openfga_store_requested, self._on_openfga_store_requested + ) + self.framework.observe(self.openfga.on.openfga_store_requested, self._record_event) + + def _on_openfga_store_requested(self, event: OpenFGAStoreRequestEvent) -> None: + self.openfga.update_relation_info(relation_id=event.relation.id, **PROVIDER_DATABAG) + + def _record_event(self, event: EventBase) -> None: + self.events.append(event) + + +@pytest.fixture() +def harness() -> Generator: + harness = Harness(OpenFGAProviderCharm, meta=METADATA) + harness.set_leader(True) + harness.begin_with_initial_hooks() + yield harness + harness.cleanup() + + +@pytest.fixture() +def requirer_databag() -> Dict: + return {"store_name": "test-openfga-store"} + + +@pytest.fixture() +def provider_databag() -> Dict: + return PROVIDER_DATABAG + + +def test_openfga_store_requested_emitted(harness: Harness, requirer_databag: Dict) -> None: + relation_id = harness.add_relation("openfga", "requirer") + + harness.update_relation_data( + relation_id, + "requirer", + requirer_databag, + ) + + assert isinstance(harness.charm.events[0], OpenFGAStoreRequestEvent) + + +def test_openfga_store_requested_info_in_relation_databag( + harness: Harness, requirer_databag: Dict, provider_databag: Dict +) -> None: + relation_id = harness.add_relation("openfga", "requirer") + + harness.update_relation_data( + relation_id, + "requirer", + requirer_databag, + ) + relation_data = harness.get_relation_data(relation_id, harness.model.app.name) + + assert relation_data == provider_databag + + +def test_update_server_info( + harness: Harness, requirer_databag: Dict, provider_databag: Dict +) -> None: + relation_id = harness.add_relation("openfga", "requirer") + relation_id_2 = harness.add_relation("openfga", "requirer2") + harness.update_relation_data( + relation_id, + "requirer", + requirer_databag, + ) + harness.update_relation_data( + relation_id_2, + "requirer2", + {"store_name": "test-openfga-store-2"}, + ) + dns_name = "other_dns_name.com" + + harness.charm.openfga.update_server_info( + provider_databag["address"], + provider_databag["scheme"], + provider_databag["port"], + dns_name, + ) + + relation_data = harness.get_relation_data(relation_id, harness.model.app.name) + assert relation_data["dns_name"] == dns_name + + relation_data = harness.get_relation_data(relation_id_2, harness.model.app.name) + assert relation_data["dns_name"] == dns_name diff --git a/tests/unit/test_openfga_requirer.py b/tests/unit/test_openfga_requirer.py new file mode 100644 index 0000000..dfdf738 --- /dev/null +++ b/tests/unit/test_openfga_requirer.py @@ -0,0 +1,123 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +from typing import Any, Dict, Generator, List + +import pytest +from charms.openfga_k8s.v0.openfga import ( + OpenFGARequires, + OpenFGAStoreCreateEvent, + OpenFGAStoreRemovedEvent, +) +from ops.charm import CharmBase +from ops.framework import EventBase +from ops.testing import Harness + +METADATA = """ +name: requirer-tester +requires: + openfga: + interface: openfga +""" + + +class OpenFGARequiresCharm(CharmBase): + def __init__(self, *args: Any) -> None: + super().__init__(*args) + self.openfga = OpenFGARequires(self, "test-openfga-store") + self.events: List = [] + + self.framework.observe(self.openfga.on.openfga_store_created, self._record_event) + self.framework.observe(self.openfga.on.openfga_store_removed, self._record_event) + + def _record_event(self, event: EventBase) -> None: + self.events.append(event) + + +@pytest.fixture() +def harness() -> Generator: + harness = Harness(OpenFGARequiresCharm, meta=METADATA) + harness.set_leader(True) + harness.begin_with_initial_hooks() + yield harness + harness.cleanup() + + +@pytest.fixture() +def requirer_databag() -> Dict: + return {"store_name": "test-openfga-store"} + + +@pytest.fixture() +def provider_databag() -> Dict: + return { + "store_id": "store_id", + "token_secret_id": "token_secret_id", + "address": "127.0.0.1", + "scheme": "http", + "port": "8080", + "dns_name": "example.domain.test.com/1234", + } + + +def test_data_in_relation_bag(harness: Harness, requirer_databag: Dict) -> None: + relation_id = harness.add_relation("openfga", "provider") + + relation_data = harness.get_relation_data(relation_id, harness.model.app.name) + + assert relation_data == requirer_databag + + +def test_event_emitted_when_data_available(harness: Harness, provider_databag: Dict) -> None: + relation_id = harness.add_relation("openfga", "provider") + harness.add_relation_unit(relation_id, "provider/0") + harness.update_relation_data( + relation_id, + "provider", + provider_databag, + ) + + events = [e for e in harness.charm.events if isinstance(e, OpenFGAStoreCreateEvent)] + assert len(events) == 1 + assert events[0].store_id == provider_databag["store_id"] + + +def test_event_emitted_when_data_removed(harness: Harness, provider_databag: Dict) -> None: + relation_id = harness.add_relation("openfga", "provider") + harness.add_relation_unit(relation_id, "provider/0") + harness.remove_relation(relation_id) + + events = [e for e in harness.charm.events if isinstance(e, OpenFGAStoreRemovedEvent)] + assert len(events) == 1 + + +def test_get_store_info_when_data_available(harness: Harness, provider_databag: Dict) -> None: + token = "token" + relation_id = harness.add_relation("openfga", "provider") + secret_id = harness.add_model_secret("provider", {"token": token}) + harness.grant_secret(secret_id, "requirer-tester") + provider_databag["token_secret_id"] = secret_id + harness.add_relation_unit(relation_id, "provider/0") + harness.update_relation_data( + relation_id, + "provider", + provider_databag, + ) + + info = harness.charm.openfga.get_store_info() + + assert info.token == token + assert info.store_id == provider_databag["store_id"] + assert info.token_secret_id == provider_databag["token_secret_id"] + assert info.address == provider_databag["address"] + assert info.scheme == provider_databag["scheme"] + assert info.port == provider_databag["port"] + + +def test_get_store_info_when_data_unavailable(harness: Harness, provider_databag: Dict) -> None: + relation_id = harness.add_relation("openfga", "provider") + harness.add_relation_unit(relation_id, "provider/0") + + info = harness.charm.openfga.get_store_info() + + assert info is None diff --git a/tox.ini b/tox.ini index 88fc062..eaec40d 100644 --- a/tox.ini +++ b/tox.ini @@ -10,7 +10,7 @@ envlist = fmt, lint, unit src_path = {toxinidir}/src/ tst_path = {toxinidir}/tests/ lib_path = {toxinidir}/lib/charms/openfga_k8s -all_path = {[vars]src_path} {[vars]tst_path} +all_path = {[vars]src_path} {[vars]tst_path} {[vars]lib_path} [testenv] setenv = @@ -49,7 +49,7 @@ description = Run unit tests deps = -r{toxinidir}/unit-requirements.txt commands = - coverage run --source={[vars]src_path},{[vars]lib_path},{[vars]tst_path}/unit \ + coverage run --source={[vars]src_path},{[vars]lib_path},{[vars]tst_path}unit \ -m pytest --ignore={[vars]tst_path}integration -vv --tb native -s {posargs} coverage report From 0a7b1ad957a5b4fc6ab0492e663cece08e7c0329 Mon Sep 17 00:00:00 2001 From: Nikos Date: Tue, 31 Oct 2023 15:13:59 +0200 Subject: [PATCH 2/3] fix: catch error on restart --- src/charm.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/src/charm.py b/src/charm.py index c57b3a4..286dd00 100755 --- a/src/charm.py +++ b/src/charm.py @@ -52,7 +52,7 @@ from ops.jujuversion import JujuVersion from ops.main import main from ops.model import ActiveStatus, BlockedStatus, ModelError, Relation, WaitingStatus -from ops.pebble import Error, ExecError, Layer +from ops.pebble import ChangeError, Error, ExecError, Layer from constants import ( DATABASE_NAME, @@ -350,7 +350,14 @@ def _update_workload(self, event: HookEvent) -> None: event.defer() return - self._container.restart(SERVICE_NAME) + try: + self._container.restart(SERVICE_NAME) + except ChangeError as err: + logger.error(str(err)) + self.unit.status = BlockedStatus( + "Failed to restart the container, please consult the logs" + ) + return self.unit.status = ActiveStatus() def _on_peer_relation_changed(self, event: RelationChangedEvent) -> None: From a0d20af5dd082a186c2f74244f56dc0ce47b454c Mon Sep 17 00:00:00 2001 From: Nikos Date: Thu, 2 Nov 2023 16:05:45 +0200 Subject: [PATCH 3/3] fix: type annotations --- lib/charms/openfga_k8s/v0/openfga.py | 35 ++++++++++++++++------------ 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/lib/charms/openfga_k8s/v0/openfga.py b/lib/charms/openfga_k8s/v0/openfga.py index a558c09..cc8dd0d 100644 --- a/lib/charms/openfga_k8s/v0/openfga.py +++ b/lib/charms/openfga_k8s/v0/openfga.py @@ -80,6 +80,7 @@ def _on_openfga_store_created(self, event: OpenFGAStoreCreateEvent): from ops.charm import CharmEvents, RelationChangedEvent, RelationEvent from ops.framework import EventSource, Object from pydantic import BaseModel, Field, validator +from typing_extensions import Self # The unique Charmhub library identifier, never change it LIBID = "216f28cfeea4447b8a576f01bfbecdf5" @@ -116,14 +117,14 @@ class Config: """Allow instantiating this class by field name (instead of forcing alias).""" @classmethod - def _load_value(cls, v: str): + def _load_value(cls, v: str) -> Union[Dict, str]: try: return json.loads(v) except json.JSONDecodeError: return v @classmethod - def load(cls, databag: MutableMapping): + def load(cls, databag: MutableMapping) -> Self: """Load this model from a Juju databag.""" try: data = { @@ -135,7 +136,7 @@ def load(cls, databag: MutableMapping): return cls.parse_raw(json.dumps(data)) # type: ignore - def dump(self, databag: Optional[MutableMapping] = None): + def dump(self, databag: Optional[MutableMapping] = None) -> MutableMapping: """Write the contents of this model to Juju databag.""" if databag is None: databag = {} @@ -173,7 +174,7 @@ class OpenfgaProviderAppData(DatabagModel): dns_name: str = Field(description="The openfga server dns name") @validator("token_secret_id", pre=True) - def validate_token(cls, v, values): # noqa: N805 # pydantic wants 'cls' as first arg + def validate_token(cls, v: str, values: Dict) -> str: # noqa: N805 """Validate token_secret_id arg.""" if not v and not values["token"]: raise ValueError("invalid scheme: neither of token and token_secret_id were defined") @@ -202,7 +203,7 @@ class OpenFGAStoreRemovedEvent(HookEvent): """Event emitted when a new OpenFGA store is removed.""" -class OpenFGAEvents(CharmEvents): +class OpenFGARequirerEvents(CharmEvents): """Custom charm events.""" openfga_store_created = EventSource(OpenFGAStoreCreateEvent) @@ -218,9 +219,11 @@ class OpenFGARequires(Object): - relation-departed """ - on = OpenFGAEvents() + on = OpenFGARequirerEvents() - def __init__(self, charm, store_name: str, relation_name: str = RELATION_NAME): + def __init__( + self, charm: CharmBase, store_name: str, relation_name: str = RELATION_NAME + ) -> None: super().__init__(charm, relation_name) self.charm = charm self.relation_name = relation_name @@ -237,7 +240,7 @@ def __init__(self, charm, store_name: str, relation_name: str = RELATION_NAME): ) def _on_relation_created(self, event: RelationCreatedEvent) -> None: - """Handle the relation-joined event.""" + """Handle the relation-created event.""" databag = event.relation.data[self.model.app] OpenfgaRequirerAppData(store_name=self.store_name).dump(databag) @@ -290,7 +293,7 @@ def get_store_info(self) -> Optional[OpenfgaProviderAppData]: class OpenFGAStoreRequestEvent(RelationEvent): """Event emitted when a new OpenFGA store is requested.""" - def __init__(self, handle: Handle, relation: Relation, store_name: str): + def __init__(self, handle: Handle, relation: Relation, store_name: str) -> None: super().__init__(handle, relation) self.store_name = store_name @@ -306,7 +309,7 @@ def restore(self, snapshot: Dict) -> None: self.store_name = snapshot["store_name"] -class OpenFGAEvents(CharmEvents): +class OpenFGAProviderEvents(CharmEvents): """Custom charm events.""" openfga_store_requested = EventSource(OpenFGAStoreRequestEvent) @@ -315,7 +318,7 @@ class OpenFGAEvents(CharmEvents): class OpenFGAProvider(Object): """Requirer side of the openfga relation.""" - on = OpenFGAEvents() + on = OpenFGAProviderEvents() def __init__(self, charm: CharmBase, relation_name: str = RELATION_NAME): super().__init__(charm, relation_name) @@ -327,8 +330,10 @@ def __init__(self, charm: CharmBase, relation_name: str = RELATION_NAME): self._on_relation_changed, ) - def _on_relation_changed(self, event: RelationChangedEvent): - data = event.relation.data[event.app] + def _on_relation_changed(self, event: RelationChangedEvent) -> None: + if not (app := event.app): + return + data = event.relation.data[app] if not data: logger.info("No relation data available.") return @@ -350,7 +355,7 @@ def update_relation_info( token: Optional[str] = None, token_secret_id: Optional[str] = None, relation_id: Optional[int] = None, - ): + ) -> None: """Update a relation databag.""" if not self.model.unit.is_leader(): return @@ -377,7 +382,7 @@ def update_relation_info( logger.info(msg, exc_info=True) raise DataValidationError(msg) from e - def update_server_info(self, address: str, scheme: str, port: str, dns_name: str): + def update_server_info(self, address: str, scheme: str, port: str, dns_name: str) -> None: """Update all the relations databags with the server info.""" if not self.model.unit.is_leader(): return