diff --git a/charmcraft.yaml b/charmcraft.yaml index d7980af..b3fae56 100644 --- a/charmcraft.yaml +++ b/charmcraft.yaml @@ -1,4 +1,4 @@ -# Copyright 2021 Canonical Ltd. +# Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. type: charm diff --git a/config.yaml b/config.yaml index bfeb594..af95f16 100644 --- a/config.yaml +++ b/config.yaml @@ -2,7 +2,7 @@ # See LICENSE file for licensing details. options: - port: + grpc-port: type: string default: '8080' description: GRPC port 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 e8e19d3..fc88971 100755 --- a/metadata.yaml +++ b/metadata.yaml @@ -1,10 +1,14 @@ -# Copyright 2021 Canonical Ltd. +# Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. name: mlmd summary: Record and retrieve metadata associated with ML workflows -description: https://www.tensorflow.org/tfx/guide/mlmd -series: [kubernetes] -min-juju-version: "2.9.0" +description: | + The ML Metadata operator provides means to store runtime information of a pipeline run in a + Metadata store. This information includes status of a task, availability of artifacts, custom properties, etc. + More info in https://github.com/google/ml-metadata/blob/master/g3doc/get_started.md +containers: + mlmd-grpc-server: + resource: oci-image resources: oci-image: type: oci-image @@ -12,27 +16,9 @@ resources: auto-fetch: true upstream-source: gcr.io/tfx-oss-public/ml_metadata_store_server:1.14.0 provides: - 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 + k8s-svc-info: + interface: k8s-service requires: - mysql: - interface: mysql -storage: - mlmd-data: - type: filesystem - location: /data - minimum-size: 10G + relational-db: + interface: mysql_client + limit: 1 diff --git a/requirements-fmt.in b/requirements-fmt.in deleted file mode 100644 index 7559a40..0000000 --- a/requirements-fmt.in +++ /dev/null @@ -1,2 +0,0 @@ -black -isort diff --git a/requirements-fmt.txt b/requirements-fmt.txt deleted file mode 100644 index 090e6eb..0000000 --- a/requirements-fmt.txt +++ /dev/null @@ -1,24 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.8 -# by the following command: -# -# pip-compile ./requirements-fmt.in -# -black==23.7.0 - # via -r ./requirements-fmt.in -click==8.1.6 - # via black -isort==5.12.0 - # via -r ./requirements-fmt.in -mypy-extensions==1.0.0 - # via black -packaging==23.1 - # via black -pathspec==0.11.2 - # via black -platformdirs==3.10.0 - # via black -tomli==2.0.1 - # via black -typing-extensions==4.7.1 - # via black diff --git a/requirements-integration.in b/requirements-integration.in deleted file mode 100644 index fb0b5a5..0000000 --- a/requirements-integration.in +++ /dev/null @@ -1,7 +0,0 @@ -aiohttp -jinja2 -# Pinning to <4.0 due to compatibility with the 3.1 controller version -juju<4.0 -pytest-operator -requests --r requirements.txt diff --git a/requirements-integration.txt b/requirements-integration.txt deleted file mode 100644 index 80eaeaa..0000000 --- a/requirements-integration.txt +++ /dev/null @@ -1,244 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.8 -# by the following command: -# -# pip-compile requirements-integration.in -# -aiohttp==3.8.5 - # via -r requirements-integration.in -aiosignal==1.3.1 - # via aiohttp -asttokens==2.4.0 - # via stack-data -async-timeout==4.0.3 - # via aiohttp -attrs==23.1.0 - # via - # -r requirements.txt - # aiohttp - # jsonschema -backcall==0.2.0 - # via ipython -bcrypt==4.0.1 - # via paramiko -cachetools==5.3.1 - # via google-auth -certifi==2023.7.22 - # via - # -r requirements.txt - # kubernetes - # requests -cffi==1.15.1 - # via - # cryptography - # pynacl -charset-normalizer==3.2.0 - # via - # -r requirements.txt - # aiohttp - # requests -cryptography==41.0.3 - # via paramiko -decorator==5.1.1 - # via - # ipdb - # ipython -exceptiongroup==1.1.3 - # via pytest -executing==1.2.0 - # via stack-data -frozenlist==1.4.0 - # via - # aiohttp - # aiosignal -google-auth==2.17.3 - # via kubernetes -hvac==1.2.1 - # via juju -idna==3.4 - # via - # -r requirements.txt - # requests - # yarl -importlib-resources==6.0.1 - # via - # -r requirements.txt - # jsonschema -iniconfig==2.0.0 - # via pytest -ipdb==0.13.13 - # via pytest-operator -ipython==8.12.2 - # via ipdb -jedi==0.19.0 - # via ipython -jinja2==3.1.2 - # via - # -r requirements-integration.in - # pytest-operator -jsonschema==4.17.3 - # via - # -r requirements.txt - # serialized-data-interface -juju==3.2.2 - # via - # -r requirements-integration.in - # pytest-operator -kubernetes==27.2.0 - # via juju -macaroonbakery==1.3.1 - # via juju -markupsafe==2.1.3 - # via jinja2 -matplotlib-inline==0.1.6 - # via ipython -multidict==6.0.4 - # via - # aiohttp - # yarl -mypy-extensions==1.0.0 - # via typing-inspect -oauthlib==3.2.2 - # via - # kubernetes - # requests-oauthlib -oci-image==1.0.0 - # via -r requirements.txt -ops==2.5.0 - # via - # -r requirements.txt - # serialized-data-interface -packaging==23.1 - # via pytest -paramiko==2.12.0 - # via juju -parso==0.8.3 - # via jedi -pexpect==4.8.0 - # via ipython -pickleshare==0.7.5 - # via ipython -pkgutil-resolve-name==1.3.10 - # via - # -r requirements.txt - # jsonschema -pluggy==1.3.0 - # via pytest -prompt-toolkit==3.0.39 - # via ipython -protobuf==3.20.3 - # via macaroonbakery -ptyprocess==0.7.0 - # via pexpect -pure-eval==0.2.2 - # via stack-data -pyasn1==0.5.0 - # via - # juju - # pyasn1-modules - # rsa -pyasn1-modules==0.3.0 - # via google-auth -pycparser==2.21 - # via cffi -pygments==2.16.1 - # via ipython -pyhcl==0.4.5 - # via hvac -pymacaroons==0.13.0 - # via macaroonbakery -pynacl==1.5.0 - # via - # macaroonbakery - # paramiko - # pymacaroons -pyrfc3339==1.1 - # via - # juju - # macaroonbakery -pyrsistent==0.19.3 - # via - # -r requirements.txt - # jsonschema -pytest==7.4.2 - # via - # pytest-asyncio - # pytest-operator -pytest-asyncio==0.21.1 - # via pytest-operator -pytest-operator==0.29.0 - # via -r requirements-integration.in -python-dateutil==2.8.2 - # via kubernetes -pytz==2023.3.post1 - # via pyrfc3339 -pyyaml==6.0.1 - # via - # -r requirements.txt - # juju - # kubernetes - # ops - # pytest-operator - # serialized-data-interface -requests==2.31.0 - # via - # -r requirements-integration.in - # -r requirements.txt - # hvac - # kubernetes - # macaroonbakery - # requests-oauthlib - # serialized-data-interface -requests-oauthlib==1.3.1 - # via kubernetes -rsa==4.9 - # via google-auth -serialized-data-interface==0.7.0 - # via -r requirements.txt -six==1.16.0 - # via - # asttokens - # google-auth - # kubernetes - # macaroonbakery - # paramiko - # pymacaroons - # python-dateutil -stack-data==0.6.2 - # via ipython -tomli==2.0.1 - # via - # ipdb - # pytest -toposort==1.10 - # via juju -traitlets==5.9.0 - # via - # ipython - # matplotlib-inline -typing-extensions==4.7.1 - # via - # ipython - # typing-inspect -typing-inspect==0.9.0 - # via juju -urllib3==2.0.4 - # via - # -r requirements.txt - # kubernetes - # requests -wcwidth==0.2.6 - # via prompt-toolkit -websocket-client==1.6.1 - # via - # -r requirements.txt - # kubernetes - # ops -websockets==8.1 - # via juju -yarl==1.9.2 - # via aiohttp -zipp==3.16.2 - # via - # -r requirements.txt - # importlib-resources diff --git a/requirements-lint.in b/requirements-lint.in deleted file mode 100644 index c77cd87..0000000 --- a/requirements-lint.in +++ /dev/null @@ -1,9 +0,0 @@ -codespell -flake8<6 -flake8-builtins -# Pinned because `flake8-copyright==0.2.3` is incompatible with `flake8>=6`. Can unpin this -# when https://github.com/savoirfairelinux/flake8-copyright/pull/20 or a similar fix is released -flake8-copyright -pep8-naming -pyproject-flake8 --r requirements-fmt.txt diff --git a/requirements-lint.txt b/requirements-lint.txt deleted file mode 100644 index 6b55edb..0000000 --- a/requirements-lint.txt +++ /dev/null @@ -1,64 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.8 -# by the following command: -# -# pip-compile ./requirements-lint.in -# -black==23.7.0 - # via -r ./requirements-fmt.txt -click==8.1.6 - # via - # -r ./requirements-fmt.txt - # black -codespell==2.2.5 - # via -r ./requirements-lint.in -flake8==5.0.4 - # via - # -r ./requirements-lint.in - # flake8-builtins - # pep8-naming - # pyproject-flake8 -flake8-builtins==2.1.0 - # via -r ./requirements-lint.in -flake8-copyright==0.2.4 - # via -r ./requirements-lint.in -isort==5.12.0 - # via -r ./requirements-fmt.txt -mccabe==0.7.0 - # via flake8 -mypy-extensions==1.0.0 - # via - # -r ./requirements-fmt.txt - # black -packaging==23.1 - # via - # -r ./requirements-fmt.txt - # black -pathspec==0.11.2 - # via - # -r ./requirements-fmt.txt - # black -pep8-naming==0.13.3 - # via -r ./requirements-lint.in -platformdirs==3.10.0 - # via - # -r ./requirements-fmt.txt - # black -pycodestyle==2.9.1 - # via flake8 -pyflakes==2.5.0 - # via flake8 -pyproject-flake8==5.0.4.post1 - # via -r ./requirements-lint.in -tomli==2.0.1 - # via - # -r ./requirements-fmt.txt - # black - # pyproject-flake8 -typing-extensions==4.7.1 - # via - # -r ./requirements-fmt.txt - # black - -# The following packages are considered to be unsafe in a requirements file: -# setuptools diff --git a/requirements-unit.in b/requirements-unit.in deleted file mode 100644 index fbce543..0000000 --- a/requirements-unit.in +++ /dev/null @@ -1,5 +0,0 @@ -coverage -pytest -pytest-mock -pytest-lazy-fixture --r requirements.txt diff --git a/requirements-unit.txt b/requirements-unit.txt deleted file mode 100644 index 3685d7e..0000000 --- a/requirements-unit.txt +++ /dev/null @@ -1,88 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.8 -# by the following command: -# -# pip-compile ./requirements-unit.in -# -attrs==23.1.0 - # via - # -r ./requirements.txt - # jsonschema -certifi==2023.7.22 - # via - # -r ./requirements.txt - # requests -charset-normalizer==3.2.0 - # via - # -r ./requirements.txt - # requests -coverage==7.2.7 - # via -r ./requirements-unit.in -exceptiongroup==1.1.2 - # via pytest -idna==3.4 - # via - # -r ./requirements.txt - # requests -importlib-resources==6.0.1 - # via - # -r ./requirements.txt - # jsonschema -iniconfig==2.0.0 - # via pytest -jsonschema==4.17.3 - # via - # -r ./requirements.txt - # serialized-data-interface -oci-image==1.0.0 - # via -r ./requirements.txt -ops==2.5.0 - # via - # -r ./requirements.txt - # serialized-data-interface -packaging==23.1 - # via pytest -pkgutil-resolve-name==1.3.10 - # via - # -r ./requirements.txt - # jsonschema -pluggy==1.2.0 - # via pytest -pyrsistent==0.19.3 - # via - # -r ./requirements.txt - # jsonschema -pytest==7.4.0 - # via - # -r ./requirements-unit.in - # pytest-lazy-fixture - # pytest-mock -pytest-lazy-fixture==0.6.3 - # via -r ./requirements-unit.in -pytest-mock==3.11.1 - # via -r ./requirements-unit.in -pyyaml==6.0.1 - # via - # -r ./requirements.txt - # ops - # serialized-data-interface -requests==2.31.0 - # via - # -r ./requirements.txt - # serialized-data-interface -serialized-data-interface==0.7.0 - # via -r ./requirements.txt -tomli==2.0.1 - # via pytest -urllib3==2.0.4 - # via - # -r ./requirements.txt - # requests -websocket-client==1.6.1 - # via - # -r ./requirements.txt - # ops -zipp==3.16.2 - # via - # -r ./requirements.txt - # importlib-resources diff --git a/requirements.in b/requirements.in deleted file mode 100644 index 9d30f48..0000000 --- a/requirements.in +++ /dev/null @@ -1,6 +0,0 @@ -# Copyright 2021 Canonical Ltd. -# See LICENSE file for licensing details. - -ops -oci-image -serialized-data-interface diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 1ad3c60..0000000 --- a/requirements.txt +++ /dev/null @@ -1,42 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.8 -# by the following command: -# -# pip-compile ./requirements.in -# -attrs==23.1.0 - # via jsonschema -certifi==2023.7.22 - # via requests -charset-normalizer==3.2.0 - # via requests -idna==3.4 - # via requests -importlib-resources==6.0.1 - # via jsonschema -jsonschema==4.17.3 - # via serialized-data-interface -oci-image==1.0.0 - # via -r ./requirements.in -ops==2.5.0 - # via - # -r ./requirements.in - # serialized-data-interface -pkgutil-resolve-name==1.3.10 - # via jsonschema -pyrsistent==0.19.3 - # via jsonschema -pyyaml==6.0.1 - # via - # ops - # serialized-data-interface -requests==2.31.0 - # via serialized-data-interface -serialized-data-interface==0.7.0 - # via -r ./requirements.in -urllib3==2.0.4 - # via requests -websocket-client==1.6.1 - # via ops -zipp==3.16.2 - # via importlib-resources diff --git a/src/charm.py b/src/charm.py index 7be34ca..e59055d 100755 --- a/src/charm.py +++ b/src/charm.py @@ -1,182 +1,59 @@ #!/usr/bin/env python3 -# Copyright 2021 Canonical Ltd. +# Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. import logging -from oci_image import OCIImageResource, OCIImageResourceError + +from charmed_kubeflow_chisme.components import ( + CharmReconciler, + LeadershipGateComponent, +) + from ops.charm import CharmBase from ops.main import main from ops.model import ActiveStatus, BlockedStatus, MaintenanceStatus, WaitingStatus -from serialized_data_interface import NoCompatibleVersions, NoVersionsListed, get_interfaces +from charms.mlops_libs.v0.kubernetes_service_info import KubernetesServiceInfoProvider, KubernetesServiceInfoRelationError +from components.pebble_components import MlmdPebbleService + +logger = logging.getLogger() + +GRPC_SVC_NAME = "metadata-grpc-service" class Operator(CharmBase): + """Charm for the ML Metadata GRPC Server.""" def __init__(self, *args): super().__init__(*args) - self.log = logging.getLogger() - - self.image = OCIImageResource(self, "oci-image") - - for event in [ - self.on.config_changed, - self.on.install, - self.on.upgrade_charm, - self.on["mysql"].relation_changed, - self.on["grpc"].relation_changed, - ]: - self.framework.observe(event, self.main) - - def main(self, event): - try: - self._check_leader() - - interfaces = self._get_interfaces() - - image_details = self._check_image_details() - - except CheckFailed as check_failed: - self.model.unit.status = check_failed.status - return - - self._send_info(interfaces) - - mysql = self.model.relations["mysql"] + # KubernetesServiceInfoProvider for broadcasting the GRPC service information + self._svc_port = self.config["grpc-port"] + self._k8s_svc_info_provider = KubernetesServiceInfoProvider(charm=self, relation_name=RELATION_NAME, name=GRPC_SVC_NAME, port=self._service_port) - if len(mysql) > 1: - self.model.unit.status = BlockedStatus("Too many mysql relations") - return + # Charm logic + self.charm_reconciler = CharmReconciler(self) - try: - mysql = mysql[0] - unit = list(mysql.units)[0] - mysql = mysql.data[unit] - mysql["database"] - db_args = [ - f"--mysql_config_database={mysql['database']}", - f"--mysql_config_host={mysql['host']}", - f"--mysql_config_port={mysql['port']}", - f"--mysql_config_user={mysql['user']}", - f"--mysql_config_password={mysql['password']}", - ] - volumes = [] - except (IndexError, KeyError): - db_args = ["--metadata_store_server_config_file=/config/config.proto"] - config_proto = 'connection_config: {sqlite: {filename_uri: "file:/data/mlmd.db"}}' - volumes = [ - { - "name": "config", - "mountPath": "/config", - "files": [{"path": "config.proto", "content": config_proto}], - } - ] - - config = self.model.config - - args = db_args + [ - f"--grpc_port={config['port']}", - "--enable_database_upgrade=true", - ] - - self.model.unit.status = MaintenanceStatus("Setting pod spec") - self.model.pod.set_spec( - { - "version": 3, - "containers": [ - { - "name": "mlmd", - "command": ["/bin/metadata_store_server"], - "args": args, - "imageDetails": image_details, - "ports": [ - { - "name": "grpc-api", - "containerPort": int(self.model.config["port"]), - }, - ], - "volumeConfig": volumes, - "kubernetes": { - "livenessProbe": { - "tcpSocket": {"port": "grpc-api"}, - "initialDelaySeconds": 3, - "periodSeconds": 5, - "timeoutSeconds": 2, - }, - "readinessProbe": { - "tcpSocket": {"port": "grpc-api"}, - "initialDelaySeconds": 3, - "periodSeconds": 5, - "timeoutSeconds": 2, - }, - }, - } - ], - }, - k8s_resources={ - "kubernetesResources": { - "services": [ - { - "name": "metadata-grpc-service", - "spec": { - "selector": {"app.kubernetes.io/name": self.model.app.name}, - "ports": [ - { - "name": "grpc-api", - "port": int(config["port"]), - "protocol": "TCP", - "targetPort": int(config["port"]), - }, - ], - }, - } - ] - } - }, + self.leadership_gate = self.charm_reconciler.add( + component=LeadershipGateComponent( + charm=self, + name="leadership-gate", + ), + depends_on=[], ) - self.model.unit.status = ActiveStatus() - - def _send_info(self, interfaces): - if interfaces["grpc"]: - interfaces["grpc"].send_data( - { - "service": "metadata-grpc-service", - "port": self.model.config["port"], - } - ) - - def _check_leader(self): - if not self.unit.is_leader(): - self.log.info("Not a leader, skipping set_pod_spec") - raise CheckFailed("", ActiveStatus) - - def _get_interfaces(self): - try: - interfaces = get_interfaces(self) - except NoVersionsListed as err: - raise CheckFailed(err, WaitingStatus) - except NoCompatibleVersions as err: - raise CheckFailed(err, BlockedStatus) - return interfaces - def _check_image_details(self): - try: - image_details = self.image.fetch() - except OCIImageResourceError as e: - raise CheckFailed(f"{e.status.message}", e.status_type) - return image_details - - -class CheckFailed(Exception): - """Raise this exception if one of the checks in main fails.""" - - def __init__(self, msg: str, status_type=None): - super().__init__() - - self.msg = str(msg) - self.status_type = status_type - self.status = status_type(self.msg) + self.mlmd_container = self.charm_reconciler.add( + component=MlmdPebbleService( + charm=self, + name="container:mlmd", + container_name="mlmd-grpc-service", + service_name="mlmd", + grpc_port=self.config["grpc-port"], + files_to_push=[ContainerFileTemplate(source_template_path=SQLITE_CONFIG_PROTO, destination_path=SQLITE_CONFIG_PROTO_DESTINATION)] + ), + depends_on=[self.leadership_gate] + ) + self.charm_reconciler.install_default_event_handlers() if __name__ == "__main__": main(Operator) diff --git a/src/components/pebble_components.py b/src/components/pebble_components.py new file mode 100644 index 0000000..85e8dac --- /dev/null +++ b/src/components/pebble_components.py @@ -0,0 +1,39 @@ +# Copyright 2023 Canonical Ltd. +# See LICENSE file for licensing details. + +import logging + +from charmed_kubeflow_chisme.components.pebble_component import PebbleServiceComponent +from ops.pebble import Layer + +logger = logging.getLogger(__name__) + + +class KfpVizPebbleService(PebbleServiceComponent): + def __init__(self, *args, grpc_port: str, **kwargs): + """Pebble service component that configures the Pebble layer.""" + super().__init__(*args, **kwars) + self._grpc_port = grpc_port + + def get_layer(self) -> Layer: + """Pebble configuration layer for MLMD GRPC Server""" + command = ( + "bin/metadata_store_server", + "--metadata_store_server_config_file=/config/config.proto", + "--grpc_port={self._grpc_port}", + "--enable_database_upgrade=true", + ) + layer = Layer( + { + "services": { + self.service_name: { + "override": "replace", + "summary": "entry point for MLMD GRPC Service", + "command": command, # Must be a string + "startup": "enabled", + } + }, + } + ) + + return layer diff --git a/src/templates/ml-pipeline-service.yaml.j2 b/src/templates/ml-pipeline-service.yaml.j2 new file mode 100644 index 0000000..970b036 --- /dev/null +++ b/src/templates/ml-pipeline-service.yaml.j2 @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: metadata-grpc-service + namespace: {{ namespace }} +spec: + ports: + - name: grpc-api + port: {{ grpc_port }} + protocol: TCP + targetPort: {{ grpc_port }} + selector: + # This selector ensures this Service identifies + # the mlmd Pod correctly as ti will have + # the same tag + app.kubernetes.io/name: {{ app_name }} diff --git a/tox.ini b/tox.ini index b7f3710..00a6516 100644 --- a/tox.ini +++ b/tox.ini @@ -1,4 +1,4 @@ -# Copyright 2022 Canonical Ltd. +# Copyright 2024 Canonical Ltd. # See LICENSE file for licensing details. [flake8]