diff --git a/lib/charms/resource_dispatcher/v0/kubernetes_manifests.py b/lib/charms/resource_dispatcher/v0/kubernetes_manifests.py new file mode 100644 index 00000000..3a135f6e --- /dev/null +++ b/lib/charms/resource_dispatcher/v0/kubernetes_manifests.py @@ -0,0 +1,354 @@ +"""KubernetesManifests Library + +This library implements data transfer for the kubernetes_manifest interface. The library can be used by the requirer +charm to send Kubernetes manifests to the provider charm. + +## Getting Started + +To get started using the library, fetch the library with `charmcraft`. + +```shell +cd some-charm +charmcraft fetch-lib charms.resource_dispatcher.v0.kubernetes_manifests +``` + +In your charm, the library can be used in two ways depending on whether the manifests +being sent by the charm are static (available when the charm starts up), +or dynamic (for example a manifest template that gets rendered with data from a relation) + +If the manifests are static, instantiate the KubernetesManifestsRequirer. +In your charm do: + +```python +from charms.resource_dispatcher.v0.kubernetes_manifests import KubernetesManifestsRequirer, KubernetesManifest +# ... + +SECRETS_MANIFESTS = [ + KubernetesManifest( + Path(SECRET1_PATH).read_text() + ), + KubernetesManifest( + Path(SECRET2_PATH).read_text() + ), +] + +SA_MANIFESTS = [ + KubernetesManifest( + Path(SA1_PATH).read_text() + ), + KubernetesManifest( + Path(SA2_PATH).read_text() + ), +] + +class SomeCharm(CharmBase): + def __init__(self, *args): + # ... + self.secrets_manifests_requirer = KubernetesManifestsRequirer( + charm=self, relation_name="secrets", manifests_items=SECRETS_MANIFESTS + ) + self.service_accounts_requirer = KubernetesManifestsRequirer( + charm=self, relation_name="service-accounts", manifests_items=SA_MANIFESTS + ) + # ... +``` + +If the manifests are dynamic, instantiate the KubernetesManifestsRequirerWrapper. +In your charm do: + +```python +class SomeCharm(CharmBase): + def __init__(self, *args): + # ... + self._secrets_manifests_wrapper = KubernetesManifestsRequirerWrapper( + charm = self, + relation_name = "secrets" + ) + self._service_accounts_manifests_wrapper = KubernetesManifestsRequirerWrapper( + charm = self, + relation_name = "service-accounts" + ) + + self.framework.observe(self.on.leader_elected, self._send_secret) + self.framework.observe(self.on["secrets"].relation_created, self._send_secret) + # observe all the other events for when the secrets manifests change + + self.framework.observe(self.on.leader_elected, self._send_service_account) + self.framework.observe(self.on["service-accounts"].relation_created, self._send_service_account) + # observe all the other events for when the service accounts manifests change + + def _send_secret(self, _): + #... + Write the logic to re-calculate the manifests + rendered_manifests = ... + #... + manifest_items = [KubernetesManifest(rendered_manifests)] + self._secrets_manifests_wrapper.send_data(manifest_items) + + + def _send_service_account(self, _): + #... + Write the logic to re-calculate the manifests + rendered_manifests = ... + #... + manifest_items = [KubernetesManifest(rendered_manifests)] + self._service_accounts_manifests_wrapper.send_data(manifest_items) +``` +""" +import json +import logging +import os +from dataclasses import dataclass, field +from typing import List, Optional, Union + +import yaml +from ops.charm import CharmBase, RelationEvent +from ops.framework import BoundEvent, EventBase, EventSource, Object, ObjectEvents + +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "372e7e90201741ba80006fc43fd81b49" + +# 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 + +KUBERNETES_MANIFESTS_FIELD = "kubernetes_manifests" + + +@dataclass +class KubernetesManifest: + """ + Representation of a Kubernetes Object sent to Kubernetes Manifests. + + Args: + manifest_content: the content of the Kubernetes manifest file + """ + + manifest_content: str + manifest: dict = field(init=False) + + def __post_init__(self): + """Validate that the manifest content is a valid YAML.""" + self.manifest = yaml.safe_load(self.manifest_content) + + +class KubernetesManifestsUpdatedEvent(RelationEvent): + """Indicates the Kubernetes Objects data was updated.""" + + +class KubernetesManifestsEvents(ObjectEvents): + """Events for the Kubernetes Manifests library.""" + + updated = EventSource(KubernetesManifestsUpdatedEvent) + + +class KubernetesManifestsProvider(Object): + """Relation manager for the Provider side of the Kubernetes Manifests relations.""" + + on = KubernetesManifestsEvents() + + def __init__( + self, + charm: CharmBase, + relation_name: str, + refresh_event: Optional[Union[BoundEvent, List[BoundEvent]]] = None, + ): + """Relation manager for the Provider side of the Kubernetes Manifests relations. + + This relation manager subscribes to: + * on[relation_name].relation_changed + * any events provided in refresh_event + + This library emits: + * KubernetesManifestsUpdatedEvent: + when data received on the relation is updated + + Args: + charm: Charm this relation is being used by + relation_name: Name of this relation (from metadata.yaml) + refresh_event: List of BoundEvents that this manager should handle. Use this to update + the data sent on this relation on demand. + """ + super().__init__(charm, relation_name) + self._charm = charm + self._relation_name = 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 + ) + + # apply user defined events + 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_manifests(self) -> List[dict]: + """ + Returns a list of dictionaries sent in the data of relation relation_name. + """ + + other_app_to_skip = get_name_of_breaking_app(relation_name=self._relation_name) + + if other_app_to_skip: + logger.debug( + f"get_kubernetes_manifests executed during a relation-broken event. Return will" + f"exclude {self._relation_name} manifests from other app named '{other_app_to_skip}'. " + ) + + manifests = [] + + kubernetes_manifests_relations = self._charm.model.relations[self._relation_name] + + for relation in kubernetes_manifests_relations: + other_app = relation.app + if other_app.name == other_app_to_skip: + # Skip this app because it is leaving a broken relation + continue + json_data = relation.data[other_app].get(KUBERNETES_MANIFESTS_FIELD, "[]") + manifests.extend(json.loads(json_data)) + + return manifests + + + def _on_relation_changed(self, event): + """Handler for relation-changed event for this relation.""" + self.on.updated.emit(event.relation) + + def _on_relation_broken(self, event: BoundEvent): + """Handler for relation-broken event for this relation.""" + self.on.updated.emit(event.relation) + + +class KubernetesManifestsRequirer(Object): + """Relation manager for the Requirer side of the Kubernetes Manifests relation.""" + + def __init__( + self, + charm: CharmBase, + relation_name: str, + manifests_items: List[KubernetesManifest], + refresh_event: Optional[Union[BoundEvent, List[BoundEvent]]] = None, + ): + """ + Relation manager for the Requirer side of the Kubernetes Manifests relation. + + This relation manager subscribes to: + * on.leader_elected: because only the leader is allowed to provide this data, and + relation_created may fire before the leadership election + * on[relation_name].relation_created + + * any events provided in refresh_event + + This library emits: + * (nothing) + + Args: + charm: Charm this relation is being used by + relation_name: Name of this relation (from metadata.yaml) + manifests_items: List of KubernetesManifest objects to send over the relation + refresh_event: List of BoundEvents that this manager should handle. Use this to update + the data sent on this relation on demand. + """ + super().__init__(charm, relation_name) + self._charm = charm + self._relation_name = relation_name + self._manifests_items = manifests_items + self._requirer_wrapper = KubernetesManifestRequirerWrapper(self._charm, self._relation_name) + + 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 + ) + + # apply user defined events + 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, event: EventBase): + """Handles any event where we should send data to the relation.""" + self._requirer_wrapper.send_data(self._manifests_items) + + + + +class KubernetesManifestRequirerWrapper(Object): + """ + Wrapper for the relation data sending logic + """ + def __init__( + self, + charm: CharmBase, + relation_name: str, + ): + self._charm = charm + self._relation_name = relation_name + + def _get_manifests_from_items(self, manifests_items: List[KubernetesManifest]): + return [ + item.manifest for item in manifests_items + ] + + def send_data(self, manifest_items: List[KubernetesManifest]): + """Sends the manifests data to the relation in json format.""" + if not self._charm.model.unit.is_leader(): + logger.info( + "KubernetesManifestsRequirer handled send_data event when it is not the " + "leader. Skipping event - no data sent." + ) + return + + manifests = self._get_manifests_from_items(manifest_items) + relations = self._charm.model.relations.get(self._relation_name) + + for relation in relations: + relation_data = relation.data[self._charm.app] + manifests_as_json = json.dumps(manifests) + relation_data.update({KUBERNETES_MANIFESTS_FIELD: manifests_as_json}) + + + +def get_name_of_breaking_app(relation_name: str) -> Optional[str]: + """ + Get the name of a remote application that is leaving the relation during a relation broken event by + checking Juju environment variables. + If the application name is available, returns the name as a string; + otherwise None. + """ + # In the case of a relation-broken event, Juju non-deterministically may or may not include + # the breaking remote app's data in the relation data bag. If this data is still in the data + # bag, the `JUJU_REMOTE_APP` name will always be set. For these cases, we return the + # remote app name so the caller can remove that app from the data bag before using it. + # + # To catch these cases, we inspect the following environment variables managed by Juju: + # JUJU_REMOTE_APP: the name of the app we are interacting with on this relation event + # JUJU_RELATION: the name of the relation we are interacting with on this relation event + # JUJU_HOOK_NAME: the name of the relation event, such as RELATION_NAME-relation-broken + # See https://juju.is/docs/sdk/charm-environment-variables for more detail on these variables. + if not os.environ.get("JUJU_REMOTE_APP", None): + # No remote app is defined + return None + if not os.environ.get("JUJU_RELATION", None) == relation_name: + # Not this relation + return None + if not os.environ.get("JUJU_HOOK_NAME", None) == f"{relation_name}-relation-broken": + # Not the relation-broken event + return None + + return os.environ.get("JUJU_REMOTE_APP", None) diff --git a/metadata.yaml b/metadata.yaml index 54423af5..3946a2d7 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -94,32 +94,12 @@ requires: - prefix versions: [v1] __schema_source: https://raw.githubusercontent.com/canonical/operator-schemas/master/ingress.yaml + secrets: + interface: kubernetes_manifest + pod-defaults: + interface: kubernetes_manifest provides: metrics-endpoint: interface: prometheus_scrape grafana-dashboard: interface: grafana_dashboard - secrets: - interface: secrets - schema: - v1: - provides: - type: object - properties: - secrets: - type: string - required: - - secrets - versions: [v1] - pod-defaults: - interface: pod-defaults - schema: - v1: - provides: - type: object - properties: - pod-defaults: - type: string - required: - - pod-defaults - versions: [v1] diff --git a/src/charm.py b/src/charm.py index 20aab1d6..f4ad4511 100755 --- a/src/charm.py +++ b/src/charm.py @@ -3,12 +3,10 @@ # See LICENSE file for licensing details. # -import json import logging from pathlib import Path import botocore.exceptions -import yaml from charmed_kubeflow_chisme.exceptions import ErrorWithStatus from charms.data_platform_libs.v0.data_interfaces import DatabaseRequires from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider @@ -18,6 +16,10 @@ ) from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointProvider +from charms.resource_dispatcher.v0.kubernetes_manifests import ( + KubernetesManifest, + KubernetesManifestRequirerWrapper, +) from jinja2 import Template from lightkube.models.core_v1 import ServicePort from ops.charm import CharmBase @@ -57,6 +59,9 @@ def __init__(self, *args): self, relation_name="relational-db", database_name=self._database_name ) + self._secrets_manifests_wrapper = None + self._poddefaults_manifests_wrapper = None + self.framework.observe(self.on.upgrade_charm, self._on_event) self.framework.observe(self.on.config_changed, self._on_event) self.framework.observe(self.on.mlflow_server_pebble_ready, self._on_pebble_ready) @@ -126,6 +131,22 @@ def exporter_container(self): """Return container.""" return self._exporter_container + @property + def secrets_manifests_wrapper(self): + if not self._secrets_manifests_wrapper: + self._secrets_manifests_wrapper = KubernetesManifestRequirerWrapper( + charm=self, relation_name="secrets" + ) + return self._secrets_manifests_wrapper + + @property + def poddefaults_manifests_wrapper(self): + if not self._poddefaults_manifests_wrapper: + self._poddefaults_manifests_wrapper = KubernetesManifestRequirerWrapper( + charm=self, relation_name="pod-defaults" + ) + return self._poddefaults_manifests_wrapper + def _create_service(self): """Create k8s service based on charm'sconfig.""" if self.config["enable_mlflow_nodeport"]: @@ -381,11 +402,12 @@ def _on_database_relation_removed(self, _) -> None: """Event is fired when relation with postgres is broken.""" self.unit.status = BlockedStatus("Please add relation to the database") - def _send_manifests(self, interfaces, context, manifest_files, relation): + def _send_manifests( + self, context, manifest_files, relation_requirer: KubernetesManifestRequirerWrapper + ): """Send manifests from folder to desired relation.""" - if relation in interfaces and interfaces[relation]: - manifests = self._create_manifests(manifest_files, context) - interfaces[relation].send_data({relation: manifests}) + manifests = self._create_manifests(manifest_files, context) + relation_requirer.send_data(manifests) def _create_manifests(self, manifest_files, context): """Create manifests string for given folder and context.""" @@ -393,9 +415,9 @@ def _create_manifests(self, manifest_files, context): for file in manifest_files: template = Template(Path(file).read_text()) rendered_template = template.render(**context) - manifest = yaml.safe_load(rendered_template) + manifest = KubernetesManifest(rendered_template) manifests.append(manifest) - return json.dumps(manifests) + return manifests def _send_ingress_info(self, interfaces): if interfaces["ingress"]: @@ -461,9 +483,9 @@ def _on_event(self, event) -> None: "s3_endpoint": secrets_context["s3_endpoint"], "mlflow_endpoint": f"http://{self.app.name}.{self.model.name}.svc.cluster.local:{self._port}", # noqa: E501 } - self._send_manifests(interfaces, secrets_context, SECRETS_FILES, "secrets") + self._send_manifests(secrets_context, SECRETS_FILES, self.secrets_manifests_wrapper) self._send_manifests( - interfaces, poddefaults_context, PODDEFAULTS_FILES, "pod-defaults" + poddefaults_context, PODDEFAULTS_FILES, self.poddefaults_manifests_wrapper ) self._send_ingress_info(interfaces) except ErrorWithStatus as err: diff --git a/tests/unit/test_operator.py b/tests/unit/test_operator.py index ab12860f..f89e3ad2 100644 --- a/tests/unit/test_operator.py +++ b/tests/unit/test_operator.py @@ -1,6 +1,7 @@ # Copyright 2021 Canonical Ltd. # See LICENSE file for licensing details. +import json from unittest.mock import MagicMock, patch import pytest @@ -437,9 +438,10 @@ def test_create_manifests(self, harness: Harness): "secret_access_key": "s", } harness.begin() - manifests = harness.charm._create_manifests(SECRETS_TEST_FILES, secrets_context) + manifests_items = harness.charm._create_manifests(SECRETS_TEST_FILES, secrets_context) + manifests_as_json = json.dumps([item.manifest for item in manifests_items]) assert ( - manifests + manifests_as_json == '[{"apiVersion": "v1", "kind": "Secret", "metadata": {"name": "mlpipeline-minio-artifact"}, "stringData": {"AWS_ACCESS_KEY_ID": "a", "AWS_SECRET_ACCESS_KEY": "s"}}]' # noqa: E501 ) @@ -448,14 +450,16 @@ def test_create_manifests(self, harness: Harness): lambda x, y, service_name, service_type, refresh_event: None, ) @patch("charm.MlflowCharm._create_manifests") - def test_send_manifests(self, create_manifests: MagicMock, harness: Harness): + @patch("charm.MlflowCharm.secrets_manifests_wrapper") + def test_send_manifests( + self, secrets_manifests_wrapper: MagicMock, create_manifests: MagicMock, harness: Harness + ): tmp_manifests = "[]" create_manifests.return_value = tmp_manifests - secrets_interface = MagicMock() - interfaces = {"secrets": secrets_interface} + secrets_manifests_wrapper = MagicMock() harness.begin() - harness.charm._send_manifests(interfaces, {}, "", "secrets") - secrets_interface.send_data.assert_called_with({"secrets": tmp_manifests}) + harness.charm._send_manifests({}, [""], secrets_manifests_wrapper) + secrets_manifests_wrapper.send_data.assert_called_once() @patch( "charm.KubernetesServicePatch",