commit 7c188a3d59b467e0dbba8a3cc8888a27fff475a5 Author: James Page Date: Wed Mar 4 10:11:16 2020 +0000 Initial commite diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af71e47 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +bin +.coverage +.testrepository +.tox +*.sw[nop] +*.pyc +.unit-state.db +.stestr +__pycache__ +func-results.json +tests/id_rsa_zaza 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/README.md b/README.md new file mode 100644 index 0000000..417b1e0 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# Overview + +TrilioVault Data Mover API provides API service for TrilioVault Datamover + +# Usage + +TrilioVault Data Mover API relies on services from mysql, rabbitmq-server +and keystone charms. Steps to deploy the charm: + +juju deploy trilio-dm-api + +juju deploy keystone + +juju deploy mysql + +juju deploy rabbitmq-server + +juju add-relation trilio-dm-api rabbitmq-server + +juju add-relation trilio-dm-api mysql + +juju add-relation trilio-dm-api keystone + +# Configuration + +python-version: "Openstack base python version(2 or 3)" + +NOTE - Default value is set to "3". Please ensure to update this based on python version since installing + python3 packages on python2 based setup might have unexpected impact. + +TrilioVault Packages are downloaded from the repository added in below config parameter. Please change this only if you wish to download +TrilioVault Packages from a different source. + +triliovault-pkg-source: Repository address of triliovault packages + +# Contact Information + +Trilio Support diff --git a/copyright b/copyright new file mode 100644 index 0000000..2d0b0ec --- /dev/null +++ b/copyright @@ -0,0 +1,16 @@ +Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0 + +Files: * +Copyright: 2018, Trilio +License: Apache-2.0 + 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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..11b099f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# Requirements to build the charm +charm-tools diff --git a/src/README.md b/src/README.md new file mode 100644 index 0000000..417b1e0 --- /dev/null +++ b/src/README.md @@ -0,0 +1,38 @@ +# Overview + +TrilioVault Data Mover API provides API service for TrilioVault Datamover + +# Usage + +TrilioVault Data Mover API relies on services from mysql, rabbitmq-server +and keystone charms. Steps to deploy the charm: + +juju deploy trilio-dm-api + +juju deploy keystone + +juju deploy mysql + +juju deploy rabbitmq-server + +juju add-relation trilio-dm-api rabbitmq-server + +juju add-relation trilio-dm-api mysql + +juju add-relation trilio-dm-api keystone + +# Configuration + +python-version: "Openstack base python version(2 or 3)" + +NOTE - Default value is set to "3". Please ensure to update this based on python version since installing + python3 packages on python2 based setup might have unexpected impact. + +TrilioVault Packages are downloaded from the repository added in below config parameter. Please change this only if you wish to download +TrilioVault Packages from a different source. + +triliovault-pkg-source: Repository address of triliovault packages + +# Contact Information + +Trilio Support diff --git a/src/config.yaml b/src/config.yaml new file mode 100644 index 0000000..d6e5964 --- /dev/null +++ b/src/config.yaml @@ -0,0 +1,52 @@ +--- +options: + openstack-origin: + type: string + default: bionic-stein + description: | + Repository from which to install. May be one of the following: + distro (default), ppa:somecustom/ppa, a deb url sources entry or a + supported Ubuntu Cloud Archive (UCA) release pocket. + . + Supported UCA sources include: + . + cloud:- + cloud:-/updates + cloud:-/staging + cloud:-/proposed + . + For series=Precise we support UCA for openstack-release= + * icehouse + . + For series=Trusty we support UCA for openstack-release= + * juno + * kilo + * ... + . + NOTE: updating this setting to a source that is known to provide + a later version of OpenStack will trigger a software upgrade. + . + python-version: + type: int + default: 3 + description: Openstack base python version(2 or 3) + triliovault-pkg-source: + type: string + default: "deb [trusted=yes] https://apt.fury.io/triliodata-3-4/ /" + description: Repository address of triliovault packages + openstack-pkg-source: + type: string + default: "cloud-archive:queens" + description: Repository address of openstack packages + public-port: + type: int + default: 8784 + description: DataMover API public endpoint port + internal-port: + type: int + default: 8784 + description: DataMover API internal endpoint port + admin-port: + type: int + default: 8784 + description: DataMover API admin endpoint port diff --git a/src/copyright b/src/copyright new file mode 100644 index 0000000..2d0b0ec --- /dev/null +++ b/src/copyright @@ -0,0 +1,16 @@ +Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0 + +Files: * +Copyright: 2018, Trilio +License: Apache-2.0 + 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/src/files/trilio/tvault-datamover-api.service b/src/files/trilio/tvault-datamover-api.service new file mode 100644 index 0000000..d321d96 --- /dev/null +++ b/src/files/trilio/tvault-datamover-api.service @@ -0,0 +1,13 @@ +[Unit] +Description=Datamover API service + +[Service] +User = dmapi +Group = dmapi +Type = Simple +ExecStart=/usr/bin/dmapi-api +KillMode=process +Restart=on-failure + +[Install] +WantedBy=multi-user.target diff --git a/src/icon.svg b/src/icon.svg new file mode 100644 index 0000000..1ef800d --- /dev/null +++ b/src/icon.svg @@ -0,0 +1,23 @@ + + + + + + + + + + diff --git a/src/layer.yaml b/src/layer.yaml new file mode 100644 index 0000000..7777bf1 --- /dev/null +++ b/src/layer.yaml @@ -0,0 +1,5 @@ +includes: ['layer:openstack-api'] +options: + basic: + use_venv: True + include_system_packages: True diff --git a/src/lib/charm/openstack/dmapi.py b/src/lib/charm/openstack/dmapi.py new file mode 100644 index 0000000..f8cc2f4 --- /dev/null +++ b/src/lib/charm/openstack/dmapi.py @@ -0,0 +1,166 @@ +import os +import charmhelpers.contrib.openstack.utils as ch_utils +import charms_openstack.charm +import charms_openstack.adapters +import charms_openstack.ip as os_ip + +from charmhelpers.core.hookenv import ( + config +) + +DMAPI_DIR = '/etc/dmapi' +DMAPI_CONF = os.path.join(DMAPI_DIR, 'dmapi.conf') + + +class DmapiDBAdapter(charms_openstack.adapters.DatabaseRelationAdapter): + """Get database URIs for the two nova databases""" + + @property + def dmapi_nova_uri(self): + """URI for nova DB""" + return self.get_uri(prefix='dmapinova') + + @property + def dmapi_nova_api_uri(self): + """URI for nova_api DB""" + return self.get_uri(prefix='dmapinovaapi') + + +class DmapiAdapters(charms_openstack.adapters.OpenStackAPIRelationAdapters): + """ + Adapters class for the Data Mover API charm. + """ + relation_adapters = { + 'shared_db': DmapiDBAdapter, + } + + +class DmapiCharm(charms_openstack.charm.HAOpenStackCharm): + + # Internal name of charm + keystone endpoint + service_name = 'dmapi' + name = 'trilio-dm-api' + + # First release supported + release = 'queens' + + # Packages the service needs installed + if config('python-version') == 3: + packages = ['python3-nova', 'python3-dmapi'] + else: + packages = ['python-nova', 'dmapi'] + + # Init services the charm manages + services = ['tvault-datamover-api'] + + # Ports that need exposing. + default_service = 'dmapi-api' + api_ports = { + 'dmapi-api': { + os_ip.PUBLIC: config('public-port'), + os_ip.ADMIN: config('admin-port'), + os_ip.INTERNAL: config('internal-port'), + } + } + + # Database sync command used to initalise the schema. + sync_cmd = [] + + # The restart map defines which services should be restarted when a given + # file changes + restart_map = { + DMAPI_CONF: services, + } + + adapters_class = DmapiAdapters + + # Resource when in HA mode + ha_resources = ['vips', 'haproxy'] + + # DataMover requires a message queue, database and keystone to work, + # so these are the 'required' relationships for the service to + # have an 'active' workload status. 'required_relations' is used in + # the assess_status() functionality to determine what the current + # workload status of the charm is. + required_relations = ['amqp', 'shared-db', 'identity-service'] + + def __init__(self, release=None, **kwargs): + """Custom initialiser for class + If no release is passed, then the charm determines the release from the + ch_utils.os_release() function. + """ + if release is None: + release = ch_utils.os_release('python-keystonemiddleware') + super(DmapiCharm, self).__init__(release=release, **kwargs) + + def install(self): + """Customise the installation, configure the source and then call the + parent install() method to install the packages + """ + self.configure_source() + # and do the actual install + super(DmapiCharm, self).install() + + @property + def public_url(self): + return super().public_url + "/v2" + + @property + def admin_url(self): + return super().admin_url + "/v2" + + @property + def internal_url(self): + return super().internal_url + "/v2" + + +def install(): + """Use the singleton from the DmapiCharm to install the packages on the + unit + """ + DmapiCharm.singleton.install() + + +def restart_all(): + """Use the singleton from the DmapiCharm to restart services on the + unit + """ + DmapiCharm.singleton.restart_all() + + +def setup_endpoint(keystone): + """When the keystone interface connects, register this unit in the keystone + catalogue. + """ + charm = DmapiCharm.singleton + keystone.register_endpoints(charm.service_name, + charm.region, + charm.public_url, + charm.internal_url, + charm.admin_url) + + +def render_configs(interfaces_list): + """Using a list of interfaces, render the configs and, if they have + changes, restart the services on the unit. + """ + DmapiCharm.singleton.render_with_interfaces(interfaces_list) + + +def assess_status(): + """Just call the DmapiCharm.singleton.assess_status() command to update + status on the unit. + """ + DmapiCharm.singleton.assess_status() + + +def configure_ha_resources(hacluster): + """Use the singleton from the DmapiCharm to run configure_ha_resources + """ + DmapiCharm.singleton.configure_ha_resources(hacluster) + + +def configure_ssl(): + """Use the singleton from the DmapiCharm to run configure_ssl + """ + DmapiCharm.singleton.configure_ssl() diff --git a/src/metadata.yaml b/src/metadata.yaml new file mode 100644 index 0000000..2f758fa --- /dev/null +++ b/src/metadata.yaml @@ -0,0 +1,24 @@ +--- +name: trilio-dm-api +maintainer: Trilio Support +summary: TrilioVault Data Mover API +description: | + API service of TrilioVault Datamover +tags: + - openstack + - storage + - backup + - TVMv3.4 +requires: + shared-db: + interface: mysql-shared + amqp: + interface: rabbitmq + identity-service: + interface: keystone +provides: + dm-api: + interface: dm-api +series: + - xenial + - bionic diff --git a/src/reactive/dmapi_handlers.py b/src/reactive/dmapi_handlers.py new file mode 100644 index 0000000..a30290f --- /dev/null +++ b/src/reactive/dmapi_handlers.py @@ -0,0 +1,195 @@ +import charms.reactive as reactive +import os +import re +# This charm's library contains all of the handler code associated with +# dmapi +import charm.openstack.dmapi as dmapi +from subprocess import ( + check_output, + check_call, +) + +from charmhelpers.core.hookenv import ( + config, + log, + application_version_set, +) + +from charmhelpers.fetch import ( + apt_update, + apt_upgrade, +) + +from charmhelpers.contrib.openstack.utils import ( + configure_installation_source, +) + +from charmhelpers.core.host import ( + service_restart, + adduser, + add_group, + add_user_to_group, + chownr, + mkdir, +) + +# Minimal inferfaces required for operation +MINIMAL_INTERFACES = [ + 'shared-db.available', + 'identity-service.available', + 'amqp.available', +] + +DMAPI_USR = 'dmapi' +DMAPI_GRP = 'dmapi' + + +def get_new_version(pkg_name): + """ + Get the latest version available on the TrilioVault node. + """ + apt_cmd = "apt list {}".format(pkg_name) + pkg = check_output(apt_cmd.split()).decode('utf-8') + new_ver = re.search(r'\s([\d.]+)', pkg).group().strip() + + return new_ver + + +def add_user(): + """ + Adding passwordless sudo access to nova user and adding to required groups + """ + try: + add_group(DMAPI_GRP, system_group=True) + adduser(DMAPI_USR, password=None, shell='/bin/bash', system_user=True) + add_user_to_group(DMAPI_USR, DMAPI_GRP) + except Exception as e: + log("Failed while adding user with msg: {}".format(e)) + return False + + return True + + +# use a synthetic state to ensure that it get it to be installed independent of +# the install hook. +@reactive.when_not('charm.installed') +def install_packages(): + # Add TrilioVault repository to install required package + # and add queens repo to install nova libraries + if not add_user(): + log("Adding dmapi user failed!") + return + + os.system('sudo echo "{}" > ' + '/etc/apt/sources.list.d/trilio-gemfury-sources.list'.format( + config('triliovault-pkg-source'))) + + new_src = config('openstack-origin') + configure_installation_source(new_src) + + if config('python-version') == 2: + dmapi_pkg = 'dmapi' + else: + dmapi_pkg = 'python3-dmapi' + + apt_update() + dmapi.install() + # Placing the service file + os.system('sudo cp files/trilio/tvault-datamover-api.service ' + '/etc/systemd/system/') + chownr('/var/log/dmapi', DMAPI_USR, DMAPI_GRP) + mkdir('/var/cache/dmapi', DMAPI_USR, DMAPI_GRP, perms=493) + os.system('sudo systemctl enable tvault-datamover-api') + service_restart('tvault-datamover-api') + + application_version_set(get_new_version(dmapi_pkg)) + reactive.set_state('charm.installed') + + +@reactive.when('amqp.connected') +def setup_amqp_req(amqp): + """Use the amqp interface to request access to the amqp broker using our + local configuration. + """ + amqp.request_access(username='dmapi', + vhost='openstack') + dmapi.assess_status() + + +@reactive.when('shared-db.connected') +def setup_database(database): + """On receiving database credentials, configure the database on the + interface. + """ + database.configure('nova', 'nova', prefix='dmapinova') + database.configure('nova_api', 'nova', prefix='dmapinovaapi') + dmapi.assess_status() + + +@reactive.when('identity-service.connected') +def setup_endpoint(keystone): + dmapi.configure_ssl() + dmapi.setup_endpoint(keystone) + dmapi.assess_status() + + +def render(*args): + dmapi.render_configs(args) + reactive.set_state('config.complete') + # change the ownership to 'dmapi' + chownr('/etc/dmapi', DMAPI_USR, DMAPI_GRP) + dmapi.assess_status() + + +@reactive.when('charm.installed') +@reactive.when_not('cluster.available') +@reactive.when(*MINIMAL_INTERFACES) +def render_unclustered(*args): + dmapi.configure_ssl() + render(*args) + + +@reactive.when('charm.installed') +@reactive.when('cluster.available', + *MINIMAL_INTERFACES) +def render_clustered(*args): + render(*args) + + +@reactive.when('charm.installed') +@reactive.when('config.complete') +@reactive.when_not('db.synced') +def run_db_migration(): + dmapi.restart_all() + reactive.set_state('db.synced') + dmapi.assess_status() + + +@reactive.when('ha.connected') +def cluster_connected(hacluster): + dmapi.configure_ha_resources(hacluster) + + +@reactive.hook('upgrade-charm') +def upgrade_charm(): + os.system('sudo echo "{}" > ' + '/etc/apt/sources.list.d/trilio-gemfury-sources.list'.format( + config('triliovault-pkg-source'))) + + new_src = config('openstack-origin') + configure_installation_source(new_src) + + apt_update() + apt_upgrade(fatal=True, dist=True) + + chownr('/var/log/dmapi', DMAPI_USR, DMAPI_GRP) + + check_call(['systemctl', 'daemon-reload']) + service_restart('tvault-datamover-api') + + if config('python-version') == 2: + dmapi_pkg = 'dmapi' + else: + dmapi_pkg = 'python3-dmapi' + + application_version_set(get_new_version(dmapi_pkg)) diff --git a/src/templates/dmapi.conf b/src/templates/dmapi.conf new file mode 100644 index 0000000..84480aa --- /dev/null +++ b/src/templates/dmapi.conf @@ -0,0 +1,60 @@ +[DEFAULT] +dmapi_workers = {{ options.workers }} +{% if amqp.ssl_port %} +transport_url = rabbit://{{amqp.username}}:{{amqp.password}}@{{amqp.host}}:{{amqp.ssl_port}}/{{amqp.vhost}} +{% else %} +transport_url = rabbit://{{amqp.username}}:{{amqp.password}}@{{amqp.host}}:5672/{{amqp.vhost}} +{% endif %} + +dmapi_link_prefix = {{ options.service_listen_info.dmapi_api.ip }}:{{ options.service_listen_info.dmapi_api.port }} +dmapi_listen_port = {{ options.service_listen_info.dmapi_api.port }} +dmapi_enabled_apis = dmapi +dmapi_enabled_ssl_apis = +bindir = /usr/bin +instance_name_template = instance-%08x +dmapi_listen = 0.0.0.0 +my_ip = {{ options.service_listen_info.dmapi_api.ip }} +rootwrap_config = /etc/dmapi/rootwrap.conf +debug = {{ options.debug }} +log_file = /var/log/dmapi/dmapi.log +log_dir = /var/log/dmapi + +[wsgi] +ssl_cert_file = {{ amqp.ssl_cert_file }} +ssl_key_file = {{ amqp.ssl_key_file }} +api_paste_config = /etc/dmapi/api-paste.ini + +[database] +connection = {{ shared_db.dmapi_nova_uri }} + +[api_database] +connection = {{ shared_db.dmapi_nova_api_uri }} + +{% include "parts/section-keystone-authtoken" %} +region_name = {{ options.region }} +{% if options.ssl_ca %} +insecure = False +{% else %} +insecure = True +{% endif %} + +{% if options.use_internal_endpoints -%} +interface = internalURL +{%- endif %} + + +[oslo_messaging_notifications] +driver = messagingv2 +{% if amqp.ssl_port %} +transport_url = rabbit://{{amqp.username}}:{{amqp.password}}@{{amqp.host}}:{{amqp.ssl_port}}/{{amqp.vhost}} +{% else %} +transport_url = rabbit://{{amqp.username}}:{{amqp.password}}@{{amqp.host}}:5672/{{amqp.vhost}} +{% endif %} + +[oslo_middleware] +enable_proxy_headers_parsing = false + +[conductor] +use_local = True + +{% include "parts/section-oslo-messaging-rabbit" %} diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..71245b8 --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,9 @@ +# Unit test requirements +netifaces +hvac +flake8>=2.2.4,<=2.4.1 +os-testr>=0.4.1 +charms.reactive +mock>=1.2 +coverage>=3.6 +git+https://github.com/openstack/charms.openstack#egg=charms.openstack diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..f887bd7 --- /dev/null +++ b/tox.ini @@ -0,0 +1,40 @@ +# tox (https://tox.readthedocs.io/) is a tool for running tests +# in multiple virtualenvs. This configuration file will run the +# test suite on all supported python versions. To use it, "pip install tox" +# and then run "tox" from this directory. + +[tox] +skipsdist = True +envlist = pep8, py27, py3 + +[testenv] +setenv = VIRTUAL_ENV={envdir} + PYTHONHASHSEED=0 + TERM=linux + INTERFACE_PATH={toxinidir}/interfaces + LAYER_PATH={toxinidir}/layers + JUJU_REPOSITORY={toxinidir}/build +install_command = + pip install {opts} {packages} +deps = + -r{toxinidir}/requirements.txt + +[testenv:build] +basepython = python3 +commands = + charm-build --log-level DEBUG -o {toxinidir}/build src {posargs} + +[testenv:py27] +basepython = python2.7 +deps = -r{toxinidir}/test-requirements.txt +commands = stestr run {posargs} + +[testenv:py3] +basepython = python3 +deps = -r{toxinidir}/test-requirements.txt +commands = stestr run {posargs} + +[testenv:pep8] +basepython = python3 +deps = -r{toxinidir}/test-requirements.txt +commands = flake8 {posargs} src diff --git a/unit_tests/__init__.py b/unit_tests/__init__.py new file mode 100644 index 0000000..981e7fd --- /dev/null +++ b/unit_tests/__init__.py @@ -0,0 +1,8 @@ +import sys + +sys.path.append('src') +sys.path.append('src/lib') + +# Mock out charmhelpers so that we can test without it. +import charms_openstack.test_mocks # noqa +charms_openstack.test_mocks.mock_charmhelpers() diff --git a/unit_tests/test_charm_openstack_dmapi.py b/unit_tests/test_charm_openstack_dmapi.py new file mode 100644 index 0000000..3512d27 --- /dev/null +++ b/unit_tests/test_charm_openstack_dmapi.py @@ -0,0 +1,109 @@ +from __future__ import absolute_import +from __future__ import print_function + +import mock +import sys + +import charm.openstack.dmapi as dmapi + +import charms_openstack.test_utils as test_utils + + +class Helper(test_utils.PatchHelper): + + def setUp(self): + super().setUp() + self.patch_release(dmapi.DmapiCharm.release) + +class TestOpenStackDmapi(Helper): + + def test_install(self): + self.patch_object(dmapi.DmapiCharm.singleton, 'install') + dmapi.install() + self.install.assert_called_once_with() + + def test_setup_endpoint(self): + self.patch_object(dmapi.DmapiCharm, 'service_name', + new_callable=mock.PropertyMock) + self.patch_object(dmapi.DmapiCharm, 'region', + new_callable=mock.PropertyMock) + self.patch_object(dmapi.DmapiCharm, 'public_url', + new_callable=mock.PropertyMock) + self.patch_object(dmapi.DmapiCharm, 'internal_url', + new_callable=mock.PropertyMock) + self.patch_object(dmapi.DmapiCharm, 'admin_url', + new_callable=mock.PropertyMock) + self.service_name.return_value = 'type1' + self.region.return_value = 'region1' + self.public_url.return_value = 'public_url' + self.internal_url.return_value = 'internal_url' + self.admin_url.return_value = 'admin_url' + keystone = mock.MagicMock() + dmapi.setup_endpoint(keystone) + keystone.register_endpoints.assert_called_once_with( + 'type1', 'region1', 'public_url', 'internal_url', 'admin_url') + + def test_render_configs(self): + self.patch_object(dmapi.DmapiCharm.singleton, 'render_with_interfaces') + dmapi.render_configs('interfaces-list') + self.render_with_interfaces.assert_called_once_with( + 'interfaces-list') + + +class TestDmapiDBAdapter(Helper): + + def fake_get_uri(self, prefix): + return 'mysql://uri/{}-database'.format(prefix) + + def test_dmapi_uri(self): + relation = mock.MagicMock() + a = dmapi.DmapiDBAdapter(relation) + self.patch_object(dmapi.DmapiDBAdapter, 'get_uri') + self.get_uri.side_effect = self.fake_get_uri + self.assertEqual(a.dmapi_nova_uri, 'mysql://uri/dmapinova-database') + self.assertEqual(a.dmapi_nova_api_uri, 'mysql://uri/dmapinovaapi-database') + + +class TestDmapiAdapters(Helper): + + @mock.patch('charmhelpers.core.hookenv.config') + def test_dmapi_adapters(self, config): + reply = { + 'keystone-api-version': '3', + } + config.side_effect = lambda: reply + self.patch_object( + dmapi.charms_openstack.adapters.APIConfigurationAdapter, + 'get_network_addresses') + cluster_relation = mock.MagicMock() + cluster_relation.endpoint_name = 'cluster' + amqp_relation = mock.MagicMock() + amqp_relation.endpoint_name = 'amqp' + shared_db_relation = mock.MagicMock() + shared_db_relation.endpoint_name = 'shared_db' + other_relation = mock.MagicMock() + other_relation.endpoint_name = 'other' + other_relation.thingy = 'help' + # verify that the class is created with a DmapiConfigurationAdapter + b = dmapi.DmapiAdapters([amqp_relation, + cluster_relation, + shared_db_relation, + other_relation]) + # ensure that the relevant things got put on. + self.assertTrue( + isinstance( + b.other, + dmapi.charms_openstack.adapters.OpenStackRelationAdapter)) + + +class TestDmapiCharm(Helper): + + def test_install(self): + b = dmapi.DmapiCharm() + self.patch_object(dmapi.charms_openstack.charm.OpenStackCharm, + 'configure_source') + self.patch_object(dmapi.charms_openstack.charm.OpenStackCharm, + 'install') + b.install() + self.configure_source.assert_called_with() + self.install.assert_called_once_with() diff --git a/unit_tests/test_dmapi_handlers.py b/unit_tests/test_dmapi_handlers.py new file mode 100644 index 0000000..6525ef5 --- /dev/null +++ b/unit_tests/test_dmapi_handlers.py @@ -0,0 +1,172 @@ +from __future__ import absolute_import +from __future__ import print_function + +import unittest + +import mock + +import sys + +import reactive.dmapi_handlers as handlers + + +_when_args = {} +_when_not_args = {} + + +def mock_hook_factory(d): + + def mock_hook(*args, **kwargs): + + def inner(f): + # remember what we were passed. Note that we can't actually + # determine the class we're attached to, as the decorator only gets + # the function. + try: + d[f.__name__].append(dict(args=args, kwargs=kwargs)) + except KeyError: + d[f.__name__] = [dict(args=args, kwargs=kwargs)] + return f + return inner + return mock_hook + + +class TestDmapiHandlers(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls._patched_when = mock.patch('charms.reactive.when', + mock_hook_factory(_when_args)) + cls._patched_when_started = cls._patched_when.start() + cls._patched_when_not = mock.patch('charms.reactive.when_not', + mock_hook_factory(_when_not_args)) + cls._patched_when_not_started = cls._patched_when_not.start() + # force requires to rerun the mock_hook decorator: + # try except is Python2/Python3 compatibility as Python3 has moved + # reload to importlib. + try: + reload(handlers) + except NameError: + import importlib + importlib.reload(handlers) + + @classmethod + def tearDownClass(cls): + cls._patched_when.stop() + cls._patched_when_started = None + cls._patched_when = None + cls._patched_when_not.stop() + cls._patched_when_not_started = None + cls._patched_when_not = None + # and fix any breakage we did to the module + try: + reload(handlers) + except NameError: + import importlib + importlib.reload(handlers) + + def setUp(self): + self._patches = {} + self._patches_start = {} + + def tearDown(self): + for k, v in self._patches.items(): + v.stop() + setattr(self, k, None) + self._patches = None + self._patches_start = None + + def patch(self, obj, attr, return_value=None, side_effect=None): + mocked = mock.patch.object(obj, attr) + self._patches[attr] = mocked + started = mocked.start() + started.return_value = return_value + started.side_effect = side_effect + self._patches_start[attr] = started + setattr(self, attr, started) + + + def test_registered_hooks(self): + # test that the hooks actually registered the relation expressions that + # are meaningful for this interface: this is to handle regressions. + # The keys are the function names that the hook attaches to. + when_patterns = { + 'setup_amqp_req': ('amqp.connected', ), + 'setup_database': ('shared-db.connected', ), + 'setup_endpoint': ('identity-service.connected', ), + 'render_unclustered': ('charm.installed', + 'shared-db.available', + 'identity-service.available', + 'amqp.available',), + 'render_clustered': ('charm.installed', + 'shared-db.available', + 'identity-service.available', + 'amqp.available', + 'cluster.available',), + 'run_db_migration': ('charm.installed', + 'config.complete', ), + 'cluster_connected': ('ha.connected', ), + } + when_not_patterns = { + 'install_packages': ('charm.installed', ), + 'render_unclustered': ('cluster.available', ), + 'run_db_migration': ('db.synced', ), + } + # check the when hooks are attached to the expected functions + for t, p in [(_when_args, when_patterns), + (_when_not_args, when_not_patterns)]: + for f, args in t.items(): + # check that function is in patterns + self.assertTrue(f in p.keys(), + "{} not found".format(f)) + # check that the lists are equal + l = [] + for a in args: + l += a['args'][:] + self.assertEqual(sorted(l), sorted(p[f]), + "{}: incorrect state registration".format(f)) + + def test_install_packages(self): + self.patch(handlers.dmapi, 'install') + self.patch(handlers.reactive, 'set_state') + self.patch(handlers, 'add_user') + self.add_user.return_value = True + self.patch(handlers.os, 'system') + self.patch(handlers, 'apt_update') + self.patch(handlers, 'get_new_version') + self.patch(handlers, 'service_restart') + handlers.install_packages() + self.install.assert_called_once_with() + self.set_state.assert_called_once_with('charm.installed') + + def test_setup_amqp_req(self): + self.patch(handlers.dmapi, 'assess_status') + amqp = mock.MagicMock() + handlers.setup_amqp_req(amqp) + amqp.request_access.assert_called_once_with( + username='dmapi', vhost='openstack') + + def test_database(self): + database = mock.MagicMock() + self.patch(handlers.dmapi, 'assess_status') + handlers.setup_database(database) + database.configure.assert_has_calls([ + mock.call('nova', 'nova', prefix='dmapinova'), + mock.call('nova_api', 'nova', prefix='dmapinovaapi'), + ]) + + def test_setup_endpoint(self): + self.patch(handlers.dmapi, 'setup_endpoint') + self.patch(handlers.dmapi, 'assess_status') + self.patch(handlers.dmapi, 'configure_ssl') + handlers.setup_endpoint('keystone') + self.setup_endpoint.assert_called_once_with('keystone') + + def test_render(self): + self.patch(handlers.dmapi, 'render_configs') + self.patch(handlers.dmapi, 'assess_status') + self.patch(handlers.dmapi, 'configure_ssl') + handlers.render_unclustered('args') + self.render_configs.assert_called_once_with(('args', )) + self.assess_status.assert_called_once() + self.configure_ssl.assert_called_once()