diff --git a/config.yaml b/config.yaml index 835a786..f072877 100644 --- a/config.yaml +++ b/config.yaml @@ -122,3 +122,37 @@ options: order for this charm to function correctly, the privacy extension must be disabled and a non-temporary address must be configured/available on your network interface. + # HA configuration settings + vip: + type: string + default: + description: | + Virtual IP(s) to use to front API services in HA configuration. + . + If multiple networks are being used, a VIP should be provided for each + network, separated by spaces. + vip_iface: + type: string + default: eth0 + description: | + Default network interface to use for HA vip when it cannot be automatically + determined. + vip_cidr: + type: int + default: 24 + description: | + Default CIDR netmask to use for HA vip when it cannot be automatically + determined. + ha-bindiface: + type: string + default: eth0 + description: | + Default network interface on which HA cluster will bind to communication + with the other members of the HA Cluster. + ha-mcastport: + type: int + default: 5959 + description: | + Default multicast port number that will be used to communicate between + HA Cluster nodes. + diff --git a/hooks/cluster-relation-changed b/hooks/cluster-relation-changed new file mode 120000 index 0000000..ab98840 --- /dev/null +++ b/hooks/cluster-relation-changed @@ -0,0 +1 @@ +heat_relations.py \ No newline at end of file diff --git a/hooks/cluster-relation-departed b/hooks/cluster-relation-departed new file mode 120000 index 0000000..ab98840 --- /dev/null +++ b/hooks/cluster-relation-departed @@ -0,0 +1 @@ +heat_relations.py \ No newline at end of file diff --git a/hooks/cluster-relation-joined b/hooks/cluster-relation-joined new file mode 120000 index 0000000..ab98840 --- /dev/null +++ b/hooks/cluster-relation-joined @@ -0,0 +1 @@ +heat_relations.py \ No newline at end of file diff --git a/hooks/ha-relation-changed b/hooks/ha-relation-changed new file mode 120000 index 0000000..ab98840 --- /dev/null +++ b/hooks/ha-relation-changed @@ -0,0 +1 @@ +heat_relations.py \ No newline at end of file diff --git a/hooks/ha-relation-joined b/hooks/ha-relation-joined new file mode 120000 index 0000000..ab98840 --- /dev/null +++ b/hooks/ha-relation-joined @@ -0,0 +1 @@ +heat_relations.py \ No newline at end of file diff --git a/hooks/heat_relations.py b/hooks/heat_relations.py index aa31715..faa8fed 100755 --- a/hooks/heat_relations.py +++ b/hooks/heat_relations.py @@ -19,7 +19,9 @@ from charmhelpers.core.hookenv import ( charm_dir, log, relation_ids, + relation_get, relation_set, + local_unit, open_port, unit_get, status_set, @@ -39,6 +41,19 @@ from charmhelpers.fetch import ( apt_update ) +from charmhelpers.contrib.hahelpers.cluster import ( + is_elected_leader, + get_hacluster_config, +) + +from charmhelpers.contrib.network.ip import ( + get_iface_for_address, + get_netmask_for_address, + get_address_in_network, + get_ipv6_addr, + is_ipv6 +) + from charmhelpers.contrib.openstack.utils import ( configure_installation_source, openstack_upgrade_available, @@ -59,6 +74,7 @@ from heat_utils import ( determine_packages, migrate_database, register_configs, + CLUSTER_RES, HEAT_CONF, REQUIRED_INTERFACES, setup_ipv6, @@ -68,6 +84,7 @@ from heat_context import ( API_PORTS, ) +from charmhelpers.contrib.openstack.context import ADDRESS_TYPES from charmhelpers.payload.execd import execd_preinstall hooks = Hooks() @@ -112,6 +129,11 @@ def config_changed(): CONFIGS.write_all() configure_https() + for rid in relation_ids('cluster'): + cluster_joined(relation_id=rid) + for r_id in relation_ids('ha'): + ha_joined(relation_id=r_id) + @hooks.hook('upgrade-charm') def upgrade_charm(): @@ -140,9 +162,9 @@ def db_joined(): config('database-user'), relation_prefix='heat') else: - relation_set(heat_database=config('database'), - heat_username=config('database-user'), - heat_hostname=unit_get('private-address')) + relation_set(database=config('database'), + username=config('database-user'), + hostname=unit_get('private-address')) @hooks.hook('shared-db-relation-changed') @@ -152,7 +174,15 @@ def db_changed(): log('shared-db relation incomplete. Peer not ready?') return CONFIGS.write(HEAT_CONF) - migrate_database() + + if is_elected_leader(CLUSTER_RES): + allowed_units = relation_get('allowed_units') + if allowed_units and local_unit() in allowed_units.split(): + log('Cluster leader, performing db sync') + migrate_database() + else: + log('allowed_units either not presented, or local unit ' + 'not in acl list: %s' % repr(allowed_units)) def configure_https(): @@ -231,6 +261,100 @@ def leader_elected(): leader_set({'heat-domain-admin-passwd': pwgen(32)}) +@hooks.hook('cluster-relation-joined') +def cluster_joined(relation_id=None): + for addr_type in ADDRESS_TYPES: + address = get_address_in_network( + config('os-{}-network'.format(addr_type)) + ) + if address: + relation_set( + relation_id=relation_id, + relation_settings={'{}-address'.format(addr_type): address} + ) + + if config('prefer-ipv6'): + private_addr = get_ipv6_addr(exc_list=[config('vip')])[0] + relation_set(relation_id=relation_id, + relation_settings={'private-address': private_addr}) + + +@hooks.hook('cluster-relation-changed', + 'cluster-relation-departed') +@restart_on_change(restart_map(), stopstart=True) +def cluster_changed(): + CONFIGS.write_all() + + +@hooks.hook('ha-relation-joined') +def ha_joined(relation_id=None): + cluster_config = get_hacluster_config() + + resources = { + 'res_heat_haproxy': 'lsb:haproxy' + } + + resource_params = { + 'res_heat_haproxy': 'op monitor interval="5s"' + } + + vip_group = [] + for vip in cluster_config['vip'].split(): + if is_ipv6(vip): + res_heat_vip = 'ocf:heartbeat:IPv6addr' + vip_params = 'ipv6addr' + else: + res_heat_vip = 'ocf:heartbeat:IPaddr2' + vip_params = 'ip' + + iface = (get_iface_for_address(vip) or + config('vip_iface')) + netmask = (get_netmask_for_address(vip) or + config('vip_cidr')) + + if iface is not None: + vip_key = 'res_heat_{}_vip'.format(iface) + resources[vip_key] = res_heat_vip + resource_params[vip_key] = ( + 'params {ip}="{vip}" cidr_netmask="{netmask}"' + ' nic="{iface}"'.format(ip=vip_params, + vip=vip, + iface=iface, + netmask=netmask) + ) + vip_group.append(vip_key) + + if len(vip_group) >= 1: + relation_set(relation_id=relation_id, + groups={'grp_heat_vips': ' '.join(vip_group)}) + + init_services = { + 'res_heat_haproxy': 'haproxy' + } + clones = { + 'cl_heat_haproxy': 'res_heat_haproxy' + } + relation_set(relation_id=relation_id, + init_services=init_services, + corosync_bindiface=cluster_config['ha-bindiface'], + corosync_mcastport=cluster_config['ha-mcastport'], + resources=resources, + resource_params=resource_params, + clones=clones) + + +@hooks.hook('ha-relation-changed') +def ha_changed(): + clustered = relation_get('clustered') + if not clustered or clustered in [None, 'None', '']: + log('ha_changed: hacluster subordinate not fully clustered.') + else: + log('Cluster configured, notifying other services and updating ' + 'keystone endpoint configuration') + for rid in relation_ids('identity-service'): + identity_joined(rid=rid) + + def main(): try: hooks.execute(sys.argv) diff --git a/hooks/heat_utils.py b/hooks/heat_utils.py index 102e6b4..8e9e209 100644 --- a/hooks/heat_utils.py +++ b/hooks/heat_utils.py @@ -68,6 +68,8 @@ BASE_SERVICES = [ 'heat-engine' ] +# Cluster resource used to determine leadership when hacluster'd +CLUSTER_RES = 'grp_heat_vips' SVC = 'heat' HEAT_DIR = '/etc/heat' HEAT_CONF = '/etc/heat/heat.conf' @@ -82,8 +84,7 @@ CONFIG_FILES = OrderedDict([ (HEAT_CONF, { 'services': BASE_SERVICES, 'contexts': [context.AMQPContext(ssl_dir=HEAT_DIR), - context.SharedDBContext(relation_prefix='heat', - ssl_dir=HEAT_DIR), + context.SharedDBContext(ssl_dir=HEAT_DIR), context.OSConfigFlagContext(), HeatIdentityServiceContext(service=SVC, service_user=SVC), HeatHAProxyContext(), diff --git a/metadata.yaml b/metadata.yaml index c30d3ae..617c6bb 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -14,3 +14,9 @@ requires: interface: rabbitmq identity-service: interface: keystone + ha: + interface: hacluster + scope: container +peers: + cluster: + interface: heat-ha diff --git a/tests/basic_deployment.py b/tests/basic_deployment.py index aa260b5..df896ec 100644 --- a/tests/basic_deployment.py +++ b/tests/basic_deployment.py @@ -345,9 +345,9 @@ class HeatBasicDeployment(OpenStackAmuletDeployment): relation = ['shared-db', 'mysql:shared-db'] expected = { 'private-address': u.valid_ip, - 'heat_database': 'heat', - 'heat_username': 'heat', - 'heat_hostname': u.valid_ip + 'database': 'heat', + 'username': 'heat', + 'hostname': u.valid_ip } ret = u.validate_relation_data(unit, relation, expected) @@ -363,8 +363,8 @@ class HeatBasicDeployment(OpenStackAmuletDeployment): expected = { 'private-address': u.valid_ip, 'db_host': u.valid_ip, - 'heat_allowed_units': 'heat/0', - 'heat_password': u.not_null + 'allowed_units': 'heat/0', + 'password': u.not_null } ret = u.validate_relation_data(unit, relation, expected) @@ -468,7 +468,7 @@ class HeatBasicDeployment(OpenStackAmuletDeployment): u.log.debug('mysql:heat relation: {}'.format(mysql_rel)) db_uri = "mysql://{}:{}@{}/{}".format('heat', - mysql_rel['heat_password'], + mysql_rel['password'], mysql_rel['db_host'], 'heat') diff --git a/unit_tests/test_heat_relations.py b/unit_tests/test_heat_relations.py index 534ba2e..3985e74 100644 --- a/unit_tests/test_heat_relations.py +++ b/unit_tests/test_heat_relations.py @@ -41,6 +41,13 @@ TO_PATCH = [ 'execd_preinstall', 'log', 'migrate_database', + 'is_elected_leader', + 'relation_ids', + 'relation_get', + 'local_unit', + 'get_hacluster_config', + 'get_iface_for_address', + 'get_netmask_for_address', ] @@ -91,12 +98,14 @@ class HeatRelationTests(CharmTestCase): def test_db_joined(self): self.unit_get.return_value = 'heat.foohost.com' relations.db_joined() - self.relation_set.assert_called_with(heat_database='heat', - heat_username='heat', - heat_hostname='heat.foohost.com') + self.relation_set.assert_called_with(database='heat', + username='heat', + hostname='heat.foohost.com') self.unit_get.assert_called_with('private-address') def _shared_db_test(self, configs): + self.relation_get.return_value = 'heat/0 heat/1' + self.local_unit.return_value = 'heat/0' configs.complete_contexts = MagicMock() configs.complete_contexts.return_value = ['shared-db'] configs.write = MagicMock() @@ -242,3 +251,42 @@ class HeatRelationTests(CharmTestCase): relations.db_joined() self.sync_db_with_multi_ipv6_addresses.assert_called_with( 'heat', 'heat', relation_prefix='heat') + + @patch.object(relations, 'CONFIGS') + def test_non_leader_db_changed(self, configs): + self.is_elected_leader.return_value = False + configs.complete_contexts.return_value = [] + self.relation_get.return_value = 'heat/0 heat/1' + self.local_unit.return_value = 'heat/0' + configs.complete_contexts = MagicMock() + configs.complete_contexts.return_value = ['shared-db'] + configs.write = MagicMock() + relations.db_changed() + self.assertFalse(self.migrate_database.called) + + @patch.object(relations, 'CONFIGS') + def test_ha_joined(self, configs): + self.get_hacluster_config.return_value = { + 'ha-bindiface': 'eth0', + 'ha-mcastport': '5959', + 'vip': '10.5.105.3' + } + self.get_iface_for_address.return_value = 'eth0' + self.get_netmask_for_address.return_value = '255.255.255.0' + relations.ha_joined() + expected = { + 'relation_id': None, + 'init_services': {'res_heat_haproxy': 'haproxy'}, + 'corosync_bindiface': 'eth0', + 'corosync_mcastport': '5959', + 'resources': { + 'res_heat_haproxy': 'lsb:haproxy', + 'res_heat_eth0_vip': 'ocf:heartbeat:IPaddr2'}, + 'resource_params': { + 'res_heat_haproxy': 'op monitor interval="5s"', + 'res_heat_eth0_vip': ('params ip="10.5.105.3" ' + 'cidr_netmask="255.255.255.0" ' + 'nic="eth0"')}, + 'clones': {'cl_heat_haproxy': 'res_heat_haproxy'} + } + self.relation_set.assert_called_with(**expected)