Browse Source

Allow filtering by arbitrary predicate and conductor_group

BREAKING: changed order of arguments in reserve_node.

BREAKING: changed exceptions inheriting ReservationFailed.

Change-Id: I79cc9b2794d8332cdb818af0b7effb28d4e9a786
Story: #2003584
Task: #24890
tags/0.7.0
Dmitry Tantsur 9 months ago
parent
commit
a638cec066

+ 5
- 1
metalsmith/_cmd.py View File

@@ -56,7 +56,9 @@ def _do_deploy(api, args, formatter):
56 56
     if args.user_name:
57 57
         config.add_user(args.user_name, sudo=args.passwordless_sudo)
58 58
 
59
-    node = api.reserve_node(args.resource_class, capabilities=capabilities,
59
+    node = api.reserve_node(resource_class=args.resource_class,
60
+                            conductor_group=args.conductor_group,
61
+                            capabilities=capabilities,
60 62
                             candidates=args.candidate)
61 63
     instance = api.provision_node(node,
62 64
                                   image=args.image,
@@ -138,6 +140,8 @@ def _parse_args(args, config):
138 140
                         'Node\'s name or UUID')
139 141
     deploy.add_argument('--resource-class',
140 142
                         help='node resource class to deploy')
143
+    deploy.add_argument('--conductor-group',
144
+                        help='conductor group to pick the node from')
141 145
     deploy.add_argument('--candidate', action='append',
142 146
                         help='A candidate node to use for scheduling (can be '
143 147
                         'specified several times)')

+ 11
- 12
metalsmith/_os_api.py View File

@@ -24,9 +24,6 @@ from metalsmith import _utils
24 24
 
25 25
 
26 26
 LOG = logging.getLogger(__name__)
27
-NODE_FIELDS = ['name', 'uuid', 'instance_info', 'instance_uuid', 'maintenance',
28
-               'maintenance_reason', 'properties', 'provision_state', 'extra',
29
-               'last_error']
30 27
 HOSTNAME_FIELD = 'metalsmith_hostname'
31 28
 
32 29
 
@@ -57,7 +54,9 @@ class API(object):
57 54
     """Various OpenStack API's."""
58 55
 
59 56
     IRONIC_VERSION = '1'
60
-    IRONIC_MICRO_VERSION = '1.28'
57
+    # TODO(dtantsur): use openstacksdk and stop hardcoding this here.
58
+    # 1.46 (Rocky) adds conductor_group.
59
+    IRONIC_MICRO_VERSION = '1.46'
61 60
 
62 61
     _node_list = None
63 62
 
@@ -139,7 +138,7 @@ class API(object):
139 138
                 if by_hostname is not None:
140 139
                     return by_hostname
141 140
 
142
-            return self.ironic.node.get(node, fields=NODE_FIELDS)
141
+            return self.ironic.node.get(node)
143 142
         elif hasattr(node, 'node'):
144 143
             # Instance object
145 144
             node = node.node
@@ -147,7 +146,7 @@ class API(object):
147 146
             node = node
148 147
 
149 148
         if refresh:
150
-            return self.ironic.node.get(node.uuid, fields=NODE_FIELDS)
149
+            return self.ironic.node.get(node.uuid)
151 150
         else:
152 151
             return node
153 152
 
@@ -161,14 +160,14 @@ class API(object):
161 160
     def list_node_ports(self, node):
162 161
         return self.ironic.node.list_ports(_node_id(node), limit=0)
163 162
 
164
-    def list_nodes(self, resource_class=None, maintenance=False,
165
-                   associated=False, provision_state='available',
166
-                   fields=None):
167
-        return self.ironic.node.list(limit=0, resource_class=resource_class,
168
-                                     maintenance=maintenance,
163
+    def list_nodes(self, maintenance=False, associated=False,
164
+                   provision_state='available', **filters):
165
+        if 'fields' not in filters:
166
+            filters['detail'] = True
167
+        return self.ironic.node.list(limit=0, maintenance=maintenance,
169 168
                                      associated=associated,
170 169
                                      provision_state=provision_state,
171
-                                     fields=fields or NODE_FIELDS)
170
+                                     **filters)
172 171
 
173 172
     def node_action(self, node, action, **kwargs):
174 173
         self.ironic.node.set_provision_state(_node_id(node), action, **kwargs)

+ 22
- 10
metalsmith/_provisioner.py View File

@@ -50,8 +50,8 @@ class Provisioner(object):
50 50
         self._api = _os_api.API(session=session, cloud_region=cloud_region)
51 51
         self._dry_run = dry_run
52 52
 
53
-    def reserve_node(self, resource_class=None, capabilities=None,
54
-                     candidates=None):
53
+    def reserve_node(self, resource_class=None, conductor_group=None,
54
+                     capabilities=None, candidates=None, predicate=None):
55 55
         """Find and reserve a suitable node.
56 56
 
57 57
         Example::
@@ -61,11 +61,17 @@ class Provisioner(object):
61 61
 
62 62
         :param resource_class: Requested resource class. If ``None``, a node
63 63
             with any resource class can be chosen.
64
+        :param conductor_group: Conductor group to pick the nodes from.
65
+            Value ``None`` means any group, use empty string "" for nodes
66
+            from the default group.
64 67
         :param capabilities: Requested capabilities as a dict.
65 68
         :param candidates: List of nodes (UUIDs, names or `Node` objects)
66 69
             to pick from. The filters (for resource class and capabilities)
67 70
             are still applied to the provided list. The order in which
68 71
             the nodes are considered is retained.
72
+        :param predicate: Custom predicate to run on nodes. A callable that
73
+            accepts a node and returns ``True`` if it should be included,
74
+            ``False`` otherwise. Any exceptions are propagated to the caller.
69 75
         :return: reserved `Node` object.
70 76
         :raises: :py:class:`metalsmith.exceptions.ReservationFailed`
71 77
         """
@@ -76,22 +82,28 @@ class Provisioner(object):
76 82
             if resource_class:
77 83
                 nodes = [node for node in nodes
78 84
                          if node.resource_class == resource_class]
85
+            if conductor_group is not None:
86
+                nodes = [node for node in nodes
87
+                         if node.conductor_group == conductor_group]
79 88
         else:
80
-            nodes = self._api.list_nodes(resource_class=resource_class)
89
+            nodes = self._api.list_nodes(resource_class=resource_class,
90
+                                         conductor_group=conductor_group)
81 91
             # Ensure parallel executions don't try nodes in the same sequence
82 92
             random.shuffle(nodes)
83 93
 
84 94
         if not nodes:
85
-            raise exceptions.ResourceClassNotFound(resource_class,
86
-                                                   capabilities)
95
+            raise exceptions.NodesNotFound(resource_class, conductor_group)
87 96
 
88 97
         LOG.debug('Ironic nodes: %s', nodes)
89 98
 
90
-        filters = [_scheduler.CapabilitiesFilter(resource_class, capabilities),
91
-                   _scheduler.ValidationFilter(self._api,
92
-                                               resource_class, capabilities)]
93
-        reserver = _scheduler.IronicReserver(self._api, resource_class,
94
-                                             capabilities)
99
+        filters = [_scheduler.CapabilitiesFilter(capabilities),
100
+                   _scheduler.ValidationFilter(self._api)]
101
+        if predicate is not None:
102
+            # NOTE(dtantsur): run the provided predicate before the validation,
103
+            # since validation requires network interactions.
104
+            filters.insert(-1, predicate)
105
+
106
+        reserver = _scheduler.IronicReserver(self._api)
95 107
         node = _scheduler.schedule_node(nodes, filters, reserver,
96 108
                                         dry_run=self._dry_run)
97 109
         if capabilities:

+ 30
- 36
metalsmith/_scheduler.py View File

@@ -117,8 +117,7 @@ def schedule_node(nodes, filters, reserver, dry_run=False):
117 117
 class CapabilitiesFilter(Filter):
118 118
     """Filter that checks capabilities."""
119 119
 
120
-    def __init__(self, resource_class, capabilities):
121
-        self._resource_class = resource_class
120
+    def __init__(self, capabilities):
122 121
         self._capabilities = capabilities
123 122
         self._counter = collections.Counter()
124 123
 
@@ -159,20 +158,16 @@ class CapabilitiesFilter(Filter):
159 158
         message = ("No available nodes found with capabilities %(req)s, "
160 159
                    "existing capabilities: %(exist)s" %
161 160
                    {'req': requested, 'exist': existing or 'none'})
162
-        raise exceptions.CapabilitiesNotFound(message,
163
-                                              self._resource_class,
164
-                                              self._capabilities)
161
+        raise exceptions.CapabilitiesNotFound(message, self._capabilities)
165 162
 
166 163
 
167 164
 class ValidationFilter(Filter):
168 165
     """Filter that runs validation on nodes."""
169 166
 
170
-    def __init__(self, api, resource_class, capabilities):
167
+    def __init__(self, api):
171 168
         self._api = api
172
-        # These are only used for better exceptions
173
-        self._resource_class = resource_class
174
-        self._capabilities = capabilities
175
-        self._failed_validation = []
169
+        self._messages = []
170
+        self._failed_nodes = []
176 171
 
177 172
     def __call__(self, node):
178 173
         try:
@@ -181,45 +176,44 @@ class ValidationFilter(Filter):
181 176
             message = ('Node %(node)s failed validation: %(err)s' %
182 177
                        {'node': _utils.log_node(node), 'err': exc})
183 178
             LOG.warning(message)
184
-            self._failed_validation.append(message)
179
+            self._messages.append(message)
180
+            self._failed_nodes.append(node)
185 181
             return False
186 182
 
187 183
         return True
188 184
 
189 185
     def fail(self):
190
-        errors = ", ".join(self._failed_validation)
186
+        errors = ", ".join(self._messages)
191 187
         message = "All available nodes have failed validation: %s" % errors
192
-        raise exceptions.ValidationFailed(message,
193
-                                          self._resource_class,
194
-                                          self._capabilities)
188
+        raise exceptions.ValidationFailed(message, self._failed_nodes)
195 189
 
196 190
 
197 191
 class IronicReserver(Reserver):
198 192
 
199
-    def __init__(self, api, resource_class, capabilities):
193
+    def __init__(self, api):
200 194
         self._api = api
201
-        # These are only used for better exceptions
202
-        self._resource_class = resource_class
203
-        self._capabilities = capabilities
195
+        self._failed_nodes = []
204 196
 
205 197
     def __call__(self, node):
206
-        result = self._api.reserve_node(node, instance_uuid=node.uuid)
207
-
208
-        # Try validation again to be sure nothing has changed
209
-        validator = ValidationFilter(self._api, self._resource_class,
210
-                                     self._capabilities)
211
-        if not validator(result):
212
-            LOG.warning('Validation of node %s failed after reservation',
213
-                        _utils.log_node(node))
214
-            try:
215
-                self._api.release_node(node)
216
-            except Exception:
217
-                LOG.exception('Failed to release the reserved node %s',
218
-                              _utils.log_node(node))
219
-            validator.fail()
198
+        try:
199
+            result = self._api.reserve_node(node, instance_uuid=node.uuid)
200
+
201
+            # Try validation again to be sure nothing has changed
202
+            validator = ValidationFilter(self._api)
203
+            if not validator(result):
204
+                LOG.warning('Validation of node %s failed after reservation',
205
+                            _utils.log_node(node))
206
+                try:
207
+                    self._api.release_node(node)
208
+                except Exception:
209
+                    LOG.exception('Failed to release the reserved node %s',
210
+                                  _utils.log_node(node))
211
+                validator.fail()
220 212
 
221
-        return result
213
+            return result
214
+        except Exception:
215
+            self._failed_nodes.append(node)
216
+            raise
222 217
 
223 218
     def fail(self):
224
-        raise exceptions.AllNodesReserved(self._resource_class,
225
-                                          self._capabilities)
219
+        raise exceptions.AllNodesReserved(self._failed_nodes)

+ 43
- 21
metalsmith/exceptions.py View File

@@ -19,42 +19,64 @@ class Error(Exception):
19 19
 
20 20
 
21 21
 class ReservationFailed(Error):
22
-    """Failed to reserve a suitable node."""
22
+    """Failed to reserve a suitable node.
23
+
24
+    This is the base class for all reservation failures.
25
+    """
23 26
 
24
-    def __init__(self, message, requested_resource_class,
25
-                 requested_capabilities):
26
-        super(ReservationFailed, self).__init__(message)
27
-        self.requested_resource_class = requested_resource_class
28
-        self.requested_capabilities = requested_capabilities
29 27
 
28
+class NodesNotFound(ReservationFailed):
29
+    """Initial nodes lookup returned an empty list.
30 30
 
31
-class ResourceClassNotFound(ReservationFailed):
32
-    """No nodes match the given resource class."""
31
+    :ivar requested_resource_class: Requested resource class.
32
+    :ivar requested_conductor_group: Requested conductor group to pick nodes
33
+        from.
34
+    """
33 35
 
34
-    def __init__(self, requested_resource_class, requested_capabilities):
35
-        message = ("No available nodes found with resource class %s" %
36
-                   requested_resource_class)
37
-        super(ResourceClassNotFound, self).__init__(message,
38
-                                                    requested_resource_class,
39
-                                                    requested_capabilities)
36
+    def __init__(self, resource_class, conductor_group):
37
+        message = "No available nodes%(rc)s found%(cg)s" % {
38
+            'rc': 'with resource class %s' % resource_class
39
+            if resource_class else '',
40
+            'cg': 'in conductor group %s' % (conductor_group or '<default>')
41
+            if conductor_group is not None else ''
42
+        }
43
+        self.requested_resource_class = resource_class
44
+        self.requested_conductor_group = conductor_group
45
+        super(NodesNotFound, self).__init__(message)
40 46
 
41 47
 
42 48
 class CapabilitiesNotFound(ReservationFailed):
43
-    """Requested capabilities do not match any nodes."""
49
+    """Requested capabilities do not match any nodes.
50
+
51
+    :ivar requested_capabilities: Requested node's capabilities.
52
+    """
53
+
54
+    def __init__(self, message, capabilities):
55
+        self.requested_capabilities = capabilities
56
+        super(CapabilitiesNotFound, self).__init__(message)
44 57
 
45 58
 
46 59
 class ValidationFailed(ReservationFailed):
47
-    """Validation failed for all requested nodes."""
60
+    """Validation failed for all requested nodes.
61
+
62
+    :ivar nodes: List of nodes that were checked.
63
+    """
64
+
65
+    def __init__(self, message, nodes):
66
+        self.nodes = nodes
67
+        super(ValidationFailed, self).__init__(message)
48 68
 
49 69
 
50 70
 class AllNodesReserved(ReservationFailed):
51
-    """All nodes are already reserved."""
71
+    """All nodes are already reserved.
52 72
 
53
-    def __init__(self, requested_resource_class, requested_capabilities):
73
+    :ivar nodes: List of nodes that were checked.
74
+    """
75
+
76
+    def __init__(self, nodes):
77
+        self.nodes = nodes
54 78
         message = 'All the candidate nodes are already reserved'
55
-        super(AllNodesReserved, self).__init__(message,
56
-                                               requested_resource_class,
57
-                                               requested_capabilities)
79
+        super(AllNodesReserved, self).__init__(message)
58 80
 
59 81
 
60 82
 class InvalidImage(Error):

+ 42
- 0
metalsmith/test/test_cmd.py View File

@@ -55,6 +55,7 @@ class TestDeploy(testtools.TestCase):
55 55
             dry_run=False)
56 56
         mock_pr.return_value.reserve_node.assert_called_once_with(
57 57
             resource_class='compute',
58
+            conductor_group=None,
58 59
             capabilities={},
59 60
             candidates=None
60 61
         )
@@ -101,6 +102,7 @@ class TestDeploy(testtools.TestCase):
101 102
             dry_run=False)
102 103
         mock_pr.return_value.reserve_node.assert_called_once_with(
103 104
             resource_class='compute',
105
+            conductor_group=None,
104 106
             capabilities={},
105 107
             candidates=None
106 108
         )
@@ -174,6 +176,7 @@ class TestDeploy(testtools.TestCase):
174 176
             dry_run=True)
175 177
         mock_pr.return_value.reserve_node.assert_called_once_with(
176 178
             resource_class='compute',
179
+            conductor_group=None,
177 180
             capabilities={},
178 181
             candidates=None
179 182
         )
@@ -197,6 +200,7 @@ class TestDeploy(testtools.TestCase):
197 200
             dry_run=False)
198 201
         mock_pr.return_value.reserve_node.assert_called_once_with(
199 202
             resource_class='compute',
203
+            conductor_group=None,
200 204
             capabilities={},
201 205
             candidates=None
202 206
         )
@@ -228,6 +232,7 @@ class TestDeploy(testtools.TestCase):
228 232
             dry_run=False)
229 233
         mock_pr.return_value.reserve_node.assert_called_once_with(
230 234
             resource_class='compute',
235
+            conductor_group=None,
231 236
             capabilities={},
232 237
             candidates=None
233 238
         )
@@ -261,6 +266,7 @@ class TestDeploy(testtools.TestCase):
261 266
             dry_run=False)
262 267
         mock_pr.return_value.reserve_node.assert_called_once_with(
263 268
             resource_class='compute',
269
+            conductor_group=None,
264 270
             capabilities={},
265 271
             candidates=None
266 272
         )
@@ -292,6 +298,7 @@ class TestDeploy(testtools.TestCase):
292 298
             dry_run=False)
293 299
         mock_pr.return_value.reserve_node.assert_called_once_with(
294 300
             resource_class='compute',
301
+            conductor_group=None,
295 302
             capabilities={},
296 303
             candidates=None
297 304
         )
@@ -323,6 +330,7 @@ class TestDeploy(testtools.TestCase):
323 330
             dry_run=False)
324 331
         mock_pr.return_value.reserve_node.assert_called_once_with(
325 332
             resource_class='compute',
333
+            conductor_group=None,
326 334
             capabilities={},
327 335
             candidates=None
328 336
         )
@@ -379,6 +387,7 @@ class TestDeploy(testtools.TestCase):
379 387
             dry_run=False)
380 388
         mock_pr.return_value.reserve_node.assert_called_once_with(
381 389
             resource_class='compute',
390
+            conductor_group=None,
382 391
             capabilities={'foo': 'bar', 'answer': '42'},
383 392
             candidates=None
384 393
         )
@@ -405,6 +414,7 @@ class TestDeploy(testtools.TestCase):
405 414
                 dry_run=False)
406 415
             mock_pr.return_value.reserve_node.assert_called_once_with(
407 416
                 resource_class='compute',
417
+                conductor_group=None,
408 418
                 capabilities={},
409 419
                 candidates=None
410 420
             )
@@ -430,6 +440,7 @@ class TestDeploy(testtools.TestCase):
430 440
             dry_run=False)
431 441
         mock_pr.return_value.reserve_node.assert_called_once_with(
432 442
             resource_class='compute',
443
+            conductor_group=None,
433 444
             capabilities={},
434 445
             candidates=None
435 446
         )
@@ -458,6 +469,7 @@ class TestDeploy(testtools.TestCase):
458 469
             dry_run=False)
459 470
         mock_pr.return_value.reserve_node.assert_called_once_with(
460 471
             resource_class='compute',
472
+            conductor_group=None,
461 473
             capabilities={},
462 474
             candidates=None
463 475
         )
@@ -483,6 +495,7 @@ class TestDeploy(testtools.TestCase):
483 495
             dry_run=False)
484 496
         mock_pr.return_value.reserve_node.assert_called_once_with(
485 497
             resource_class='compute',
498
+            conductor_group=None,
486 499
             capabilities={},
487 500
             candidates=None
488 501
         )
@@ -504,6 +517,7 @@ class TestDeploy(testtools.TestCase):
504 517
             dry_run=False)
505 518
         mock_pr.return_value.reserve_node.assert_called_once_with(
506 519
             resource_class='compute',
520
+            conductor_group=None,
507 521
             capabilities={},
508 522
             candidates=None
509 523
         )
@@ -527,6 +541,7 @@ class TestDeploy(testtools.TestCase):
527 541
             dry_run=False)
528 542
         mock_pr.return_value.reserve_node.assert_called_once_with(
529 543
             resource_class='compute',
544
+            conductor_group=None,
530 545
             capabilities={},
531 546
             candidates=None
532 547
         )
@@ -550,6 +565,7 @@ class TestDeploy(testtools.TestCase):
550 565
             dry_run=False)
551 566
         mock_pr.return_value.reserve_node.assert_called_once_with(
552 567
             resource_class='compute',
568
+            conductor_group=None,
553 569
             capabilities={},
554 570
             candidates=None
555 571
         )
@@ -572,6 +588,7 @@ class TestDeploy(testtools.TestCase):
572 588
             dry_run=False)
573 589
         mock_pr.return_value.reserve_node.assert_called_once_with(
574 590
             resource_class=None,
591
+            conductor_group=None,
575 592
             capabilities={},
576 593
             candidates=['node1', 'node2']
577 594
         )
@@ -585,6 +602,29 @@ class TestDeploy(testtools.TestCase):
585 602
             netboot=False,
586 603
             wait=1800)
587 604
 
605
+    def test_args_conductor_group(self, mock_os_conf, mock_pr):
606
+        args = ['deploy', '--conductor-group', 'loc1', '--image', 'myimg',
607
+                '--resource-class', 'compute']
608
+        _cmd.main(args)
609
+        mock_pr.assert_called_once_with(
610
+            cloud_region=mock_os_conf.return_value.get_one.return_value,
611
+            dry_run=False)
612
+        mock_pr.return_value.reserve_node.assert_called_once_with(
613
+            resource_class='compute',
614
+            conductor_group='loc1',
615
+            capabilities={},
616
+            candidates=None
617
+        )
618
+        mock_pr.return_value.provision_node.assert_called_once_with(
619
+            mock_pr.return_value.reserve_node.return_value,
620
+            image='myimg',
621
+            nics=None,
622
+            root_disk_size=None,
623
+            config=mock.ANY,
624
+            hostname=None,
625
+            netboot=False,
626
+            wait=1800)
627
+
588 628
     def test_args_custom_wait(self, mock_os_conf, mock_pr):
589 629
         args = ['deploy', '--network', 'mynet', '--image', 'myimg',
590 630
                 '--wait', '3600', '--resource-class', 'compute']
@@ -594,6 +634,7 @@ class TestDeploy(testtools.TestCase):
594 634
             dry_run=False)
595 635
         mock_pr.return_value.reserve_node.assert_called_once_with(
596 636
             resource_class='compute',
637
+            conductor_group=None,
597 638
             capabilities={},
598 639
             candidates=None
599 640
         )
@@ -616,6 +657,7 @@ class TestDeploy(testtools.TestCase):
616 657
             dry_run=False)
617 658
         mock_pr.return_value.reserve_node.assert_called_once_with(
618 659
             resource_class='compute',
660
+            conductor_group=None,
619 661
             capabilities={},
620 662
             candidates=None
621 663
         )

+ 6
- 12
metalsmith/test/test_os_api.py View File

@@ -54,8 +54,7 @@ class TestNodes(testtools.TestCase):
54 54
 
55 55
     def test_get_node_by_uuid(self):
56 56
         res = self.api.get_node('uuid1')
57
-        self.cli.node.get.assert_called_once_with('uuid1',
58
-                                                  fields=_os_api.NODE_FIELDS)
57
+        self.cli.node.get.assert_called_once_with('uuid1')
59 58
         self.assertIs(res, self.cli.node.get.return_value)
60 59
 
61 60
     def test_get_node_by_hostname(self):
@@ -66,8 +65,7 @@ class TestNodes(testtools.TestCase):
66 65
         ]
67 66
         res = self.api.get_node('host1', accept_hostname=True)
68 67
         # Loading details
69
-        self.cli.node.get.assert_called_once_with('uuid1',
70
-                                                  fields=_os_api.NODE_FIELDS)
68
+        self.cli.node.get.assert_called_once_with('uuid1')
71 69
         self.assertIs(res, self.cli.node.get.return_value)
72 70
 
73 71
     def test_get_node_by_hostname_not_found(self):
@@ -78,8 +76,7 @@ class TestNodes(testtools.TestCase):
78 76
         ]
79 77
         res = self.api.get_node('host1', accept_hostname=True)
80 78
         # Loading details
81
-        self.cli.node.get.assert_called_once_with('host1',
82
-                                                  fields=_os_api.NODE_FIELDS)
79
+        self.cli.node.get.assert_called_once_with('host1')
83 80
         self.assertIs(res, self.cli.node.get.return_value)
84 81
 
85 82
     def test_get_node_by_node(self):
@@ -90,8 +87,7 @@ class TestNodes(testtools.TestCase):
90 87
     def test_get_node_by_node_with_refresh(self):
91 88
         res = self.api.get_node(mock.Mock(spec=['uuid'], uuid='uuid1'),
92 89
                                 refresh=True)
93
-        self.cli.node.get.assert_called_once_with('uuid1',
94
-                                                  fields=_os_api.NODE_FIELDS)
90
+        self.cli.node.get.assert_called_once_with('uuid1')
95 91
         self.assertIs(res, self.cli.node.get.return_value)
96 92
 
97 93
     def test_get_node_by_instance(self):
@@ -104,8 +100,7 @@ class TestNodes(testtools.TestCase):
104 100
         inst = _instance.Instance(mock.Mock(),
105 101
                                   mock.Mock(spec=['uuid'], uuid='uuid1'))
106 102
         res = self.api.get_node(inst, refresh=True)
107
-        self.cli.node.get.assert_called_once_with('uuid1',
108
-                                                  fields=_os_api.NODE_FIELDS)
103
+        self.cli.node.get.assert_called_once_with('uuid1')
109 104
         self.assertIs(res, self.cli.node.get.return_value)
110 105
 
111 106
     def test_find_node_by_hostname(self):
@@ -116,8 +111,7 @@ class TestNodes(testtools.TestCase):
116 111
         ]
117 112
         res = self.api.find_node_by_hostname('host1')
118 113
         # Loading details
119
-        self.cli.node.get.assert_called_once_with('uuid1',
120
-                                                  fields=_os_api.NODE_FIELDS)
114
+        self.cli.node.get.assert_called_once_with('uuid1')
121 115
         self.assertIs(res, self.cli.node.get.return_value)
122 116
 
123 117
     def test_find_node_by_hostname_cached(self):

+ 55
- 10
metalsmith/test/test_provisioner.py View File

@@ -25,13 +25,18 @@ from metalsmith import exceptions
25 25
 from metalsmith import sources
26 26
 
27 27
 
28
+NODE_FIELDS = ['name', 'uuid', 'instance_info', 'instance_uuid', 'maintenance',
29
+               'maintenance_reason', 'properties', 'provision_state', 'extra',
30
+               'last_error']
31
+
32
+
28 33
 class Base(testtools.TestCase):
29 34
 
30 35
     def setUp(self):
31 36
         super(Base, self).setUp()
32 37
         self.pr = _provisioner.Provisioner(mock.Mock())
33 38
         self._reset_api_mock()
34
-        self.node = mock.Mock(spec=_os_api.NODE_FIELDS + ['to_dict'],
39
+        self.node = mock.Mock(spec=NODE_FIELDS + ['to_dict'],
35 40
                               uuid='000', instance_uuid=None,
36 41
                               properties={'local_gb': 100},
37 42
                               instance_info={},
@@ -59,8 +64,8 @@ class TestReserveNode(Base):
59 64
     def test_no_nodes(self):
60 65
         self.api.list_nodes.return_value = []
61 66
 
62
-        self.assertRaises(exceptions.ResourceClassNotFound,
63
-                          self.pr.reserve_node, 'control')
67
+        self.assertRaises(exceptions.NodesNotFound,
68
+                          self.pr.reserve_node, resource_class='control')
64 69
         self.assertFalse(self.api.reserve_node.called)
65 70
 
66 71
     def test_simple_ok(self):
@@ -99,12 +104,30 @@ class TestReserveNode(Base):
99 104
         self.api.list_nodes.return_value = nodes
100 105
         self.api.reserve_node.side_effect = lambda n, instance_uuid: n
101 106
 
102
-        node = self.pr.reserve_node('control', {'answer': '42'})
107
+        node = self.pr.reserve_node('control', capabilities={'answer': '42'})
103 108
 
104 109
         self.assertIs(node, expected)
105 110
         self.api.update_node.assert_called_once_with(
106 111
             node, {'/instance_info/capabilities': {'answer': '42'}})
107 112
 
113
+    def test_custom_predicate(self):
114
+        nodes = [
115
+            mock.Mock(spec=['uuid', 'name', 'properties'],
116
+                      properties={'local_gb': 100}),
117
+            mock.Mock(spec=['uuid', 'name', 'properties'],
118
+                      properties={'local_gb': 150}),
119
+            mock.Mock(spec=['uuid', 'name', 'properties'],
120
+                      properties={'local_gb': 200}),
121
+        ]
122
+        self.api.list_nodes.return_value = nodes[:]
123
+        self.api.reserve_node.side_effect = lambda n, instance_uuid: n
124
+
125
+        node = self.pr.reserve_node(
126
+            predicate=lambda node: 100 < node.properties['local_gb'] < 200)
127
+
128
+        self.assertEqual(node, nodes[1])
129
+        self.assertFalse(self.api.update_node.called)
130
+
108 131
     def test_provided_node(self):
109 132
         nodes = [
110 133
             mock.Mock(spec=['uuid', 'name', 'properties'],
@@ -153,6 +176,28 @@ class TestReserveNode(Base):
153 176
         self.api.update_node.assert_called_once_with(
154 177
             node, {'/instance_info/capabilities': {'cat': 'meow'}})
155 178
 
179
+    def test_nodes_filtered_by_conductor_group(self):
180
+        nodes = [
181
+            mock.Mock(spec=['uuid', 'name', 'properties', 'conductor_group'],
182
+                      properties={'local_gb': 100}, conductor_group='loc1'),
183
+            mock.Mock(spec=['uuid', 'name', 'properties', 'conductor_group'],
184
+                      properties={'local_gb': 100, 'capabilities': 'cat:meow'},
185
+                      conductor_group=''),
186
+            mock.Mock(spec=['uuid', 'name', 'properties', 'conductor_group'],
187
+                      properties={'local_gb': 100, 'capabilities': 'cat:meow'},
188
+                      conductor_group='loc1'),
189
+        ]
190
+        self.api.reserve_node.side_effect = lambda n, instance_uuid: n
191
+
192
+        node = self.pr.reserve_node(conductor_group='loc1',
193
+                                    candidates=nodes,
194
+                                    capabilities={'cat': 'meow'})
195
+
196
+        self.assertEqual(node, nodes[2])
197
+        self.assertFalse(self.api.list_nodes.called)
198
+        self.api.update_node.assert_called_once_with(
199
+            node, {'/instance_info/capabilities': {'cat': 'meow'}})
200
+
156 201
 
157 202
 CLEAN_UP = {
158 203
     '/extra/metalsmith_created_ports': _os_api.REMOVE,
@@ -848,7 +893,7 @@ class TestWaitForState(Base):
848 893
 
849 894
     def test_success_one_node(self, mock_sleep):
850 895
         nodes = [
851
-            mock.Mock(spec=_os_api.NODE_FIELDS, provision_state=state)
896
+            mock.Mock(spec=NODE_FIELDS, provision_state=state)
852 897
             for state in ('deploying', 'deploy wait', 'deploying', 'active')
853 898
         ]
854 899
         self.api.get_node.side_effect = nodes
@@ -862,7 +907,7 @@ class TestWaitForState(Base):
862 907
 
863 908
     def test_success_several_nodes(self, mock_sleep):
864 909
         nodes = [
865
-            mock.Mock(spec=_os_api.NODE_FIELDS, provision_state=state)
910
+            mock.Mock(spec=NODE_FIELDS, provision_state=state)
866 911
             for state in ('deploying', 'deploy wait',  # iteration 1
867 912
                           'deploying', 'active',       # iteration 2
868 913
                           'active')                    # iteration 3
@@ -879,7 +924,7 @@ class TestWaitForState(Base):
879 924
 
880 925
     def test_one_node_failed(self, mock_sleep):
881 926
         nodes = [
882
-            mock.Mock(spec=_os_api.NODE_FIELDS, provision_state=state)
927
+            mock.Mock(spec=NODE_FIELDS, provision_state=state)
883 928
             for state in ('deploying', 'deploy wait',    # iteration 1
884 929
                           'deploying', 'deploy failed',  # iteration 2
885 930
                           'active')                      # iteration 3
@@ -898,7 +943,7 @@ class TestWaitForState(Base):
898 943
     def test_timeout(self, mock_sleep):
899 944
         def _fake_get(*args, **kwargs):
900 945
             while True:
901
-                yield mock.Mock(spec=_os_api.NODE_FIELDS,
946
+                yield mock.Mock(spec=NODE_FIELDS,
902 947
                                 provision_state='deploying')
903 948
 
904 949
         self.api.get_node.side_effect = _fake_get()
@@ -913,7 +958,7 @@ class TestWaitForState(Base):
913 958
 
914 959
     def test_custom_delay(self, mock_sleep):
915 960
         nodes = [
916
-            mock.Mock(spec=_os_api.NODE_FIELDS, provision_state=state)
961
+            mock.Mock(spec=NODE_FIELDS, provision_state=state)
917 962
             for state in ('deploying', 'deploy wait', 'deploying', 'active')
918 963
         ]
919 964
         self.api.get_node.side_effect = nodes
@@ -930,7 +975,7 @@ class TestListInstances(Base):
930 975
     def setUp(self):
931 976
         super(TestListInstances, self).setUp()
932 977
         self.nodes = [
933
-            mock.Mock(spec=_os_api.NODE_FIELDS, provision_state=state,
978
+            mock.Mock(spec=NODE_FIELDS, provision_state=state,
934 979
                       instance_info={'metalsmith_hostname': '1234'})
935 980
             for state in ('active', 'active', 'deploying', 'wait call-back',
936 981
                           'deploy failed', 'available')

+ 10
- 11
metalsmith/test/test_scheduler.py View File

@@ -113,35 +113,35 @@ class TestScheduleNode(testtools.TestCase):
113 113
 class TestCapabilitiesFilter(testtools.TestCase):
114 114
 
115 115
     def test_fail_no_capabilities(self):
116
-        fltr = _scheduler.CapabilitiesFilter('rsc', {'profile': 'compute'})
116
+        fltr = _scheduler.CapabilitiesFilter({'profile': 'compute'})
117 117
         self.assertRaisesRegex(exceptions.CapabilitiesNotFound,
118 118
                                'No available nodes found with capabilities '
119 119
                                'profile=compute, existing capabilities: none',
120 120
                                fltr.fail)
121 121
 
122 122
     def test_nothing_requested_nothing_found(self):
123
-        fltr = _scheduler.CapabilitiesFilter('rsc', {})
123
+        fltr = _scheduler.CapabilitiesFilter({})
124 124
         node = mock.Mock(properties={}, spec=['properties', 'name', 'uuid'])
125 125
         self.assertTrue(fltr(node))
126 126
 
127 127
     def test_matching_node(self):
128
-        fltr = _scheduler.CapabilitiesFilter('rsc', {'profile': 'compute',
129
-                                                     'foo': 'bar'})
128
+        fltr = _scheduler.CapabilitiesFilter({'profile': 'compute',
129
+                                              'foo': 'bar'})
130 130
         node = mock.Mock(
131 131
             properties={'capabilities': 'foo:bar,profile:compute,answer:42'},
132 132
             spec=['properties', 'name', 'uuid'])
133 133
         self.assertTrue(fltr(node))
134 134
 
135 135
     def test_not_matching_node(self):
136
-        fltr = _scheduler.CapabilitiesFilter('rsc', {'profile': 'compute',
137
-                                                     'foo': 'bar'})
136
+        fltr = _scheduler.CapabilitiesFilter({'profile': 'compute',
137
+                                              'foo': 'bar'})
138 138
         node = mock.Mock(
139 139
             properties={'capabilities': 'foo:bar,answer:42'},
140 140
             spec=['properties', 'name', 'uuid'])
141 141
         self.assertFalse(fltr(node))
142 142
 
143 143
     def test_fail_message(self):
144
-        fltr = _scheduler.CapabilitiesFilter('rsc', {'profile': 'compute'})
144
+        fltr = _scheduler.CapabilitiesFilter({'profile': 'compute'})
145 145
         node = mock.Mock(
146 146
             properties={'capabilities': 'profile:control'},
147 147
             spec=['properties', 'name', 'uuid'])
@@ -153,7 +153,7 @@ class TestCapabilitiesFilter(testtools.TestCase):
153 153
                                fltr.fail)
154 154
 
155 155
     def test_malformed_capabilities(self):
156
-        fltr = _scheduler.CapabilitiesFilter('rsc', {'profile': 'compute'})
156
+        fltr = _scheduler.CapabilitiesFilter({'profile': 'compute'})
157 157
         for cap in ['foo,profile:control', 42, 'a:b:c']:
158 158
             node = mock.Mock(properties={'capabilities': cap},
159 159
                              spec=['properties', 'name', 'uuid'])
@@ -169,8 +169,7 @@ class TestValidationFilter(testtools.TestCase):
169 169
     def setUp(self):
170 170
         super(TestValidationFilter, self).setUp()
171 171
         self.api = mock.Mock(spec=['validate_node'])
172
-        self.fltr = _scheduler.ValidationFilter(self.api, 'rsc',
173
-                                                {'profile': 'compute'})
172
+        self.fltr = _scheduler.ValidationFilter(self.api)
174 173
 
175 174
     def test_pass(self):
176 175
         node = mock.Mock(spec=['uuid', 'name'])
@@ -195,7 +194,7 @@ class TestIronicReserver(testtools.TestCase):
195 194
         self.node = mock.Mock(spec=['uuid', 'name'])
196 195
         self.api = mock.Mock(spec=['reserve_node', 'release_node'])
197 196
         self.api.reserve_node.side_effect = lambda node, instance_uuid: node
198
-        self.reserver = _scheduler.IronicReserver(self.api, 'rsc', {})
197
+        self.reserver = _scheduler.IronicReserver(self.api)
199 198
 
200 199
     def test_fail(self, mock_validation):
201 200
         self.assertRaisesRegex(exceptions.AllNodesReserved,

+ 7
- 0
roles/metalsmith_deployment/README.rst View File

@@ -17,6 +17,8 @@ The following optional variables provide the defaults for Instance_ attributes:
17 17
     the default for ``candidates``.
18 18
 ``metalsmith_capabilities``
19 19
     the default for ``capabilities``.
20
+``metalsmith_conductor_group``
21
+    the default for ``conductor_group``.
20 22
 ``metalsmith_extra_args``
21 23
     the default for ``extra_args``.
22 24
 ``metalsmith_image``
@@ -43,6 +45,11 @@ Each instances has the following attributes:
43 45
     list of nodes (UUIDs or names) to be considered for deployment.
44 46
 ``capabilities`` (defaults to ``metalsmith_capabilities``)
45 47
     node capabilities to request when scheduling.
48
+``conductor_group`` (defaults to ``metalsmith_conductor_group``)
49
+    conductor group to pick nodes from.
50
+
51
+    .. note:: Currently it's not possible to specify the default group.
52
+
46 53
 ``extra_args`` (defaults to ``metalsmith_extra_args``)
47 54
     additional arguments to pass to the ``metalsmith`` CLI on all calls.
48 55
 ``image`` (defaults to ``metalsmith_image``)

+ 1
- 0
roles/metalsmith_deployment/defaults/main.yml View File

@@ -1,6 +1,7 @@
1 1
 # Optional parameters
2 2
 metalsmith_candidates: []
3 3
 metalsmith_capabilities: {}
4
+metalsmith_conductor_group:
4 5
 metalsmith_extra_args:
5 6
 metalsmith_netboot: false
6 7
 metalsmith_nics: []

+ 4
- 0
roles/metalsmith_deployment/tasks/main.yml View File

@@ -28,6 +28,9 @@
28 28
     {% if resource_class %}
29 29
     --resource-class {{ resource_class }}
30 30
     {% endif %}
31
+    {% if conductor_group %}
32
+    --conductor-group {{ conductor_group }}
33
+    {% endif %}
31 34
     {% for node in candidates %}
32 35
       --candidate {{ node }}
33 36
     {% endfor %}
@@ -35,6 +38,7 @@
35 38
   vars:
36 39
     candidates: "{{ instance.candidates | default(metalsmith_candidates) }}"
37 40
     capabilities: "{{ instance.capabilities | default(metalsmith_capabilities) }}"
41
+    conductor_group: "{{ instance.conductor_group | default(metalsmith_conductor_group) }}"
38 42
     extra_args: "{{ instance.extra_args | default(metalsmith_extra_args) }}"
39 43
     image: "{{ instance.image | default(metalsmith_image) }}"
40 44
     netboot: "{{ instance.netboot | default(metalsmith_netboot) }}"

Loading…
Cancel
Save