diff --git a/charms/kfp-metadata-writer/charmcraft.yaml b/charms/kfp-metadata-writer/charmcraft.yaml index 6e7253e7..bbc7cf93 100644 --- a/charms/kfp-metadata-writer/charmcraft.yaml +++ b/charms/kfp-metadata-writer/charmcraft.yaml @@ -1,6 +1,7 @@ -# Learn more about charmcraft.yaml configuration at: -# https://juju.is/docs/sdk/charmcraft-config -type: "charm" +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +type: charm bases: - build-on: - name: "ubuntu" @@ -10,8 +11,12 @@ bases: channel: "20.04" parts: charm: + # These build-packages are defined here because pydantic needs them + # at build time. As long as pydantic is listed in requirements.in, this + # list cannot be removed/changed. + build-packages: [cargo, rustc, pkg-config, libffi-dev, libssl-dev] charm-python-packages: [setuptools, pip] # Fixes install of some packages # Install jinja2 (a dependency of charmed-kubeflow-chisme) from binary to avoid build-time issues # See https://github.com/canonical/bundle-kubeflow/issues/883 # Remove when https://github.com/canonical/charmcraft/issues/1664 is fixed - charm-binary-python-packages: [jinja2] + charm-binary-python-packages: [jinja2] \ No newline at end of file diff --git a/charms/kfp-metadata-writer/lib/charms/mlops_libs/v0/k8s_service_info.py b/charms/kfp-metadata-writer/lib/charms/mlops_libs/v0/k8s_service_info.py new file mode 100644 index 00000000..81784778 --- /dev/null +++ b/charms/kfp-metadata-writer/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/charms/kfp-metadata-writer/metadata.yaml b/charms/kfp-metadata-writer/metadata.yaml index 3b8c0b25..30cc9c6b 100755 --- a/charms/kfp-metadata-writer/metadata.yaml +++ b/charms/kfp-metadata-writer/metadata.yaml @@ -16,18 +16,4 @@ resources: upstream-source: gcr.io/ml-pipeline/metadata-writer:2.0.3 requires: grpc: - interface: grpc - schema: - v1: - provides: - type: object - properties: - service: - type: string - port: - type: string - required: - - service - - port - versions: [v1] - __schema_source: https://raw.githubusercontent.com/canonical/operator-schemas/master/grpc.yaml + interface: k8s-service diff --git a/charms/kfp-metadata-writer/requirements-unit.in b/charms/kfp-metadata-writer/requirements-unit.in index 82a8e551..d6873440 100644 --- a/charms/kfp-metadata-writer/requirements-unit.in +++ b/charms/kfp-metadata-writer/requirements-unit.in @@ -1,21 +1,8 @@ -# Copyright 2023 Canonical Ltd. +# Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. -# Please note this file introduces dependencies from the charm's requirements.in, -# special attention must be taken when updating this or the other .in file to try -# to avoid incompatibilities. -# Rules for editing this file: -# * Removing a dependency that is no longer used in the unit test file(s) -# is allowed, and should not represent any risk. -# * Adding a dependency in this file means the dependency is directly used -# in the unit test files(s). -# * ALL python packages/libs used directly in the unit test file(s) must be -# listed here even if requirements.in is already adding them. This will -# add clarity to the dependency list. -# * Pinning a version of a python package/lib shared with requirements.in -# must not introduce any incompatibilities. coverage ops pytest pytest-mock pyyaml --r requirements.in +-r requirements.txt diff --git a/charms/kfp-metadata-writer/requirements-unit.txt b/charms/kfp-metadata-writer/requirements-unit.txt index 7fd4bc63..cbb5f08a 100644 --- a/charms/kfp-metadata-writer/requirements-unit.txt +++ b/charms/kfp-metadata-writer/requirements-unit.txt @@ -4,70 +4,113 @@ # # pip-compile requirements-unit.in # +annotated-types==0.6.0 + # via + # -r requirements.txt + # pydantic anyio==4.0.0 - # via httpcore + # via + # -r requirements.txt + # httpcore attrs==23.1.0 - # via jsonschema + # via + # -r requirements.txt + # jsonschema certifi==2023.7.22 # via + # -r requirements.txt # httpcore # httpx # requests charmed-kubeflow-chisme==0.2.0 - # via -r requirements.in + # via -r requirements.txt charset-normalizer==3.3.0 - # via requests + # via + # -r requirements.txt + # requests coverage==7.3.2 # via -r requirements-unit.in deepdiff==6.2.1 - # via charmed-kubeflow-chisme + # via + # -r requirements.txt + # charmed-kubeflow-chisme exceptiongroup==1.1.3 # via + # -r requirements.txt # anyio # pytest h11==0.14.0 - # via httpcore + # via + # -r requirements.txt + # httpcore httpcore==0.18.0 - # via httpx + # via + # -r requirements.txt + # httpx httpx==0.25.0 - # via lightkube + # via + # -r requirements.txt + # lightkube idna==3.4 # via + # -r requirements.txt # anyio # httpx # requests importlib-resources==6.1.0 - # via jsonschema + # via + # -r requirements.txt + # jsonschema iniconfig==2.0.0 # via pytest jinja2==3.1.2 - # via charmed-kubeflow-chisme + # via + # -r requirements.txt + # charmed-kubeflow-chisme jsonschema==4.17.3 - # via serialized-data-interface + # via + # -r requirements.txt + # serialized-data-interface lightkube==0.14.0 # via - # -r requirements.in + # -r requirements.txt # charmed-kubeflow-chisme lightkube-models==1.28.1.4 - # via lightkube + # via + # -r requirements.txt + # lightkube markupsafe==2.1.3 - # via jinja2 + # via + # -r requirements.txt + # jinja2 ops==2.7.0 # via # -r requirements-unit.in - # -r requirements.in + # -r requirements.txt # charmed-kubeflow-chisme # serialized-data-interface ordered-set==4.1.0 - # via deepdiff + # via + # -r requirements.txt + # deepdiff packaging==23.2 # via pytest pkgutil-resolve-name==1.3.10 - # via jsonschema + # via + # -r requirements.txt + # jsonschema pluggy==1.3.0 # via pytest +pydantic==2.6.4 + # via -r requirements.txt +pydantic-core==2.16.3 + # via + # -r requirements.txt + # pydantic pyrsistent==0.19.3 - # via jsonschema + # via + # -r requirements.txt + # jsonschema pytest==7.4.2 # via # -r requirements-unit.in @@ -77,31 +120,53 @@ pytest-mock==3.11.1 pyyaml==6.0.1 # via # -r requirements-unit.in + # -r requirements.txt # lightkube # ops # serialized-data-interface requests==2.31.0 - # via serialized-data-interface + # via + # -r requirements.txt + # serialized-data-interface ruamel-yaml==0.17.35 - # via charmed-kubeflow-chisme + # via + # -r requirements.txt + # charmed-kubeflow-chisme ruamel-yaml-clib==0.2.8 - # via ruamel-yaml + # via + # -r requirements.txt + # ruamel-yaml serialized-data-interface==0.7.0 # via - # -r requirements.in + # -r requirements.txt # charmed-kubeflow-chisme sniffio==1.3.0 # via + # -r requirements.txt # anyio # httpcore # httpx tenacity==8.2.3 - # via charmed-kubeflow-chisme + # via + # -r requirements.txt + # charmed-kubeflow-chisme tomli==2.0.1 # via pytest +typing-extensions==4.11.0 + # via + # -r requirements.txt + # annotated-types + # pydantic + # pydantic-core urllib3==2.0.6 - # via requests + # via + # -r requirements.txt + # requests websocket-client==1.6.4 - # via ops + # via + # -r requirements.txt + # ops zipp==3.17.0 - # via importlib-resources + # via + # -r requirements.txt + # importlib-resources diff --git a/charms/kfp-metadata-writer/requirements.in b/charms/kfp-metadata-writer/requirements.in index 5d329801..0d3542eb 100644 --- a/charms/kfp-metadata-writer/requirements.in +++ b/charms/kfp-metadata-writer/requirements.in @@ -4,4 +4,10 @@ charmed-kubeflow-chisme >= 0.2.0 lightkube ops +# pydantic>=2.7 requires rustc v1.76 or newer, +# which is not available in the base OS this charm has at the moment (Ubuntu 20.04). +# To avoid build-time errors, pydantic has to be pinned to a version that can be built +# with the rustc version that the OS can provide. +# Remove this pin when the base OS can install rustc v1.76 or newer. +pydantic>=2.6,<2.7 serialized-data-interface diff --git a/charms/kfp-metadata-writer/requirements.txt b/charms/kfp-metadata-writer/requirements.txt index de6c5187..0d1768c4 100644 --- a/charms/kfp-metadata-writer/requirements.txt +++ b/charms/kfp-metadata-writer/requirements.txt @@ -4,6 +4,8 @@ # # pip-compile requirements.in # +annotated-types==0.6.0 + # via pydantic anyio==4.0.0 # via httpcore attrs==23.1.0 @@ -55,6 +57,10 @@ ordered-set==4.1.0 # via deepdiff pkgutil-resolve-name==1.3.10 # via jsonschema +pydantic==2.6.4 + # via -r requirements.in +pydantic-core==2.16.3 + # via pydantic pyrsistent==0.19.3 # via jsonschema pyyaml==6.0.1 @@ -79,6 +85,11 @@ sniffio==1.3.0 # httpx tenacity==8.2.3 # via charmed-kubeflow-chisme +typing-extensions==4.11.0 + # via + # annotated-types + # pydantic + # pydantic-core urllib3==2.0.6 # via requests websocket-client==1.6.4 diff --git a/charms/kfp-metadata-writer/src/charm.py b/charms/kfp-metadata-writer/src/charm.py index a5617132..a9c7d90c 100755 --- a/charms/kfp-metadata-writer/src/charm.py +++ b/charms/kfp-metadata-writer/src/charm.py @@ -13,19 +13,18 @@ from charmed_kubeflow_chisme.components.charm_reconciler import CharmReconciler from charmed_kubeflow_chisme.components.kubernetes_component import KubernetesComponent from charmed_kubeflow_chisme.components.leadership_gate_component import LeadershipGateComponent -from charmed_kubeflow_chisme.components.serialised_data_interface_components import ( - SdiRelationDataReceiverComponent, -) from charmed_kubeflow_chisme.kubernetes import create_charm_default_labels from lightkube.resources.core_v1 import ServiceAccount from lightkube.resources.rbac_authorization_v1 import ClusterRole, ClusterRoleBinding from ops.charm import CharmBase from ops.main import main +from components.k8s_service_info_requirer_component import K8sServiceInfoRequirerComponent from components.pebble_component import KfpMetadataWriterInputs, KfpMetadataWriterPebbleService logger = logging.getLogger(__name__) +GRPC_RELATION_NAME = "grpc" K8S_RESOURCE_FILES = ["src/templates/auth_manifests.yaml.j2"] @@ -46,8 +45,9 @@ def __init__(self, *args): ) self.grpc_relation = self.charm_reconciler.add( - component=SdiRelationDataReceiverComponent( - charm=self, name="relation:grpc", relation_name="grpc" + component=K8sServiceInfoRequirerComponent( + charm=self, + relation_name=GRPC_RELATION_NAME, ), depends_on=[self.leadership_gate], ) @@ -75,12 +75,8 @@ def __init__(self, *args): service_name="kfp-metadata-writer", namespace_to_watch="", inputs_getter=lambda: KfpMetadataWriterInputs( - METADATA_GRPC_SERVICE_SERVICE_HOST=self.grpc_relation.component.get_data()[ - "service" - ], - METADATA_GRPC_SERVICE_SERVICE_PORT=self.grpc_relation.component.get_data()[ - "port" - ], + METADATA_GRPC_SERVICE_SERVICE_HOST=self.grpc_relation.component.get_service_info().name, # noqa + METADATA_GRPC_SERVICE_SERVICE_PORT=self.grpc_relation.component.get_service_info().port, # noqa ), ), depends_on=[self.kubernetes_resources, self.grpc_relation], diff --git a/charms/kfp-metadata-writer/src/components/k8s_service_info_requirer_component.py b/charms/kfp-metadata-writer/src/components/k8s_service_info_requirer_component.py new file mode 100644 index 00000000..5767f35f --- /dev/null +++ b/charms/kfp-metadata-writer/src/components/k8s_service_info_requirer_component.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +# Copyright 2024 Ubuntu +# See LICENSE file for licensing details. + +import logging +from typing import Optional + +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, WaitingStatus + +logger = logging.getLogger(__name__) + + +class K8sServiceInfoRequirerComponent(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", + ): + super().__init__(charm, relation_name) + self.relation_name = relation_name + self.charm = charm + + self._k8s_service_info_requirer = KubernetesServiceInfoRequirer( + charm=self.charm, + relation_name=self.relation_name, + ) + + self._events_to_observe = [self._k8s_service_info_requirer.on.updated] + + 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: + logger.error(f"Empty or missing data. Got: {data_error.message}") + return WaitingStatus( + f"Empty or missing data in {self.relation_name} relation." + " This may be transient, but if it persists it is likely an error." + ) + return ActiveStatus() diff --git a/charms/kfp-metadata-writer/tests/unit/test_operator.py b/charms/kfp-metadata-writer/tests/unit/test_operator.py index 7229deec..fb37902a 100644 --- a/charms/kfp-metadata-writer/tests/unit/test_operator.py +++ b/charms/kfp-metadata-writer/tests/unit/test_operator.py @@ -4,13 +4,12 @@ from unittest.mock import MagicMock import pytest -from charmed_kubeflow_chisme.testing import add_sdi_relation_to_harness from ops.model import ActiveStatus, BlockedStatus, WaitingStatus from ops.testing import Harness -from charm import KfpMetadataWriter +from charm import GRPC_RELATION_NAME, KfpMetadataWriter -MOCK_GRPC_DATA = {"service": "service-name", "port": "1234"} +MOCK_GRPC_DATA = {"name": "service-name", "port": "1234"} def test_not_leader( @@ -54,14 +53,17 @@ def test_grpc_relation_with_data(harness, mocked_lightkube_client): harness.charm.leadership_gate.get_status = MagicMock(return_value=ActiveStatus()) # Add relation with data. This should trigger a charm reconciliation due to relation-changed. - add_sdi_relation_to_harness(harness, "grpc", data=MOCK_GRPC_DATA) + harness.add_relation( + relation_name=GRPC_RELATION_NAME, remote_app="other-app", app_data=MOCK_GRPC_DATA + ) # Assert - assert isinstance(harness.charm.grpc_relation.status, ActiveStatus) + assert harness.charm.grpc_relation.component.get_service_info().name == MOCK_GRPC_DATA["name"] + assert harness.charm.grpc_relation.component.get_service_info().port == MOCK_GRPC_DATA["port"] -def test_grpc_relation_without_data(harness, mocked_lightkube_client): - """Test that the grpc relation goes Blocked if no data is available.""" +def test_grpc_relation_with_empty_data(harness, mocked_lightkube_client): + """Test the grpc relation component returns WaitingStatus when data is missing.""" # Arrange harness.begin() @@ -69,11 +71,31 @@ def test_grpc_relation_without_data(harness, mocked_lightkube_client): # * leadership_gate to be active and executed harness.charm.leadership_gate.get_status = MagicMock(return_value=ActiveStatus()) - # Add relation with data. This should trigger a charm reconciliation due to relation-changed. - add_sdi_relation_to_harness(harness, "grpc", data={}) + harness.charm.on.install.emit() + + # Add relation without data. + harness.add_relation(relation_name=GRPC_RELATION_NAME, remote_app="other-app", app_data={}) + + assert isinstance(harness.charm.grpc_relation.get_status(), WaitingStatus) - # Assert - assert isinstance(harness.charm.grpc_relation.status, BlockedStatus) + +def test_grpc_relation_with_missing_data(harness, mocked_lightkube_client): + """Test the grpc relation component returns WaitingStatus when data is incomplete.""" + # Arrange + harness.begin() + + # Mock: + # * leadership_gate to be active and executed + harness.charm.leadership_gate.get_status = MagicMock(return_value=ActiveStatus()) + + harness.charm.on.install.emit() + + # Add relation without data. + harness.add_relation( + relation_name=GRPC_RELATION_NAME, remote_app="other-app", app_data={"name": "some-name"} + ) + + assert isinstance(harness.charm.grpc_relation.component.get_status(), WaitingStatus) def test_grpc_relation_without_relation(harness, mocked_lightkube_client): @@ -90,6 +112,10 @@ def test_grpc_relation_without_relation(harness, mocked_lightkube_client): # Assert assert isinstance(harness.charm.grpc_relation.status, BlockedStatus) + assert ( + harness.charm.grpc_relation.status.message + == "Missing relation with a k8s service info provider. Please add the missing relation." + ) def test_pebble_service_container_running(harness, mocked_lightkube_client): @@ -98,11 +124,13 @@ def test_pebble_service_container_running(harness, mocked_lightkube_client): harness.begin() harness.set_can_connect("kfp-metadata-writer", True) - harness.charm.kubernetes_resources.get_status = MagicMock(return_value=ActiveStatus()) - add_sdi_relation_to_harness(harness, "grpc", data=MOCK_GRPC_DATA) - harness.charm.on.install.emit() + harness.charm.kubernetes_resources.get_status = MagicMock(return_value=ActiveStatus()) + harness.add_relation( + relation_name=GRPC_RELATION_NAME, remote_app="other-app", app_data=MOCK_GRPC_DATA + ) + assert isinstance(harness.charm.unit.status, ActiveStatus) container = harness.charm.unit.get_container("kfp-metadata-writer") @@ -114,11 +142,11 @@ def test_pebble_service_container_running(harness, mocked_lightkube_client): assert environment["NAMESPACE_TO_WATCH"] == "" assert ( environment["METADATA_GRPC_SERVICE_SERVICE_HOST"] - == harness.charm.grpc_relation.component.get_data()["service"] + == harness.charm.grpc_relation.component.get_service_info().name ) assert ( environment["METADATA_GRPC_SERVICE_SERVICE_PORT"] - == harness.charm.grpc_relation.component.get_data()["port"] + == harness.charm.grpc_relation.component.get_service_info().port ) @@ -128,7 +156,9 @@ def test_install_before_pebble_service_container(harness, mocked_lightkube_clien harness.begin() harness.charm.kubernetes_resources.get_status = MagicMock(return_value=ActiveStatus()) - add_sdi_relation_to_harness(harness, "grpc", data=MOCK_GRPC_DATA) + harness.add_relation( + relation_name=GRPC_RELATION_NAME, remote_app="other-app", app_data=MOCK_GRPC_DATA + ) harness.charm.on.install.emit() diff --git a/tests/integration/bundles/kfp_latest_edge.yaml.j2 b/tests/integration/bundles/kfp_latest_edge.yaml.j2 index abeb0c40..63e91c97 100644 --- a/tests/integration/bundles/kfp_latest_edge.yaml.j2 +++ b/tests/integration/bundles/kfp_latest_edge.yaml.j2 @@ -6,7 +6,7 @@ applications: minio: { charm: ch:minio, channel: latest/edge, scale: 1 } # We should use `8.0/stable` once changes for https://github.com/canonical/mysql-k8s-operator/issues/337 are published there. kfp-db: { charm: ch:mysql-k8s, channel: 8.0/edge, scale: 1, constraints: mem=2G, trust: true } - mlmd: { charm: ch:mlmd, channel: latest/edge, scale: 1 } + mlmd: { charm: ch:mlmd, channel: latest/edge, scale: 1, trust: true} envoy: { charm: ch:envoy, channel: latest/edge, scale: 1 } kubeflow-profiles: { charm: ch:kubeflow-profiles, channel: latest/edge, scale: 1, trust: true } istio-ingressgateway: