diff --git a/setup.cfg b/setup.cfg index 601d7cb1b..c87813441 100644 --- a/setup.cfg +++ b/setup.cfg @@ -82,6 +82,8 @@ mistral.actions = tripleo.baremetal.probe_node = tripleo_common.actions.baremetal:ProbeNode tripleo.baremetal_deploy.check_existing_instances = tripleo_common.actions.baremetal_deploy:CheckExistingInstancesAction tripleo.baremetal_deploy.deploy_node = tripleo_common.actions.baremetal_deploy:DeployNodeAction + tripleo.baremetal_deploy.expand_roles = tripleo_common.actions.baremetal_deploy:ExpandRolesAction + tripleo.baremetal_deploy.populate_environment = tripleo_common.actions.baremetal_deploy:PopulateEnvironmentAction tripleo.baremetal_deploy.reserve_nodes = tripleo_common.actions.baremetal_deploy:ReserveNodesAction tripleo.baremetal_deploy.undeploy_instance = tripleo_common.actions.baremetal_deploy:UndeployInstanceAction tripleo.baremetal_deploy.wait_for_deploy = tripleo_common.actions.baremetal_deploy:WaitForDeploymentAction diff --git a/tripleo_common/actions/baremetal_deploy.py b/tripleo_common/actions/baremetal_deploy.py index f5a929921..54b110377 100644 --- a/tripleo_common/actions/baremetal_deploy.py +++ b/tripleo_common/actions/baremetal_deploy.py @@ -12,6 +12,7 @@ # License for the specific language governing permissions and limitations # under the License. +import copy import logging import jsonschema @@ -32,42 +33,62 @@ def _provisioner(context): return metalsmith.Provisioner(session=session) +_INSTANCE_SCHEMA = { + 'type': 'object', + 'properties': { + 'capabilities': {'type': 'object'}, + 'hostname': {'type': 'string', + 'minLength': 2, + 'maxLength': 255}, + 'image': {'type': 'string'}, + 'image_checksum': {'type': 'string'}, + 'image_kernel': {'type': 'string'}, + 'image_ramdisk': {'type': 'string'}, + 'name': {'type': 'string'}, + 'nics': {'type': 'array', + 'items': {'type': 'object', + 'properties': { + 'network': {'type': 'string'}, + 'port': {'type': 'string'}, + 'fixed_ip': {'type': 'string'}, + 'subnet': {'type': 'string'}, + }, + 'additionalProperties': False}}, + 'profile': {'type': 'string'}, + 'resource_class': {'type': 'string'}, + 'root_size_gb': {'type': 'integer', 'minimum': 4}, + 'swap_size_mb': {'type': 'integer', 'minimum': 64}, + 'traits': {'type': 'array', + 'items': {'type': 'string'}}, + }, + 'additionalProperties': False, +} + + _INSTANCES_INPUT_SCHEMA = { + 'type': 'array', + 'items': _INSTANCE_SCHEMA +} +"""JSON schema of the instances list.""" + + +_ROLES_INPUT_SCHEMA = { 'type': 'array', 'items': { 'type': 'object', 'properties': { - 'capabilities': {'type': 'object'}, - 'hostname': {'type': 'string', - 'minLength': 2, - 'maxLength': 255}, - 'image': {'type': 'string'}, - 'image_checksum': {'type': 'string'}, - 'image_kernel': {'type': 'string'}, - 'image_ramdisk': {'type': 'string'}, 'name': {'type': 'string'}, - 'nics': {'type': 'array', - 'items': {'type': 'object', - 'properties': { - 'network': {'type': 'string'}, - 'port': {'type': 'string'}, - 'fixed_ip': {'type': 'string'}, - 'subnet': {'type': 'string'}, - }, - 'additionalProperties': False}}, + 'count': {'type': 'integer', 'minimum': 0}, 'profile': {'type': 'string'}, - 'resource_class': {'type': 'string'}, - 'root_size_gb': {'type': 'integer', 'minimum': 4}, - 'swap_size_mb': {'type': 'integer', 'minimum': 64}, - 'traits': {'type': 'array', - 'items': {'type': 'string'}}, + 'hostname_format': {'type': 'string'}, + 'instances': {'type': 'array', + 'items': _INSTANCE_SCHEMA}, }, 'additionalProperties': False, - # Host name is required, but defaults to name in _validate_instances - 'required': ['hostname'], + 'required': ['name'], } } -"""JSON schema of the input for these actions.""" +"""JSON schema of the roles list.""" class CheckExistingInstancesAction(base.TripleOAction): @@ -113,7 +134,8 @@ class CheckExistingInstancesAction(base.TripleOAction): "hostname") % (request['hostname'], instance.uuid) return actions.Result(error=error) - found.append(instance.to_dict()) + found.append(_instance_to_dict(provisioner.connection, + instance)) if found: LOG.info('Found existing instances: %s', @@ -241,7 +263,7 @@ class DeployNodeAction(base.TripleOAction): LOG.info('Started provisioning of %s on node %s', self.instance, self.node) - return instance.to_dict() + return _instance_to_dict(provisioner.connection, instance) class WaitForDeploymentAction(base.TripleOAction): @@ -269,7 +291,7 @@ class WaitForDeploymentAction(base.TripleOAction): ) LOG.info('Successfully provisioned instance %s', self.instance['hostname']) - return instance.to_dict() + return _instance_to_dict(provisioner.connection, instance) class UndeployInstanceAction(base.TripleOAction): @@ -295,11 +317,101 @@ class UndeployInstanceAction(base.TripleOAction): LOG.info('Successfully unprovisioned %s', instance.hostname) +class ExpandRolesAction(base.TripleOAction): + """Convert a baremetal_deployment file to list of instances.""" + + def __init__(self, roles, stackname='overcloud'): + super(ExpandRolesAction, self).__init__() + self.roles = roles + self.stackname = stackname + + def run(self, context): + try: + _validate_roles(self.roles, stackname=self.stackname) + except Exception as exc: + LOG.error('Failed to validate the request. %s', exc) + return actions.Result(error=six.text_type(exc)) + + instances = [] + parameter_defaults = {'HostnameMap': {}} + for role in self.roles: + name = role['name'] + hostname_format = role.get('hostname_format') + if not hostname_format: + hostname_format = '%stackname%-{}-%index%'.format( + 'novacompute' if name == 'Compute' else name.lower()) + # NOTE(dtantsur): our hostname format may differ from THT defaults, + # so override it in the resulting environment + parameter_defaults['%sDeployedServerHostnameFormat' % name] = ( + hostname_format) + + if 'instances' in role: + parameter_defaults['%sDeployedServerCount' % name] = len( + role['instances']) + # TODO(dtantsur): ordering-dependent logic here, can we do + # better? + for index, instance in enumerate(role['instances']): + # _validate_instances ensures that either of these is + # not empty + hostname = instance.get('hostname') or instance.get('name') + gen_name = (hostname_format.replace('%index%', str(index)) + .replace('%stackname%', self.stackname)) + parameter_defaults['HostnameMap'][gen_name] = hostname + instances.append(instance) + else: + count = role.get('count', 1) + parameter_defaults['%sDeployedServerCount' % name] = count + if not count: + continue + + for index in range(count): + hostname = (hostname_format.replace('%index%', str(index)) + .replace('%stackname%', self.stackname)) + inst = {'hostname': hostname} + if 'profile' in role: + inst['profile'] = role['profile'] + instances.append(inst) + parameter_defaults['HostnameMap'][hostname] = hostname + + return {'instances': instances, + 'environment': {'parameter_defaults': parameter_defaults}} + + +class PopulateEnvironmentAction(base.TripleOAction): + """Populate the resulting environment file. + + Fills in DeployedServerPortMap with the IP addresses of the nodes. + """ + + def __init__(self, environment, port_map, ctlplane_network='ctlplane'): + super(PopulateEnvironmentAction, self).__init__() + self.environment = environment + self.port_map = port_map + self.ctlplane_network = ctlplane_network + + def run(self, context): + port_map = (self.environment.setdefault('parameter_defaults', {}) + .setdefault('DeployedServerPortMap', {})) + for hostname, nets in self.port_map.items(): + ctlplane = nets.get(self.ctlplane_network) + if not ctlplane: + LOG.warning('No ctlplane ports information for %s', hostname) + continue + + port_map['%s-%s' % (hostname, self.ctlplane_network)] = ctlplane + + return self.environment + + def _validate_instances(instances, default_image='overcloud-full'): for inst in instances: if inst.get('name') and not inst.get('hostname'): inst['hostname'] = inst['name'] + if not inst.get('hostname'): + raise ValueError('Either hostname or name is required in %s' + % inst) + # Set the default image so that the source validation can succeed. inst.setdefault('image', default_image) @@ -323,6 +435,23 @@ def _validate_instances(instances, default_image='overcloud-full'): names.add(inst['name']) +def _validate_roles(roles, stackname='overcloud'): + jsonschema.validate(roles, _ROLES_INPUT_SCHEMA) + + for item in roles: + if 'count' in item and 'instances' in item: + raise ValueError("Count and instances cannot be provided together") + + if 'instances' in item: + for index, inst in enumerate(item['instances']): + if 'profile' not in inst and 'profile' in item: + inst['profile'] = item['profile'] + + # NOTE(dtantsur): make a copy since _validate_instances modifies + # the original, and we don't need it at this stage. + _validate_instances(copy.deepcopy(item['instances'])) + + def _release_nodes(provisioner, nodes): for node in nodes: LOG.debug('Removing reservation from node %s', node) @@ -339,3 +468,19 @@ def _get_source(instance): kernel=instance.get('image_kernel'), ramdisk=instance.get('image_ramdisk'), checksum=instance.get('image_checksum')) + + +def _instance_to_dict(connection, instance): + """Convert an instance to a dict, adding ports information.""" + result = instance.to_dict() + result['port_map'] = {} + for nic in instance.nics(): + for ip in nic.fixed_ips: + net_name = getattr(nic.network, 'name', None) or nic.network.id + subnet = connection.network.get_subnet(ip['subnet_id']) + net_info = result['port_map'].setdefault( + net_name, {'network': nic.network.to_dict(), + 'fixed_ips': [], 'subnets': []}) + net_info['fixed_ips'].append({'ip_address': ip['ip_address']}) + net_info['subnets'].append(subnet.to_dict()) + return result diff --git a/tripleo_common/tests/actions/test_baremetal_deploy.py b/tripleo_common/tests/actions/test_baremetal_deploy.py index 7ed0a76d0..2e8a05610 100644 --- a/tripleo_common/tests/actions/test_baremetal_deploy.py +++ b/tripleo_common/tests/actions/test_baremetal_deploy.py @@ -56,7 +56,7 @@ class TestReserveNodes(base.TestCase): action = baremetal_deploy.ReserveNodesAction(instances) result = action.run(mock.Mock()) - self.assertIn("'hostname' is a required property", result.error) + self.assertIn("hostname or name is required", result.error) self.assertFalse(mock_pr.return_value.reserve_node.called) self.assertFalse(mock_pr.return_value.unprovision_node.called) @@ -421,7 +421,7 @@ class TestCheckExistingInstances(base.TestCase): action = baremetal_deploy.CheckExistingInstancesAction(instances) result = action.run(mock.Mock()) - self.assertIn("'hostname' is a required property", result.error) + self.assertIn("hostname or name is required", result.error) self.assertFalse(mock_pr.return_value.show_instance.called) def test_hostname_mismatch(self, mock_pr): @@ -453,14 +453,50 @@ class TestWaitForDeployment(base.TestCase): def test_success(self, mock_pr): pr = mock_pr.return_value + inst = pr.wait_for_provisioning.return_value[0] + inst.to_dict.return_value = {'hostname': 'compute.cloud'} + action = baremetal_deploy.WaitForDeploymentAction( {'hostname': 'compute.cloud', 'uuid': 'uuid1'}) result = action.run(mock.Mock()) pr.wait_for_provisioning.assert_called_once_with(['uuid1'], timeout=3600) - inst = pr.wait_for_provisioning.return_value[0] self.assertIs(result, inst.to_dict.return_value) + self.assertEqual({}, result['port_map']) + + def test_with_nics(self, mock_pr): + pr = mock_pr.return_value + net_mock = mock.Mock() + net_mock.name = 'ctlplane' + net_mock.to_dict.return_value = {'tags': ['foo']} + inst = pr.wait_for_provisioning.return_value[0] + inst.nics.return_value = [ + mock.Mock(fixed_ips=[{'ip_address': '1.2.3.5', + 'subnet_id': 'abcd'}], + network=net_mock) + ] + pr.connection.network.get_subnet.return_value.to_dict.return_value = { + 'cidr': '1.2.3.0/24' + } + inst.to_dict.return_value = {'hostname': 'compute.cloud'} + + action = baremetal_deploy.WaitForDeploymentAction( + {'hostname': 'compute.cloud', 'uuid': 'uuid1'}) + result = action.run(mock.Mock()) + + pr.wait_for_provisioning.assert_called_once_with(['uuid1'], + timeout=3600) + self.assertIs(result, inst.to_dict.return_value) + self.assertEqual( + { + 'ctlplane': { + 'network': {'tags': ['foo']}, + 'fixed_ips': [{'ip_address': '1.2.3.5'}], + 'subnets': [{'cidr': '1.2.3.0/24'}], + } + }, + result['port_map']) def test_failure(self, mock_pr): pr = mock_pr.return_value @@ -497,3 +533,200 @@ class TestUndeployInstance(base.TestCase): pr.show_instance.assert_called_once_with('inst1') self.assertFalse(pr.unprovision_node.called) + + +class TestExpandRoles(base.TestCase): + + def test_simple(self): + roles = [ + {'name': 'Compute'}, + {'name': 'Controller'}, + ] + action = baremetal_deploy.ExpandRolesAction(roles) + result = action.run(mock.Mock()) + self.assertEqual( + [ + {'hostname': 'overcloud-novacompute-0'}, + {'hostname': 'overcloud-controller-0'}, + ], + result['instances']) + self.assertEqual( + { + 'ComputeDeployedServerHostnameFormat': + '%stackname%-novacompute-%index%', + 'ComputeDeployedServerCount': 1, + 'ControllerDeployedServerHostnameFormat': + '%stackname%-controller-%index%', + 'ControllerDeployedServerCount': 1, + 'HostnameMap': { + 'overcloud-novacompute-0': 'overcloud-novacompute-0', + 'overcloud-controller-0': 'overcloud-controller-0' + } + }, + result['environment']['parameter_defaults']) + + def test_with_parameters(self): + roles = [ + {'name': 'Compute', 'count': 2, 'profile': 'compute', + 'hostname_format': 'compute-%index%.example.com'}, + {'name': 'Controller', 'count': 3, 'profile': 'control', + 'hostname_format': 'controller-%index%.example.com'}, + ] + action = baremetal_deploy.ExpandRolesAction(roles) + result = action.run(mock.Mock()) + self.assertEqual( + [ + {'hostname': 'compute-0.example.com', 'profile': 'compute'}, + {'hostname': 'compute-1.example.com', 'profile': 'compute'}, + {'hostname': 'controller-0.example.com', 'profile': 'control'}, + {'hostname': 'controller-1.example.com', 'profile': 'control'}, + {'hostname': 'controller-2.example.com', 'profile': 'control'}, + ], + result['instances']) + self.assertEqual( + { + 'ComputeDeployedServerHostnameFormat': + 'compute-%index%.example.com', + 'ComputeDeployedServerCount': 2, + 'ControllerDeployedServerHostnameFormat': + 'controller-%index%.example.com', + 'ControllerDeployedServerCount': 3, + 'HostnameMap': { + 'compute-0.example.com': 'compute-0.example.com', + 'compute-1.example.com': 'compute-1.example.com', + 'controller-0.example.com': 'controller-0.example.com', + 'controller-1.example.com': 'controller-1.example.com', + 'controller-2.example.com': 'controller-2.example.com', + } + }, + result['environment']['parameter_defaults']) + + def test_explicit_instances(self): + roles = [ + {'name': 'Compute', 'count': 2, 'profile': 'compute', + 'hostname_format': 'compute-%index%.example.com'}, + {'name': 'Controller', + 'profile': 'control', + 'instances': [ + {'hostname': 'controller-X.example.com', + 'profile': 'control-X'}, + {'name': 'node-0', 'traits': ['CUSTOM_FOO'], + 'nics': [{'subnet': 'leaf-2'}]}, + ]}, + ] + action = baremetal_deploy.ExpandRolesAction(roles) + result = action.run(mock.Mock()) + self.assertEqual( + [ + {'hostname': 'compute-0.example.com', 'profile': 'compute'}, + {'hostname': 'compute-1.example.com', 'profile': 'compute'}, + {'hostname': 'controller-X.example.com', + 'profile': 'control-X'}, + # Name provides the default for hostname later on. + {'name': 'node-0', 'profile': 'control', + 'traits': ['CUSTOM_FOO'], 'nics': [{'subnet': 'leaf-2'}]}, + ], + result['instances']) + self.assertEqual( + { + 'ComputeDeployedServerHostnameFormat': + 'compute-%index%.example.com', + 'ComputeDeployedServerCount': 2, + 'ControllerDeployedServerHostnameFormat': + '%stackname%-controller-%index%', + 'ControllerDeployedServerCount': 2, + 'HostnameMap': { + 'compute-0.example.com': 'compute-0.example.com', + 'compute-1.example.com': 'compute-1.example.com', + 'overcloud-controller-0': 'controller-X.example.com', + 'overcloud-controller-1': 'node-0', + } + }, + result['environment']['parameter_defaults']) + + def test_count_with_instances(self): + roles = [ + {'name': 'Compute', 'count': 2, 'profile': 'compute', + 'hostname_format': 'compute-%index%.example.com'}, + {'name': 'Controller', + 'profile': 'control', + # Count makes no sense with instances and thus is disallowed. + 'count': 3, + 'instances': [ + {'hostname': 'controller-X.example.com', + 'profile': 'control-X'}, + {'name': 'node-0', 'traits': ['CUSTOM_FOO'], + 'nics': [{'subnet': 'leaf-2'}]}, + ]}, + ] + action = baremetal_deploy.ExpandRolesAction(roles) + result = action.run(mock.Mock()) + self.assertIn("Count and instances cannot be provided together", + result.error) + + def test_instances_without_hostname(self): + roles = [ + {'name': 'Compute', 'count': 2, 'profile': 'compute', + 'hostname_format': 'compute-%index%.example.com'}, + {'name': 'Controller', + 'profile': 'control', + 'instances': [ + {'profile': 'control-X'}, # missing hostname here + {'name': 'node-0', 'traits': ['CUSTOM_FOO'], + 'nics': [{'subnet': 'leaf-2'}]}, + ]}, + ] + action = baremetal_deploy.ExpandRolesAction(roles) + result = action.run(mock.Mock()) + self.assertIn("Either hostname or name is required", result.error) + + +class TestPopulateEnvironment(base.TestCase): + + def test_success(self): + port_map = { + 'compute-0': { + 'ctlplane': { + 'network': {'tags': ['foo']}, + 'fixed_ips': [{'ip_address': '1.2.3.5'}], + 'subnets': [{'cidr': '1.2.3.0/24'}], + }, + 'foobar': { + 'network': {}, + 'fixed_ips': [{'ip_address': '1.2.4.5'}], + 'subnets': [{'cidr': '1.2.4.0/24'}], + }, + }, + 'controller-0': { + 'ctlplane': { + 'network': {'tags': ['foo']}, + 'fixed_ips': [{'ip_address': '1.2.3.4'}], + 'subnets': [{'cidr': '1.2.3.0/24'}], + }, + 'foobar': { + 'network': {}, + 'fixed_ips': [{'ip_address': '1.2.4.4'}], + 'subnets': [{'cidr': '1.2.4.0/24'}], + }, + }, + } + action = baremetal_deploy.PopulateEnvironmentAction({}, port_map) + result = action.run(mock.Mock()) + self.assertEqual( + { + 'parameter_defaults': { + 'DeployedServerPortMap': { + 'compute-0-ctlplane': { + 'fixed_ips': [{'ip_address': '1.2.3.5'}], + 'subnets': [{'cidr': '1.2.3.0/24'}], + 'network': {'tags': ['foo']}, + }, + 'controller-0-ctlplane': { + 'fixed_ips': [{'ip_address': '1.2.3.4'}], + 'subnets': [{'cidr': '1.2.3.0/24'}], + 'network': {'tags': ['foo']}, + }, + } + } + }, + result) diff --git a/workbooks/baremetal_deploy.yaml b/workbooks/baremetal_deploy.yaml index ee534b74f..107ebbf16 100644 --- a/workbooks/baremetal_deploy.yaml +++ b/workbooks/baremetal_deploy.yaml @@ -133,6 +133,7 @@ workflows: publish: ctlplane_ips: <% $.all_instances.toDict($.hostname, $.ip_addresses.ctlplane[0]) %> instances: <% $.all_instances.toDict($.hostname, $) %> + port_map: <% $.all_instances.toDict($.hostname, $.port_map) %> on-complete: send_message send_message: @@ -146,12 +147,14 @@ workflows: payload: ctlplane_ips: <% $.get('ctlplane_ips', {}) %> instances: <% $.get('instances', {}) %> + port_map: <% $.get('port_map', {}) %> output: ctlplane_ips: <% $.ctlplane_ips %> existing_instances: <% $.existing_instances.toDict($.hostname, $) %> instances: <% $.instances %> new_instances: <% $.new_instances.toDict($.hostname, $) %> + port_map: <% $.port_map %> undeploy_instances: @@ -188,3 +191,89 @@ workflows: status: <% $.get('status', 'SUCCESS') %> message: <% $.get('message', '') %> execution: <% execution() %> + + + deploy_roles: + description: Deploy roles on bare metal nodes. + + input: + - roles + - plan: overcloud + - ctlplane_network: ctlplane + - ssh_keys: [] + - ssh_user_name: heat-admin + - timeout: 3600 + - concurrency: 20 + - queue_name: tripleo + + tags: + - tripleo-common-managed + + tasks: + + expand_roles: + action: tripleo.baremetal_deploy.expand_roles + input: + roles: <% $.roles %> + stackname: <% $.plan %> + publish: + input_instances: <% task().result.instances %> + environment: <% task().result.environment %> + publish-on-error: + status: FAILED + message: <% task().result %> + on-success: deploy_instances + on-error: send_message + + deploy_instances: + workflow: tripleo.baremetal_deploy.v1.deploy_instances + input: + instances: <% $.input_instances %> + ssh_keys: <% $.ssh_keys %> + ssh_user_name: <% $.ssh_user_name %> + timeout: <% $.timeout %> + queue_name: <% $.queue_name %> + publish: + ctlplane_ips: <% task().result.ctlplane_ips %> + instances: <% task().result.instances %> + new_instances: <% task().result.new_instances %> + existing_instances: <% task().result.existing_instances %> + port_map: <% task().result.port_map %> + publish-on-error: + status: FAILED + message: <% task().result %> + on-success: populate_environment + on-error: send_message + + populate_environment: + action: tripleo.baremetal_deploy.populate_environment + input: + ctlplane_network: <% $.ctlplane_network %> + environment: <% $.environment %> + port_map: <% $.port_map %> + publish: + environment: <% task().result %> + publish-on-error: + status: FAILED + message: <% task().result %> + on-complete: send_message + + send_message: + workflow: tripleo.messaging.v1.send + input: + queue_name: <% $.queue_name %> + type: <% execution().name %> + status: <% $.get('status', 'SUCCESS') %> + message: <% $.get('message', '') %> + execution: <% execution() %> + payload: + environment: <% $.get('environment', {}) %> + instances: <% $.get('instances', {}) %> + + output: + ctlplane_ips: <% $.ctlplane_ips %> + environment: <% $.environment %> + existing_instances: <% $.existing_instances %> + instances: <% $.instances %> + new_instances: <% $.new_instances %> + port_map: <% $.port_map %>