diff --git a/metalsmith/_cmd.py b/metalsmith/_cmd.py index b908b90..e5ae636 100644 --- a/metalsmith/_cmd.py +++ b/metalsmith/_cmd.py @@ -56,7 +56,8 @@ def _do_deploy(api, args, formatter): if args.user_name: config.add_user(args.user_name, sudo=args.passwordless_sudo) - node = api.reserve_node(args.resource_class, capabilities=capabilities) + node = api.reserve_node(args.resource_class, capabilities=capabilities, + candidates=args.candidate) instance = api.provision_node(node, image=args.image, nics=args.nics, @@ -130,8 +131,11 @@ def _parse_args(args, config): 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') - deploy.add_argument('--resource-class', required=True, + deploy.add_argument('--resource-class', help='node resource class to deploy') + deploy.add_argument('--candidate', action='append', + help='A candidate node to use for scheduling (can be ' + 'specified several times)') deploy.add_argument('--user-name', help='Name of the admin user to create') deploy.add_argument('--passwordless-sudo', action='store_true', help='allow password-less sudo for the user') diff --git a/metalsmith/_provisioner.py b/metalsmith/_provisioner.py index e9588a5..06ef913 100644 --- a/metalsmith/_provisioner.py +++ b/metalsmith/_provisioner.py @@ -49,7 +49,8 @@ class Provisioner(object): self._api = _os_api.API(session=session, cloud_region=cloud_region) self._dry_run = dry_run - def reserve_node(self, resource_class, capabilities=None): + def reserve_node(self, resource_class=None, capabilities=None, + candidates=None): """Find and reserve a suitable node. Example:: @@ -57,20 +58,32 @@ class Provisioner(object): node = provisioner.reserve_node("compute", capabilities={"boot_mode": "uefi"}) - :param resource_class: Requested resource class. + :param resource_class: Requested resource class. If ``None``, a node + with any resource class can be chosen. :param capabilities: Requested capabilities as a dict. + :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 + the nodes are considered is retained. :return: reserved `Node` object. :raises: :py:class:`metalsmith.exceptions.ReservationFailed` """ capabilities = capabilities or {} - nodes = self._api.list_nodes(resource_class=resource_class) + if candidates: + nodes = [self._api.get_node(node) for node in candidates] + if resource_class: + nodes = [node for node in nodes + if node.resource_class == resource_class] + else: + nodes = self._api.list_nodes(resource_class=resource_class) + # Ensure parallel executions don't try nodes in the same sequence + random.shuffle(nodes) + if not nodes: raise exceptions.ResourceClassNotFound(resource_class, capabilities) - # Make sure parallel executions don't try nodes in the same sequence - random.shuffle(nodes) LOG.debug('Ironic nodes: %s', nodes) filters = [_scheduler.CapabilitiesFilter(resource_class, capabilities), diff --git a/metalsmith/test/test_cmd.py b/metalsmith/test/test_cmd.py index f5a8077..28188d6 100644 --- a/metalsmith/test/test_cmd.py +++ b/metalsmith/test/test_cmd.py @@ -55,7 +55,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -100,7 +101,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -172,7 +174,8 @@ class TestDeploy(testtools.TestCase): dry_run=True) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -194,7 +197,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -224,7 +228,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -256,7 +261,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -286,7 +292,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -316,7 +323,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -371,7 +379,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={'foo': 'bar', 'answer': '42'} + capabilities={'foo': 'bar', 'answer': '42'}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -396,7 +405,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -420,7 +430,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -447,7 +458,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -471,7 +483,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -491,7 +504,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -513,7 +527,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -535,7 +550,30 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None + ) + mock_pr.return_value.provision_node.assert_called_once_with( + mock_pr.return_value.reserve_node.return_value, + image='myimg', + nics=None, + root_disk_size=None, + config=mock.ANY, + hostname='host', + netboot=False, + wait=1800) + + def test_args_with_candidates(self, mock_os_conf, mock_pr): + args = ['deploy', '--hostname', 'host', '--image', 'myimg', + '--candidate', 'node1', '--candidate', 'node2'] + _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=None, + capabilities={}, + candidates=['node1', 'node2'] ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -556,7 +594,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, @@ -577,7 +616,8 @@ class TestDeploy(testtools.TestCase): dry_run=False) mock_pr.return_value.reserve_node.assert_called_once_with( resource_class='compute', - capabilities={} + capabilities={}, + candidates=None ) mock_pr.return_value.provision_node.assert_called_once_with( mock_pr.return_value.reserve_node.return_value, diff --git a/metalsmith/test/test_provisioner.py b/metalsmith/test/test_provisioner.py index ce15558..57b49e7 100644 --- a/metalsmith/test/test_provisioner.py +++ b/metalsmith/test/test_provisioner.py @@ -75,6 +75,19 @@ class TestReserveNode(Base): self.assertIn(node, nodes) self.assertFalse(self.api.update_node.called) + def test_any_resource_class(self): + nodes = [ + mock.Mock(spec=['uuid', 'name', 'properties'], + properties={'local_gb': 100}) + ] + self.api.list_nodes.return_value = nodes + self.api.reserve_node.side_effect = lambda n, instance_uuid: n + + node = self.pr.reserve_node() + + self.assertIn(node, nodes) + self.assertFalse(self.api.update_node.called) + def test_with_capabilities(self): nodes = [ mock.Mock(spec=['uuid', 'name', 'properties'], @@ -91,6 +104,54 @@ class TestReserveNode(Base): self.api.update_node.assert_called_once_with( node, {'/instance_info/capabilities': {'answer': '42'}}) + def test_provided_node(self): + nodes = [ + mock.Mock(spec=['uuid', 'name', 'properties'], + properties={'local_gb': 100}) + ] + self.api.reserve_node.side_effect = lambda n, instance_uuid: n + + node = self.pr.reserve_node(candidates=nodes) + + self.assertEqual(node, nodes[0]) + self.assertFalse(self.api.list_nodes.called) + self.assertFalse(self.api.update_node.called) + + def test_provided_nodes(self): + nodes = [ + mock.Mock(spec=['uuid', 'name', 'properties'], + properties={'local_gb': 100}), + mock.Mock(spec=['uuid', 'name', 'properties'], + properties={'local_gb': 100}) + ] + self.api.reserve_node.side_effect = lambda n, instance_uuid: n + + node = self.pr.reserve_node(candidates=nodes) + + self.assertEqual(node, nodes[0]) + self.assertFalse(self.api.list_nodes.called) + self.assertFalse(self.api.update_node.called) + + def test_nodes_filtered(self): + nodes = [ + mock.Mock(spec=['uuid', 'name', 'properties', 'resource_class'], + properties={'local_gb': 100}, resource_class='banana'), + mock.Mock(spec=['uuid', 'name', 'properties', 'resource_class'], + properties={'local_gb': 100}, resource_class='compute'), + mock.Mock(spec=['uuid', 'name', 'properties', 'resource_class'], + properties={'local_gb': 100, 'capabilities': 'cat:meow'}, + resource_class='compute'), + ] + self.api.reserve_node.side_effect = lambda n, instance_uuid: n + + node = self.pr.reserve_node('compute', candidates=nodes, + capabilities={'cat': 'meow'}) + + self.assertEqual(node, nodes[2]) + self.assertFalse(self.api.list_nodes.called) + self.api.update_node.assert_called_once_with( + node, {'/instance_info/capabilities': {'cat': 'meow'}}) + CLEAN_UP = { '/extra/metalsmith_created_ports': _os_api.REMOVE, diff --git a/roles/metalsmith_deployment/README.rst b/roles/metalsmith_deployment/README.rst index 9d655c7..cff9aec 100644 --- a/roles/metalsmith_deployment/README.rst +++ b/roles/metalsmith_deployment/README.rst @@ -13,12 +13,14 @@ The only required variable is: The following optional variables provide the defaults for Instance_ attributes: +``metalsmith_candidates`` + the default for ``candidates``. +``metalsmith_capabilities`` + the default for ``capabilities``. ``metalsmith_extra_args`` the default for ``extra_args``. ``metalsmith_image`` the default for ``image``. -``metalsmith_capabilities`` - the default for ``capabilities``. ``metalsmith_netboot`` the default for ``netboot`` ``metalsmith_nics`` @@ -35,12 +37,14 @@ Instance Each instances has the following attributes: +``candidates`` (defaults to ``metalsmith_candidates``) + list of nodes (UUIDs or names) to be considered for deployment. +``capabilities`` (defaults to ``metalsmith_capabilities``) + node capabilities to request when scheduling. ``extra_args`` (defaults to ``metalsmith_extra_args``) additional arguments to pass to the ``metalsmith`` CLI on all calls. ``image`` (defaults to ``metalsmith_image``) UUID or name of the image to use for deployment. Mandatory. -``capabilities`` (defaults to ``metalsmith_capabilities``) - node capabilities to request when scheduling. ``netboot`` whether to boot the deployed instance from network (PXE, iPXE, etc). The default is to use local boot (requires a bootloader on the image). @@ -66,7 +70,7 @@ Each instances has the following attributes: - port: b2254316-7867-4615-9fb7-911b3f38ca2a ``resource_class`` (defaults to ``metalsmith_resource_class``) - requested node's resource class. Mandatory. + requested node's resource class. ``root_size`` (defaults to ``metalsmith_root_size``) size of the root partition, if partition images are used. diff --git a/roles/metalsmith_deployment/defaults/main.yml b/roles/metalsmith_deployment/defaults/main.yml index c45ae33..6915d17 100644 --- a/roles/metalsmith_deployment/defaults/main.yml +++ b/roles/metalsmith_deployment/defaults/main.yml @@ -1,8 +1,10 @@ # Optional parameters +metalsmith_candidates: [] metalsmith_capabilities: {} metalsmith_extra_args: metalsmith_netboot: false metalsmith_nics: [] +metalsmith_resource_class: metalsmith_root_size: 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 5979f07..f6ab396 100644 --- a/roles/metalsmith_deployment/tasks/main.yml +++ b/roles/metalsmith_deployment/tasks/main.yml @@ -25,12 +25,18 @@ {% if user_name %} --user-name {{ user_name }} {% endif %} + {% if resource_class %} --resource-class {{ resource_class }} + {% endif %} + {% for node in candidates %} + --candidate {{ node }} + {% endfor %} when: state == 'present' vars: + candidates: "{{ instance.candidates | default(metalsmith_candidates) }}" + capabilities: "{{ instance.capabilities | default(metalsmith_capabilities) }}" extra_args: "{{ instance.extra_args | default(metalsmith_extra_args) }}" image: "{{ instance.image | default(metalsmith_image) }}" - capabilities: "{{ instance.capabilities | default(metalsmith_capabilities) }}" netboot: "{{ instance.netboot | default(metalsmith_netboot) }}" nics: "{{ instance.nics | default(metalsmith_nics) }}" resource_class: "{{ instance.resource_class | default(metalsmith_resource_class) }}"