diff --git a/doc/source/modules/modules-tripleo_baremetal_check_existing.rst b/doc/source/modules/modules-tripleo_baremetal_check_existing.rst new file mode 100644 index 000000000..28a5639e5 --- /dev/null +++ b/doc/source/modules/modules-tripleo_baremetal_check_existing.rst @@ -0,0 +1,14 @@ +========================================= +Module - tripleo_baremetal_check_existing +========================================= + + +This module provides for the following ansible plugin: + + * tripleo_baremetal_check_existing + + +.. ansibleautoplugin:: + :module: tripleo_ansible/ansible_plugins/modules/tripleo_baremetal_check_existing.py + :documentation: true + :examples: true diff --git a/tripleo_ansible/ansible_plugins/module_utils/baremetal_deploy.py b/tripleo_ansible/ansible_plugins/module_utils/baremetal_deploy.py index eb93720ce..d2a05e6a3 100644 --- a/tripleo_ansible/ansible_plugins/module_utils/baremetal_deploy.py +++ b/tripleo_ansible/ansible_plugins/module_utils/baremetal_deploy.py @@ -17,6 +17,7 @@ import jsonschema +import metalsmith from metalsmith import sources @@ -229,6 +230,58 @@ def expand(roles, stack_name, expand_provisioned=True, default_image=None, return instances, env +def check_existing(instances, provisioner, baremetal): + validate_instances(instances) + + # Due to the name shadowing we should import other way + import importlib + sdk = importlib.import_module('openstack') + + not_found = [] + found = [] + for request in instances: + ident = request.get('name', request['hostname']) + + try: + instance = provisioner.show_instance(ident) + # TODO(dtantsur): replace Error with a specific exception + except (sdk.exceptions.ResourceNotFound, + metalsmith.exceptions.Error): + not_found.append(request) + except Exception as exc: + + message = ('Failed to request instance information for %s' + % ident) + raise BaremetalDeployException( + "%s. %s: %s" % (message, type(exc).__name__, exc) + ) + else: + # NOTE(dtantsur): metalsmith can match instances by node names, + # provide a safeguard to avoid conflicts. + if (instance.hostname + and instance.hostname != request['hostname']): + error = ("Requested hostname %s was not found, but the " + "deployed node %s has a matching name. Refusing " + "to proceed to avoid confusing results. Please " + "either rename the node or use a different " + "hostname") % (request['hostname'], instance.uuid) + raise BaremetalDeployException(error) + + if (not instance.allocation + and instance.state == metalsmith.InstanceState.ACTIVE + and 'name' in request): + # Existing node is missing an allocation record, + # so create one without triggering allocation + baremetal.create_allocation( + resource_class=request.get('resource_class', 'baremetal'), + name=request['hostname'], + node=request['name'] + ) + found.append(instance) + + return found, not_found + + def build_hostname_format(hostname_format, role_name): if not hostname_format: hostname_format = '%stackname%-{}-%index%'.format( diff --git a/tripleo_ansible/ansible_plugins/modules/tripleo_baremetal_check_existing.py b/tripleo_ansible/ansible_plugins/modules/tripleo_baremetal_check_existing.py new file mode 100644 index 000000000..5b02de8b0 --- /dev/null +++ b/tripleo_ansible/ansible_plugins/modules/tripleo_baremetal_check_existing.py @@ -0,0 +1,148 @@ +#!/usr/bin/python +# Copyright 2020 Red Hat, Inc. +# 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. + +from __future__ import absolute_import +__metaclass__ = type + +from ansible.module_utils import baremetal_deploy as bd +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.openstack import openstack_cloud_from_module +from ansible.module_utils.openstack import openstack_full_argument_spec +from ansible.module_utils.openstack import openstack_module_kwargs + +import metalsmith + +import yaml + + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community' +} + + +DOCUMENTATION = ''' +--- +module: tripleo_baremetal_check_existing +short_description: Given a list of instances, build a list of found and + not found instances +version_added: "2.9" +author: "Steve Baker (@stevebaker)" +description: + - Takes a baremetal deployment description of roles and node instances + and transforms that into an instance list and a heat environment file + for deployed-server. +options: + instances: + description: + - List of instances to be filtered into found and not found. + Only the name and hostname are used for finding. + required: true + type: list + elements: dict +''' + +RETURN = ''' +instances: + description: List of instances which actually exist + returned: changed + type: list + sample: [ + { + "hostname": "overcloud-controller-0", + "image": { + "href": "overcloud-full" + } + }, + { + "hostname": "overcloud-controller-1", + "image": { + "href": "overcloud-full" + } + } + ] +not_found: + description: List of instances which were not found + returned: changed + type: list + sample: [ + { + "hostname": "overcloud-controller-2", + "image": { + "href": "overcloud-full" + } + } + ] +''' + +EXAMPLES = ''' +- name: Find existing instances + tripleo_baremetal_check_existing: + instances: + - name: node-1 + hostname: overcloud-controller-0 + - name: node-2 + hostname: overcloud-novacompute-0 + register: tripleo_baremetal_existing +''' + + +def main(): + argument_spec = openstack_full_argument_spec( + **yaml.safe_load(DOCUMENTATION)['options'] + ) + module_kwargs = openstack_module_kwargs() + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=False, + **module_kwargs + ) + + sdk, cloud = openstack_cloud_from_module(module) + provisioner = metalsmith.Provisioner(cloud_region=cloud.config) + + try: + found, not_found = bd.check_existing( + instances=module.params['instances'], + provisioner=provisioner, + baremetal=cloud.baremetal + ) + msg = '' + if found: + msg += ('Found existing instances: %s. ' + % ', '.join([i.uuid for i in found])) + if not_found: + msg += ('Instance(s) %s do not exist. ' + % ', '.join(r['hostname'] for r in not_found)) + + instances = [{ + 'name': i.node.name or i.uuid, + 'hostname': i.hostname, + 'id': i.uuid, + } for i in found] + module.exit_json( + changed=False, + msg=msg, + instances=instances, + not_found=not_found + ) + except Exception as e: + module.fail_json(msg=str(e)) + + +if __name__ == '__main__': + main() diff --git a/tripleo_ansible/tests/plugins/module_utils/test_baremetal_deploy.py b/tripleo_ansible/tests/plugins/module_utils/test_baremetal_deploy.py index 587922154..f6fea1198 100644 --- a/tripleo_ansible/tests/plugins/module_utils/test_baremetal_deploy.py +++ b/tripleo_ansible/tests/plugins/module_utils/test_baremetal_deploy.py @@ -13,8 +13,13 @@ # License for the specific language governing permissions and limitations # under the License. +import metalsmith +import mock +from openstack import exceptions as sdk_exc + from tripleo_ansible.tests import base + # load baremetal_deploy so the next import works base.load_module_utils('baremetal_deploy') @@ -670,3 +675,91 @@ class TestExpandRoles(base.TestCase): self.assertIn('Compute: number of instance entries 4 ' 'cannot be greater than count 3', str(exc)) + + +class TestCheckExistingInstances(base.TestCase): + + def test_success(self): + pr = mock.Mock() + baremetal = mock.Mock() + instances = [ + {'hostname': 'host1', + 'image': {'href': 'overcloud-full'}}, + {'hostname': 'host3', + 'image': {'href': 'overcloud-full'}}, + {'hostname': 'host2', 'resource_class': 'compute', + 'capabilities': {'answer': '42'}, + 'image': {'href': 'overcloud-full'}} + ] + existing = mock.MagicMock(hostname='host2', allocation=None) + existing.uuid = 'aaaa' + pr.show_instance.side_effect = [ + sdk_exc.ResourceNotFound(""), + metalsmith.exceptions.Error(""), + existing, + ] + found, not_found = bd.check_existing(instances, pr, baremetal) + + self.assertEqual([existing], found) + self.assertEqual([{ + 'hostname': 'host1', + 'image': {'href': 'overcloud-full'}, + }, { + 'hostname': 'host3', + 'image': {'href': 'overcloud-full'}, + }], not_found) + pr.show_instance.assert_has_calls([ + mock.call(host) for host in ['host1', 'host3', 'host2'] + ]) + + def test_existing_no_allocation(self): + pr = mock.Mock() + baremetal = mock.Mock() + instances = [ + {'name': 'server2', 'resource_class': 'compute', + 'hostname': 'host2', + 'capabilities': {'answer': '42'}, + 'image': {'href': 'overcloud-full'}} + ] + existing = mock.MagicMock( + hostname='host2', allocation=None, + state=metalsmith.InstanceState.ACTIVE) + existing.uuid = 'aaaa' + pr.show_instance.return_value = existing + + found, not_found = bd.check_existing(instances, pr, baremetal) + baremetal.create_allocation.assert_called_once_with( + name='host2', node='server2', resource_class='compute') + + self.assertEqual([], not_found) + self.assertEqual([existing], found) + pr.show_instance.assert_called_once_with('server2') + + def test_hostname_mismatch(self): + pr = mock.Mock() + instances = [ + {'hostname': 'host1', + 'image': {'href': 'overcloud-full'}}, + ] + pr.show_instance.return_value.hostname = 'host2' + exc = self.assertRaises( + bd.BaremetalDeployException, bd.check_existing, + instances, pr, mock.Mock()) + + self.assertIn("hostname host1 was not found", str(exc)) + pr.show_instance.assert_called_once_with('host1') + + def test_unexpected_error(self): + pr = mock.Mock() + instances = [ + {'image': {'href': 'overcloud-full'}, + 'hostname': 'host%d' % i} for i in range(3) + ] + pr.show_instance.side_effect = RuntimeError('boom') + exc = self.assertRaises( + bd.BaremetalDeployException, bd.check_existing, + instances, pr, mock.Mock()) + + self.assertIn("for host0", str(exc)) + self.assertIn("RuntimeError: boom", str(exc)) + pr.show_instance.assert_called_once_with('host0')