Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: resource dispatcher charm library #42

Merged
merged 19 commits into from
Jan 22, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions lib/charms/harness_extensions/v0/capture_events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
'''This is a library providing a utility for unittesting events fired on a
Harness-ed Charm.

Example usage:

>>> from charms.harness_extensions.v0.capture_events import capture
>>> with capture(RelationEvent) as captured:
>>> harness.add_relation('foo', 'remote')
>>> assert captured.event.unit.name == 'remote'
'''

# The unique Charmhub library identifier, never change it
LIBID = "9fcdab70e26d4eee9797c0e542ab397a"

# 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 = 3

# Copyright 2022 Canonical Ltd.
# See LICENSE file for licensing details.

from contextlib import contextmanager
from typing import Generic, Iterator, Optional, Type, TypeVar

from ops.charm import CharmBase
from ops.framework import EventBase

_T = TypeVar("_T", bound=EventBase)


@contextmanager
def capture_events(charm: CharmBase, *types: Type[EventBase]):
"""Capture all events of type `*types` (using instance checks)."""
allowed_types = types or (EventBase,)

captured = []
_real_emit = charm.framework._emit

def _wrapped_emit(evt):
if isinstance(evt, allowed_types):
captured.append(evt)
return _real_emit(evt)

charm.framework._emit = _wrapped_emit # type: ignore # noqa # ugly

yield captured

charm.framework._emit = _real_emit # type: ignore # noqa # ugly


class Captured(Generic[_T]):
"""Object to type and expose return value of capture()."""

_event = None

@property
def event(self) -> Optional[_T]:
"""Return the captured event."""
return self._event

@event.setter
def event(self, val: _T):
self._event = val


@contextmanager
def capture(charm: CharmBase, typ_: Type[_T] = EventBase) -> Iterator[Captured[_T]]:
"""Capture exactly 1 event of type `typ_`.

Will raise if more/less events have been fired, or if the returned event
does not pass an instance check.
"""
result = Captured()
with capture_events(charm, typ_) as captured:
if not captured:
yield result

assert len(captured) <= 1, f"too many events captured: {captured}"
assert len(captured) >= 1, f"no event of type {typ_} emitted."
event = captured[0]
assert isinstance(event, typ_), f"expected {typ_}, not {type(event)}"
result.event = event
270 changes: 270 additions & 0 deletions lib/charms/resource_dispatcher/v0/resource_dispatcher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
"""ResourceDispatcher Library

This library implements data transfer for interfaces used by Resource Dispatcher
to deploy resources to Kubeflow user namespaces. This library can be used
for relations where Resource Dispatcher is the requirer, and a Kubernetes Resource
is sent by the provider in the relation databag, to apply it to Kubeflow user namespaces.
DnPlas marked this conversation as resolved.
Show resolved Hide resolved

## Getting Started

To get started using the library, fetch the library with `charmcraft`.

```shell
cd some-charm
charmcraft fetch-lib charms.resource_dispatcher.v0.resource_dispatcher
```

Then in your charm, do:

```python
from charms.resource_dispatcher.v0.resource_dispatcher import KubernetesManifestsRequirer, KubernetesManifest
# ...

MANIFESTS = [
KubernetesManifest(
Path(YAML_FILE_PATH).read_text()
),
KubernetesManifest(
Path(OTHER_YAML_FILE_PATH).read_text()
),
]

class SomeCharm(CharmBase):
def __init__(self, *args):
# ...
self.manifests_requirer = KubernetesManifestsRequirer(
charm=self, relation_name=RELATION_NAME, manifests_items=MANIFESTS
)
# ...
```
"""
import json
import logging
import os
from dataclasses import asdict, dataclass
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:
ca-scribner marked this conversation as resolved.
Show resolved Hide resolved
"""
Representation of a Kubernetes Object sent to Resource Dispatcher.

Args:
manifest_content: the content of the Kubernetes manifest file
"""

manifest_content: str

def __post_init__(self):
"""Validate that the manifest content is a valid YAML."""
yaml.safe_load(self.manifest_content)

def get_manifest(self):
return yaml.safe_load(self.manifest_content)


class KubernetesManifestsUpdatedEvent(RelationEvent):
"""Indicates the Kubernetes Objects data was updated."""


class KubernetesManifestsEvents(ObjectEvents):
"""Events for the Resource Dispatcher library."""

updated = EventSource(KubernetesManifestsUpdatedEvent)


class KubernetesManifestsProvider(Object):
"""Relation manager for the Provider side of the Resource Dispatcher relations."""
DnPlas marked this conversation as resolved.
Show resolved Hide resolved

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 Resource Dispatcher 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]:
DnPlas marked this conversation as resolved.
Show resolved Hide resolved
"""
Returns a list of all Kubernetes manifests sent in a relation.

Returns:
List of KubernetesManifests sent in the data of relation relation_name.
"""
DnPlas marked this conversation as resolved.
Show resolved Hide resolved

other_app_to_skip = get_name_of_breaking_app(relation_name=self._relation_name)
DnPlas marked this conversation as resolved.
Show resolved Hide resolved

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.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, "[]")
DnPlas marked this conversation as resolved.
Show resolved Hide resolved
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 Resource Dispatcher 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 Resource Dispatcher 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: List of KubernetesManifest objects to send over the relation
DnPlas marked this conversation as resolved.
Show resolved Hide resolved
refresh_event: List of BoundEvents that this manager should handle. Use this to update
DnPlas marked this conversation as resolved.
Show resolved Hide resolved
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.framework.observe(self._charm.on.leader_elected, self._on_send_data)

self.framework.observe(
self._charm.on[self._relation_name].relation_created, self._on_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._on_send_data)

@property
def _manifests(self):
return [
item.get_manifest() for item in self._manifests_items
]

def _on_send_data(self, event: EventBase):
DnPlas marked this conversation as resolved.
Show resolved Hide resolved
"""Handles any event where we should send data to the relation."""
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

relations = self._charm.model.relations.get(self._relation_name)

for relation in relations:
DnPlas marked this conversation as resolved.
Show resolved Hide resolved
relation_data = relation.data[self._charm.app]
manifests_as_json = json.dumps(self._manifests)
relation_data.update({KUBERNETES_MANIFESTS_FIELD: manifests_as_json})


def get_name_of_breaking_app(relation_name: str) -> Optional[str]:
"""Returns breaking app name if called during RELATION_NAME-relation-broken and the breaking app name is available. # noqa

Else, returns None.

Relation type and app name are inferred from juju environment variables.
"""
DnPlas marked this conversation as resolved.
Show resolved Hide resolved
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
DnPlas marked this conversation as resolved.
Show resolved Hide resolved

return os.environ.get("JUJU_REMOTE_APP", None)
36 changes: 3 additions & 33 deletions metadata.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -17,38 +17,8 @@ resources:
upstream-source: charmedkubeflow/resource-dispatcher:1.0-22.04
requires:
secrets:
interface: secrets
schema:
v1:
provides:
type: object
properties:
secrets:
type: string
required:
- secrets
versions: [v1]
interface: kubernetes_manifest
DnPlas marked this conversation as resolved.
Show resolved Hide resolved
service-accounts:
interface: service-accounts
schema:
v1:
provides:
type: object
properties:
service-accounts:
type: string
required:
- service-accounts
versions: [v1]
interface: kubernetes_manifest
pod-defaults:
interface: pod-defaults
schema:
v1:
provides:
type: object
properties:
pod-defaults:
type: string
required:
- pod-defaults
versions: [v1]
interface: kubernetes_manifest
Loading
Loading