diff --git a/tripleo_ansible/ansible_plugins/action/tripleo_all_nodes_data.py b/tripleo_ansible/ansible_plugins/action/tripleo_all_nodes_data.py index 30634ae40..1f2fb424d 100644 --- a/tripleo_ansible/ansible_plugins/action/tripleo_all_nodes_data.py +++ b/tripleo_ansible/ansible_plugins/action/tripleo_all_nodes_data.py @@ -167,6 +167,7 @@ class ActionModule(ActionBase): self.service_net_map = task_vars['service_net_map'] self.nova_additional_cell = task_vars['nova_additional_cell'] self.all_nodes_extra_map_data = task_vars['all_nodes_extra_map_data'] + service_vip_vars = task_vars.get('service_vip_vars', {}) net_vip_map = task_vars['net_vip_map'] enabled_services = task_vars['enabled_services'] primary_role_name = task_vars['primary_role_name'] @@ -198,6 +199,8 @@ class ActionModule(ActionBase): if 'redis' in enabled_services or self.nova_additional_cell: if 'redis_vip' in self.all_nodes_extra_map_data: all_nodes['redis_vip'] = self.all_nodes_extra_map_data['redis_vip'] + elif 'redis' in service_vip_vars: + all_nodes['redis_vip'] = service_vip_vars['redis'] elif 'redis' in net_vip_map: all_nodes['redis_vip'] = net_vip_map['redis'] @@ -207,6 +210,8 @@ class ActionModule(ActionBase): if 'ovn_dbs_vip' in self.all_nodes_extra_map_data: all_nodes['ovn_dbs_vip'] = \ self.all_nodes_extra_map_data['ovn_dbs_vip'] + elif 'ovn_dbs' in service_vip_vars: + all_nodes['ovn_dbs_vip'] = service_vip_vars['ovn_dbs'] elif 'ovn_dbs' in net_vip_map: all_nodes['ovn_dbs_vip'] = net_vip_map['ovn_dbs'] diff --git a/tripleo_ansible/ansible_plugins/modules/tripleo_service_vip.py b/tripleo_ansible/ansible_plugins/modules/tripleo_service_vip.py new file mode 100644 index 000000000..00e205c1d --- /dev/null +++ b/tripleo_ansible/ansible_plugins/modules/tripleo_service_vip.py @@ -0,0 +1,294 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2021 OpenStack Foundation +# All Rights Reserved. +# +# 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 os +import yaml + +import keystoneauth1.exceptions as kauth1_exc + +try: + from ansible.module_utils import network_data_v2 +except ImportError: + from tripleo_ansible.ansible_plugins.module_utils import network_data_v2 +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.openstack import openstack_full_argument_spec +from ansible.module_utils.openstack import openstack_module_kwargs +from ansible.module_utils.openstack import openstack_cloud_from_module + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + +DOCUMENTATION = ''' +--- +module: tripleo_service_vip + +short_description: Create a Virtual IP address for a service + +version_added: "2.8" + +description: + - "Create a Virtual IP address for a service" + +options: + playbook_dir: + description: + - The path to the directory of the playbook that was passed to the + ansible-playbook command line. + type: str + stack_name: + description: + - Name of the overcloud stack which will be deployed on these instances + type: str + default: overcloud + service_name: + description: + - Name of the service the Virtual IP is intended for + type: str + network: + description: + - Neutron network where the Virtual IP port will be created + type: str + fixed_ips: + description: + - A list of ip allocation definitions + type: list + elements: dict + suboptions: + ip_address: + description: + - IP address + type: str + subnet: + description: + - Neutron subnet name or id + type: str + +author: + - Harald Jensås +''' + +RETURN = ''' +''' + +EXAMPLES = ''' +- name: Create redis Virtual IP + tripleo_service_vip_port: + stack_name: overcloud + service_name: redis + network: internal_api + fixed_ip: + - subnet: internal_api_subnet + register: redis_vip +''' + +VIRTUAL_IP_NAME_SUFFIX = '_virtual_ip' + + +class FakePort: + def __init__(self, fixed_ips): + self.fixed_ips = fixed_ips + + +def create_or_update_port(conn, net, stack=None, service=None, + fixed_ips=None): + if not fixed_ips: + raise Exception('ERROR: No IP allocation definition provided. ' + 'Please provide at least one IP allocation ' + 'definition using the fixed_ips argument.') + + tags = {'tripleo_stack_name={}'.format(stack), + 'tripleo_service_vip={}'.format(service)} + port_def = dict(name=service + VIRTUAL_IP_NAME_SUFFIX, network_id=net.id) + + try: + port = next(conn.network.ports(tags=list(tags), network_id=net.id)) + except StopIteration: + port = None + + fixed_ips_def = port_def['fixed_ips'] = [] + + for fixed_ip in fixed_ips: + ip_address = fixed_ip.get('ip_address') + subnet_name = fixed_ip.get('subnet') + ip_def = {} + if ip_address: + ip_def['ip_address'] = ip_address + if subnet_name: + subnet = conn.network.find_subnet(subnet_name, network_id=net.id) + if subnet is None: + raise Exception('ERROR: Subnet {} does not exist for network ' + '{}. Service {} is mapped to a subnet that ' + 'does not exist. Verify that the VipSubnetMap ' + 'parameter has the correct values.'.format( + subnet_name, net.name, service)) + ip_def['subnet_id'] = subnet.id + + fixed_ips_def.append(ip_def) + + if not port: + port = conn.network.create_port(**port_def) + else: + # TODO: Check if port needs update + port = conn.network.update_port(port, **port_def) + + p_tags = set(port.tags) + if not tags.issubset(p_tags): + p_tags.update(tags) + conn.network.set_tags(port, list(p_tags)) + + return port + + +def find_ctlplane_vip(conn, stack=None, service=None): + tags = ['tripleo_stack_name={}'.format(stack), + 'tripleo_vip_net=ctlplane'] + try: + port = next(conn.network.ports(tags=tags)) + except StopIteration: + raise Exception('Virtual IP address on the ctlplane network for stack ' + '{} not found. Service {} is mapped to the ctlplane ' + 'network and thus require a virtual IP address to be ' + 'present on the ctlplane network.'.format(stack, + service)) + + return port + + +def validate_playbook_dir(playbook_dir_path): + if not os.path.exists(playbook_dir_path): + raise Exception('ERROR: Playbook directory {} does not exist.'.format( + playbook_dir_path)) + + if not os.path.isdir(playbook_dir_path): + raise Exception( + 'ERROR: Playbook directory {} is not a directory'.format( + playbook_dir_path)) + + +def validate_service_vip_vars_file(service_vip_var_file): + if not os.path.isfile(service_vip_var_file): + raise Exception( + 'ERROR: Service VIP var file {} is not a file'.format( + service_vip_var_file)) + + +def write_vars_file(port, service, playbook_dir): + ips = [x['ip_address'] for x in port.fixed_ips] + if len(ips) == 1: + ips = ips[0] + + playbook_dir_path = os.path.abspath(playbook_dir) + validate_playbook_dir(playbook_dir) + + service_vip_var_file = os.path.join(playbook_dir_path, + 'service_vip_vars.yaml') + + if not os.path.exists(service_vip_var_file): + data = dict() + else: + validate_service_vip_vars_file(service_vip_var_file) + with open(service_vip_var_file, 'r') as f: + data = yaml.safe_load(f.read()) + + data.update({service: ips}) + with open(service_vip_var_file, 'w') as f: + f.write(yaml.safe_dump(data, default_flow_style=False)) + + +def use_neutron(conn, stack, service, network, fixed_ips): + + net = conn.network.find_network(network) + + # NOTE: If the network does'nt exist fall back to use the ctlplane VIP + if net is None or net.name == 'ctlplane': + port = find_ctlplane_vip(conn, stack=stack, service=service) + else: + port = create_or_update_port(conn, net, stack=stack, service=service, + fixed_ips=fixed_ips) + + return port + + +def use_fake(service, fixed_ips): + if [fixed_ip for fixed_ip in fixed_ips if 'ip_address' in fixed_ip]: + port = FakePort(fixed_ips) + else: + raise Exception('Neutron service is not available and no fixed IP ' + 'address provided for {} service virtual IP. When ' + 'neutron service is not available a fixed IP ' + 'address must be provided'.format(service)) + + return port + + +def run_module(): + result = dict( + success=False, + changed=False, + error="", + ) + + argument_spec = openstack_full_argument_spec( + **yaml.safe_load(DOCUMENTATION)['options'] + ) + + module = AnsibleModule( + argument_spec, + supports_check_mode=False, + **openstack_module_kwargs() + ) + + stack = module.params.get('stack_name', 'overcloud') + service = module.params['service_name'] + network = module.params['network'] + fixed_ips = module.params['fixed_ips'] + playbook_dir = module.params['playbook_dir'] + + try: + try: + _, conn = openstack_cloud_from_module(module) + neutron_found = conn.identity.find_service('neutron') is not None + except kauth1_exc.MissingRequiredOptions: + neutron_found = False + + if neutron_found: + port = use_neutron(conn, stack, service, network, fixed_ips) + else: + port = use_fake(service, fixed_ips) + + write_vars_file(port, service, playbook_dir) + + result['changed'] = True + result['success'] = True + module.exit_json(**result) + + except Exception as err: + result['error'] = str(err) + result['msg'] = ('ERROR: Failed creating service virtual IP!' + ' {}'.format(err)) + module.fail_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/tripleo_ansible/roles/tripleo_hieradata/templates/all_nodes.j2 b/tripleo_ansible/roles/tripleo_hieradata/templates/all_nodes.j2 index 6f73fa092..31a4a30fb 100644 --- a/tripleo_ansible/roles/tripleo_hieradata/templates/all_nodes.j2 +++ b/tripleo_ansible/roles/tripleo_hieradata/templates/all_nodes.j2 @@ -46,11 +46,15 @@ {% set _ = all_nodes.__setitem__('redis_vip', all_nodes_extra_map_data['redis_vip']) %} {% elif net_vip_map.redis is defined %} {% set _ = all_nodes.__setitem__('redis_vip', (net_vip_map.redis)) %} +{% elif service_vip_vars.redis is defined %} +{% set _ = all_nodes.__setitem__('redis_vip', (service_vip_vars.redis)) %} {% endif %} {% endif %} {% if 'ovn_dbs' in all_enabled_services or nova_additional_cell %} {% if 'ovn_dbs_vip' in all_nodes_extra_map_data %} {% set _ = all_nodes.__setitem__('ovn_dbs_vip', all_nodes_extra_map_data['ovn_dbs_vip']) %} +{% elif service_vip_vars.ovn_dbs is defined %} +{% set _ = all_nodes.__setitem__('ovn_dbs_vip', (service_vip_vars.ovn_dbs)) %} {% elif net_vip_map.ovn_dbs is defined %} {% set _ = all_nodes.__setitem__('ovn_dbs_vip', (net_vip_map.ovn_dbs)) %} {% endif %} diff --git a/tripleo_ansible/roles/tripleo_hieradata/templates/vip_data.j2 b/tripleo_ansible/roles/tripleo_hieradata/templates/vip_data.j2 index d238f70c7..da13743fd 100644 --- a/tripleo_ansible/roles/tripleo_hieradata/templates/vip_data.j2 +++ b/tripleo_ansible/roles/tripleo_hieradata/templates/vip_data.j2 @@ -30,10 +30,18 @@ {% endif %} {% set _ = vip_data.__setitem__('tripleo::haproxy::controller_virtual_ip', (net_vip_map.ctlplane)) %} {% set _ = vip_data.__setitem__('tripleo::keepalived::controller_virtual_ip', (net_vip_map.ctlplane)) %} +{% if service_vip_vars.redis is defined %} +{% set _ = vip_data.__setitem__('tripleo::keepalived::redis_virtual_ip', (service_vip_vars.redis)) %} +{% elif net_vip_map.redis is defined %} {% set _ = vip_data.__setitem__('tripleo::keepalived::redis_virtual_ip', (net_vip_map.redis)) %} +{% endif %} {% set _ = vip_data.__setitem__('tripleo::redis_notification::haproxy_monitor_ip', (net_vip_map.ctlplane)) %} -{% if 'ovn_dbs' in enabled_services and net_vip_map.ovn_dbs is defined %} -{% set _ = vip_data.__setitem__('tripleo::keepalived::ovndbs_virtual_ip', (net_vip_map.ovn_dbs)) %} +{% if 'ovn_dbs' in enabled_services %} +{% if service_vip_vars.ovn_dbs is defined %} +{% set _ = vip_data.__setitem__('tripleo::keepalived::ovndbs_virtual_ip', (service_vip_vars.ovn_dbs)) %} +{% elif net_vip_map.ovn_dbs is defined %} +{% set _ = vip_data.__setitem__('tripleo::keepalived::ovndbs_virtual_ip', (net_vip_map.ovn_dbs)) %} +{% endif %} {% endif %} {% for key, value in cloud_names.items() %} {% set _ = vip_data.__setitem__(key, value) %} @@ -46,7 +54,7 @@ {% set _ = vip_data.__setitem__((service ~ '_vip'), (net_vip_map[service_net_map[service ~ '_network']])) %} {% endif %} {# we set the ovn_dbs_vip to the per-network VIP *if* we detect that there is no separate ovn_dbs VIP set (I.e. THT patch for separate OVN VIP is missing) #} -{% if service in ['ovn_dbs'] and net_vip_map.ovn_dbs is not defined%} +{% if service in ['ovn_dbs'] and net_vip_map.ovn_dbs is not defined and service_vip_vars.ovn_dbs is not defined%} {% set _ = vip_data.__setitem__((service ~ '_vip'), (net_vip_map[service_net_map[service ~ '_network']])) %} {% endif %} {% endif %} diff --git a/tripleo_ansible/tests/modules/test_tripleo_service_vip.py b/tripleo_ansible/tests/modules/test_tripleo_service_vip.py new file mode 100644 index 000000000..d827f8e8d --- /dev/null +++ b/tripleo_ansible/tests/modules/test_tripleo_service_vip.py @@ -0,0 +1,139 @@ +# Copyright (c) 2021 OpenStack Foundation +# All Rights Reserved. +# +# 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 mock +import openstack + +from tripleo_ansible.ansible_plugins.modules import ( + tripleo_service_vip as plugin) +from tripleo_ansible.tests import base as tests_base +from tripleo_ansible.tests import stubs + + +class TestTripleoServiceVip(tests_base.TestCase): + + def setUp(self): + super(TestTripleoServiceVip, self).setUp() + + # Helper function to convert array to generator + self.a2g = lambda x: (n for n in x) + + @mock.patch.object(openstack.connection, 'Connection', autospec=True) + def test_create(self, mock_conn): + fixed_ips = [{'subnet': 'test'}] + fake_net = stubs.FakeNeutronNetwork( + name='test', + id='net_id' + ) + fake_subnet = stubs.FakeNeutronSubnet( + name='test', + id='subnet_id' + ) + fake_port = stubs.FakeNeutronPort( + name='test_virtual_ip', + id='port_id', + fixed_ips=[{'ip_address': '10.0.0.10', 'subnet_id': 'subnet_id'}], + tags=[] + ) + mock_conn.network.find_subnet.return_value = fake_subnet + mock_conn.network.ports.return_value = self.a2g([]) + mock_conn.network.create_port.return_value = fake_port + plugin.create_or_update_port(mock_conn, fake_net, stack='test', + service='test', fixed_ips=fixed_ips) + mock_conn.network.create_port.assert_called_once_with( + name='test_virtual_ip', network_id='net_id', + fixed_ips=[{'subnet_id': 'subnet_id'}]) + mock_conn.network.update_port.assert_not_called() + mock_conn.network.set_tags.assert_called_once_with( + fake_port, [mock.ANY, mock.ANY]) + + @mock.patch.object(openstack.connection, 'Connection', autospec=True) + def test_update(self, mock_conn): + fixed_ips = [{'subnet': 'test'}] + fake_net = stubs.FakeNeutronNetwork( + name='test', + id='net_id' + ) + fake_subnet = stubs.FakeNeutronSubnet( + name='test', + id='subnet_id' + ) + fake_port = stubs.FakeNeutronPort( + name='test_virtual_ip', + id='port_id', + fixed_ips=[{'ip_address': '10.0.0.10', 'subnet_id': 'subnet_id'}], + tags=[] + ) + mock_conn.network.find_subnet.return_value = fake_subnet + mock_conn.network.ports.return_value = self.a2g([fake_port]) + mock_conn.network.update_port.return_value = fake_port + plugin.create_or_update_port(mock_conn, fake_net, stack='test', + service='test', fixed_ips=fixed_ips) + mock_conn.network.create_port.assert_not_called() + mock_conn.network.update_port.assert_called_once_with( + fake_port, name='test_virtual_ip', network_id='net_id', + fixed_ips=[{'subnet_id': 'subnet_id'}]) + mock_conn.network.set_tags.assert_called_once_with( + fake_port, [mock.ANY, mock.ANY]) + + @mock.patch.object(openstack.connection, 'Connection', autospec=True) + def test_no_change_no_update(self, mock_conn): + # TODO + pass + + @mock.patch.object(openstack.connection, 'Connection', autospec=True) + def test_fail_if_no_fixed_ips(self, mock_conn): + fake_net = stubs.FakeNeutronNetwork( + name='test', + id='net_id' + ) + msg = ('ERROR: No IP allocation definition provided. ' + 'Please provide at least one IP allocation ' + 'definition using the fixed_ips argument.') + self.assertRaisesRegex(Exception, msg, + plugin.create_or_update_port, mock_conn, + fake_net) + + @mock.patch.object(openstack.connection, 'Connection', autospec=True) + def test_find_ctlplane_vip_found(self, mock_conn): + tags = ['tripleo_stack_name=overcloud', 'tripleo_vip_net=ctlplane'] + fake_port = stubs.FakeNeutronPort( + name='test_virtual_ip', + id='port_id', + fixed_ips=[{'ip_address': '10.0.0.10', 'subnet_id': 'subnet_id'}], + tags=['tripleo_stack_name=overcloud', + 'tripleo_vip_net=ctlplane'] + ) + mock_conn.network.ports.return_value = self.a2g([fake_port]) + port = plugin.find_ctlplane_vip(mock_conn, stack='overcloud', + service='test') + mock_conn.network.ports.assert_called_once_with(tags=tags) + self.assertEqual(fake_port, port) + + @mock.patch.object(openstack.connection, 'Connection', autospec=True) + def test_find_ctlplane_vip_not_found(self, mock_conn): + stack = 'overcloud' + service = 'test' + msg = ('Virtual IP address on the ctlplane network for stack ' + '{} not found. Service {} is mapped to the ctlplane ' + 'network and thus require a virtual IP address to be ' + 'present on the ctlplane network.'.format(stack, service)) + mock_conn.network.ports.return_value = self.a2g([]) + self.assertRaisesRegex(Exception, msg, + plugin.find_ctlplane_vip, mock_conn, + stack=stack, service=service) + tags = ['tripleo_stack_name={}'.format(stack), + 'tripleo_vip_net=ctlplane'] + mock_conn.network.ports.assert_called_once_with(tags=tags)