diff --git a/interface.yaml b/interface.yaml index edd0c90..f03f3d7 100644 --- a/interface.yaml +++ b/interface.yaml @@ -11,3 +11,6 @@ ignore: - 'tox.ini' - 'unit_tests' - '.zuul.yaml' + - 'setup.cfg' + - 'setup.py' + - '**/ops_ha_interface.py' diff --git a/interface_hacluster/__init__.py b/interface_hacluster/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/interface_hacluster/ops_ha_interface.py b/interface_hacluster/ops_ha_interface.py new file mode 100644 index 0000000..cfad252 --- /dev/null +++ b/interface_hacluster/ops_ha_interface.py @@ -0,0 +1,123 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Copyright 2021 Ubuntu +# See LICENSE file for licensing details. + +import hashlib +import json +import interface_hacluster.common as common + +from ops.framework import ( + StoredState, + EventBase, + ObjectEvents, + EventSource, + Object) + + +class HAServiceReadyEvent(EventBase): + pass + + +class HAServiceEvents(ObjectEvents): + ha_ready = EventSource(HAServiceReadyEvent) + + +class HAServiceRequires(Object, common.ResourceManagement): + + on = HAServiceEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name): + super().__init__(charm, relation_name) + self.relation_name = relation_name + self.framework.observe( + charm.on[self.relation_name].relation_changed, + self._on_relation_changed) + self._stored.set_default( + resources={}) + + def get_local(self, key, default=None): + key = '%s.%s' % ('local-data', key) + json_value = getattr(self._stored, key, None) + if json_value: + return json.loads(json_value) + if default: + return default + return None + + def set_local(self, key=None, value=None, data=None, **kwdata): + if data is None: + data = {} + if key is not None: + data[key] = value + data.update(kwdata) + if not data: + return + for k, v in data.items(): + setattr( + self._stored, + 'local-data.{}'.format(k), + json.dumps(v)) + + def _on_relation_changed(self, event): + if self.is_clustered(): + self.on.ha_ready.emit() + + def data_changed(self, data_id, data, hash_type='md5'): + """Check if the given set of data has changed since the previous call. + + This works by hashing the JSON-serialization of the data. Note that, + while the data will be serialized using ``sort_keys=True``, some types + of data structures, such as sets, may lead to false positivies. + + NOTE: This method is adapted from the charms.reactive framework and + is used by the parent class `common.ResourceManagement`. It is unlikely + that a charm utilising this interface would call this method directly. + + :param str data_id: Unique identifier for this set of data. + :param data: JSON-serializable data. + :param str hash_type: Any hash algorithm supported by :mod:`hashlib`. + """ + key = 'data_changed.%s' % data_id + alg = getattr(hashlib, hash_type) + serialized = json.dumps(data, sort_keys=True).encode('utf8') + old_hash = self.get_local(key) + new_hash = alg(serialized).hexdigest() + self.set_local(key, new_hash) + return old_hash != new_hash + + def set_remote(self, key=None, value=None, data=None, **kwdata): + if data is None: + data = {} + if key is not None: + data[key] = value + data.update(kwdata) + if not data: + return + for relation in self.framework.model.relations[self.relation_name]: + for k, v in data.items(): + # The reactive framework copes with integer values but the ops + # framework insists on strings so convert them. + if isinstance(v, int): + v = str(v) + relation.data[self.model.unit][k] = v + + def get_remote_all(self, key, default=None): + """Return a list of all values presented by remote units for key""" + values = [] + for relation in self.framework.model.relations[self.relation_name]: + for unit in relation.units: + value = relation.data[unit].get(key) + if value: + values.append(value) + return list(set(values)) diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..73e2f7c --- /dev/null +++ b/setup.cfg @@ -0,0 +1,18 @@ +[metadata] +name = interface_hacluster +summary = Charm interface for Hacluster using Operator Framework +version = 0.0.1.dev1 +description-file = + README.rst +author = OpenStack Charmers +author-email = openstack-charmers@lists.ubuntu.com +url = https://github.com/openstack/charm-interface-hacluster.git +classifier = + Development Status :: 2 - Pre-Alpha + Intended Audience :: Developers + Topic :: System + Topic :: System :: Installation/Setup + opic :: System :: Software Distribution + Programming Language :: Python :: 3 + Programming Language :: Python :: 3.5 + License :: OSI Approved :: Apache Software License diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..d41471d --- /dev/null +++ b/setup.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- + +# Copyright 2021 Canonical Ltd. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Module used to setup the interface_hacluster framework.""" + +from __future__ import print_function + +from setuptools import setup, find_packages + +version = "0.0.1.dev1" +install_require = [ + 'charmhelpers', + 'ops', +] + +tests_require = [ + 'tox >= 2.3.1', +] + +setup( + license='Apache-2.0: http://www.apache.org/licenses/LICENSE-2.0', + packages=find_packages(exclude=["unit_tests"]), + zip_safe=False, + install_requires=install_require, +) diff --git a/test-requirements.txt b/test-requirements.txt index 6da7df2..12452e5 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -4,3 +4,4 @@ stestr>=2.2.0 charms.reactive coverage>=3.6 netifaces +git+https://github.com/canonical/operator.git#egg=ops diff --git a/unit_tests/test_ops.py b/unit_tests/test_ops.py new file mode 100644 index 0000000..306968d --- /dev/null +++ b/unit_tests/test_ops.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 + +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# Copyright 2021 Ubuntu +# See LICENSE file for licensing details. + +import unittest +import sys +sys.path.append('.') # noqa +from ops.testing import Harness +from ops.charm import CharmBase +import interface_hacluster.ops_ha_interface as ops_ha_interface + + +class HAServiceRequires(unittest.TestCase): + + class MyCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.seen_events = [] + self.ha = ops_ha_interface.HAServiceRequires(self, 'ha') + + self.framework.observe( + self.ha.on.ha_ready, + self._log_event) + + def _log_event(self, event): + self.seen_events.append(type(event).__name__) + + def setUp(self): + super().setUp() + self.harness = Harness( + self.MyCharm, + meta=''' +name: my-charm +requires: + ha: + interface: hacluster + scope: container +''' + ) + + def test_local_vars(self): + self.harness.begin() + self.harness.charm.ha.set_local('a', 'b') + self.assertEqual( + self.harness.charm.ha.get_local('a'), + 'b') + self.harness.charm.ha.set_local(**{'c': 'd', 'e': 'f'}) + self.assertEqual( + self.harness.charm.ha.get_local('c'), + 'd') + self.assertEqual( + self.harness.charm.ha.get_local('e'), + 'f') + self.harness.charm.ha.set_local(data={'g': 'h', 'i': 'j'}) + self.assertEqual( + self.harness.charm.ha.get_local('g'), + 'h') + self.assertEqual( + self.harness.charm.ha.get_local('i'), + 'j') + + def test_remote_vars(self): + self.harness.begin() + rel_id = self.harness.add_relation( + 'ha', + 'hacluster') + self.harness.add_relation_unit( + rel_id, + 'hacluster/0') + self.harness.charm.ha.set_remote('a', 'b') + rel_data = self.harness.get_relation_data( + rel_id, + 'my-charm/0') + self.assertEqual(rel_data, {'a': 'b'}) + + def test_get_remote_all(self): + self.harness.begin() + rel_id1 = self.harness.add_relation( + 'ha', + 'hacluster-a') + self.harness.add_relation_unit( + rel_id1, + 'hacluster-a/0') + self.harness.update_relation_data( + rel_id1, + 'hacluster-a/0', + {'fruit': 'banana'}) + self.harness.add_relation_unit( + rel_id1, + 'hacluster-a/1') + self.harness.update_relation_data( + rel_id1, + 'hacluster-a/1', + {'fruit': 'orange'}) + rel_id2 = self.harness.add_relation( + 'ha', + 'hacluster-b') + self.harness.add_relation_unit( + rel_id2, + 'hacluster-b/0') + self.harness.update_relation_data( + rel_id2, + 'hacluster-b/0', + {'fruit': 'grape'}) + self.harness.add_relation_unit( + rel_id2, + 'hacluster-b/1') + self.harness.update_relation_data( + rel_id2, + 'hacluster-b/1', + {'veg': 'carrot'}) + self.assertEqual( + self.harness.charm.ha.get_remote_all('fruit'), + ['orange', 'grape', 'banana']) + + def test_ha_ready(self): + self.harness.begin() + self.assertEqual( + self.harness.charm.seen_events, + []) + rel_id = self.harness.add_relation( + 'ha', + 'hacluster') + self.harness.add_relation_unit( + rel_id, + 'hacluster/0') + self.harness.update_relation_data( + rel_id, + 'hacluster/0', + {'clustered': 'yes'}) + self.assertEqual( + self.harness.charm.seen_events, + ['HAServiceReadyEvent']) + + def test_data_changed(self): + self.harness.begin() + self.assertTrue( + self.harness.charm.ha.data_changed( + 'relation-data', {'a': 'b'})) + self.assertFalse( + self.harness.charm.ha.data_changed( + 'relation-data', {'a': 'b'})) + self.assertTrue( + self.harness.charm.ha.data_changed( + 'relation-data', {'a': 'c'}))