Browse Source
Adds a module tripleo_service_vip which manages a neutron API port resource for service virtuap IPs. (redis and ovn_dbs) when neutron service is available. When the service network is 'ctlplane' the module does a find_port for the controlplane_virtual_ip so that the ctlplane VIP is used in this case. When the neutron service is not available the module looks for a pre-defined ip address in the fixed_ips option. If present this address is used, if not present an exception is raised. The module updates the 'service_vip_vars.yaml' file in the playbook directory so that include_vars can be used to load the variables. The change also updates action/tripleo_all_nodes_data.py and tripleo_hieradata templates to use the new variable to source the service virtual IPs for redis and ovn_dbs services. Related: blueprint network-data-v2-ports Depends-On: https://review.opendev.org/777252 Change-Id: I6b2ae7388f8af15f2fd3dcbc5e671c169700fff6changes/57/777257/10
5 changed files with 453 additions and 3 deletions
@ -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 <hjensas@redhat.com> |
||||
''' |
||||
|
||||
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() |
@ -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) |
Loading…
Reference in new issue