Merge lp:~lamont/maas/bug-1372544 into lp:~maas-committers/maas/trunk

Proposed by LaMont Jones
Status: Merged
Approved by: LaMont Jones
Approved revision: no longer in the source branch.
Merged at revision: 5021
Proposed branch: lp:~lamont/maas/bug-1372544
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 777 lines (+362/-136)
7 files modified
src/maasserver/api/tests/test_tag.py (+15/-18)
src/maasserver/models/tag.py (+58/-17)
src/maasserver/models/tests/test_tag.py (+95/-36)
src/maasserver/populate_tags.py (+77/-43)
src/maasserver/testing/factory.py (+13/-4)
src/maasserver/testing/orm.py (+1/-1)
src/maasserver/tests/test_populate_tags.py (+103/-17)
To merge this branch: bzr merge lp:~lamont/maas/bug-1372544
Reviewer Review Type Date Requested Status
Blake Rouse (community) Approve
Gavin Panella (community) Abstain
Review via email: mp+294044@code.launchpad.net

Commit message

If there are no rack controllers connected, populate tags in the region controller.

Description of the change

If there are no rack controllers connected, populate tags in the region controller.

To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote :

I got a bit carried away reviewing this. I built on what you've done and came up with quite a large bunch of changes. I've made the evaluations more efficient and put everything into post-commit hooks. Take a look at lp:~allenap/maas/lamont--bug-1372544 (or the merge proposal at https://code.launchpad.net/~allenap/maas/lamont--bug-1372544/+merge/294167).

In time, a better solution might be to model a work queue, like we did for DNS zones recently. That way we can better manage the work in an HA environment, and better recover from failures. For example, we really don't want to be doing lots of tag evaluation work for more than one tag update at a time otherwise we run a real risk of DOSing the MAAS installation. There's no protection against that right now.

(We could add the DOS protection to this current implementation quite easily, but recovery would still need a queue, or some other additional support in the data model.)

Anyway, look at what I've done. If you like it, merge it into this, write some tests for `populate_tag_for_multiple_nodes` and it should be good to land.

Revision history for this message
Blake Rouse (blake-rouse) wrote :

Looks like you have a conflict. I also have one question.

review: Needs Information
Revision history for this message
Gavin Panella (allenap) wrote :

Responded to Blake's question.

Revision history for this message
Gavin Panella (allenap) :
review: Abstain
Revision history for this message
LaMont Jones (lamont) wrote :

replied

Revision history for this message
Blake Rouse (blake-rouse) wrote :

Looks good. Thanks for the fix.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api/tests/test_tag.py'
2--- src/maasserver/api/tests/test_tag.py 2016-05-06 11:41:05 +0000
3+++ src/maasserver/api/tests/test_tag.py 2016-05-13 14:02:27 +0000
4@@ -38,11 +38,6 @@
5 from testtools.matchers import MatchesStructure
6
7
8-def patch_populate_tags(test):
9- from maasserver import populate_tags
10- return test.patch_autospec(populate_tags, "populate_tags")
11-
12-
13 class TestTagAPI(APITestCase):
14 """Tests for /api/2.0/tags/<tagname>/."""
15
16@@ -116,15 +111,16 @@
17 self.assertTrue(Tag.objects.filter(name='new-tag-name').exists())
18
19 def test_PUT_updates_node_associations(self):
20- populate_tags = patch_populate_tags(self)
21- tag = factory.make_Tag(definition='//node/foo')
22- self.expectThat(populate_tags, MockCalledOnceWith(tag))
23+ populate_nodes = self.patch_autospec(Tag, "populate_nodes")
24+ tag = Tag(name=factory.make_name("tag"), definition='//node/foo')
25+ tag.save()
26+ self.expectThat(populate_nodes, MockCalledOnceWith(tag))
27 self.become_admin()
28 response = self.client.put(
29 self.get_tag_uri(tag),
30 {'definition': '//node/bar'})
31 self.assertEqual(http.client.OK, response.status_code)
32- self.expectThat(populate_tags, MockCallsMatch(call(tag), call(tag)))
33+ self.expectThat(populate_nodes, MockCallsMatch(call(tag), call(tag)))
34
35 def test_GET_nodes_with_no_nodes(self):
36 tag = factory.make_Tag()
37@@ -431,7 +427,7 @@
38 node = factory.make_Node()
39 client = make_worker_client(rack_controller)
40 tag.definition = '//new/node/definition'
41- tag.save()
42+ tag.save(populate=False)
43 response = client.post(
44 self.get_tag_uri(tag), {
45 'op': 'update_nodes',
46@@ -444,16 +440,17 @@
47 self.assertItemsEqual([], node.tags.all())
48
49 def test_POST_rebuild_rebuilds_node_mapping(self):
50- populate_tags = patch_populate_tags(self)
51- tag = factory.make_Tag(definition='//foo/bar')
52+ populate_nodes = self.patch_autospec(Tag, "populate_nodes")
53+ tag = Tag(name=factory.make_name("tag"), definition='//foo/bar')
54+ tag.save()
55 self.become_admin()
56- self.assertThat(populate_tags, MockCalledOnceWith(tag))
57+ self.assertThat(populate_nodes, MockCalledOnceWith(tag))
58 response = self.client.post(self.get_tag_uri(tag), {'op': 'rebuild'})
59 self.assertEqual(http.client.OK, response.status_code)
60 parsed_result = json.loads(
61 response.content.decode(settings.DEFAULT_CHARSET))
62 self.assertEqual({'rebuilding': tag.name}, parsed_result)
63- self.assertThat(populate_tags, MockCallsMatch(call(tag), call(tag)))
64+ self.assertThat(populate_nodes, MockCallsMatch(call(tag), call(tag)))
65
66 def test_POST_rebuild_leaves_manual_tags(self):
67 tag = factory.make_Tag(definition='')
68@@ -589,7 +586,7 @@
69 extra_kernel_opts, Tag.objects.filter(name=name)[0].kernel_opts)
70
71 def test_POST_new_populates_nodes(self):
72- populate_tags = patch_populate_tags(self)
73+ populate_nodes = self.patch_autospec(Tag, "populate_nodes")
74 self.become_admin()
75 name = factory.make_string()
76 definition = '//node/child'
77@@ -602,8 +599,8 @@
78 'definition': definition,
79 })
80 self.assertEqual(http.client.OK, response.status_code)
81- self.assertThat(populate_tags, MockCalledOnceWith(ANY))
82- # The tag passed to populate_tags() is the one created above.
83- [tag], _ = populate_tags.call_args
84+ self.assertThat(populate_nodes, MockCalledOnceWith(ANY))
85+ # The tag passed to populate_nodes() is the one created above.
86+ [tag], _ = populate_nodes.call_args
87 self.assertThat(tag, MatchesStructure.byEquality(
88 name=name, comment=comment, definition=definition))
89
90=== modified file 'src/maasserver/models/tag.py'
91--- src/maasserver/models/tag.py 2015-12-01 18:12:59 +0000
92+++ src/maasserver/models/tag.py 2016-05-13 14:02:27 +0000
93@@ -22,6 +22,9 @@
94 from maasserver import DefaultMeta
95 from maasserver.models.cleansave import CleanSave
96 from maasserver.models.timestampedmodel import TimestampedModel
97+from maasserver.utils.orm import post_commit_do
98+from maasserver.utils.threads import deferToDatabase
99+from twisted.internet import reactor
100
101
102 class TagManager(Manager):
103@@ -90,25 +93,63 @@
104 def __str__(self):
105 return self.name
106
107+ def _populate_nodes_later(self):
108+ """Find all nodes that match this tag, and update them, later.
109+
110+ This schedules population to happen post-commit, without waiting for
111+ its outcome.
112+ """
113+ # Avoid circular imports.
114+ from maasserver.populate_tags import populate_tags
115+
116+ if self.is_defined:
117+ # Schedule repopulate to happen after commit. This thread does not
118+ # wait for it to complete.
119+ post_commit_do(
120+ reactor.callLater, 0, deferToDatabase,
121+ populate_tags, self)
122+
123+ def _populate_nodes_now(self):
124+ """Find all nodes that match this tag, and update them, now.
125+
126+ All computation will be done within the current transaction, within
127+ the current thread. This could be costly.
128+ """
129+ # Avoid circular imports.
130+ from maasserver.populate_tags import populate_tag_for_multiple_nodes
131+ from maasserver.models.node import Node
132+
133+ if self.is_defined:
134+ # Do the work here and now in this thread. This is probably a
135+ # terrible mistake... unless you're testing.
136+ populate_tag_for_multiple_nodes(self, Node.objects.all())
137+
138 def populate_nodes(self):
139- """Find all nodes that match this tag, and update them."""
140- if not self.is_defined:
141- return
142- # before we pass off any work, ensure the definition is valid XPATH
143- try:
144- etree.XPath(self.definition)
145- except etree.XPathSyntaxError as e:
146- msg = 'Invalid xpath expression: %s' % (e,)
147- raise ValidationError({'definition': [msg]})
148- # Now delete the existing tags
149- self.node_set.clear()
150- # Avoid circular imports.
151- from maasserver.populate_tags import populate_tags
152- populate_tags(self)
153-
154- def save(self, *args, **kwargs):
155+ """Find all nodes that match this tag, and update them.
156+
157+ By default, node population is deferred.
158+ """
159+ return self._populate_nodes_later()
160+
161+ def clean_definition(self):
162+ if self.is_defined:
163+ try:
164+ etree.XPath(self.definition)
165+ except etree.XPathSyntaxError as e:
166+ msg = 'Invalid XPath expression: %s' % (e,)
167+ raise ValidationError({'definition': [msg]})
168+
169+ def clean(self):
170+ self.clean_definition()
171+
172+ def save(self, *args, populate=True, **kwargs):
173+ """Save this tag.
174+
175+ :param populate: Whether or not to call `populate_nodes` if the
176+ definition has changed.
177+ """
178 super(Tag, self).save(*args, **kwargs)
179- if self.definition != self._original_definition:
180+ if populate and (self.definition != self._original_definition):
181 self.populate_nodes()
182 self._original_definition = self.definition
183
184
185=== modified file 'src/maasserver/models/tests/test_tag.py'
186--- src/maasserver/models/tests/test_tag.py 2016-03-28 13:54:47 +0000
187+++ src/maasserver/models/tests/test_tag.py 2016-05-13 14:02:27 +0000
188@@ -5,12 +5,20 @@
189
190 __all__ = []
191
192+from unittest.mock import ANY
193+
194 from django.core.exceptions import ValidationError
195-from maasserver import populate_tags as populate_tags_module
196+from maasserver import populate_tags
197+from maasserver.models import tag as tag_module
198 from maasserver.models.tag import Tag
199 from maasserver.testing.factory import factory
200 from maasserver.testing.testcase import MAASServerTestCase
201-from maastesting.matchers import MockCalledOnceWith
202+from maasserver.utils.threads import deferToDatabase
203+from maastesting.matchers import (
204+ MockCalledOnceWith,
205+ MockNotCalled,
206+)
207+from twisted.internet import reactor
208
209
210 class TagTest(MAASServerTestCase):
211@@ -56,11 +64,17 @@
212 'too-long' * 33, '\xb5']:
213 self.assertRaises(ValidationError, factory.make_Tag, name=invalid)
214
215- def test_applies_tags_to_nodes(self):
216- populate_tags = self.patch_autospec(
217- populate_tags_module, "populate_tags")
218- tag = factory.make_Tag(definition='//node/child')
219- self.assertThat(populate_tags, MockCalledOnceWith(tag))
220+ def test_validate_traps_invalid_tag_definitions(self):
221+ self.assertRaises(
222+ ValidationError, factory.make_Tag,
223+ definition="invalid::definition")
224+
225+ def test_applies_tags_to_nodes_on_save(self):
226+ populate_nodes = self.patch_autospec(Tag, "populate_nodes")
227+ tag = Tag(name=factory.make_name("tag"), definition='//node/child')
228+ self.assertThat(populate_nodes, MockNotCalled())
229+ tag.save()
230+ self.assertThat(populate_nodes, MockCalledOnceWith(tag))
231
232 def test_will_not_save_invalid_xpath(self):
233 tag = factory.make_Tag(definition='//node/foo')
234@@ -83,41 +97,86 @@
235 self.assertIs(self.expected, tag.is_defined)
236
237
238-class TestTagPopulateNodes(MAASServerTestCase):
239+class TestTagPopulateNodesLater(MAASServerTestCase):
240+
241+ def test__populates_if_tag_is_defined(self):
242+ post_commit_do = self.patch(tag_module, "post_commit_do")
243+
244+ tag = Tag(name=factory.make_name("tag"), definition="//foo")
245+ tag.save(populate=False)
246+
247+ self.assertTrue(tag.is_defined)
248+ self.assertThat(post_commit_do, MockNotCalled())
249+ tag._populate_nodes_later()
250+ self.assertThat(
251+ post_commit_do, MockCalledOnceWith(
252+ reactor.callLater, 0, deferToDatabase,
253+ populate_tags.populate_tags, tag))
254
255 def test__does_nothing_if_tag_is_not_defined(self):
256- tag = factory.make_Tag(definition="")
257+ post_commit_do = self.patch(tag_module, "post_commit_do")
258+
259+ tag = Tag(name=factory.make_name("tag"), definition="")
260+ tag.save(populate=False)
261+
262 self.assertFalse(tag.is_defined)
263+ self.assertThat(post_commit_do, MockNotCalled())
264+ tag._populate_nodes_later()
265+ self.assertThat(post_commit_do, MockNotCalled())
266+
267+ def test__does_not_clear_node_set_before_populating(self):
268+ post_commit_do = self.patch(tag_module, "post_commit_do")
269+
270+ tag = Tag(name=factory.make_name("tag"), definition="//foo")
271+ tag.save(populate=False)
272+
273 nodes = [factory.make_Node() for _ in range(3)]
274 tag.node_set.add(*nodes)
275- tag.populate_nodes()
276- # The set of related nodes is unchanged.
277+ tag._populate_nodes_later()
278 self.assertItemsEqual(nodes, tag.node_set.all())
279-
280- def test__checks_definition_before_proceeding(self):
281- tag = factory.make_Tag(definition="")
282- tag.definition = "invalid::definition"
283- self.assertRaises(ValidationError, tag.populate_nodes)
284-
285- def test__clears_node_set(self):
286- self.patch_autospec(populate_tags_module, "populate_tags")
287-
288- tag = factory.make_Tag(definition="")
289- # Define the tag now but don't save because .save() calls
290- # populate_nodes(), but we want to do it explicitly here.
291- tag.definition = "//foo"
292+ self.assertThat(post_commit_do, MockCalledOnceWith(
293+ reactor.callLater, 0, deferToDatabase,
294+ populate_tags.populate_tags, tag))
295+
296+ def test__later_is_the_default(self):
297+ tag = Tag(name=factory.make_name("tag"))
298+ self.patch(tag, "_populate_nodes_later")
299+ self.assertThat(tag._populate_nodes_later, MockNotCalled())
300+ tag.save()
301+ self.assertThat(tag._populate_nodes_later, MockCalledOnceWith())
302+
303+
304+class TestTagPopulateNodesNow(MAASServerTestCase):
305+
306+ def test__populates_if_tag_is_defined(self):
307+ populate_multiple = self.patch_autospec(
308+ populate_tags, "populate_tag_for_multiple_nodes")
309+
310+ tag = Tag(name=factory.make_name("tag"), definition="//foo")
311+ tag.save(populate=False)
312+
313+ self.assertTrue(tag.is_defined)
314+ self.assertThat(populate_multiple, MockNotCalled())
315+ tag._populate_nodes_now()
316+ self.assertThat(populate_multiple, MockCalledOnceWith(tag, ANY))
317+
318+ def test__does_nothing_if_tag_is_not_defined(self):
319+ populate_multiple = self.patch_autospec(
320+ populate_tags, "populate_tag_for_multiple_nodes")
321+
322+ tag = Tag(name=factory.make_name("tag"), definition="")
323+ tag.save(populate=False)
324+
325+ self.assertFalse(tag.is_defined)
326+ self.assertThat(populate_multiple, MockNotCalled())
327+ tag._populate_nodes_now()
328+ self.assertThat(populate_multiple, MockNotCalled())
329+
330+ def test__clears_node_set_before_populating(self):
331+ tag = Tag(name=factory.make_name("tag"), definition="//foo")
332+ tag.save(populate=False)
333+
334 nodes = [factory.make_Node() for _ in range(3)]
335 tag.node_set.add(*nodes)
336- tag.populate_nodes()
337+ tag._populate_nodes_now()
338 self.assertItemsEqual([], tag.node_set.all())
339-
340- def test__calls_populate_tags(self):
341- populate_tags = self.patch_autospec(
342- populate_tags_module, "populate_tags")
343-
344- tag = factory.make_Tag(definition="")
345- # Define the tag now but don't save because .save() calls
346- # populate_nodes(), but we want to do it explicitly here.
347- tag.definition = "//foo"
348- tag.populate_nodes()
349- self.assertThat(populate_tags, MockCalledOnceWith(tag))
350
351=== modified file 'src/maasserver/populate_tags.py'
352--- src/maasserver/populate_tags.py 2016-04-27 20:52:15 +0000
353+++ src/maasserver/populate_tags.py 2016-05-13 14:02:27 +0000
354@@ -4,9 +4,10 @@
355 """Populate what nodes are associated with a tag."""
356
357 __all__ = [
358+ 'populate_tag_for_multiple_nodes',
359 'populate_tags',
360 'populate_tags_for_single_node',
361- ]
362+]
363
364 from functools import partial
365 from math import ceil
366@@ -19,6 +20,7 @@
367 RackController,
368 )
369 from maasserver.models.nodeprobeddetails import (
370+ get_probed_details,
371 get_single_probed_details,
372 script_output_nsmap,
373 )
374@@ -28,9 +30,14 @@
375 get_creds_tuple,
376 )
377 from maasserver.rpc import getAllClients
378+from maasserver.utils.orm import transactional
379 from provisioningserver.logger import get_maas_logger
380 from provisioningserver.rpc.cluster import EvaluateTag
381-from provisioningserver.tags import merge_details
382+from provisioningserver.tags import (
383+ DEFAULT_BATCH_SIZE,
384+ gen_batches,
385+ merge_details,
386+)
387 from provisioningserver.utils import classify
388 from provisioningserver.utils.twisted import (
389 asynchronous,
390@@ -68,6 +75,7 @@
391
392
393 @synchronous
394+@transactional
395 def populate_tags(tag):
396 """Evaluate `tag` for all nodes.
397
398@@ -85,45 +93,42 @@
399 logger.debug('Evaluating the "%s" tag for all nodes.', tag.name)
400 clients = getAllClients()
401 if len(clients) == 0:
402- # XXX: allenap 2014-09-22 bug=1372544: No connected rack controllers.
403- # Nothing can be evaluated when this occurs.
404- return
405-
406- # Split the work between the connected rack controllers. Building all the
407- # information that needs to be sent to them all.
408- node_ids = Node.objects.all().values_list("system_id", flat=True)
409- node_ids = [
410- {"system_id": node_id}
411- for node_id in node_ids
412- ]
413- chunked_node_ids = list(chunk_list(node_ids, len(clients)))
414- connected_racks = []
415- for idx, client in enumerate(clients):
416- rack = RackController.objects.get(system_id=client.ident)
417- tokens = list(get_auth_tokens(rack.owner))
418- if len(tokens) > 0:
419- # Use the latest token.
420- token = tokens[-1]
421- else:
422- token = create_auth_token(rack.owner)
423- creds = convert_tuple_to_string(get_creds_tuple(token))
424- if len(chunked_node_ids) > idx:
425- connected_racks.append({
426- "system_id": rack.system_id,
427- "hostname": rack.hostname,
428- "client": client,
429- "tag_name": tag.name,
430- "tag_definition": tag.definition,
431- "tag_nsmap": [
432- {"prefix": prefix, "uri": uri}
433- for prefix, uri in tag_nsmap.items()
434- ],
435- "credentials": creds,
436- "nodes": list(chunked_node_ids[idx]),
437- })
438-
439- [d] = _do_populate_tags(connected_racks)
440- return d
441+ # We have no clients so we need to do the work locally.
442+ return populate_tag_for_multiple_nodes(tag, Node.objects.all())
443+ else:
444+ # Split the work between the connected rack controllers.
445+ node_ids = Node.objects.all().values_list("system_id", flat=True)
446+ node_ids = [{"system_id": node_id} for node_id in node_ids]
447+ chunked_node_ids = list(chunk_list(node_ids, len(clients)))
448+ connected_racks = []
449+ for idx, client in enumerate(clients):
450+ rack = RackController.objects.get(system_id=client.ident)
451+ token = _get_or_create_auth_token(rack.owner)
452+ creds = convert_tuple_to_string(get_creds_tuple(token))
453+ if len(chunked_node_ids) > idx:
454+ connected_racks.append({
455+ "system_id": rack.system_id,
456+ "hostname": rack.hostname,
457+ "client": client,
458+ "tag_name": tag.name,
459+ "tag_definition": tag.definition,
460+ "tag_nsmap": [
461+ {"prefix": prefix, "uri": uri}
462+ for prefix, uri in tag_nsmap.items()
463+ ],
464+ "credentials": creds,
465+ "nodes": list(chunked_node_ids[idx]),
466+ })
467+ [d] = _do_populate_tags(connected_racks)
468+ return d
469+
470+
471+def _get_or_create_auth_token(user):
472+ """Get the most recent OAuth token for `user`, or create one."""
473+ for token in reversed(get_auth_tokens(user)):
474+ return token
475+ else:
476+ return create_auth_token(user)
477
478
479 @asynchronous(timeout=FOREVER)
480@@ -177,11 +182,15 @@
481 return [d]
482
483
484+@synchronous
485 def populate_tags_for_single_node(tags, node):
486 """Reevaluate all tags for a single node.
487
488- Presumably this node's details have recently changed. Use
489- `populate_tags` when many nodes need reevaluating.
490+ Presumably this node's details have recently changed. Use `populate_tags`
491+ when many nodes need reevaluating AND there are rack controllers available
492+ to which to farm-out work. Use `populate_tag_for_multiple_nodes` when many
493+ nodes need reevaluating locally, i.e. when there are no rack controllers
494+ connected.
495 """
496 probed_details = get_single_probed_details(node.system_id)
497 probed_details_doc = merge_details(probed_details)
498@@ -192,3 +201,28 @@
499 tags_matching, tags_nonmatching = classify(evaluator, tags_defined)
500 node.tags.remove(*tags_nonmatching)
501 node.tags.add(*tags_matching)
502+
503+
504+@synchronous
505+def populate_tag_for_multiple_nodes(tag, nodes, batch_size=DEFAULT_BATCH_SIZE):
506+ """Reevaluate a single tag for a multiple nodes.
507+
508+ Presumably this tag's expression has recently changed. Use `populate_tags`
509+ when many nodes need reevaluating AND there are rack controllers available
510+ to which to farm-out work. Use this only when many nodes need reevaluating
511+ locally, i.e. when there are no rack controllers connected.
512+ """
513+ # Same expression, multuple documents: compile expression with XPath.
514+ xpath = etree.XPath(tag.definition, namespaces=tag_nsmap)
515+ # The XML details documents can be large so work in batches.
516+ for batch in gen_batches(nodes, batch_size):
517+ probed_details = get_probed_details(node.system_id for node in batch)
518+ probed_details_docs_by_node = {
519+ node: merge_details(probed_details[node.system_id])
520+ for node in batch
521+ }
522+ nodes_matching, nodes_nonmatching = classify(
523+ partial(try_match_xpath, xpath, logger=maaslog),
524+ probed_details_docs_by_node.items())
525+ tag.node_set.remove(*nodes_nonmatching)
526+ tag.node_set.add(*nodes_matching)
527
528=== modified file 'src/maasserver/testing/factory.py'
529--- src/maasserver/testing/factory.py 2016-05-11 19:31:40 +0000
530+++ src/maasserver/testing/factory.py 2016-05-13 14:02:27 +0000
531@@ -1025,8 +1025,9 @@
532 else:
533 return self.make_ipv4_Subnet_with_IPRanges()
534
535- def make_Tag(self, name=None, definition=None, comment='',
536- kernel_opts=None, created=None, updated=None):
537+ def make_Tag(
538+ self, name=None, definition=None, comment='', kernel_opts=None,
539+ created=None, updated=None, populate=True):
540 if name is None:
541 name = self.make_name('tag')
542 if definition is None:
543@@ -1035,14 +1036,22 @@
544 tag = Tag(
545 name=name, definition=definition, comment=comment,
546 kernel_opts=kernel_opts)
547- tag.save()
548+ # Save without populating nodes.
549+ tag.save(populate=False)
550 # Update the 'updated'/'created' fields with a call to 'update'
551 # preventing a call to save() from overriding the values.
552 if updated is not None:
553 Tag.objects.filter(id=tag.id).update(updated=updated)
554 if created is not None:
555 Tag.objects.filter(id=tag.id).update(created=created)
556- return reload_object(tag)
557+ # Reload if we've changed the underlying record.
558+ if updated is not None or created is not None:
559+ tag = reload_object(tag)
560+ # Populate nodes for this tag now that it's fully configured, if
561+ # requested. This avoids dealing with the post-commit hook stuff.
562+ if populate:
563+ tag._populate_nodes_now()
564+ return tag
565
566 def make_user_with_keys(self, n_keys=2, user=None, **kwargs):
567 """Create a user with n `SSHKey`. If user is not None, use this user
568
569=== modified file 'src/maasserver/testing/orm.py'
570--- src/maasserver/testing/orm.py 2016-03-28 13:54:47 +0000
571+++ src/maasserver/testing/orm.py 2016-05-13 14:02:27 +0000
572@@ -71,7 +71,7 @@
573 self.expectThat(
574 post_commit_hooks.hooks, HasLength(0),
575 "One or more post-commit tasks were present at the end of "
576- "this test." + description_of_hooks)
577+ "this test.\n" + description_of_hooks)
578 super(PostCommitHooksTestMixin, self).tearDown()
579 finally:
580 # By this point we will have reported the leaked post-commit
581
582=== modified file 'src/maasserver/tests/test_populate_tags.py'
583--- src/maasserver/tests/test_populate_tags.py 2016-05-10 11:02:10 +0000
584+++ src/maasserver/tests/test_populate_tags.py 2016-05-13 14:02:27 +0000
585@@ -13,8 +13,15 @@
586
587 from apiclient.creds import convert_tuple_to_string
588 from fixtures import FakeLogger
589-from maasserver import populate_tags as populate_tags_module
590-from maasserver.models import Tag
591+from maasserver import (
592+ populate_tags as populate_tags_module,
593+ rpc as rpc_module,
594+)
595+from maasserver.models import (
596+ Node,
597+ Tag,
598+ tag as tag_module,
599+)
600 from maasserver.models.user import (
601 create_auth_token,
602 get_auth_tokens,
603@@ -22,6 +29,7 @@
604 )
605 from maasserver.populate_tags import (
606 _do_populate_tags,
607+ populate_tag_for_multiple_nodes,
608 populate_tags,
609 populate_tags_for_single_node,
610 )
611@@ -31,7 +39,12 @@
612 RunningEventLoopFixture,
613 )
614 from maasserver.testing.factory import factory
615-from maasserver.testing.testcase import MAASServerTestCase
616+from maasserver.testing.testcase import (
617+ MAASServerTestCase,
618+ MAASTransactionServerTestCase,
619+)
620+from maasserver.utils.orm import post_commit_hooks
621+from maasserver.utils.threads import deferToDatabase
622 from maastesting.matchers import (
623 MockCalledOnceWith,
624 MockCallsMatch,
625@@ -48,13 +61,16 @@
626 from provisioningserver.rpc.cluster import EvaluateTag
627 from provisioningserver.rpc.common import Client
628 from provisioningserver.utils.twisted import asynchronous
629-from testtools.monkey import MonkeyPatcher
630-
631-
632-def make_Tag_without_populating():
633- # Create a tag but prevent evaluation when saving.
634- dont_populate = MonkeyPatcher((Tag, "populate_nodes", lambda self: None))
635- return dont_populate.run_with_patches(factory.make_Tag)
636+from testtools.matchers import (
637+ HasLength,
638+ IsInstance,
639+ MatchesAll,
640+ MatchesStructure,
641+)
642+from twisted.internet import reactor
643+from twisted.internet.base import DelayedCall
644+from twisted.internet.task import Clock
645+from twisted.internet.threads import blockingCallFromThread
646
647
648 class TestDoPopulateTags(MAASServerTestCase):
649@@ -186,6 +202,11 @@
650
651
652 class TestPopulateTagsEndToNearlyEnd(MAASServerTestCase):
653+ """Tests for populating tags on racks.
654+
655+ This happens when there are connected rack controllers able to carry out
656+ the task.
657+ """
658
659 def prepare_live_rpc(self):
660 self.useFixture(RegionEventLoopFixture("rpc"))
661@@ -210,7 +231,7 @@
662 protocol = rpc_fixture.makeCluster(rack, EvaluateTag)
663 protocol.EvaluateTag.side_effect = always_succeed_with({})
664 protocols.append(protocol)
665- tag = make_Tag_without_populating()
666+ tag = factory.make_Tag(populate=False)
667
668 d = populate_tags(tag)
669
670@@ -227,6 +248,57 @@
671 tag_nsmap=ANY, credentials=creds, nodes=ANY))
672
673
674+class TestPopulateTagsInRegion(MAASTransactionServerTestCase):
675+ """Tests for populating tags in the region.
676+
677+ This happens when there are no rack controllers to carry out the task.
678+ """
679+
680+ def test__saving_tag_schedules_node_population(self):
681+ clock = self.patch(tag_module, "reactor", Clock())
682+
683+ with post_commit_hooks:
684+ # Make a Tag by hand to trigger normal node population handling
685+ # behaviour rather than the (generally more convenient) default
686+ # behaviour in the factory.
687+ tag = Tag(name=factory.make_name("tag"), definition='true()')
688+ tag.save()
689+
690+ # A call has been scheduled to populate tags.
691+ calls = clock.getDelayedCalls()
692+ self.assertThat(calls, HasLength(1))
693+ [call] = calls
694+ self.assertThat(
695+ call, MatchesAll(
696+ IsInstance(DelayedCall),
697+ MatchesStructure.byEquality(
698+ time=0, func=deferToDatabase,
699+ args=(populate_tags, tag), kw={}),
700+ first_only=True,
701+ ))
702+
703+ def test__populate_in_region_when_no_clients(self):
704+ clock = self.patch(tag_module, "reactor", Clock())
705+
706+ # Ensure there are no clients.
707+ self.patch(rpc_module, 'getAllClients').return_value = []
708+
709+ with post_commit_hooks:
710+ node = factory.make_Node()
711+ # Make a Tag by hand to trigger normal node population handling
712+ # behaviour rather than the (generally more convenient) default
713+ # behaviour in the factory.
714+ tag = Tag(name=factory.make_name("tag"), definition='true()')
715+ tag.save()
716+
717+ # A call has been scheduled to populate tags.
718+ [call] = clock.getDelayedCalls()
719+ # Execute the call ourselves in the real reactor.
720+ blockingCallFromThread(reactor, call.func, *call.args, **call.kw)
721+ # The tag's node set has been updated.
722+ self.assertItemsEqual([node], tag.node_set.all())
723+
724+
725 class TestPopulateTagsForSingleNode(MAASServerTestCase):
726
727 def test_updates_node_with_all_applicable_tags(self):
728@@ -236,9 +308,9 @@
729 factory.make_NodeResult_for_commissioning(
730 node, LLDP_OUTPUT_NAME, 0, b"<bar/>")
731 tags = [
732- factory.make_Tag("foo", "/foo"),
733- factory.make_Tag("bar", "//lldp:bar"),
734- factory.make_Tag("baz", "/foo/bar"),
735+ factory.make_Tag("foo", "/foo", populate=False),
736+ factory.make_Tag("bar", "//lldp:bar", populate=False),
737+ factory.make_Tag("baz", "/foo/bar", populate=False),
738 ]
739 populate_tags_for_single_node(tags, node)
740 self.assertItemsEqual(
741@@ -249,8 +321,8 @@
742 factory.make_NodeResult_for_commissioning(
743 node, LSHW_OUTPUT_NAME, 0, b"<foo/>")
744 tags = [
745- factory.make_Tag("foo", "/foo"),
746- factory.make_Tag("lou", "//nge:bar"),
747+ factory.make_Tag("foo", "/foo", populate=False),
748+ factory.make_Tag("lou", "//nge:bar", populate=False),
749 ]
750 populate_tags_for_single_node(tags, node) # Look mom, no exception!
751 self.assertSequenceEqual(
752@@ -261,10 +333,24 @@
753 factory.make_NodeResult_for_commissioning(
754 node, LSHW_OUTPUT_NAME, 0, b"<foo/>")
755 tags = [
756- factory.make_Tag("foo", "/foo"),
757+ factory.make_Tag("foo", "/foo", populate=False),
758 Tag(name="empty", definition=""),
759 Tag(name="null", definition=None),
760 ]
761 populate_tags_for_single_node(tags, node) # Look mom, no exception!
762 self.assertSequenceEqual(
763 ["foo"], [tag.name for tag in node.tags.all()])
764+
765+
766+class TestPopulateTagForMultipleNodes(MAASServerTestCase):
767+
768+ def test_updates_nodes_with_tag(self):
769+ nodes = [factory.make_Node() for _ in range(5)]
770+ for node in nodes[0:2]:
771+ factory.make_NodeResult_for_commissioning(
772+ node, LLDP_OUTPUT_NAME, 0, b"<bar/>")
773+ tag = factory.make_Tag("bar", "//lldp:bar", populate=False)
774+ populate_tag_for_multiple_nodes(tag, nodes)
775+ self.assertItemsEqual(
776+ [node.hostname for node in nodes[0:2]],
777+ [node.hostname for node in Node.objects.filter(tags__name='bar')])