diff --git a/.zuul.yaml b/.zuul.yaml index f79722b..07984ad 100644 --- a/.zuul.yaml +++ b/.zuul.yaml @@ -124,6 +124,7 @@ metalsmith_precreate_port: false metalsmith_partition_image: test-centos-partition metalsmith_whole_disk_image: test-centos-wholedisk + metalsmith_traits: [CUSTOM_GOLD] - job: name: metalsmith-integration-glance-netboot-cirros-iscsi-py3 diff --git a/metalsmith/_cmd.py b/metalsmith/_cmd.py index d319253..d9b5050 100644 --- a/metalsmith/_cmd.py +++ b/metalsmith/_cmd.py @@ -76,6 +76,7 @@ def _do_deploy(api, args, formatter): node = api.reserve_node(resource_class=args.resource_class, conductor_group=args.conductor_group, capabilities=capabilities, + traits=args.trait, candidates=args.candidate) instance = api.provision_node(node, image=source, @@ -153,7 +154,9 @@ def _parse_args(args, config): help='root disk size (in GiB), defaults to (local_gb ' '- 2)') deploy.add_argument('--capability', action='append', metavar='NAME=VALUE', - default=[], help='capabilities the nodes should have') + default=[], help='capabilities the node should have') + deploy.add_argument('--trait', action='append', + default=[], help='trait the node should have') deploy.add_argument('--ssh-public-key', help='SSH public key to load') deploy.add_argument('--hostname', help='Host name to use, defaults to ' 'Node\'s name or UUID') diff --git a/metalsmith/_provisioner.py b/metalsmith/_provisioner.py index 694f57f..7e34985 100644 --- a/metalsmith/_provisioner.py +++ b/metalsmith/_provisioner.py @@ -67,7 +67,8 @@ class Provisioner(object): self._dry_run = dry_run def reserve_node(self, resource_class=None, conductor_group=None, - capabilities=None, candidates=None, predicate=None): + capabilities=None, traits=None, candidates=None, + predicate=None): """Find and reserve a suitable node. Example:: @@ -81,6 +82,7 @@ class Provisioner(object): Value ``None`` means any group, use empty string "" for nodes from the default group. :param capabilities: Requested capabilities as a dict. + :param traits: Requested traits as a list of strings. :param candidates: List of nodes (UUIDs, names or `Node` objects) to pick from. The filters (for resource class and capabilities) are still applied to the provided list. The order in which @@ -111,15 +113,21 @@ class Provisioner(object): LOG.debug('Candidate nodes: %s', nodes) filters.append(_scheduler.CapabilitiesFilter(capabilities)) + filters.append(_scheduler.TraitsFilter(traits)) if predicate is not None: filters.append(_scheduler.CustomPredicateFilter(predicate)) reserver = _scheduler.IronicReserver(self._api) node = _scheduler.schedule_node(nodes, filters, reserver, dry_run=self._dry_run) + + update = {} if capabilities: - node = self._api.update_node( - node, {'/instance_info/capabilities': capabilities}) + update['/instance_info/capabilities'] = capabilities + if traits: + update['/instance_info/traits'] = traits + if update: + node = self._api.update_node(node, update) LOG.debug('Reserved node: %s', node) return node diff --git a/metalsmith/_scheduler.py b/metalsmith/_scheduler.py index c2ae191..9d1737a 100644 --- a/metalsmith/_scheduler.py +++ b/metalsmith/_scheduler.py @@ -142,6 +142,9 @@ class CapabilitiesFilter(Filter): self._counter = collections.Counter() def __call__(self, node): + if not self._capabilities: + return True + try: caps = _utils.get_capabilities(node) except Exception: @@ -181,6 +184,41 @@ class CapabilitiesFilter(Filter): raise exceptions.CapabilitiesNotFound(message, self._capabilities) +class TraitsFilter(Filter): + """Filter that checks traits.""" + + def __init__(self, traits): + self._traits = traits + self._counter = collections.Counter() + + def __call__(self, node): + if not self._traits: + return True + + traits = node.traits or [] + LOG.debug('Traits for node %(node)s: %(traits)s', + {'node': _utils.log_node(node), 'traits': traits}) + for trait in traits: + self._counter[trait] += 1 + + missing = set(self._traits) - set(traits) + if missing: + LOG.debug('Node %(node)s does not have traits %(missing)s', + {'node': _utils.log_node(node), 'missing': missing}) + return False + + return True + + def fail(self): + existing = ", ".join("%s (%d node(s))" % item + for item in self._counter.items()) + requested = ', '.join(self._traits) + message = ("No available nodes found with traits %(req)s, " + "existing traits: %(exist)s" % + {'req': requested, 'exist': existing or 'none'}) + raise exceptions.TraitsNotFound(message, self._traits) + + class CustomPredicateFilter(Filter): def __init__(self, predicate): diff --git a/metalsmith/exceptions.py b/metalsmith/exceptions.py index d25871c..94a4bd9 100644 --- a/metalsmith/exceptions.py +++ b/metalsmith/exceptions.py @@ -67,6 +67,17 @@ class CapabilitiesNotFound(ReservationFailed): super(CapabilitiesNotFound, self).__init__(message) +class TraitsNotFound(ReservationFailed): + """Requested traits do not match any nodes. + + :ivar requested_traits: Requested node's traits. + """ + + def __init__(self, message, traits): + self.requested_traits = traits + super(TraitsNotFound, self).__init__(message) + + class ValidationFailed(ReservationFailed): """Validation failed for all requested nodes.""" diff --git a/metalsmith/test/test_cmd.py b/metalsmith/test/test_cmd.py index a8160fc..f390abf 100644 --- a/metalsmith/test/test_cmd.py +++ b/metalsmith/test/test_cmd.py @@ -58,6 +58,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -105,6 +106,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -179,6 +181,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -203,6 +206,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -235,6 +239,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -269,6 +274,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -301,6 +307,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -333,6 +340,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -390,6 +398,32 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={'foo': 'bar', 'answer': '42'}, + traits=[], + candidates=None + ) + mock_pr.return_value.provision_node.assert_called_once_with( + mock_pr.return_value.reserve_node.return_value, + image='myimg', + nics=[{'network': 'mynet'}], + root_disk_size=None, + config=mock.ANY, + hostname=None, + netboot=False, + wait=1800) + + def test_args_traits(self, mock_os_conf, mock_pr): + args = ['deploy', '--network', 'mynet', '--image', 'myimg', + '--trait', 'foo:bar', '--trait', 'answer:42', + '--resource-class', 'compute'] + _cmd.main(args) + mock_pr.assert_called_once_with( + cloud_region=mock_os_conf.return_value.get_one.return_value, + dry_run=False) + mock_pr.return_value.reserve_node.assert_called_once_with( + resource_class='compute', + conductor_group=None, + capabilities={}, + traits=['foo:bar', 'answer:42'], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -417,6 +451,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -443,6 +478,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -472,6 +508,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -498,6 +535,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -520,6 +558,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -544,6 +583,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -568,6 +608,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -591,6 +632,7 @@ class TestDeploy(testtools.TestCase): resource_class=None, conductor_group=None, capabilities={}, + traits=[], candidates=['node1', 'node2'] ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -614,6 +656,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group='loc1', capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -638,6 +681,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -666,6 +710,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -703,6 +748,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( @@ -726,6 +772,7 @@ class TestDeploy(testtools.TestCase): resource_class='compute', conductor_group=None, capabilities={}, + traits=[], candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( diff --git a/metalsmith/test/test_provisioner.py b/metalsmith/test/test_provisioner.py index ce0b2ef..56fceb2 100644 --- a/metalsmith/test/test_provisioner.py +++ b/metalsmith/test/test_provisioner.py @@ -29,7 +29,7 @@ from metalsmith import sources NODE_FIELDS = ['name', 'uuid', 'instance_info', 'instance_uuid', 'maintenance', 'maintenance_reason', 'properties', 'provision_state', 'extra', - 'last_error'] + 'last_error', 'traits'] class TestInit(testtools.TestCase): @@ -138,6 +138,23 @@ class TestReserveNode(Base): self.api.update_node.assert_called_once_with( node, {'/instance_info/capabilities': {'answer': '42'}}) + def test_with_traits(self): + nodes = [ + mock.Mock(spec=['uuid', 'name', 'properties'], + properties={'local_gb': 100}, traits=traits) + for traits in [['foo', 'answer:1'], ['answer:42', 'foo'], + ['answer'], None] + ] + expected = nodes[1] + self.api.list_nodes.return_value = nodes + self.api.reserve_node.side_effect = lambda n, instance_uuid: n + + node = self.pr.reserve_node(traits=['foo', 'answer:42']) + + self.assertIs(node, expected) + self.api.update_node.assert_called_once_with( + node, {'/instance_info/traits': ['foo', 'answer:42']}) + def test_custom_predicate(self): nodes = [ mock.Mock(spec=['uuid', 'name', 'properties'], diff --git a/metalsmith/test/test_scheduler.py b/metalsmith/test/test_scheduler.py index 6282dc7..69d8df4 100644 --- a/metalsmith/test/test_scheduler.py +++ b/metalsmith/test/test_scheduler.py @@ -164,6 +164,38 @@ class TestCapabilitiesFilter(testtools.TestCase): fltr.fail) +class TestTraitsFilter(testtools.TestCase): + + def test_fail_no_traits(self): + fltr = _scheduler.TraitsFilter(['tr1', 'tr2']) + self.assertRaisesRegex(exceptions.TraitsNotFound, + 'No available nodes found with traits ' + 'tr1, tr2, existing traits: none', + fltr.fail) + + def test_no_traits(self): + fltr = _scheduler.TraitsFilter([]) + node = mock.Mock(spec=['name', 'uuid']) + self.assertTrue(fltr(node)) + + def test_ok(self): + fltr = _scheduler.TraitsFilter(['tr1', 'tr2']) + node = mock.Mock(spec=['name', 'uuid', 'traits'], + traits=['tr3', 'tr2', 'tr1']) + self.assertTrue(fltr(node)) + + def test_missing_one(self): + fltr = _scheduler.TraitsFilter(['tr1', 'tr2']) + node = mock.Mock(spec=['name', 'uuid', 'traits'], + traits=['tr3', 'tr1']) + self.assertFalse(fltr(node)) + + def test_missing_all(self): + fltr = _scheduler.TraitsFilter(['tr1', 'tr2']) + node = mock.Mock(spec=['name', 'uuid', 'traits'], traits=None) + self.assertFalse(fltr(node)) + + class TestIronicReserver(testtools.TestCase): def setUp(self): diff --git a/roles/metalsmith_deployment/README.rst b/roles/metalsmith_deployment/README.rst index 8c28b21..b2c2048 100644 --- a/roles/metalsmith_deployment/README.rst +++ b/roles/metalsmith_deployment/README.rst @@ -35,6 +35,8 @@ The following optional variables provide the defaults for Instance_ attributes: the default for ``root_size``. ``metalsmith_ssh_public_keys`` the default for ``ssh_public_keys``. +``metalsmith_traits`` + the default for ``traits``. ``metalsmith_user_name`` the default for ``user_name``, the default value is ``metalsmith``. @@ -93,6 +95,8 @@ Each instances has the following attributes: ``ssh_public_keys`` (defaults to ``metalsmith_ssh_public_keys``) list of file names with SSH public keys to put to the node. +``traits`` + list of traits the node should have. ``user_name`` (defaults to ``metalsmith_user_name``) name of the user to create on the instance via configdrive. Requires cloud-init_ on the image. @@ -121,6 +125,8 @@ Example root_size: 100 capabilities: boot_mode: uefi + traits: + - CUSTOM_GPU - hostname: compute-1 resource_class: compute root_size: 100 diff --git a/roles/metalsmith_deployment/defaults/main.yml b/roles/metalsmith_deployment/defaults/main.yml index 9d0e784..8c60646 100644 --- a/roles/metalsmith_deployment/defaults/main.yml +++ b/roles/metalsmith_deployment/defaults/main.yml @@ -8,6 +8,7 @@ metalsmith_netboot: false metalsmith_nics: [] metalsmith_resource_class: metalsmith_root_size: +metalsmith_traits: [] metalsmith_ssh_public_keys: [] metalsmith_user_name: metalsmith diff --git a/roles/metalsmith_deployment/tasks/main.yml b/roles/metalsmith_deployment/tasks/main.yml index 705f8fb..f0a0ead 100644 --- a/roles/metalsmith_deployment/tasks/main.yml +++ b/roles/metalsmith_deployment/tasks/main.yml @@ -6,6 +6,9 @@ {% for cap_name, cap_value in capabilities.items() %} --capability {{ cap_name }}={{ cap_value }} {% endfor %} + {% for trait in traits %} + --trait {{ trait }} + {% endfor %} {% for nic in nics %} {% for nic_type, nic_value in nic.items() %} --{{ nic_type }} {{ nic_value }} @@ -51,6 +54,7 @@ root_size: "{{ instance.root_size | default(metalsmith_root_size) }}" ssh_public_keys: "{{ instance.ssh_public_keys | default(metalsmith_ssh_public_keys) }}" state: "{{ instance.state | default('present') }}" + traits: "{{ instance.traits | default(metalsmith_traits) }}" user_name: "{{ instance.user_name | default(metalsmith_user_name) }}" with_items: "{{ metalsmith_instances }}" loop_control: