Support traits for scheduling

Story: #2003685
Task: #26209
Change-Id: I4895c414abc55ece6cf56133f13ffaa7cd4f5f92
This commit is contained in:
Dmitry Tantsur 2018-09-07 14:47:10 +02:00
parent 51a006e307
commit df831309ba
11 changed files with 173 additions and 5 deletions

View File

@ -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

View File

@ -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')

View File

@ -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

View File

@ -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):

View File

@ -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."""

View File

@ -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(

View File

@ -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'],

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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: