diff --git a/charmcraft.yaml b/charmcraft.yaml index 26b33b0..459ec10 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -1,14 +1,15 @@ -# Copyright 2021 Canonical Ltd. +# Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. -type: "charm" +type: charm bases: - build-on: - - name: "ubuntu" - channel: "20.04" + - name: "ubuntu" + channel: "20.04" run-on: - - name: "ubuntu" - channel: "20.04" + - name: "ubuntu" + channel: "20.04" parts: charm: - charm-python-packages: [setuptools, pip] # Fixes install of some packages + charm-python-packages: [setuptools, pip] + build-packages: [cargo, rustc, pkg-config, libffi-dev, libssl-dev] diff --git a/lib/charms/mlops_libs/v0/k8s_service_info.py b/lib/charms/mlops_libs/v0/k8s_service_info.py new file mode 100644 index 0000000..8178477 --- /dev/null +++ b/lib/charms/mlops_libs/v0/k8s_service_info.py @@ -0,0 +1,385 @@ +#!/usr/bin/env python3 +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Library for sharing Kubernetes Services information. + +This library offers a Python API for providing and requesting information about +any Kubernetes Service resource. +The default relation name is `k8s-svc-info` and it's recommended to use that name, +though if changed, you must ensure to pass the correct name when instantiating the +provider and requirer classes, as well as in `metadata.yaml`. + +## Getting Started + +### Fetching the library with charmcraft + +Using charmcraft you can: +```shell +charmcraft fetch-lib charms.mlops_libs.v0.k8s_service_info +``` + +## Using the library as requirer + +### Add relation to metadata.yaml +```yaml +requires: + k8s-svc-info: + interface: k8s-service + limit: 1 +``` + +### Instantiate the KubernetesServiceInfoRequirer class in charm.py + +```python +from ops.charm import CharmBase +from charms.mlops_libs.v0.kubernetes_service_info import KubernetesServiceInfoRequirer, KubernetesServiceInfoRelationError + +class RequirerCharm(CharmBase): + def __init__(self, *args): + self._k8s_svc_info_requirer = KubernetesServiceInfoRequirer(self) + self.framework.observe(self.on.some_event_emitted, self.some_event_function) + + def some_event_function(): + # use the getter function wherever the info is needed + try: + k8s_svc_info_data = self._k8s_svc_info_requirer.get_data() + except KubernetesServiceInfoRelationError as error: + "your error handler goes here" +``` + +## Using the library as provider + +### Add relation to metadata.yaml +```yaml +provides: + k8s-svc-info: + interface: k8s-service +``` + +### Instantiate the KubernetesServiceInfoProvider class in charm.py + +```python +from ops.charm import CharmBase +from charms.mlops_libs.v0.kubernetes_service_info import KubernetesServiceInfoProvider, KubernetesServiceInfoRelationError + +class ProviderCharm(CharmBase): + def __init__(self, *args, **kwargs): + ... + self._k8s_svc_info_provider = KubernetesServiceInfoProvider(self) + self.observe(self.on.some_event, self._some_event_handler) + def _some_event_handler(self, ...): + # This will update the relation data bag with the Service name and port + try: + self._k8s_svc_info_provider.send_data(name, port) + except KubernetesServiceInfoRelationError as error: + "your error handler goes here" +``` + +## Relation data + +The data shared by this library is: +* name: the name of the Kubernetes Service + as it appears in the resource metadata, e.g. "metadata-grpc-service". +* port: the port of the Kubernetes Service +""" + +import logging +from typing import List, Optional, Union + +from ops.charm import CharmBase, RelationEvent +from ops.framework import BoundEvent, EventSource, Object, ObjectEvents +from ops.model import Relation +from pydantic import BaseModel + +# The unique Charmhub library identifier, never change it +LIBID = "f5c3f6cc023e40468d6f9a871e8afcd0" + +# 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 = 1 + +# Default relation and interface names. If changed, consistency must be kept +# across the provider and requirer. +DEFAULT_RELATION_NAME = "k8s-service-info" +DEFAULT_INTERFACE_NAME = "k8s-service" +REQUIRED_ATTRIBUTES = ["name", "port"] + +logger = logging.getLogger(__name__) + + +class KubernetesServiceInfoRelationError(Exception): + """Base exception class for any relation error handled by this library.""" + + pass + + +class KubernetesServiceInfoRelationMissingError(KubernetesServiceInfoRelationError): + """Exception to raise when the relation is missing on either end.""" + + def __init__(self): + self.message = "Missing relation with a k8s service info provider." + super().__init__(self.message) + + +class KubernetesServiceInfoRelationDataMissingError(KubernetesServiceInfoRelationError): + """Exception to raise when there is missing data in the relation data bag.""" + + def __init__(self, message): + self.message = message + super().__init__(self.message) + + +class KubernetesServiceInfoUpdatedEvent(RelationEvent): + """Indicates the Kubernetes Service Info data was updated.""" + + +class KubernetesServiceInfoEvents(ObjectEvents): + """Events for the Kubernetes Service Info library.""" + + updated = EventSource(KubernetesServiceInfoUpdatedEvent) + + +class KubernetesServiceInfoObject(BaseModel): + """Representation of a Kubernetes Service info object. + + Args: + name: The name of the Service + port: The port of the Service + """ + + name: str + port: str + + +class KubernetesServiceInfoRequirer(Object): + """Implement the Requirer end of the Kubernetes Service Info relation. + + Observes the relation events and get data of a related application. + + This library emits: + * KubernetesServiceInfoUpdatedEvent: when data received on the relation is updated. + + Args: + charm (CharmBase): the provider application + refresh_event: (list, optional): list of BoundEvents that this manager should handle. + Use this to update the data sent on this relation on demand. + relation_name (str, optional): the name of the relation + + Attributes: + charm (CharmBase): variable for storing the requirer application + relation_name (str): variable for storing the name of the relation + """ + + on = KubernetesServiceInfoEvents() + + def __init__( + self, + charm: CharmBase, + refresh_event: Optional[Union[BoundEvent, List[BoundEvent]]] = None, + relation_name: Optional[str] = DEFAULT_RELATION_NAME, + ): + super().__init__(charm, relation_name) + self._charm = charm + self._relation_name = relation_name + self._requirer_wrapper = KubernetesServiceInfoRequirerWrapper( + self._charm, self._relation_name + ) + + self.framework.observe( + self._charm.on[self._relation_name].relation_changed, self._on_relation_changed + ) + + self.framework.observe( + self._charm.on[self._relation_name].relation_broken, self._on_relation_broken + ) + + if refresh_event: + if not isinstance(refresh_event, (tuple, list)): + refresh_event = [refresh_event] + for evt in refresh_event: + self.framework.observe(evt, self._on_relation_changed) + + def get_data(self) -> KubernetesServiceInfoObject: + """Return a KubernetesServiceInfoObject.""" + return self._requirer_wrapper.get_data() + + def _on_relation_changed(self, event: BoundEvent) -> None: + """Handle relation-changed event for this relation.""" + self.on.updated.emit(event.relation) + + def _on_relation_broken(self, event: BoundEvent) -> None: + """Handle relation-broken event for this relation.""" + self.on.updated.emit(event.relation) + + +class KubernetesServiceInfoRequirerWrapper(Object): + """Wrapper for the relation data getting logic. + + Args: + charm (CharmBase): the requirer application + relation_name (str, optional): the name of the relation + + Attributes: + relation_name (str): variable for storing the name of the relation + """ + + def __init__(self, charm, relation_name: Optional[str] = DEFAULT_RELATION_NAME): + super().__init__(charm, relation_name) + self.relation_name = relation_name + + @staticmethod + def _validate_relation(relation: Relation) -> None: + """Series of checks for the relation and relation data. + + Args: + relation (Relation): the relation object to run the checks on + + Raises: + KubernetesServiceInfoRelationDataMissingError if data is missing or incomplete + KubernetesServiceInfoRelationMissingError: if there is no related application + """ + # Raise if there is no related application + if not relation: + raise KubernetesServiceInfoRelationMissingError() + + # Extract remote app information from relation + remote_app = relation.app + # Get relation data from remote app + relation_data = relation.data[remote_app] + + # Raise if there is no data found in the relation data bag + if not relation_data: + raise KubernetesServiceInfoRelationDataMissingError( + f"No data found in relation {relation.name} data bag." + ) + + # Check if the relation data contains the expected attributes + missing_attributes = [ + attribute for attribute in REQUIRED_ATTRIBUTES if attribute not in relation_data + ] + if missing_attributes: + raise KubernetesServiceInfoRelationDataMissingError( + f"Missing attributes: {missing_attributes} in relation {relation.name}" + ) + + def get_data(self) -> KubernetesServiceInfoObject: + """Return a KubernetesServiceInfoObject containing Kubernetes Service information. + + Raises: + KubernetesServiceInfoRelationDataMissingError: if data is missing entirely or some attributes + KubernetesServiceInfoRelationMissingError: if there is no related application + ops.model.TooManyRelatedAppsError: if there is more than one related application + """ + # Validate relation data + # Raises TooManyRelatedAppsError if related to more than one app + relation = self.model.get_relation(self.relation_name) + + self._validate_relation(relation=relation) + + # Get relation data from remote app + relation_data = relation.data[relation.app] + + return KubernetesServiceInfoObject(name=relation_data["name"], port=relation_data["port"]) + + +class KubernetesServiceInfoProvider(Object): + """Implement the Provider end of the Kubernetes Service Info relation. + + Observes relation events to send data to related applications. + + Args: + charm (CharmBase): the provider application + name (str): the name of the Kubernetes Service the provider knows about + port (str): the port number of the Kubernetes Service the provider knows about + refresh_event: (list, optional): list of BoundEvents that this manager should handle. Use this to update + the data sent on this relation on demand. + relation_name (str, optional): the name of the relation + + Attributes: + charm (CharmBase): variable for storing the provider application + relation_name (str): variable for storing the name of the relation + """ + + def __init__( + self, + charm: CharmBase, + name: str, + port: str, + refresh_event: Optional[Union[BoundEvent, List[BoundEvent]]] = None, + relation_name: Optional[str] = DEFAULT_RELATION_NAME, + ): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self._provider_wrapper = KubernetesServiceInfoProviderWrapper( + self.charm, self.relation_name + ) + self._svc_name = name + self._svc_port = port + + self.framework.observe(self.charm.on.leader_elected, self._send_data) + + self.framework.observe(self.charm.on[self.relation_name].relation_created, self._send_data) + + if refresh_event: + if not isinstance(refresh_event, (tuple, list)): + refresh_event = [refresh_event] + for evt in refresh_event: + self.framework.observe(evt, self._send_data) + + def _send_data(self, _) -> None: + """Serve as an event handler for sending the Kubernetes Service information.""" + self._provider_wrapper.send_data(self._svc_name, self._svc_port) + + +class KubernetesServiceInfoProviderWrapper(Object): + """Wrapper for the relation data sending logic. + + Args: + charm (CharmBase): the provider application + relation_name (str, optional): the name of the relation + + Attributes: + charm (CharmBase): variable for storing the provider application + relation_name (str): variable for storing the name of the relation + """ + + def __init__(self, charm: CharmBase, relation_name: Optional[str] = DEFAULT_RELATION_NAME): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + + def send_data( + self, + name: str, + port: str, + ) -> None: + """Update the relation data bag with data from a Kubernetes Service. + + This method will complete successfully even if there are no related applications. + + Args: + name (str): the name of the Kubernetes Service the provider knows about + port (str): the port number of the Kubernetes Service the provider knows about + """ + # Validate unit is leader to send data; otherwise return + if not self.charm.model.unit.is_leader(): + logger.info( + "KubernetesServiceInfoProvider handled send_data event when it is not the leader." + "Skipping event - no data sent." + ) + # Update the relation data bag with a Kubernetes Service information + relations = self.charm.model.relations[self.relation_name] + + # Update relation data + for relation in relations: + relation.data[self.charm.app].update( + { + "name": name, + "port": port, + } + ) diff --git a/metadata.yaml b/metadata.yaml index b0606a3..88ef0ba 100755 --- a/metadata.yaml +++ b/metadata.yaml @@ -19,9 +19,7 @@ provides: interface: grafana_dashboard requires: grpc: - interface: grpc - schema: https://raw.githubusercontent.com/canonical/operator-schemas/master/grpc.yaml - versions: [v1] + interface: k8s-service ingress: interface: ingress schema: diff --git a/requirements-unit.txt b/requirements-unit.txt index 36f4509..1e9645f 100644 --- a/requirements-unit.txt +++ b/requirements-unit.txt @@ -4,6 +4,10 @@ # # pip-compile requirements-unit.in # +annotated-types==0.6.0 + # via + # -r requirements.txt + # pydantic anyio==4.3.0 # via # -r requirements.txt @@ -127,6 +131,12 @@ pkgutil-resolve-name==1.3.10 # jsonschema pluggy==1.4.0 # via pytest +pydantic==2.7.0 + # via -r requirements.txt +pydantic-core==2.18.1 + # via + # -r requirements.txt + # pydantic pyrsistent==0.20.0 # via # -r requirements.txt @@ -185,8 +195,11 @@ tomli==2.0.1 typing-extensions==4.10.0 # via # -r requirements.txt + # annotated-types # anyio # cosl + # pydantic + # pydantic-core urllib3==2.2.1 # via # -r requirements.txt diff --git a/requirements.in b/requirements.in index 5e26272..5de35d6 100644 --- a/requirements.in +++ b/requirements.in @@ -1,6 +1,5 @@ # Copyright 2021 Canonical Ltd. # See LICENSE file for licensing details. - charmed-kubeflow-chisme >= 0.3.0 # from observability_libs.v0.juju_topology.py cosl @@ -10,3 +9,5 @@ cosl # if unpinned causes problems with installation resulting in module 'platform' has no attribute 'dist' oci-image ops +# This is a dependency of the k8s_service_info lib +pydantic diff --git a/requirements.txt b/requirements.txt index 2158cdb..99e7e5c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,8 @@ # # pip-compile requirements.in # +annotated-types==0.6.0 + # via pydantic anyio==4.3.0 # via httpx attrs==23.2.0 @@ -72,6 +74,10 @@ ordered-set==4.1.0 # via deepdiff pkgutil-resolve-name==1.3.10 # via jsonschema +pydantic==2.7.0 + # via -r requirements.in +pydantic-core==2.18.1 + # via pydantic pyrsistent==0.20.0 # via jsonschema python-dateutil==2.9.0.post0 @@ -100,8 +106,11 @@ tenacity==8.2.3 # via charmed-kubeflow-chisme typing-extensions==4.10.0 # via + # annotated-types # anyio # cosl + # pydantic + # pydantic-core urllib3==2.2.1 # via requests websocket-client==1.7.0 diff --git a/src/charm.py b/src/charm.py index f14be23..ac8d128 100755 --- a/src/charm.py +++ b/src/charm.py @@ -6,7 +6,6 @@ CharmReconciler, LeadershipGateComponent, SdiRelationBroadcasterComponent, - SdiRelationDataReceiverComponent, ) from charmed_kubeflow_chisme.components.pebble_component import LazyContainerFileTemplate from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider @@ -18,9 +17,11 @@ from components.config_generation import GenerateEnvoyConfig, GenerateEnvoyConfigInputs from components.ingress import IngressRelationWarnIfMissing, IngressRelationWarnIfMissingInputs +from components.k8s_service_info_component import K8sServiceInfoComponent from components.pebble import EnvoyPebbleService, EnvoyPebbleServiceInputs ENVOY_CONFIG_FILE_PATH = "/envoy/envoy.json" +GRPC_RELATION_NAME = "grpc" METRICS_PATH = "/stats/prometheus" @@ -37,13 +38,10 @@ def __init__(self, *args): ) ) - # TODO before ckf 1.9: Change this from SDI to the service-info lib - # https://github.com/canonical/envoy-operator/issues/76 self.grpc = self.charm_reconciler.add( - component=SdiRelationDataReceiverComponent( + component=K8sServiceInfoComponent( charm=self, - name="relation:grpc", - relation_name="grpc", + relation_name=GRPC_RELATION_NAME, ), depends_on=[self.leadership_gate], ) @@ -87,8 +85,8 @@ def __init__(self, *args): inputs_getter=lambda: GenerateEnvoyConfigInputs( admin_port=int(self.config["admin-port"]), http_port=int(self.config["http-port"]), - upstream_service=self.grpc.component.get_data()["service"], - upstream_port=self.grpc.component.get_data()["port"], + upstream_service=self.grpc.component.get_service_info().name, + upstream_port=self.grpc.component.get_service_info().port, ), ), depends_on=[self.grpc], diff --git a/src/components/k8s_service_info_component.py b/src/components/k8s_service_info_component.py new file mode 100644 index 0000000..85835cc --- /dev/null +++ b/src/components/k8s_service_info_component.py @@ -0,0 +1,55 @@ +#!/usr/bin/env python3 +# Copyright 2024 Ubuntu +# See LICENSE file for licensing details. + +from typing import List, Optional, Union + +from charmed_kubeflow_chisme.components.component import Component +from charms.mlops_libs.v0.k8s_service_info import ( + KubernetesServiceInfoObject, + KubernetesServiceInfoRelationDataMissingError, + KubernetesServiceInfoRelationMissingError, + KubernetesServiceInfoRequirer, +) +from ops import ActiveStatus, BlockedStatus, CharmBase, StatusBase +from ops.framework import BoundEvent + + +class K8sServiceInfoComponent(Component): + """A Component that wraps the requirer side of the k8s_service_info charm library. + + Args: + charm(CharmBase): the requirer charm + relation_name(str, Optional): name of the relation that uses the k8s-service interface + """ + + def __init__( + self, + charm: CharmBase, + relation_name: Optional[str] = "k8s-service-info", + refresh_event: Optional[Union[BoundEvent, List[BoundEvent]]] = None, + ): + super().__init__(charm, relation_name) + self.relation_name = relation_name + self.charm = charm + self.refresh_event = refresh_event + + self._k8s_service_info_requirer = KubernetesServiceInfoRequirer( + charm=self.charm, relation_name=self.relation_name, refresh_event=self.refresh_event + ) + + def get_service_info(self) -> KubernetesServiceInfoObject: + """Wrap the get_data method and return a KubernetesServiceInfoObject.""" + return self._k8s_service_info_requirer.get_data() + + def get_status(self) -> StatusBase: + """Return this component's status based on the presence of the relation and its data.""" + try: + self.get_service_info() + except KubernetesServiceInfoRelationMissingError as rel_error: + return BlockedStatus(f"{rel_error.message} Please add the missing relation.") + except KubernetesServiceInfoRelationDataMissingError as data_error: + # Nothing can be done, just re-raise + raise data_error + else: + return ActiveStatus() diff --git a/tests/integration/test_charm.py b/tests/integration/test_charm.py index 7c42873..3c81bf8 100644 --- a/tests/integration/test_charm.py +++ b/tests/integration/test_charm.py @@ -20,7 +20,7 @@ MLMD = "mlmd" MLMD_CHANNEL = "latest/edge" -MLMD_TRUST = False +MLMD_TRUST = True ISTIO_OPERATORS_CHANNEL = "latest/edge" ISTIO_PILOT = "istio-pilot" diff --git a/tests/unit/test_charm.py b/tests/unit/test_charm.py index 7196472..ef5fe38 100644 --- a/tests/unit/test_charm.py +++ b/tests/unit/test_charm.py @@ -2,12 +2,13 @@ # See LICENSE file for licensing details. import pytest -import yaml from ops import BlockedStatus -from ops.model import ActiveStatus +from ops.model import ActiveStatus, TooManyRelatedAppsError from ops.testing import Harness -from charm import EnvoyOperator +from charm import GRPC_RELATION_NAME, EnvoyOperator + +MOCK_GRPC_DATA = {"name": "service-name", "port": "1234"} @pytest.fixture @@ -26,7 +27,6 @@ def mocked_kubernetes_service_patch(mocker): class TestCharm: - def test_not_leader(self, harness): """Test that the charm is not active when not leader.""" harness.begin_with_initial_hooks() @@ -39,7 +39,7 @@ def test_no_grpc_relation(self, harness): harness.begin_with_initial_hooks() assert ( - "Expected data from exactly 1 related applications - got 0" + "Missing relation with a k8s service info provider. Please add the missing relation." in harness.charm.grpc.status.message ) assert isinstance(harness.charm.grpc.status, BlockedStatus) @@ -53,13 +53,13 @@ def test_many_relations(self, harness): setup_grpc_relation(harness, "grpc-two", "9090") # In order to avoid the charm going to Blocked setup_ingress_relation(harness) + harness.begin_with_initial_hooks() - assert ( - "Expected data from at most 1 related applications - got 2" - in harness.charm.grpc.status.message - ) - assert isinstance(harness.charm.grpc.status, BlockedStatus) + with pytest.raises(TooManyRelatedAppsError) as error: + harness.charm.grpc.get_status() + + assert "Too many remote applications on grpc (2 > 1)" in error.value.args assert not isinstance(harness.charm.model.unit.status, ActiveStatus) def test_with_grpc_relation(self, harness): @@ -136,7 +136,7 @@ def test_envoy_config_generator_if_grpc_get_data_returns_empty_dict(self, harnes harness.begin_with_initial_hooks() # Now mock out the grpc relation's get_data to return an empty dict - harness.charm.grpc.component.get_data = lambda: {} + harness.charm.grpc.component.get_service_info = lambda: {} assert isinstance(harness.charm.envoy_config_generator.status, BlockedStatus) @@ -167,11 +167,8 @@ def setup_ingress_relation(harness: Harness): def setup_grpc_relation(harness: Harness, name: str, port: str): rel_id = harness.add_relation( - relation_name="grpc", + relation_name=GRPC_RELATION_NAME, remote_app=name, - app_data={ - "_supported_versions": "- v1", - "data": yaml.dump({"service": name, "port": port}), - }, + app_data=MOCK_GRPC_DATA, ) return rel_id