commit e61876310ba4b7401f06b3668ce1eb1d2bdc7ccb Author: David Ames Date: Fri Oct 4 07:50:33 2019 -0700 MySQL InnoDB Cluster Interface * peers handles cluster communication diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eda88d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.tox +.testrepository +.stestr/ +__pycache__ diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 0000000..5fcccac --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=./unit_tests +top_dir=./ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..63a95a0 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,9 @@ +language: python +python: + - "3.6" +install: pip install tox-travis +env: + - ENV=pep8 + - ENV=py3 +script: + - tox -e $ENV diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..71d88f5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..2667d23 --- /dev/null +++ b/README.md @@ -0,0 +1,80 @@ +# Overview + +This interface layer handles the intra-cluster relations for +msyql-innodb-cluster. + +# Usage + +## Peers + +The interface layer will set the following state: + + * `{relation_name}.connected` The cluster relation is established. + * `{relation_name}.available` The cluster relation data is complete. + * `{relation_name}.clustered` All cluster units are clustered. + +Cluster relation information can be set on the relation with the following methods: + + +Set the cluster relation information to connect to this unit: + +```python + def cluster.set_cluster_connection_info( + "192.168.1.5", "clusteruser", "passwd") +``` + +Indicate this unit's readiness for clustering: + +```python + cluster.set_unit_configure_ready(): +``` + +Indicate this unit has been clustered: + +```python + cluster.set_unit_clustered(self): + cluster.set_unit_configure_ready(self): +``` + + +For example: + +```python + +@reactive.when('cluster.connected') +@reactive.when_not('cluster.available') +def send_cluster_connection_info(cluster): + with charm.provide_charm_instance() as instance: + cluster.set_cluster_connection_info( + instance.cluster_address, + instance.cluster_user, + instance.cluster_password) + + +@reactive.when('cluster.available') +def create_remote_cluster_user(cluster): + with charm.provide_charm_instance() as instance: + for unit in cluster.all_joined_units: + instance.create_cluster_user( + unit.received['cluster-address'], + unit.received['cluster-user'], + unit.received['cluster-password']) + + cluster.set_unit_configure_ready() + +@reactive.when('leadership.set.cluster-created') +@reactive.when('cluster.available') +def signal_clustered(cluster): + # Optimize clustering by causing a cluster relation changed + with charm.provide_charm_instance() as instance: + if reactive.is_flag_set( + "leadership.set.cluster-instance-clustered-{}" + .format(instance.cluster_address)): + cluster.set_unit_clustered() + instance.assess_status() +``` + +The interface will automatically determine the network space binding on the +local unit to present to the MySQL InnoDB cluster based on the name of the +relation. This can be overridden using the cluster_host parameter of the +set_cluster_connection_info method. diff --git a/interface.yaml b/interface.yaml new file mode 100644 index 0000000..d070864 --- /dev/null +++ b/interface.yaml @@ -0,0 +1,12 @@ +name: mysql-innodb-cluster +summary: A peer interface for MySQL 8 InnoDB clustering +maintainer: OpenStack Charmers +ignore: + - 'unit_tests' + - '.stestr.conf' + - 'test-requirements.txt' + - 'tox.ini' + - '.gitignore' + - '.travis.yml' + - '.zuul.yaml' + - '.tox' diff --git a/peers.py b/peers.py new file mode 100644 index 0000000..3e194a6 --- /dev/null +++ b/peers.py @@ -0,0 +1,139 @@ +# Copyright 2019 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. + +from charms import reactive +import charmhelpers.contrib.network.ip as ch_net_ip + + +class MySQLInnoDBClusterPeer(reactive.Endpoint): + + # MySQL InnoDB Cluster must have at least 3 units for viability + minimum_cluster_size = 3 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.ingress_address = ch_net_ip.get_relation_ip(self.endpoint_name) + + def relation_ids(self): + return [x.relation_id for x in self.relations] + + def set_ingress_address(self): + for relation in self.relations: + relation.to_publish_raw["ingress-address"] = self.ingress_address + relation.to_publish_raw["private-address"] = self.ingress_address + + @property + def peer_relation(self): + # Get the first relation object as we only have one relation to peers + return self.relations[0] + + def available(self): + if len(self.all_joined_units) < (self.minimum_cluster_size - 1): + return False + for unit in self.all_joined_units: + if not unit.received['cluster-address']: + return False + if not unit.received['cluster-user']: + return False + if not unit.received['cluster-password']: + return False + return True + + def clustered(self): + if len(self.all_joined_units) < (self.minimum_cluster_size - 1): + return False + for unit in self.all_joined_units: + if not unit.received['unit-clustered']: + return False + return True + + @reactive.when('endpoint.{endpoint_name}.joined') + def joined(self): + reactive.set_flag(self.expand_name('{endpoint_name}.connected')) + self.set_ingress_address() + + @reactive.when('endpoint.{endpoint_name}.changed') + def changed(self): + flags = ( + self.expand_name( + 'endpoint.{endpoint_name}.changed.cluster-address'), + self.expand_name( + 'endpoint.{endpoint_name}.changed.cluster-user'), + self.expand_name( + 'endpoint.{endpoint_name}.changed.cluster-password'), + # Optimizers + self.expand_name( + 'endpoint.{endpoint_name}.changed.unit-configure-ready'), + self.expand_name( + 'endpoint.{endpoint_name}.changed.unit-clustered'), + ) + if reactive.all_flags_set(*flags): + for flag in flags: + reactive.clear_flag(flag) + + if self.available(): + reactive.set_flag(self.expand_name('{endpoint_name}.available')) + else: + reactive.clear_flag(self.expand_name('{endpoint_name}.available')) + + if self.clustered(): + reactive.set_flag(self.expand_name('{endpoint_name}.clustered')) + else: + reactive.clear_flag(self.expand_name('{endpoint_name}.clustered')) + + @reactive.when_any('endpoint.{endpoint_name}.broken', + 'endpoint.{endpoint_name}.departed') + def departed(self): + flags = ( + self.expand_name('{endpoint_name}.connected'), + self.expand_name('{endpoint_name}.available'), + ) + for flag in flags: + reactive.clear_flag(flag) + + def set_cluster_connection_info( + self, cluster_address, cluster_user, cluster_password): + """Send cluster connection information to peers. + + :param cluster_address: Cluster IP or hostname + :type cluster_address: str + :param cluster_user: User for cluster user + :type cluster_user: str + :param cluster_password: Password for cluster user + :type cluster_password: str + :side effect: Data is set on the relation + :returns: None, this function is called for its side effect + :rtype: None + """ + self.peer_relation.to_publish['cluster-address'] = cluster_address + self.peer_relation.to_publish['cluster-user'] = cluster_user + self.peer_relation.to_publish['cluster-password'] = cluster_password + + def set_unit_configure_ready(self): + """Indicate to the cluster peers this unit is ready for configuration. + + :side effect: Data is set on the relation + :returns: None, this function is called for its side effect + :rtype: None + """ + self.peer_relation.to_publish['unit-configure-ready'] = True + + def set_unit_clustered(self): + """Indicate to the cluster peers this unit is clustered. + + :side effect: Data is set on the relation + :returns: None, this function is called for its side effect + :rtype: None + """ + self.peer_relation.to_publish['unit-clustered'] = True diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..3900c51 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,5 @@ +charms.reactive +flake8>=2.2.4,<=2.4.1 +mock>=1.2 +stestr>=2.2.0 +git+https://github.com/openstack/charms.openstack.git#egg=charms.openstack diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..07ad7a8 --- /dev/null +++ b/tox.ini @@ -0,0 +1,42 @@ +[tox] +envlist = pep8,py3 +skipsdist = True +# NOTE(beisner): Avoid build/test env pollution by not enabling sitepackages. +sitepackages = False +# NOTE(beisner): Avoid false positives by not skipping missing interpreters. +skip_missing_interpreters = False + +[testenv] +setenv = VIRTUAL_ENV={envdir} + PYTHONHASHSEED=0 +install_command = + pip install {opts} {packages} +commands = stestr run {posargs} +passenv = HOME TERM +deps = -r{toxinidir}/test-requirements.txt + +[testenv:py34] +basepython = python3.4 + +[testenv:py35] +basepython = python3.5 + +[testenv:py36] +basepython = python3.6 + +[testenv:py37] +basepython = python3.7 + +[testenv:py3] +basepython = python3 + +[testenv:pep8] +basepython = python3 +commands = flake8 {posargs} + +[testenv:venv] +basepython = python3 +commands = {posargs} + +[flake8] +ignore = E402,E226 diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py new file mode 100644 index 0000000..0df0b6a --- /dev/null +++ b/unit_tests/__init__.py @@ -0,0 +1,22 @@ +# Copyright 2019 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. + +import sys + +sys.path.append('src') +sys.path.append('src/lib') + +# Mock out charmhelpers so that we can test without it. +import charms_openstack.test_mocks # noqa +charms_openstack.test_mocks.mock_charmhelpers() diff --git a/unit_tests/test_peers.py b/unit_tests/test_peers.py new file mode 100644 index 0000000..10bd475 --- /dev/null +++ b/unit_tests/test_peers.py @@ -0,0 +1,209 @@ +# Copyright 2019 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. + +import charms_openstack.test_utils as test_utils +import mock +import peers + + +class TestRegisteredHooks(test_utils.TestRegisteredHooks): + + def test_hooks(self): + defaults = [] + hook_set = { + "when": { + "joined": ( + "endpoint.{endpoint_name}.joined",), + + "changed": ( + "endpoint.{endpoint_name}.changed",), + "departed": ("endpoint.{endpoint_name}.broken", + "endpoint.{endpoint_name}.departed",), + }, + } + # test that the hooks were registered + self.registered_hooks_test_helper(peers, hook_set, defaults) + + +class TestMySQLInnoDBClusterPeer(test_utils.PatchHelper): + + def setUp(self): + super().setUp() + self._patches = {} + self._patches_start = {} + self.patch_object(peers.reactive, "clear_flag") + self.patch_object(peers.reactive, "set_flag") + + _data = { + "cluster-address": None, + "cluster-user": None, + "cluster-password": None, + "unit-clustered": None} + self.fake_unit_0 = mock.MagicMock() + self.fake_unit_0.unit_name = "unit/0" + self.fake_unit_0.received = _data + + self.fake_unit_1 = mock.MagicMock() + self.fake_unit_1.unit_name = "unit/1" + self.fake_unit_1.received = _data + + self.fake_unit_2 = mock.MagicMock() + self.fake_unit_2.unit_name = "unit/2" + self.fake_unit_2.received = _data + + self.fake_relation_id = "cluster:19" + self.fake_relation = mock.MagicMock() + self.fake_relation.relation_id = self.fake_relation_id + self.fake_relation.units = [ + self.fake_unit_0, + self.fake_unit_1, + self.fake_unit_2] + self.fake_unit_0.relation = self.fake_relation + self.fake_unit_1.relation = self.fake_relation + self.fake_unit_2.relation = self.fake_relation + + self.ep_name = "ep" + self.ep = peers.MySQLInnoDBClusterPeer( + self.ep_name, [self.fake_relation]) + self.ep.ingress_address = "10.10.10.10" + self.ep.relations[0] = self.fake_relation + + def tearDown(self): + self.ep = None + for k, v in self._patches.items(): + v.stop() + setattr(self, k, None) + self._patches = None + self._patches_start = None + + def test_joined(self): + self.ep.set_ingress_address = mock.MagicMock() + self.ep.joined() + self.set_flag.assert_called_once_with( + "{}.connected".format(self.ep_name)) + self.ep.set_ingress_address.assert_called_once() + + def test_changed_not_available(self): + self.ep.available = mock.MagicMock(return_value=False) + self.ep.clustered = mock.MagicMock(return_value=False) + self.ep.changed() + + _calls = [ + mock.call("endpoint.{}.changed.cluster-address" + .format(self.ep_name)), + mock.call("endpoint.{}.changed.cluster-user" + .format(self.ep_name)), + mock.call("endpoint.{}.changed.cluster-password" + .format(self.ep_name)), + mock.call("endpoint.{}.changed.unit-configure-ready" + .format(self.ep_name)), + mock.call("endpoint.{}.changed.unit-clustered" + .format(self.ep_name)), + mock.call("{}.available".format(self.ep_name)), + mock.call("{}.clustered".format(self.ep_name))] + self.clear_flag.assert_has_calls(_calls, any_order=True) + self.set_flag.assert_not_called() + + def test_changed_available(self): + self.ep.available = mock.MagicMock(return_value=True) + self.ep.clustered = mock.MagicMock(return_value=True) + self.ep.changed() + + _ccalls = [ + mock.call("endpoint.{}.changed.cluster-address" + .format(self.ep_name)), + mock.call("endpoint.{}.changed.cluster-user" + .format(self.ep_name)), + mock.call("endpoint.{}.changed.cluster-password" + .format(self.ep_name)), + mock.call("endpoint.{}.changed.unit-configure-ready" + .format(self.ep_name)), + mock.call("endpoint.{}.changed.unit-clustered" + .format(self.ep_name))] + self.clear_flag.assert_has_calls(_ccalls, any_order=True) + _scalls = [ + mock.call("{}.available".format(self.ep_name)), + mock.call("{}.clustered".format(self.ep_name))] + self.set_flag.assert_has_calls(_scalls, any_order=True) + + def test_departed(self): + self.ep.departed() + _calls = [ + mock.call("{}.available".format(self.ep_name)), + mock.call("{}.connected".format(self.ep_name))] + self.clear_flag.assert_has_calls(_calls, any_order=True) + + def test_relation_ids(self): + self.assertEqual([self.fake_relation_id], self.ep.relation_ids()) + + def test_set_ingress_address(self): + _calls = [ + mock.call("ingress-address", self.ep.ingress_address), + mock.call("private-address", self.ep.ingress_address)] + self.ep.set_ingress_address() + self.fake_relation.to_publish_raw.__setitem__.assert_has_calls(_calls) + + def test_available_not_available(self): + self.assertFalse(self.ep.available()) + + def test_available_one_unit_not_available(self): + _data = { + "cluster-address": "10.5.0.21", + "cluster-user": "user", + "cluster-password": "pw"} + for unit in self.fake_relation.units: + unit.received = _data + self.fake_unit_2.received = { + "cluster-address": "10.5.0.26", + "cluster-user": None, + "cluster-password": "pass"} + self.assertFalse(self.ep.available()) + + def test_available_available(self): + _data = { + "cluster-address": "10.5.0.21", + "cluster-user": "user", + "cluster-password": "pw"} + for unit in self.fake_relation.units: + unit.received = _data + self.assertTrue(self.ep.available()) + + def test_clustered_not_clustered(self): + self.assertFalse(self.ep.clustered()) + + def test_clustered_one_unit_not_clustered(self): + _data = {"unit-clustered": True} + for unit in self.fake_relation.units: + unit.received = _data + self.fake_unit_2.received = {"unit-clustered": None} + self.assertFalse(self.ep.clustered()) + + def test_clustered_clustered(self): + _data = {"unit-clustered": True} + for unit in self.fake_relation.units: + unit.received = _data + self.assertTrue(self.ep.clustered()) + + def test_set_cluster_connection_info(self): + _pw = "fakepassword" + _user = "fakeuser" + self.ep.set_cluster_connection_info( + self.ep.ingress_address, + _user, + _pw) + _calls = [ + mock.call("cluster-address", self.ep.ingress_address), + mock.call("cluster-user", _user), + mock.call("cluster-password", _pw)] + self.fake_relation.to_publish.__setitem__.assert_has_calls(_calls)