Initial cut of ceph-dashboard charm

This commit is contained in:
Liam Young 2021-06-24 13:14:57 +00:00
commit 0d463e0de2
24 changed files with 1430 additions and 0 deletions

9
.flake8 Normal file
View File

@ -0,0 +1,9 @@
[flake8]
max-line-length = 99
select: E,W,F,C,N
exclude:
venv
.git
build
dist
*.egg_info

8
.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
.tox
**/*.swp
__pycache__
.stestr/
lib/*
!lib/README.txt
build
*.charm

3
.jujuignore Normal file
View File

@ -0,0 +1,3 @@
/venv
*.py[cod]
*.charm

3
.stestr.conf Normal file
View File

@ -0,0 +1,3 @@
[DEFAULT]
test_path=./unit_tests
top_dir=./

202
LICENSE Normal file
View File

@ -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.

27
README.md Normal file
View File

@ -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
<!-- LINKS -->
[ceph-dashboard]: https://docs.ceph.com/en/latest/mgr/dashboard/
[ceph-mon-charm]: https://jaas.ai/ceph-mon

14
actions.yaml Normal file
View File

@ -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: ""

10
config.yaml Normal file
View File

@ -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

27
metadata.yaml Normal file
View File

@ -0,0 +1,27 @@
# Copyright 2021 Canonical
# See LICENSE file for licensing details.
name: ceph-dashboard
display-name: Ceph Dashboard
maintainer: OpenStack Charmers <openstack-discuss@lists.openstack.org>
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

3
requirements-dev.txt Normal file
View File

@ -0,0 +1,3 @@
-r requirements.txt
coverage
flake8

5
requirements.txt Normal file
View File

@ -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

192
src/charm.py Executable file
View File

@ -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)

View File

@ -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()

View File

@ -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)

17
test-requirements.txt Normal file
View File

@ -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?)

0
tests/__init__.py Normal file
View File

39
tests/bundles/focal.yaml Normal file
View File

@ -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'

View File

@ -0,0 +1,3 @@
applications:
ceph-dashboard:
charm: ../../ceph-dashboard.charm

13
tests/tests.yaml Normal file
View File

@ -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

134
tox.ini Normal file
View File

@ -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

19
unit_tests/__init__.py Normal file
View File

@ -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()

View File

@ -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'])

View File

@ -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'])

View File

@ -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'])