commit 0d463e0de27380c6d807821607f6aef8f67e46a0 Author: Liam Young Date: Thu Jun 24 13:14:57 2021 +0000 Initial cut of ceph-dashboard charm diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..8ef84fc --- /dev/null +++ b/.flake8 @@ -0,0 +1,9 @@ +[flake8] +max-line-length = 99 +select: E,W,F,C,N +exclude: + venv + .git + build + dist + *.egg_info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c9e80f --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.tox +**/*.swp +__pycache__ +.stestr/ +lib/* +!lib/README.txt +build +*.charm diff --git a/.jujuignore b/.jujuignore new file mode 100644 index 0000000..6ccd559 --- /dev/null +++ b/.jujuignore @@ -0,0 +1,3 @@ +/venv +*.py[cod] +*.charm 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/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /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 [yyyy] [name of copyright owner] + + 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..b0dc846 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Overview + +The ceph-dashboard configures the [Ceph Dashboard][ceph-dashboard-upstream]. +The charm is intended to be used in conjunction with the +[ceph-mon][ceph-mon-charm] charm. + +# Usage + +## Configuration + +See file `config.yaml` for the full list of options, along with their +descriptions and default values. + +## Deployment + +We are assuming a pre-existing Ceph cluster. + +Deploy the ceph-dashboard as a subordinate to the ceph-mon charm. + + juju deploy ceph-dashboard + juju relate ceph-dashboard ceph-mon + + + + +[ceph-dashboard]: https://docs.ceph.com/en/latest/mgr/dashboard/ +[ceph-mon-charm]: https://jaas.ai/ceph-mon diff --git a/actions.yaml b/actions.yaml new file mode 100644 index 0000000..c76df64 --- /dev/null +++ b/actions.yaml @@ -0,0 +1,14 @@ +# Copyright 2021 Canonical +# See LICENSE file for licensing details. + +add-user: + description: add a dashboard user + params: + username: + description: Name of user to create + type: string + default: "" + role: + description: Role to give user + type: string + default: "" diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..d4bed64 --- /dev/null +++ b/config.yaml @@ -0,0 +1,10 @@ +# Copyright 2021 Canonical +# See LICENSE file for licensing details. + +options: + public-hostname: + type: string + default: + description: | + The hostname or address of the public endpoints created for the + dashboard diff --git a/metadata.yaml b/metadata.yaml new file mode 100644 index 0000000..a052461 --- /dev/null +++ b/metadata.yaml @@ -0,0 +1,27 @@ +# Copyright 2021 Canonical +# See LICENSE file for licensing details. +name: ceph-dashboard +display-name: Ceph Dashboard +maintainer: OpenStack Charmers +summary: Enable dashboard for Ceph +description: | + Enable the ceph dashboard on the ceph mon units +tags: +- openstack +- storage +- backup +extra-bindings: + public: +subordinate: true +series: +- focal +- groovy +requires: + dashboard: + interface: ceph-dashboard + scope: container + certificates: + interface: tls-certificates + loadbalancer: + interface: api-endpoints + diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..4f2a3f5 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +coverage +flake8 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..56cbcc9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +ops >= 1.2.0 +git+https://github.com/openstack/charms.ceph#egg=charms_ceph +git+https://opendev.org/openstack/charm-ops-openstack#egg=ops_openstack +#git+https://opendev.org/openstack/charm-ops-interface-tls-certificates#egg=interface_tls_certificates +git+https://github.com/gnuoy/ops-interface-tls-certificates@no-exception-for-inflight-request#egg=interface_tls_certificates diff --git a/src/charm.py b/src/charm.py new file mode 100755 index 0000000..63be231 --- /dev/null +++ b/src/charm.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +# Copyright 2021 Canonical +# See LICENSE file for licensing details. +# +# Learn more at: https://juju.is/docs/sdk + +"""Charm for the Ceph Dashboard.""" + +import logging +import tempfile + +from ops.framework import StoredState +from ops.main import main +from ops.model import ActiveStatus, BlockedStatus, StatusBase +from ops.charm import ActionEvent +import interface_tls_certificates.ca_client as ca_client +import re +import secrets +import socket +import string +import subprocess +import ops_openstack.plugins.classes +import interface_dashboard +import interface_api_endpoints +import cryptography.hazmat.primitives.serialization as serialization +import charms_ceph.utils as ceph_utils + +from pathlib import Path + +logger = logging.getLogger(__name__) + + +class CephDashboardCharm(ops_openstack.core.OSBaseCharm): + """Ceph Dashboard charm.""" + + _stored = StoredState() + PACKAGES = ['ceph-mgr-dashboard'] + CEPH_CONFIG_PATH = Path('/etc/ceph') + TLS_KEY_PATH = CEPH_CONFIG_PATH / 'ceph-dashboard.key' + TLS_PUB_KEY_PATH = CEPH_CONFIG_PATH / 'ceph-dashboard-pub.key' + TLS_CERT_PATH = CEPH_CONFIG_PATH / 'ceph-dashboard.crt' + TLS_KEY_AND_CERT_PATH = CEPH_CONFIG_PATH / 'ceph-dashboard.pem' + TLS_CA_CERT_PATH = Path( + '/usr/local/share/ca-certificates/vault_ca_cert_dashboard.crt') + TLS_PORT = 8443 + + def __init__(self, *args) -> None: + """Setup adapters and observers.""" + super().__init__(*args) + super().register_status_check(self.check_dashboard) + self.mon = interface_dashboard.CephDashboardRequires( + self, + 'dashboard') + self.ca_client = ca_client.CAClient( + self, + 'certificates') + self.framework.observe( + self.mon.on.mon_ready, + self._configure_dashboard) + self.framework.observe( + self.ca_client.on.ca_available, + self._on_ca_available) + self.framework.observe( + self.ca_client.on.tls_server_config_ready, + self._on_tls_server_config_ready) + self.framework.observe(self.on.add_user_action, self._add_user_action) + self.ingress = interface_api_endpoints.APIEndpointsRequires( + self, + 'loadbalancer', + { + 'endpoints': [{ + 'service-type': 'ceph-dashboard', + 'frontend-port': self.TLS_PORT, + 'backend-port': self.TLS_PORT, + 'backend-ip': self._get_bind_ip(), + 'check-type': 'httpd'}]}) + self._stored.set_default(is_started=False) + + def _on_ca_available(self, _) -> None: + """Request TLS certificates.""" + addresses = set() + for binding_name in ['public']: + binding = self.model.get_binding(binding_name) + addresses.add(binding.network.ingress_address) + addresses.add(binding.network.bind_address) + sans = [str(s) for s in addresses] + sans.append(socket.gethostname()) + if self.config.get('public-hostname'): + sans.append(self.config.get('public-hostname')) + self.ca_client.request_server_certificate(socket.getfqdn(), sans) + + def check_dashboard(self) -> StatusBase: + """Check status of dashboard""" + self._stored.is_started = ceph_utils.is_dashboard_enabled() + if self._stored.is_started: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + result = sock.connect_ex((self._get_bind_ip(), self.TLS_PORT)) + if result == 0: + return ActiveStatus() + else: + return BlockedStatus( + 'Dashboard not responding') + else: + return BlockedStatus( + 'Dashboard is not enabled') + return ActiveStatus() + + def kick_dashboard(self) -> None: + """Disable and re-enable dashboard""" + ceph_utils.mgr_disable_dashboard() + ceph_utils.mgr_enable_dashboard() + + def _configure_dashboard(self, _) -> None: + """Configure dashboard""" + if self.unit.is_leader() and not ceph_utils.is_dashboard_enabled(): + ceph_utils.mgr_enable_dashboard() + ceph_utils.mgr_config_set( + 'mgr/dashboard/{hostname}/server_addr'.format( + hostname=socket.gethostname()), + str(self._get_bind_ip())) + self.update_status() + + def _get_bind_ip(self) -> str: + """Return the IP to bind the dashboard to""" + binding = self.model.get_binding('public') + return str(binding.network.ingress_address) + + def _on_tls_server_config_ready(self, _) -> None: + """Configure TLS.""" + self.TLS_KEY_PATH.write_bytes( + self.ca_client.server_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption())) + self.TLS_CERT_PATH.write_bytes( + self.ca_client.server_certificate.public_bytes( + encoding=serialization.Encoding.PEM)) + self.TLS_CA_CERT_PATH.write_bytes( + self.ca_client.ca_certificate.public_bytes( + encoding=serialization.Encoding.PEM) + + self.ca_client.root_ca_chain.public_bytes( + encoding=serialization.Encoding.PEM)) + + hostname = socket.gethostname() + subprocess.check_call(['update-ca-certificates']) + ceph_utils.dashboard_set_ssl_certificate( + self.TLS_CERT_PATH, + hostname=hostname) + ceph_utils.dashboard_set_ssl_certificate_key( + self.TLS_KEY_PATH, + hostname=hostname) + if self.unit.is_leader(): + ceph_utils.mgr_config_set( + 'mgr/dashboard/standby_behaviour', + 'redirect') + ceph_utils.mgr_config_set( + 'mgr/dashboard/ssl', + 'true') + # Set the ssl artifacte without the hostname which appears to + # be required even though they aren't used. + ceph_utils.dashboard_set_ssl_certificate( + self.TLS_CERT_PATH) + ceph_utils.dashboard_set_ssl_certificate_key( + self.TLS_KEY_PATH) + self.kick_dashboard() + + def _gen_user_password(self, length: int = 8) -> str: + """Generate a password""" + alphabet = string.ascii_letters + string.digits + return ''.join(secrets.choice(alphabet) for i in range(length)) + + def _add_user_action(self, event: ActionEvent) -> None: + """Create a user""" + username = event.params["username"] + role = event.params["role"] + if not all([username, role]): + event.fail("Config missing") + else: + password = self._gen_user_password() + with tempfile.NamedTemporaryFile(mode='w', delete=True) as fp: + fp.write(password) + fp.flush() + cmd_out = subprocess.check_output([ + 'ceph', 'dashboard', 'ac-user-create', '--enabled', + '-i', fp.name, username, role]).decode('UTF-8') + if re.match('User.*already exists', cmd_out): + event.fail("User already exists") + else: + event.set_results({"password": password}) + +if __name__ == "__main__": + main(CephDashboardCharm) diff --git a/src/interface_api_endpoints.py b/src/interface_api_endpoints.py new file mode 100644 index 0000000..8908e44 --- /dev/null +++ b/src/interface_api_endpoints.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 + +import json + +from ops.framework import ( + StoredState, + EventBase, + ObjectEvents, + EventSource, + Object) + + +class EndpointDataEvent(EventBase): + pass + + +class APIEndpointsEvents(ObjectEvents): + ep_ready = EventSource(EndpointDataEvent) + + +class APIEndpointsRequires(Object): + + on = APIEndpointsEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name, config_dict): + super().__init__(charm, relation_name) + self.config_dict = config_dict + self.relation_name = relation_name + self.framework.observe( + charm.on[self.relation_name].relation_changed, + self._on_relation_changed) + + def _on_relation_changed(self, event): + """Handle the relation-changed event.""" + event.relation.data[self.model.unit]['endpoints'] = json.dumps( + self.config_dict['endpoints']) + + def update_config(self, config_dict): + """Allow for updates to relation.""" + self.config_dict = config_dict + relation = self.model.get_relation(self.relation_name) + if relation: + relation.data[self.model.unit]['endpoints'] = json.dumps( + self.config_dict['endpoints']) + + +class APIEndpointsProvides(Object): + + on = APIEndpointsEvents() + _stored = StoredState() + + def __init__(self, charm): + super().__init__(charm, "loadbalancer") + # Observe the relation-changed hook event and bind + # self.on_relation_changed() to handle the event. + self.framework.observe( + charm.on["loadbalancer"].relation_changed, + self._on_relation_changed) + self.charm = charm + + def _on_relation_changed(self, event): + """Handle a change to the loadbalancer relation.""" + self.on.ep_ready.emit() diff --git a/src/interface_dashboard.py b/src/interface_dashboard.py new file mode 100644 index 0000000..19183f7 --- /dev/null +++ b/src/interface_dashboard.py @@ -0,0 +1,43 @@ +#!/usr/bin/env python3 + +import logging + +from ops.framework import ( + StoredState, + EventBase, + ObjectEvents, + EventSource, + Object) + + +class MonReadyEvent(EventBase): + pass + + +class CephDashboardEvents(ObjectEvents): + mon_ready = EventSource(MonReadyEvent) + + +class CephDashboardRequires(Object): + + on = CephDashboardEvents() + _stored = StoredState() + READY_KEY = 'mon-ready' + + def __init__(self, charm, relation_name): + super().__init__(charm, relation_name) + self.relation_name = relation_name + self.framework.observe( + charm.on[relation_name].relation_changed, + self.on_changed) + + def on_changed(self, event): + logging.debug("CephDashboardRequires on_changed") + for u in self.dashboard_relation.units: + if self.dashboard_relation.data[u].get(self.READY_KEY) == 'True': + logging.debug("Emitting mon ready") + self.on.mon_ready.emit() + + @property + def dashboard_relation(self): + return self.framework.model.get_relation(self.relation_name) diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..8057d2c --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,17 @@ +# This file is managed centrally. If you find the need to modify this as a +# one-off, please don't. Intead, consult #openstack-charms and ask about +# requirements management in charms via bot-control. Thank you. +charm-tools>=2.4.4 +coverage>=3.6 +mock>=1.2 +flake8>=2.2.4,<=2.4.1 +pyflakes==2.1.1 +stestr>=2.2.0 +requests>=2.18.4 +psutil +# oslo.i18n dropped py35 support +oslo.i18n<4.0.0 +git+https://github.com/openstack-charmers/zaza.git#egg=zaza +git+https://github.com/openstack-charmers/zaza-openstack-tests.git#egg=zaza.openstack +pytz # workaround for 14.04 pip/tox +pyudev # for ceph-* charm unit tests (not mocked?) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/bundles/focal.yaml b/tests/bundles/focal.yaml new file mode 100644 index 0000000..64c8869 --- /dev/null +++ b/tests/bundles/focal.yaml @@ -0,0 +1,39 @@ +local_overlay_enabled: False +series: focal +applications: + ceph-osd: + charm: cs:~openstack-charmers-next/ceph-osd + num_units: 6 + storage: + osd-devices: 'cinder,10G' + options: + osd-devices: '/dev/test-non-existent' + ceph-mon: + charm: cs:~gnuoy/ceph-mon-26 + num_units: 3 + options: + monitor-count: '3' + vault: + num_units: 1 + charm: cs:~openstack-charmers-next/vault + mysql-innodb-cluster: + charm: cs:~openstack-charmers-next/mysql-innodb-cluster-79 + constraints: mem=3072M + num_units: 3 + vault-mysql-router: + charm: cs:~openstack-charmers-next/mysql-router + ceph-dashboard: + charm: ../../ceph-dashboard.charm + options: + public-hostname: 'ceph-dashboard.zaza.local' +relations: + - - 'ceph-osd:mon' + - 'ceph-mon:osd' + - - 'vault:shared-db' + - 'vault-mysql-router:shared-db' + - - 'vault-mysql-router:db-router' + - 'mysql-innodb-cluster:db-router' + - - 'ceph-dashboard:dashboard' + - 'ceph-mon:dashboard' + - - 'ceph-dashboard:certificates' + - 'vault:certificates' diff --git a/tests/bundles/overlays/local-charm-overlay.yaml.j2 b/tests/bundles/overlays/local-charm-overlay.yaml.j2 new file mode 100644 index 0000000..64c91cf --- /dev/null +++ b/tests/bundles/overlays/local-charm-overlay.yaml.j2 @@ -0,0 +1,3 @@ +applications: + ceph-dashboard: + charm: ../../ceph-dashboard.charm diff --git a/tests/tests.yaml b/tests/tests.yaml new file mode 100644 index 0000000..63e365f --- /dev/null +++ b/tests/tests.yaml @@ -0,0 +1,13 @@ +charm_name: ceph-dasboard +gate_bundles: + - focal +smoke_bundles: + - focal +configure: + - zaza.openstack.charm_tests.vault.setup.auto_initialize_no_validation +tests: + - zaza.openstack.charm_tests.ceph.dashboard.tests.CephDashboardTest +target_deploy_status: + vault: + workload-status: blocked + workload-status-message: Vault needs to be initialized diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..3220044 --- /dev/null +++ b/tox.ini @@ -0,0 +1,134 @@ +# Operator charm (with zaza): tox.ini + +[tox] +envlist = pep8,py3 +skipsdist = True +# NOTE: Avoid build/test env pollution by not enabling sitepackages. +sitepackages = False +# NOTE: Avoid false positives by not skipping missing interpreters. +skip_missing_interpreters = False +# NOTES: +# * We avoid the new dependency resolver by pinning pip < 20.3, see +# https://github.com/pypa/pip/issues/9187 +# * Pinning dependencies requires tox >= 3.2.0, see +# https://tox.readthedocs.io/en/latest/config.html#conf-requires +# * It is also necessary to pin virtualenv as a newer virtualenv would still +# lead to fetching the latest pip in the func* tox targets, see +# https://stackoverflow.com/a/38133283 +requires = pip < 20.3 + virtualenv < 20.0 +# NOTE: https://wiki.canonical.com/engineering/OpenStack/InstallLatestToxOnOsci +minversion = 3.2.0 + +[testenv] +setenv = VIRTUAL_ENV={envdir} + PYTHONHASHSEED=0 + CHARM_DIR={envdir} +install_command = + pip install {opts} {packages} +commands = stestr run --slowest {posargs} +whitelist_externals = + git + add-to-archive.py + bash + charmcraft +passenv = HOME TERM CS_* OS_* TEST_* +deps = -r{toxinidir}/test-requirements.txt + +[testenv:py35] +basepython = python3.5 +# python3.5 is irrelevant on a focal+ charm. +commands = /bin/true + +[testenv:py36] +basepython = python3.6 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:py37] +basepython = python3.7 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:py38] +basepython = python3.8 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:py3] +basepython = python3 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:pep8] +basepython = python3 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = flake8 {posargs} src unit_tests tests + +[testenv:cover] +# Technique based heavily upon +# https://github.com/openstack/nova/blob/master/tox.ini +basepython = python3 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +setenv = + {[testenv]setenv} + PYTHON=coverage run +commands = + coverage erase + stestr run --slowest {posargs} + coverage combine + coverage html -d cover + coverage xml -o cover/coverage.xml + coverage report + +[coverage:run] +branch = True +concurrency = multiprocessing +parallel = True +source = + . +omit = + .tox/* + */charmhelpers/* + unit_tests/* + +[testenv:venv] +basepython = python3 +commands = {posargs} + +[testenv:build] +basepython = python3 +deps = +commands = + charmcraft build + +[testenv:func-noop] +basepython = python3 +commands = + functest-run-suite --help + +[testenv:func] +basepython = python3 +commands = + functest-run-suite --keep-model + +[testenv:func-smoke] +basepython = python3 +commands = + functest-run-suite --keep-model --smoke + +[testenv:func-dev] +basepython = python3 +commands = + functest-run-suite --keep-model --dev + +[testenv:func-target] +basepython = python3 +commands = + functest-run-suite --keep-model --bundle {posargs} + +[flake8] +# Ignore E902 because the unit_tests directory is missing in the built charm. +ignore = E402,E226,E902 diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py new file mode 100644 index 0000000..577ab7e --- /dev/null +++ b/unit_tests/__init__.py @@ -0,0 +1,19 @@ +# Copyright 2020 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 +import mock + +# Mock out secrets to make py35 happy. +sys.modules['secrets'] = mock.MagicMock() diff --git a/unit_tests/test_ceph_dashboard_charm.py b/unit_tests/test_ceph_dashboard_charm.py new file mode 100644 index 0000000..c6447e6 --- /dev/null +++ b/unit_tests/test_ceph_dashboard_charm.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 + +# Copyright 2020 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 unittest +import sys + +sys.path.append('lib') # noqa +sys.path.append('src') # noqa + +from mock import call, patch, MagicMock + +from ops.testing import Harness, _TestingModelBackend +from ops.model import ( + ActiveStatus, + BlockedStatus, +) +from ops import framework, model +import charm + +TEST_CA = '''-----BEGIN CERTIFICATE----- +MIIC8TCCAdmgAwIBAgIUAK1dgpjTc850TgQx6y3W1brByOwwDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPRGl2aW5lQXV0aG9yaXR5MB4XDTIxMDYyMTExNTg1OFoX +DTIxMDcyMTExNTg1OVowGjEYMBYGA1UEAwwPRGl2aW5lQXV0aG9yaXR5MIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA08gO8TDPARVhfVLOkYYRvCU1Rviv +RYmy+ptA82XIHO1HvAuLQ8x/4bGxE+IMKSNIl+DIF9TMdmOCvKOBgRKsoOibZNfW +MJIeQwff/8LMFWReAjOxcf9Bu2EqOqkLmUV72FU+Weta8r2kuFhgryqvz1rZeZzQ +jP6OsscoY2FVt/TnvUL5cCOSTpKuQLSr8pDms3OuFIyhFkUinpGbgJQ83xQO1tRh +MGiA87lahsLECTKXsLPyFMMPZ/QQuoDmuUHNkR2deOLcYRSWIBy23PctuV893gbM +2sFTprWo1PKXSmFUd3lg6G5wSM2XRQAP81CTA3Hp8Fj5XCpOHa4HFQLxDwIDAQAB +oy8wLTAaBgNVHREEEzARgg9EaXZpbmVBdXRob3JpdHkwDwYDVR0TAQH/BAUwAwEB +/zANBgkqhkiG9w0BAQsFAAOCAQEAKsrUnYBJyyEIPXkWaemR5vmp0G+V6Xz3KvPB +hLYKRONMba8xFwrjRv7b0DNAws8TcXXOKtRtJWbnSIMGhfVESF6ohqEdn+J1crXs +2RpJgyF2u+l6gg9Sg2ngYMQYBkzjAHYTroO/itI4AWLPLHpgygzz8ho6ykWpDoxJ +QfrrtHCl90zweYDhl4g2joIOJSZdd36+Nx9f2guItRMN87EZy1mOrKs94HlW9jwj +mAfiGaYhgFn4JH2jVcZu4wVJErh4Z0A3UNNyOq4zlAq8pHa/54jerHTDB49UQbaI +vZ5PsZhTZLy3FImSbe25xMUZNTt/2MMjsQwSjwiQuxLSuicJAA== +-----END CERTIFICATE-----''' + +TEST_CERT = '''-----BEGIN CERTIFICATE----- +MIIEdjCCA16gAwIBAgIUPmsr+BnLb6Yy22Zg6hkXn1B6KZcwDQYJKoZIhvcNAQEL +BQAwRTFDMEEGA1UEAxM6VmF1bHQgSW50ZXJtZWRpYXRlIENlcnRpZmljYXRlIEF1 +dGhvcml0eSAoY2hhcm0tcGtpLWxvY2FsKTAeFw0yMTA2MjExMTU4MzNaFw0yMjA2 +MjExMDU5MDJaMD4xPDA6BgNVBAMTM2p1anUtOGMzOTI5LXphemEtZWZjMDU2ZjE2 +NmNkLTAucHJvamVjdC5zZXJ2ZXJzdGFjazCCASIwDQYJKoZIhvcNAQEBBQADggEP +ADCCAQoCggEBANW0NkSLH53M2Aok6lxN4qSSUDTnIWeuKsemLp7FwZn6zN7fRa4V +utuGWbeYahdSIY6AG3w5opCyijM/+L4+HWoY5BWGFPj/U5V4CDF9jOerNDcoxKDy ++h+CbJ324xJrCBOjMyW8wqK/lzCadQzy6DymOtK0RBJNHXsXiGWta7UMFo2AZcqM +8OkOd0HkBeDM90dzTRSuy3pvqNBKmpwG4Hmg/ESh7VuobuHTtkD2/sGEVMGoXm7Q +qk6Yf8POzNqdPoHzvY40uZWqL3OwedGWDrnNbH4sTYb1xB7fwBthvs+LNPUDzRXA +NOYlKsfRrsiH9ELyMWUfarKXxg+7JelBIdECAwEAAaOCAWMwggFfMA4GA1UdDwEB +/wQEAwIDqDAdBgNVHSUEFjAUBggrBgEFBQcDAQYIKwYBBQUHAwIwHQYDVR0OBBYE +FEpYZVtgGevbnUrzWsjAXZix5zgzMEoGCCsGAQUFBwEBBD4wPDA6BggrBgEFBQcw +AoYuaHR0cDovLzE3Mi4yMC4wLjExOTo4MjAwL3YxL2NoYXJtLXBraS1sb2NhbC9j +YTCBgAYDVR0RBHkwd4IZY2VwaC1kYXNoYm9hcmQuemF6YS5sb2NhbIIfanVqdS04 +YzM5MjktemF6YS1lZmMwNTZmMTY2Y2QtMIIzanVqdS04YzM5MjktemF6YS1lZmMw +NTZmMTY2Y2QtMC5wcm9qZWN0LnNlcnZlcnN0YWNrhwSsFAD9MEAGA1UdHwQ5MDcw +NaAzoDGGL2h0dHA6Ly8xNzIuMjAuMC4xMTk6ODIwMC92MS9jaGFybS1wa2ktbG9j +YWwvY3JsMA0GCSqGSIb3DQEBCwUAA4IBAQBRUsmnc5fnNh1TSO1hVdpYBo6SRqdN +VPuG3EV6QYPGnqadzGTr3uREUyZdkOUu4nhqDONMTdlfCwg744AIlY+eo2tpiNEp +GOeFV0qZOiGRq7q2kllCTYCnh7hKCTCSN17o9QDTCL6w46cmH5OXo84BHkozdBiO +cHPQ+uJ/VZaRCuOIlVS4Y4vTDB0LpNX2nHC/tMYL0zA5+pu+N6e8OWcCgKwObdh5 +38iuimYbbwv2QWBD+4eQUbxY0+TXlhdg42Um41N8BVdPapNAQRXIHrZJC5P6fXqX +uoZ6TvbI2U0GSfpjScPP5D2F6tWK7/3nbA8bPLUJ1MKDofBVtrlA4PIH +-----END CERTIFICATE-----''' + +TEST_KEY = '''-----BEGIN RSA PRIVATE KEY----- +MIIEowIBAAKCAQEA1bQ2RIsfnczYCiTqXE3ipJJQNOchZ64qx6YunsXBmfrM3t9F +rhW624ZZt5hqF1IhjoAbfDmikLKKMz/4vj4dahjkFYYU+P9TlXgIMX2M56s0NyjE +oPL6H4JsnfbjEmsIE6MzJbzCor+XMJp1DPLoPKY60rREEk0dexeIZa1rtQwWjYBl +yozw6Q53QeQF4Mz3R3NNFK7Lem+o0EqanAbgeaD8RKHtW6hu4dO2QPb+wYRUwahe +btCqTph/w87M2p0+gfO9jjS5laovc7B50ZYOuc1sfixNhvXEHt/AG2G+z4s09QPN +FcA05iUqx9GuyIf0QvIxZR9qspfGD7sl6UEh0QIDAQABAoIBAAHqAk5s3JSiQBEf +MYYwIGaO9O70XwU5tyJgp6w+YzSI3Yrlfw9HHIxY0LbnQ5P/5VMMbLKZJY6cOsao +vQafMc5AeNKEh+2PA+Wj1Jb04+0zSF1yHQjABGOB3I0xp+kDUmgynwOohCnHA4io +6YF7L39TkdVPTgjH7gqrNEqM2hkeBWg1LY5QARDtz6Nj10LRtpQXjx/zwfGfzV2c +TGpO8ArfPLS+a7LAJ+E+iSgDUX272Fd7DYAv7xRcRe8991umpqFzbY8FDigLWEdd +3muWnRsJjricYM+2OO0QO8fyKhWCE31Dvc0xMLgrSTWoZAl8t7/WxyowevuVAm5o +oclYFU0CgYEA4M6seEB/neaqAWMIshwIcwZWaLy7oQAQagjXbKohSAXNlYqgTuv7 +glk0P6uzeQOu0ejipwga6mQIc093WSzpG1sdT4bBysHS0b44Gx/6Cv0Jf6hmJGcU +wNo3XV8b0rHZ+KWDCfr1dUjxCA9rR2fOTJniCh9Ng28cyhrFyZ6HaUcCgYEA81sj +Z3ATs2uMxZePmGMWxOJqbQ+bHaoE+UG1dTQIVO//MmanJm3+o4ciH46D2QRWkYha +4Eqb5wnPKCQjun8JDpwgkLkd0EGGG4uJ6E6YqL3I0+cs5lwMWJ9M3oOaFGGoFAoP +V9lgz5f3yVdSChoubklS4KLeCiAojW/qX1rrKCcCgYEAuALz0YqZ6xm/1lrF52Ri +1iQ93oV934854FFUZDHuBBIb8WgDSBaJTGzQA737rfaBxngl7isIPQucjyZgvrGw +LSArocjgH6L/eYeGTU2jUhNFDyU8Vle5+RGld9w93fyOOqTf2e99s379LGfSnCQw +DSt4hmiQ/iCZJCU9+Ia2uEkCgYAGsPjWPUStaEWkoTg3jnHv0/HtMcKoHCaq292r +bVTVUQwJTL1H1zprMKoFiBuj+fSPZ9pn1GVZAvIJPoUk+Z08I5rZn91r/oE7fKi8 +FH0qFp3RBcg8RUepoCey7pdr/AttEaG+XqHE037isF33HSUtryJyPsgwKxYyXWNq +X8ubfQKBgBwIpk7N754lN0i6V08Dadz0BlpfFYGO/ZfTmvVrPUxwehogtvpGnjhO +xPs1epK65/vHbBtaUDExayOEIvVhVWcnaXdx3z1aw/Hr29NlOi62x4g/RRSloLZH +08UCW9F5C8Ian6kglB5bPrZiJxcmssj7vSA+O6k9BjsO+ebaSRgk +-----END RSA PRIVATE KEY-----''' + +TEST_CHAIN = '''-----BEGIN CERTIFICATE----- +MIIDADCCAeigAwIBAgIUN93XI0mOu3wkX5YureWnMImedUMwDQYJKoZIhvcNAQEL +BQAwGjEYMBYGA1UEAwwPRGl2aW5lQXV0aG9yaXR5MB4XDTIxMDYyMzEwMzcwMFoX +DTMyMDYwNjEwMzcwMFowRTFDMEEGA1UEAxM6VmF1bHQgSW50ZXJtZWRpYXRlIENl +cnRpZmljYXRlIEF1dGhvcml0eSAoY2hhcm0tcGtpLWxvY2FsKTCCASIwDQYJKoZI +hvcNAQEBBQADggEPADCCAQoCggEBAL1t5WYd7IVsfT5d4uztBhOPBA0EtrKw81Fe +Rp2TNdPUkkKSQxOYKV6F1ndyD88Nxx1mcxwi8U28b1azTNVaPRjSLxyDCOD0L5qk +LaFqppTWv8vLcjjlp6Ed3BLXoVMThWwMxJm/VSPuEXnWN5GrMR97Ae8vmnlrYDTF +re67j0zjDPhkyevVQ5+pLeZ/saQtNNeal1qzfWMPDQK0COfXolXmlmZGzhap742e +x4gE6alyYYrpTPA6CL9NbGhNovuz/LJvHN8fIdfw3jX+GW+yy312xDG+67PCW342 +VDrPcG+Vq/BhEPwL3blYgbmtNPDQ1plWJqoPqoJzbCxLesXZHP8CAwEAAaMTMBEw +DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEARv1bBEgwlDG3PuhF +Zt5kIeDLEnjFH2STz4LLERZXdKhTzuaV08QvYr+cL8XHi4Sop5BDkAuQq8mVC/xj +7DoW/Lb9SnxfsCIu6ugwKLfJ2El6r23kDzTauIaovDYNSEo21yBYALsFZjzMJotJ +XLpLklASTAdMmLP703hcgKgY8yxzS3WEXA9jekmn6z0y3+UZjIF5W9dW9gaQk0Eg +vsLN7xzG9TmQfk1OHUj7y+cEbYr0M3Jdif/gG8Kl2SuaYUmvU6leA5+oZVF/Inle +jdSckxCCd1rbvGd60AY5azD1pAuazijwW9Y9Icv2tS5oZI/4MN7YJEssj/ZLjEA7 +Alm0ZQ== +-----END CERTIFICATE-----''' + + +class CharmTestCase(unittest.TestCase): + + def setUp(self, obj, patches): + super().setUp() + self.patches = patches + self.obj = obj + self.patch_all() + + def patch(self, method): + _m = patch.object(self.obj, method) + mock = _m.start() + self.addCleanup(_m.stop) + return mock + + def patch_all(self): + for method in self.patches: + setattr(self, method, self.patch(method)) + + +class _CephDashboardCharm(charm.CephDashboardCharm): + + def _get_bind_ip(self): + return '10.0.0.10' + + +class TestCephDashboardCharmBase(CharmTestCase): + + PATCHES = [ + 'ceph_utils', + 'socket', + 'subprocess' + ] + + def setUp(self): + super().setUp(charm, self.PATCHES) + self.harness = Harness( + _CephDashboardCharm, + ) + + # BEGIN: Workaround until network_get is implemented + class _TestingOPSModelBackend(_TestingModelBackend): + + def network_get(self, endpoint_name, relation_id=None): + network_data = { + 'bind-addresses': [{ + 'interface-name': 'eth0', + 'addresses': [{ + 'cidr': '10.0.0.0/24', + 'value': '10.0.0.10'}]}], + 'ingress-addresses': ['10.0.0.10'], + 'egress-subnets': ['10.0.0.0/24']} + return network_data + + self.harness._backend = _TestingOPSModelBackend( + self.harness._unit_name, self.harness._meta) + self.harness._model = model.Model( + self.harness._meta, + self.harness._backend) + self.harness._framework = framework.Framework( + ":memory:", + self.harness._charm_dir, + self.harness._meta, + self.harness._model) + # END Workaround + self.socket.gethostname.return_value = 'server1' + self.socket.getfqdn.return_value = 'server1.local' + + def test_init(self): + self.harness.begin() + self.assertFalse(self.harness.charm._stored.is_started) + + def test__on_ca_available(self): + rel_id = self.harness.add_relation('certificates', 'vault') + self.harness.begin() + self.harness.add_relation_unit( + rel_id, + 'vault/0') + self.harness.update_relation_data( + rel_id, + 'vault/0', + {'ingress-address': '10.0.0.3'}) + rel_data = self.harness.get_relation_data(rel_id, 'ceph-dashboard/0') + self.assertEqual( + rel_data['cert_requests'], + '{"server1.local": {"sans": ["10.0.0.10", "server1"]}}') + + def test_check_dashboard(self): + socket_mock = MagicMock() + self.socket.socket.return_value = socket_mock + socket_mock.connect_ex.return_value = 0 + self.ceph_utils.is_dashboard_enabled.return_value = True + self.harness.begin() + self.assertEqual( + self.harness.charm.check_dashboard(), + ActiveStatus()) + + socket_mock.connect_ex.return_value = 1 + self.assertEqual( + self.harness.charm.check_dashboard(), + BlockedStatus('Dashboard not responding')) + + socket_mock.connect_ex.return_value = 0 + self.ceph_utils.is_dashboard_enabled.return_value = False + self.assertEqual( + self.harness.charm.check_dashboard(), + BlockedStatus('Dashboard is not enabled')) + + def test_kick_dashboard(self): + self.harness.begin() + self.harness.charm.kick_dashboard() + self.ceph_utils.mgr_disable_dashboard.assert_called_once_with() + self.ceph_utils.mgr_enable_dashboard.assert_called_once_with() + + def test__configure_dashboard(self): + self.harness.begin() + + self.ceph_utils.is_dashboard_enabled.return_value = True + self.harness.set_leader(False) + self.harness.charm._configure_dashboard(None) + self.assertFalse(self.ceph_utils.mgr_enable_dashboard.called) + self.ceph_utils.mgr_config_set.assert_called_once_with( + 'mgr/dashboard/server1/server_addr', + '10.0.0.10') + + self.ceph_utils.mgr_config_set.reset_mock() + self.ceph_utils.is_dashboard_enabled.return_value = True + self.harness.set_leader() + self.harness.charm._configure_dashboard(None) + self.assertFalse(self.ceph_utils.mgr_enable_dashboard.called) + self.ceph_utils.mgr_config_set.assert_called_once_with( + 'mgr/dashboard/server1/server_addr', + '10.0.0.10') + + self.ceph_utils.mgr_config_set.reset_mock() + self.ceph_utils.is_dashboard_enabled.return_value = False + self.harness.set_leader() + self.harness.charm._configure_dashboard(None) + self.ceph_utils.mgr_enable_dashboard.assert_called_once_with() + self.ceph_utils.mgr_config_set.assert_called_once_with( + 'mgr/dashboard/server1/server_addr', + '10.0.0.10') + + def test__get_bind_ip(self): + self.harness.begin() + self.assertEqual( + self.harness.charm._get_bind_ip(), + '10.0.0.10') + + @patch('socket.gethostname') + def test__on_tls_server_config_ready(self, _gethostname): + mock_TLS_KEY_PATH = MagicMock() + mock_TLS_CERT_PATH = MagicMock() + mock_TLS_CA_CERT_PATH = MagicMock() + _gethostname.return_value = 'server1' + rel_id = self.harness.add_relation('certificates', 'vault') + self.harness.begin() + self.harness.set_leader() + self.harness.charm.TLS_CERT_PATH = mock_TLS_CERT_PATH + self.harness.charm.TLS_CA_CERT_PATH = mock_TLS_CA_CERT_PATH + self.harness.charm.TLS_KEY_PATH = mock_TLS_KEY_PATH + self.harness.add_relation_unit( + rel_id, + 'vault/0') + self.harness.update_relation_data( + rel_id, + 'vault/0', + { + 'ceph-dashboard_0.server.cert': TEST_CERT, + 'ceph-dashboard_0.server.key': TEST_KEY, + 'chain': TEST_CHAIN, + 'ca': TEST_CA}) + mock_TLS_CERT_PATH.write_bytes.assert_called_once() + mock_TLS_CA_CERT_PATH.write_bytes.assert_called_once() + mock_TLS_KEY_PATH.write_bytes.assert_called_once() + self.subprocess.check_call.assert_called_once_with( + ['update-ca-certificates']) + self.ceph_utils.dashboard_set_ssl_certificate.assert_has_calls([ + call(mock_TLS_CERT_PATH, hostname='server1'), + call(mock_TLS_CERT_PATH)]) + self.ceph_utils.dashboard_set_ssl_certificate_key.assert_has_calls([ + call(mock_TLS_KEY_PATH, hostname='server1'), + call(mock_TLS_KEY_PATH)]) + self.ceph_utils.mgr_config_set.assert_has_calls([ + call('mgr/dashboard/standby_behaviour', 'redirect'), + call('mgr/dashboard/ssl', 'true')]) + self.ceph_utils.mgr_disable_dashboard.assert_called_once_with() + self.ceph_utils.mgr_enable_dashboard.assert_called_once_with() + + @patch.object(charm.secrets, 'choice') + def test__gen_user_password(self, _choice): + self.harness.begin() + _choice.return_value = 'r' + self.assertEqual( + self.harness.charm._gen_user_password(), + 'rrrrrrrr') + + @patch.object(charm.tempfile, 'NamedTemporaryFile') + @patch.object(charm.secrets, 'choice') + def test__add_user_action(self, _choice, _NTFile): + self.subprocess.check_output.return_value = b'' + _NTFile.return_value.__enter__.return_value.name = 'tempfilename' + _choice.return_value = 'r' + self.harness.begin() + action_event = MagicMock() + action_event.params = { + 'username': 'auser', + 'role': 'administrator'} + self.harness.charm._add_user_action(action_event) + self.subprocess.check_output.assert_called_once_with( + ['ceph', 'dashboard', 'ac-user-create', '--enabled', + '-i', 'tempfilename', 'auser', 'administrator']) diff --git a/unit_tests/test_interface_api_endpoints.py b/unit_tests/test_interface_api_endpoints.py new file mode 100644 index 0000000..69f576d --- /dev/null +++ b/unit_tests/test_interface_api_endpoints.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 + +# Copyright 2020 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 copy +import json +import unittest +import sys +sys.path.append('lib') # noqa +sys.path.append('src') # noqa +from ops.testing import Harness +from ops.charm import CharmBase +import interface_api_endpoints + + +class TestAPIEndpointsRequires(unittest.TestCase): + + class MyCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.seen_events = [] + self.ingress = interface_api_endpoints.APIEndpointsRequires( + self, + 'loadbalancer', + { + 'endpoints': [{ + 'service-type': 'ceph-dashboard', + 'frontend-port': 8443, + 'backend-port': 8443, + 'backend-ip': '10.0.0.10', + 'check-type': 'httpd'}]}) + + def setUp(self): + super().setUp() + self.harness = Harness( + self.MyCharm, + meta=''' +name: my-charm +requires: + loadbalancer: + interface: api-endpoints +''' + ) + self.eps = [{ + 'service-type': 'ceph-dashboard', + 'frontend-port': 8443, + 'backend-port': 8443, + 'backend-ip': '10.0.0.10', + 'check-type': 'httpd'}] + + def add_loadbalancer_relation(self): + rel_id = self.harness.add_relation( + 'loadbalancer', + 'service-loadbalancer') + self.harness.add_relation_unit( + rel_id, + 'service-loadbalancer/0') + self.harness.update_relation_data( + rel_id, + 'service-loadbalancer/0', + {'ingress-address': '10.0.0.3'}) + return rel_id + + def test_init(self): + self.harness.begin() + self.assertEqual( + self.harness.charm.ingress.config_dict, + {'endpoints': self.eps}) + self.assertEqual( + self.harness.charm.ingress.relation_name, + 'loadbalancer') + + def test__on_relation_changed(self): + self.harness.begin() + rel_id = self.add_loadbalancer_relation() + rel_data = self.harness.get_relation_data( + rel_id, + 'my-charm/0') + self.assertEqual( + rel_data['endpoints'], + json.dumps(self.eps)) + + def test_update_config(self): + self.harness.begin() + rel_id = self.add_loadbalancer_relation() + new_eps = copy.deepcopy(self.eps) + new_eps.append({ + 'service-type': 'ceph-dashboard', + 'frontend-port': 9443, + 'backend-port': 9443, + 'backend-ip': '10.0.0.10', + 'check-type': 'https'}) + self.harness.charm.ingress.update_config( + {'endpoints': new_eps}) + rel_data = self.harness.get_relation_data( + rel_id, + 'my-charm/0') + self.assertEqual( + rel_data['endpoints'], + json.dumps(new_eps)) + + +class TestAPIEndpointsProvides(unittest.TestCase): + + class MyCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.seen_events = [] + self.api_eps = interface_api_endpoints.APIEndpointsProvides(self) + self.framework.observe( + self.api_eps.on.ep_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 +provides: + loadbalancer: + interface: api-endpoints +''' + ) + + def test_on_changed(self): + self.harness.begin() + # No MonReadyEvent as relation is absent + self.assertEqual( + self.harness.charm.seen_events, + []) + rel_id = self.harness.add_relation('loadbalancer', 'ceph-dashboard') + self.harness.add_relation_unit( + rel_id, + 'ceph-dashboard/0') + self.harness.update_relation_data( + rel_id, + 'ceph-dashboard/0', + {'ingress-address': '10.0.0.3'}) + self.assertEqual( + self.harness.charm.seen_events, + ['EndpointDataEvent']) diff --git a/unit_tests/test_interface_dashboard.py b/unit_tests/test_interface_dashboard.py new file mode 100644 index 0000000..9e18777 --- /dev/null +++ b/unit_tests/test_interface_dashboard.py @@ -0,0 +1,98 @@ +#!/usr/bin/env python3 + +# Copyright 2020 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 unittest +import sys +sys.path.append('lib') # noqa +sys.path.append('src') # noqa +from ops.testing import Harness +from ops.charm import CharmBase, CharmMeta +import interface_dashboard + + +class MyCharm(CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.framework.meta = CharmMeta.from_yaml(metadata=''' +name: my-charm +requires: + dashboard: + interface: ceph-dashboard + scope: container +''') + + self.seen_events = [] + self.mon = interface_dashboard.CephDashboardRequires( + self, + 'dashboard') + + self.framework.observe( + self.mon.on.mon_ready, + self._log_event) + + def _log_event(self, event): + self.seen_events.append(type(event).__name__) + + +class TestCephDashboardRequires(unittest.TestCase): + + def setUp(self): + super().setUp() + self.harness = Harness( + MyCharm, + ) + + def add_dashboard_relation(self): + rel_id = self.harness.add_relation('dashboard', 'ceph-mon') + self.harness.add_relation_unit( + rel_id, + 'ceph-mon/0') + return rel_id + + def test_relation_name(self): + self.harness.begin() + self.assertEqual( + self.harness.charm.mon.relation_name, + 'dashboard') + + def test_dashboard_relation(self): + self.harness.begin() + self.assertIsNone( + self.harness.charm.mon.dashboard_relation) + rel_id = self.add_dashboard_relation() + self.assertEqual( + self.harness.charm.mon.dashboard_relation.id, + rel_id) + + def test_on_changed(self): + self.harness.begin() + # No MonReadyEvent as relation is absent + self.assertEqual( + self.harness.charm.seen_events, + []) + rel_id = self.add_dashboard_relation() + # No MonReadyEvent as ceph-mon has not declared it is ready. + self.assertEqual( + self.harness.charm.seen_events, + []) + self.harness.update_relation_data( + rel_id, + 'ceph-mon/0', + {'mon-ready': 'True'}) + self.assertEqual( + self.harness.charm.seen_events, + ['MonReadyEvent'])