Merge lp:~mpontillo/maas/networking-constraints-refactor-part4 into lp:~maas-committers/maas/trunk

Proposed by Mike Pontillo
Status: Merged
Approved by: Mike Pontillo
Approved revision: no longer in the source branch.
Merged at revision: 4465
Proposed branch: lp:~mpontillo/maas/networking-constraints-refactor-part4
Merge into: lp:~maas-committers/maas/trunk
Prerequisite: lp:~mpontillo/maas/networking-constraints-refactor-part3
Diff against target: 604 lines (+325/-39)
8 files modified
src/maasserver/api/nodes.py (+48/-5)
src/maasserver/api/tests/test_nodes.py (+137/-11)
src/maasserver/models/interface.py (+16/-0)
src/maasserver/models/tests/test_interface.py (+52/-0)
src/maasserver/node_constraint_filter_forms.py (+31/-11)
src/maasserver/tests/test_node_constraint_filter_forms.py (+12/-12)
src/maasserver/utils/orm.py (+26/-0)
src/maasserver/websockets/tests/test_listener.py (+3/-0)
To merge this branch: bzr merge lp:~mpontillo/maas/networking-constraints-refactor-part4
Reviewer Review Type Date Requested Status
Ricardo Bánffy (community) Approve
Review via email: mp+276482@code.launchpad.net

Commit message

Network interfaces constraint refactoring, part 4.

 * Return a data structure from the AcquireNodeForm indicating which interfaces matched a constraint.
 * Annotate the Node object returned from Nodes.acquire() with a constraints_by_type field to indicate which labels matched which constraints.
 * Added a 'verbose' option to Nodes.acquire() so that users can determine the full set of nodes that matched storage or interface constraints.
 * Added a 'dry_run' option to Nodes.acquire() so that users can try out constraints without actually making any changes.
 * Refactor logic to compute interface/node matches into model, so that any (sub-object, object) can be mapped in the future. (interface.py, orm.py)

To post a comment you must log in.
Revision history for this message
Mike Pontillo (mpontillo) wrote :

While this branch seems to be functionally complete, there are a lot of tests that assume the Node.acquire() API only returns two values that need to be updated, since it now returns three.

I patched the Nodes.acquire() API so that it is compatible for now, but I'd like to discuss the best way to change this for 1.9.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Well, I've done a "best-effort" approach to be backward-compatible and still return the new constraints data in the returned node. It's a little less-than-perfect since I don't think I should change the name of the existing storage constraint map.

Revision history for this message
Mike Pontillo (mpontillo) wrote :

Note: I still need to add unit tests for what is returned from the API. I'll do that tomorrow after we talk to the Juju team in the morning (Pacific time).

Revision history for this message
Ricardo Bánffy (rbanffy) wrote :

Looks good.

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (1014.4 KiB)

The attempt to merge lp:~mpontillo/maas/networking-constraints-refactor-part4 into lp:maas failed. Below is the output from the failed tests.

Get:1 http://security.ubuntu.com trusty-security InRelease [64.4 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Get:2 http://nova.clouds.archive.ubuntu.com trusty-updates InRelease [64.4 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Get:3 http://security.ubuntu.com trusty-security/main Sources [98.0 kB]
Get:4 http://security.ubuntu.com trusty-security/universe Sources [31.0 kB]
Get:5 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [242 kB]
Get:6 http://security.ubuntu.com trusty-security/main amd64 Packages [361 kB]
Get:7 http://security.ubuntu.com trusty-security/universe amd64 Packages [117 kB]
Get:8 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [143 kB]
Get:9 http://security.ubuntu.com trusty-security/main Translation-en [196 kB]
Get:10 http://security.ubuntu.com trusty-security/universe Translation-en [68.4 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [644 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [326 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
Hit http://nova.clouds.archive.ubuntu.com trusty/main amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/universe amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Fetched 2,357 kB in 4s (548 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 authbind bind9 bind9utils build-essential bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libpq-dev make nodejs-legacy npm pep8 phantomjs postgresql pyflakes python-apt python-bson python-bzrlib python-convoy python-coverage python-crochet python-cssselect python-curtin python-dev python-distro-info python-django python-django-piston python-django-south python-djorm-ext-pgarray python-docutils python-extras python-fixtures python-flake8 python-formencode python-hivex python-httplib2 python-jinja2 python-jsonschema python-lxml python-mock python-netaddr python-netifaces python-nose python-oauth python-openssl python-paramiko python-pexpect python-pip python-pocket-lint python-psycopg2 python-pyinotify python-pyparsing python-seamicroclient python-simplejson python-simplestreams python-sphinx python-subunit python-tempita py...

Revision history for this message
MAAS Lander (maas-lander) wrote :
Download full text (1013.8 KiB)

The attempt to merge lp:~mpontillo/maas/networking-constraints-refactor-part4 into lp:maas failed. Below is the output from the failed tests.

Get:1 http://security.ubuntu.com trusty-security InRelease [64.4 kB]
Ign http://nova.clouds.archive.ubuntu.com trusty InRelease
Get:2 http://nova.clouds.archive.ubuntu.com trusty-updates InRelease [64.4 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty Release.gpg
Hit http://nova.clouds.archive.ubuntu.com trusty Release
Get:3 http://security.ubuntu.com trusty-security/main Sources [98.0 kB]
Get:4 http://security.ubuntu.com trusty-security/universe Sources [31.0 kB]
Get:5 http://nova.clouds.archive.ubuntu.com trusty-updates/main Sources [242 kB]
Get:6 http://security.ubuntu.com trusty-security/main amd64 Packages [361 kB]
Get:7 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Sources [143 kB]
Get:8 http://security.ubuntu.com trusty-security/universe amd64 Packages [117 kB]
Hit http://security.ubuntu.com trusty-security/main Translation-en
Get:9 http://nova.clouds.archive.ubuntu.com trusty-updates/main amd64 Packages [644 kB]
Hit http://security.ubuntu.com trusty-security/universe Translation-en
Get:10 http://nova.clouds.archive.ubuntu.com trusty-updates/universe amd64 Packages [326 kB]
Get:11 http://nova.clouds.archive.ubuntu.com trusty-updates/main Translation-en [313 kB]
Get:12 http://nova.clouds.archive.ubuntu.com trusty-updates/universe Translation-en [172 kB]
Hit http://nova.clouds.archive.ubuntu.com trusty/main Sources
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Sources
Hit http://nova.clouds.archive.ubuntu.com trusty/main amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/universe amd64 Packages
Hit http://nova.clouds.archive.ubuntu.com trusty/main Translation-en
Hit http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en
Ign http://nova.clouds.archive.ubuntu.com trusty/main Translation-en_US
Ign http://nova.clouds.archive.ubuntu.com trusty/universe Translation-en_US
Fetched 2,577 kB in 4s (580 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
     --no-install-recommends install apache2 authbind bind9 bind9utils build-essential bzr-builddeb chromium-browser chromium-chromedriver curl daemontools debhelper dh-apport dh-systemd distro-info dnsutils firefox freeipmi-tools git gjs ipython isc-dhcp-common libjs-angularjs libjs-jquery libjs-jquery-hotkeys libjs-yui3-full libjs-yui3-min libpq-dev make nodejs-legacy npm pep8 phantomjs postgresql pyflakes python-apt python-bson python-bzrlib python-convoy python-coverage python-crochet python-cssselect python-curtin python-dev python-distro-info python-django python-django-piston python-django-south python-djorm-ext-pgarray python-docutils python-extras python-fixtures python-flake8 python-formencode python-hivex python-httplib2 python-jinja2 python-jsonschema python-lxml python-mock python-netaddr python-netifaces python-nose python-oauth python-openssl python-paramiko python-pexpect python-pip python-pocket-lint python-psycopg2 python-pyinotify python-pyparsing python-seamicroclient python-simplejson python-simplestreams python-sphinx python-subunit python-tempita pyt...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/api/nodes.py'
2--- src/maasserver/api/nodes.py 2015-10-30 20:50:01 +0000
3+++ src/maasserver/api/nodes.py 2015-11-05 00:32:58 +0000
4@@ -26,6 +26,7 @@
5 from django.core.exceptions import PermissionDenied
6 from django.http import HttpResponse
7 from django.shortcuts import get_object_or_404
8+from formencode.validators import StringBool
9 from maasserver import (
10 eventloop,
11 locks,
12@@ -138,6 +139,7 @@
13 'zone',
14 'disable_ipv4',
15 'constraint_map',
16+ 'constraints_by_type',
17 'boot_disk',
18 'blockdevice_set',
19 'physicalblockdevice_set',
20@@ -1398,6 +1400,16 @@
21 :type agent_name: unicode
22 :param comment: Optional comment for the event log.
23 :type comment: unicode
24+ :param dry_run: Optional boolean to indicate that the node should not
25+ actually be acquired (this is for support/troubleshooting, or users
26+ who want to see which node would match a constraint, without
27+ acquiring a node). Defaults to False.
28+ :type dry_run: bool
29+ :param verbose: Optional boolean to indicate that the user would like
30+ additional verbosity in the constraints_by_type field (each
31+ constraint will be prefixed by `verbose_`, and contain the full
32+ data structure that indicates which node(s) matched).
33+ :type verbose: bool
34
35 Returns 409 if a suitable node matching the constraints could not be
36 found.
37@@ -1407,6 +1419,10 @@
38 maaslog.info(
39 "Request from user %s to acquire a node with constraints %s",
40 request.user.username, request.data)
41+ verbose = get_optional_param(
42+ request.POST, 'verbose', default=False, validator=StringBool)
43+ dry_run = get_optional_param(
44+ request.POST, 'dry_run', default=False, validator=StringBool)
45
46 if not form.is_valid():
47 raise MAASAPIValidationError(form.errors)
48@@ -1416,7 +1432,7 @@
49 with locks.node_acquire:
50 nodes = Node.nodes.get_available_nodes_for_acquisition(
51 request.user)
52- nodes, constraint_map = form.filter_nodes(nodes)
53+ nodes, storage, interfaces = form.filter_nodes(nodes)
54 node = get_first(nodes)
55 if node is None:
56 constraints = form.describe_constraints()
57@@ -1430,10 +1446,37 @@
58 % constraints)
59 raise NodesNotAvailable(message)
60 agent_name = request.data.get('agent_name', '')
61- node.acquire(
62- request.user, get_oauth_token(request),
63- agent_name=agent_name, comment=comment)
64- node.constraint_map = constraint_map.get(node.id, {})
65+ if not dry_run:
66+ node.acquire(
67+ request.user, get_oauth_token(request),
68+ agent_name=agent_name, comment=comment)
69+ node.constraint_map = storage.get(node.id, {})
70+ node.constraints_by_type = {}
71+ # Need to get the interface constraints map into the proper format
72+ # to return it here.
73+ # Backward compatibility: provide the storage constraints in both
74+ # formats.
75+ if len(node.constraint_map) > 0:
76+ node.constraints_by_type['storage'] = {}
77+ new_storage = node.constraints_by_type['storage']
78+ # Convert this to the "new style" constraints map format.
79+ for storage_key in node.constraint_map.iterkeys():
80+ # Each key in the storage map is actually a value which
81+ # contains the ID of the matching storage device.
82+ # Convert this to a label: list-of-matches format, to
83+ # match how the constraints will be done going forward.
84+ new_key = node.constraint_map[storage_key]
85+ matches = new_storage.get(new_key, [])
86+ matches.append(storage_key)
87+ new_storage[new_key] = matches
88+ if len(interfaces) > 0:
89+ node.constraints_by_type['interfaces'] = {
90+ label: interfaces.get(label, {}).get(node.id)
91+ for label in interfaces
92+ }
93+ if verbose:
94+ node.constraints_by_type['verbose_storage'] = storage
95+ node.constraints_by_type['verbose_interfaces'] = interfaces
96 return node
97
98 @admin_method
99
100=== modified file 'src/maasserver/api/tests/test_nodes.py'
101--- src/maasserver/api/tests/test_nodes.py 2015-10-30 14:59:16 +0000
102+++ src/maasserver/api/tests/test_nodes.py 2015-11-05 00:32:58 +0000
103@@ -80,6 +80,7 @@
104 Equals,
105 HasLength,
106 MatchesListwise,
107+ Not,
108 )
109
110
111@@ -1139,17 +1140,142 @@
112 """Storage label is returned alongside node data"""
113 node = factory.make_Node(
114 status=NODE_STATUS.READY, with_boot_disk=False)
115- factory.make_PhysicalBlockDevice(
116- node=node, size=11 * (1000 ** 3), tags=['ssd'])
117- response = self.client.post(reverse('nodes_handler'), {
118- 'op': 'acquire',
119- 'storage': 'needed:10(ssd)',
120- })
121- self.assertResponseCode(httplib.OK, response)
122- response_json = json.loads(response.content)
123- device_id = response_json['physicalblockdevice_set'][0]['id'].__str__()
124- constraint_name = response_json['constraint_map'][device_id]
125- self.assertItemsEqual(constraint_name, 'needed')
126+ # The ID may always be '1', which won't be interesting for testing.
127+ for _ in range(1, random.choice([1, 3, 5])):
128+ factory.make_PhysicalBlockDevice()
129+ factory.make_PhysicalBlockDevice(
130+ node=node, size=11 * (1000 ** 3), tags=['ssd'])
131+ response = self.client.post(reverse('nodes_handler'), {
132+ 'op': 'acquire',
133+ 'storage': 'needed:10(ssd)',
134+ })
135+ self.assertResponseCode(httplib.OK, response)
136+ response_json = json.loads(response.content)
137+ device_id = response_json['physicalblockdevice_set'][0]['id']
138+ constraint_map = response_json.get('constraint_map')
139+ constraint_name = constraint_map[unicode(device_id)]
140+ self.assertItemsEqual(constraint_name, 'needed')
141+ constraints = response_json['constraints_by_type']
142+ self.expectThat(constraints, Contains('storage'))
143+ self.expectThat(constraints['storage'], Contains('needed'))
144+ self.expectThat(constraints['storage']['needed'], Contains(device_id))
145+ self.expectThat(constraints, Not(Contains('verbose_storage')))
146+
147+ def test_POST_acquire_allocates_node_by_storage_with_verbose(self):
148+ """Storage label is returned alongside node data"""
149+ node = factory.make_Node(
150+ status=NODE_STATUS.READY, with_boot_disk=False)
151+ # The ID may always be '1', which won't be interesting for testing.
152+ for _ in range(1, random.choice([1, 3, 5])):
153+ factory.make_PhysicalBlockDevice()
154+ factory.make_PhysicalBlockDevice(
155+ node=node, size=11 * (1000 ** 3), tags=['ssd'])
156+ response = self.client.post(reverse('nodes_handler'), {
157+ 'op': 'acquire',
158+ 'storage': 'needed:10(ssd)',
159+ 'verbose': 'true',
160+ })
161+ self.assertResponseCode(httplib.OK, response)
162+ response_json = json.loads(response.content)
163+ device_id = response_json['physicalblockdevice_set'][0]['id']
164+ constraint_map = response_json.get('constraint_map')
165+ constraint_name = constraint_map[unicode(device_id)]
166+ self.assertItemsEqual(constraint_name, 'needed')
167+ constraints = response_json['constraints_by_type']
168+ self.expectThat(constraints, Contains('storage'))
169+ self.expectThat(constraints['storage'], Contains('needed'))
170+ self.expectThat(constraints['storage']['needed'], Contains(device_id))
171+ verbose_storage = constraints.get('verbose_storage')
172+ self.expectThat(verbose_storage, Contains(unicode(node.id)))
173+ self.expectThat(
174+ verbose_storage[unicode(node.id)], Equals(constraint_map))
175+
176+ def test_POST_acquire_allocates_node_by_interfaces(self):
177+ """Interface label is returned alongside node data"""
178+ fabric = factory.make_Fabric('ubuntu')
179+ # The ID may always be '1', which won't be interesting for testing.
180+ for _ in range(1, random.choice([1, 3, 5])):
181+ factory.make_Interface()
182+ node = factory.make_Node_with_Interface_on_Subnet(
183+ status=NODE_STATUS.READY, fabric=fabric)
184+ iface = node.get_boot_interface()
185+ response = self.client.post(reverse('nodes_handler'), {
186+ 'op': 'acquire',
187+ 'interfaces': 'needed:fabric=ubuntu',
188+ })
189+ self.assertResponseCode(httplib.OK, response)
190+ response_json = json.loads(response.content)
191+ self.expectThat(
192+ response_json['substatus'], Equals(NODE_STATUS.ALLOCATED))
193+ constraints = response_json['constraints_by_type']
194+ self.expectThat(constraints, Contains('interfaces'))
195+ interfaces = constraints.get('interfaces')
196+ self.expectThat(interfaces, Contains('needed'))
197+ self.expectThat(interfaces['needed'], Contains(iface.id))
198+ self.expectThat(constraints, Not(Contains('verbose_interfaces')))
199+
200+ def test_POST_acquire_allocates_node_by_interfaces_dry_run_with_verbose(
201+ self):
202+ """Interface label is returned alongside node data"""
203+ fabric = factory.make_Fabric('ubuntu')
204+ # The ID may always be '1', which won't be interesting for testing.
205+ for _ in range(1, random.choice([1, 3, 5])):
206+ factory.make_Interface()
207+ node = factory.make_Node_with_Interface_on_Subnet(
208+ status=NODE_STATUS.READY, fabric=fabric)
209+ iface = node.get_boot_interface()
210+ response = self.client.post(reverse('nodes_handler'), {
211+ 'op': 'acquire',
212+ 'interfaces': 'needed:fabric=ubuntu',
213+ 'verbose': 'true',
214+ 'dry_run': 'true',
215+ })
216+ self.assertResponseCode(httplib.OK, response)
217+ response_json = json.loads(response.content)
218+ self.expectThat(
219+ response_json['substatus'], Equals(NODE_STATUS.READY))
220+ # Check that we still got the verbose constraints output even if
221+ # it was a dry run.
222+ constraints = response_json['constraints_by_type']
223+ self.expectThat(constraints, Contains('interfaces'))
224+ interfaces = constraints.get('interfaces')
225+ self.expectThat(interfaces, Contains('needed'))
226+ self.expectThat(interfaces['needed'], Contains(iface.id))
227+ verbose_interfaces = constraints.get('verbose_interfaces')
228+ self.expectThat(
229+ verbose_interfaces['needed'], Contains(unicode(node.id)))
230+ self.expectThat(
231+ verbose_interfaces['needed'][unicode(node.id)],
232+ Contains(iface.id))
233+
234+ def test_POST_acquire_allocates_node_by_interfaces_with_verbose(self):
235+ """Interface label is returned alongside node data"""
236+ fabric = factory.make_Fabric('ubuntu')
237+ # The ID may always be '1', which won't be interesting for testing.
238+ for _ in range(1, random.choice([1, 3, 5])):
239+ factory.make_Interface()
240+ factory.make_Node()
241+ node = factory.make_Node_with_Interface_on_Subnet(
242+ status=NODE_STATUS.READY, fabric=fabric)
243+ iface = node.get_boot_interface()
244+ response = self.client.post(reverse('nodes_handler'), {
245+ 'op': 'acquire',
246+ 'interfaces': 'needed:fabric=ubuntu',
247+ 'verbose': 'true',
248+ })
249+ self.assertResponseCode(httplib.OK, response)
250+ response_json = json.loads(response.content)
251+ constraints = response_json['constraints_by_type']
252+ self.expectThat(constraints, Contains('interfaces'))
253+ interfaces = constraints.get('interfaces')
254+ self.expectThat(interfaces, Contains('needed'))
255+ self.expectThat(interfaces['needed'], Equals([iface.id]))
256+ verbose_interfaces = constraints.get('verbose_interfaces')
257+ self.expectThat(
258+ verbose_interfaces['needed'], Contains(unicode(node.id)))
259+ self.expectThat(
260+ verbose_interfaces['needed'][unicode(node.id)],
261+ Contains(iface.id))
262
263 def test_POST_acquire_fails_without_all_tags(self):
264 # Asking for particular tags does not acquire if no node has all tags.
265
266=== modified file 'src/maasserver/models/interface.py'
267--- src/maasserver/models/interface.py 2015-11-03 00:36:13 +0000
268+++ src/maasserver/models/interface.py 2015-11-05 00:32:58 +0000
269@@ -180,6 +180,22 @@
270 current_q,
271 Q(ip_addresses__ip__isnull=True) | Q(ip_addresses__ip=''))
272
273+ def get_matching_node_map(self, specifiers):
274+ """Returns a tuple where the first element is a set of matching node
275+ IDs, and the second element is a dictionary mapping a node ID to a list
276+ of matching interfaces, such as:
277+
278+ {
279+ <node1>: [<interface1>, <interface2>, ...]
280+ <node2>: [<interface3>, ...]
281+ ...
282+ }
283+
284+ :returns: tuple (set, dict)
285+ """
286+ return super(InterfaceQueriesMixin, self).get_matching_object_map(
287+ specifiers, 'node__id')
288+
289
290 class InterfaceQuerySet(InterfaceQueriesMixin, QuerySet):
291 """Custom QuerySet which mixes in some additional queries specific to
292
293=== modified file 'src/maasserver/models/tests/test_interface.py'
294--- src/maasserver/models/tests/test_interface.py 2015-11-02 20:24:09 +0000
295+++ src/maasserver/models/tests/test_interface.py 2015-11-05 00:32:58 +0000
296@@ -333,6 +333,58 @@
297 Interface.objects.filter_by_specifiers(
298 "mode:unconfigured"))
299
300+ def test__get_matching_node_map(self):
301+ space1 = factory.make_Space()
302+ space2 = factory.make_Space()
303+ subnet1 = factory.make_Subnet(space=space1)
304+ subnet2 = factory.make_Subnet(space=space2)
305+ node1 = factory.make_Node_with_Interface_on_Subnet(subnet=subnet1)
306+ node2 = factory.make_Node_with_Interface_on_Subnet(subnet=subnet2)
307+ iface1 = node1.get_boot_interface()
308+ iface2 = node2.get_boot_interface()
309+ nodes1, map1 = Interface.objects.get_matching_node_map(
310+ "space:%s" % space1.name)
311+ self.assertItemsEqual(nodes1, [node1.id])
312+ self.assertItemsEqual(map1, {node1.id: [iface1.id]})
313+ nodes2, map2 = Interface.objects.get_matching_node_map(
314+ "space:%s" % space2.name)
315+ self.assertItemsEqual(nodes2, [node2.id])
316+ self.assertItemsEqual(map2, {node2.id: [iface2.id]})
317+ nodes3, map3 = Interface.objects.get_matching_node_map(
318+ ["space:%s" % space1.name, "space:%s" % space2.name])
319+ self.assertItemsEqual(nodes3, [node1.id, node2.id])
320+ self.assertItemsEqual(map3, {
321+ node1.id: [iface1.id],
322+ node2.id: [iface2.id],
323+ })
324+
325+ def test__get_matching_node_map_with_multiple_interfaces(self):
326+ space1 = factory.make_Space()
327+ space2 = factory.make_Space()
328+ subnet1 = factory.make_Subnet(space=space1)
329+ subnet2 = factory.make_Subnet(space=space2)
330+ node1 = factory.make_Node_with_Interface_on_Subnet(subnet=subnet1)
331+ node2 = factory.make_Node_with_Interface_on_Subnet(subnet=subnet2)
332+ iface1 = node1.get_boot_interface()
333+ iface2 = node2.get_boot_interface()
334+ iface3 = factory.make_Interface(node=node1)
335+ factory.make_StaticIPAddress(interface=iface3, subnet=subnet1)
336+ nodes1, map1 = Interface.objects.get_matching_node_map(
337+ "space:%s" % space1.name)
338+ self.assertItemsEqual(nodes1, [node1.id])
339+ self.assertItemsEqual(map1, {node1.id: [iface1.id, iface3.id]})
340+ nodes2, map2 = Interface.objects.get_matching_node_map(
341+ "space:%s" % space2.name)
342+ self.assertItemsEqual(nodes2, [node2.id])
343+ self.assertItemsEqual(map2, {node2.id: [iface2.id]})
344+ nodes3, map3 = Interface.objects.get_matching_node_map(
345+ ["space:%s" % space1.name, "space:%s" % space2.name])
346+ self.assertItemsEqual(nodes3, [node1.id, node2.id])
347+ self.assertItemsEqual(map3, {
348+ node1.id: [iface1.id, iface3.id],
349+ node2.id: [iface2.id],
350+ })
351+
352
353 class InterfaceTest(MAASServerTestCase):
354
355
356=== modified file 'src/maasserver/node_constraint_filter_forms.py'
357--- src/maasserver/node_constraint_filter_forms.py 2015-11-03 00:19:51 +0000
358+++ src/maasserver/node_constraint_filter_forms.py 2015-11-05 00:32:58 +0000
359@@ -382,10 +382,21 @@
360 """Determines the set of nodes that match the specified
361 LabeledConstraintMap (which must be a map of interface constraints.)
362
363+ Returns a dictionary in the format:
364+ {
365+ <label1>: {
366+ <node1>: [<interface1>, <interface2>, ...]
367+ <node2>: ...
368+ ...
369+ }
370+ <label2>: ...
371+ }
372+
373 :param interfaces_label_map: LabeledConstraintMap
374- :return: set
375+ :return: dict
376 """
377 node_ids = None
378+ label_map = {}
379 for label in interfaces_label_map:
380 specifiers = []
381 constraints = interfaces_label_map[label]
382@@ -398,18 +409,22 @@
383 if node_ids is None:
384 # The first time through the filter, build the list
385 # of candidate nodes.
386- nodes = Interface.objects.filter_by_specifiers(
387+ node_ids, node_map = Interface.objects.get_matching_node_map(
388 specifiers)
389- node_ids = set(nodes.values_list('node__id', flat=True))
390+ label_map[label] = node_map
391 else:
392 # For subsequent labels, only match nodes that already matched a
393- # preceding label.
394- current_matching_nodes = Interface.objects.filter(
395- node__id__in=node_ids)
396- filtered_nodes = current_matching_nodes.filter_by_specifiers(
397+ # preceding label. Use the set intersection operator to do this,
398+ # because that will yield more complete data in the label_map.
399+ # (which is less efficient, but may be needed for troubleshooting.)
400+ # If a more efficient approach is desired, this could be changed
401+ # to filter the nodes starting from an 'id__in' filter using the
402+ # current 'node_ids' set.
403+ new_node_ids, node_map = Interface.objects.get_matching_node_map(
404 specifiers)
405- node_ids = filtered_nodes.values_list('node__id', flat=True)
406- return node_ids
407+ label_map[label] = node_map
408+ node_ids &= new_node_ids
409+ return node_ids, label_map
410
411
412 class LabeledConstraintMapField(Field):
413@@ -801,10 +816,12 @@
414 filtered_nodes = filtered_nodes.filter(id__in=node_ids)
415
416 # Filter by interfaces (networking)
417+ compatible_interfaces = {}
418 interfaces_label_map = self.cleaned_data.get(
419 self.get_field_name('interfaces'))
420 if interfaces_label_map is not None:
421- node_ids = nodes_by_interface(interfaces_label_map)
422+ node_ids, compatible_interfaces = nodes_by_interface(
423+ interfaces_label_map)
424 if node_ids is not None:
425 filtered_nodes = filtered_nodes.filter(id__in=node_ids)
426
427@@ -816,4 +833,7 @@
428 # constraints.
429 filtered_nodes = filtered_nodes.distinct().extra(
430 select={'cost': "cpu_count + memory / 1024"})
431- return filtered_nodes.order_by("cost"), compatible_nodes
432+ return (
433+ filtered_nodes.order_by("cost"), compatible_nodes,
434+ compatible_interfaces
435+ )
436
437=== modified file 'src/maasserver/tests/test_node_constraint_filter_forms.py'
438--- src/maasserver/tests/test_node_constraint_filter_forms.py 2015-11-02 22:58:39 +0000
439+++ src/maasserver/tests/test_node_constraint_filter_forms.py 2015-11-05 00:32:58 +0000
440@@ -234,7 +234,7 @@
441 def assertConstrainedNodes(self, nodes, data):
442 form = AcquireNodeForm(data=data)
443 self.assertTrue(form.is_valid(), dict(form.errors))
444- filtered_nodes, _ = form.filter_nodes(Node.objects.all())
445+ filtered_nodes, _, _ = form.filter_nodes(Node.objects.all())
446 self.assertItemsEqual(nodes, filtered_nodes)
447
448 def test_no_constraints(self):
449@@ -921,7 +921,7 @@
450 form = AcquireNodeForm({
451 u'fabrics': [u'fabric2']})
452 self.assertTrue(form.is_valid(), dict(form.errors))
453- filtered_nodes, _ = form.filter_nodes(Node.nodes)
454+ filtered_nodes, _, _ = form.filter_nodes(Node.nodes)
455 self.assertItemsEqual([node2], filtered_nodes)
456
457 def test_not_fabrics_constraint(self):
458@@ -932,7 +932,7 @@
459 form = AcquireNodeForm({
460 u'not_fabrics': [u'fabric1']})
461 self.assertTrue(form.is_valid(), dict(form.errors))
462- filtered_nodes, _ = form.filter_nodes(Node.nodes)
463+ filtered_nodes, _, _ = form.filter_nodes(Node.nodes)
464 self.assertItemsEqual([node2], filtered_nodes)
465
466 def test_fabric_classes_constraint(self):
467@@ -943,7 +943,7 @@
468 form = AcquireNodeForm({
469 u'fabric_classes': [u'1g']})
470 self.assertTrue(form.is_valid(), dict(form.errors))
471- filtered_nodes, _ = form.filter_nodes(Node.nodes)
472+ filtered_nodes, _, _ = form.filter_nodes(Node.nodes)
473 self.assertItemsEqual([node2], filtered_nodes)
474
475 def test_not_fabric_classes_constraint(self):
476@@ -954,7 +954,7 @@
477 form = AcquireNodeForm({
478 u'not_fabric_classes': [u'10g']})
479 self.assertTrue(form.is_valid(), dict(form.errors))
480- filtered_nodes, _ = form.filter_nodes(Node.nodes)
481+ filtered_nodes, _, _ = form.filter_nodes(Node.nodes)
482 self.assertItemsEqual([node2], filtered_nodes)
483
484 def test_interfaces_constraint_rejected_if_syntax_is_invalid(self):
485@@ -995,13 +995,13 @@
486 form = AcquireNodeForm({
487 u'interfaces': u'label:fabric_class=10g'})
488 self.assertTrue(form.is_valid(), dict(form.errors))
489- filtered_nodes, _ = form.filter_nodes(Node.nodes)
490+ filtered_nodes, _, _ = form.filter_nodes(Node.nodes)
491 self.assertItemsEqual([node2], filtered_nodes)
492
493 form = AcquireNodeForm({
494 u'interfaces': u'label:fabric_class=1g'})
495 self.assertTrue(form.is_valid(), dict(form.errors))
496- filtered_nodes, _ = form.filter_nodes(Node.nodes)
497+ filtered_nodes, _, _ = form.filter_nodes(Node.nodes)
498 self.assertItemsEqual([node1], filtered_nodes)
499
500 def test_interfaces_filters_work_with_multiple_labels(self):
501@@ -1017,13 +1017,13 @@
502 form = AcquireNodeForm({
503 u'interfaces': u'fabric:fabric_class=1g;vlan:vid=1'})
504 self.assertTrue(form.is_valid(), dict(form.errors))
505- filtered_nodes, _ = form.filter_nodes(Node.nodes)
506+ filtered_nodes, _, _ = form.filter_nodes(Node.nodes)
507 self.assertItemsEqual([node1], filtered_nodes)
508
509 form = AcquireNodeForm({
510 u'interfaces': u'label:fabric_class=10g;vlan:vid=2'})
511 self.assertTrue(form.is_valid(), dict(form.errors))
512- filtered_nodes, _ = form.filter_nodes(Node.nodes)
513+ filtered_nodes, _, _ = form.filter_nodes(Node.nodes)
514 self.assertItemsEqual([node2], filtered_nodes)
515
516 def test_interfaces_filters_multiples_treated_as_OR_operation(self):
517@@ -1040,14 +1040,14 @@
518 u'interfaces':
519 u'fabric:fabric_class=1g,fabric_class=10g;vlan:vid=1'})
520 self.assertTrue(form.is_valid(), dict(form.errors))
521- filtered_nodes, _ = form.filter_nodes(Node.nodes)
522+ filtered_nodes, _, _ = form.filter_nodes(Node.nodes)
523 self.assertItemsEqual([node1], filtered_nodes)
524
525 form = AcquireNodeForm({
526 u'interfaces':
527 u'label:fabric_class=10g,fabric_class=1g;vlan:vid=2'})
528 self.assertTrue(form.is_valid(), dict(form.errors))
529- filtered_nodes, _ = form.filter_nodes(Node.nodes)
530+ filtered_nodes, _, _ = form.filter_nodes(Node.nodes)
531 self.assertItemsEqual([node2], filtered_nodes)
532
533 def test_combined_constraints(self):
534@@ -1186,7 +1186,7 @@
535 # in here is the ordering.
536 form = AcquireNodeForm(data={'cpu_count': 4})
537 self.assertTrue(form.is_valid(), form.errors)
538- filtered_nodes, _ = form.filter_nodes(Node.objects.all())
539+ filtered_nodes, _, _ = form.filter_nodes(Node.objects.all())
540 self.assertEqual(
541 sorted_nodes,
542 list(filtered_nodes))
543
544=== modified file 'src/maasserver/utils/orm.py'
545--- src/maasserver/utils/orm.py 2015-11-03 01:02:03 +0000
546+++ src/maasserver/utils/orm.py 2015-11-05 00:32:58 +0000
547@@ -888,3 +888,29 @@
548 "(0-4094; 0 for untagged.)")
549 current_q = op(current_q, Q(vlan__vid=vid))
550 return current_q
551+
552+ def get_matching_object_map(self, specifiers, query):
553+ filter = self.filter_by_specifiers(specifiers)
554+ # We'll be looping through the list assuming a particular order later
555+ # in this function, so make sure the interfaces are grouped by their
556+ # attached nodes.
557+ matches = filter.order_by(query)
558+ matches = matches.values_list('id', query)
559+ foreign_object_map = {}
560+ object_ids = set()
561+ object_id = None
562+ foreign_object_matches = None
563+ for foreign_id, current_id in matches:
564+ if foreign_id is None:
565+ # Skip objects that do not have a corresponding foreign key.
566+ continue
567+ if current_id != object_id:
568+ # Encountered a new node ID in the list, so create an empty
569+ # list and add it to the map. (and add it to the set of matched
570+ # nodes)
571+ foreign_object_matches = []
572+ foreign_object_map[current_id] = foreign_object_matches
573+ object_ids.add(current_id)
574+ object_id = current_id
575+ foreign_object_matches.append(foreign_id)
576+ return object_ids, foreign_object_map
577
578=== modified file 'src/maasserver/websockets/tests/test_listener.py'
579--- src/maasserver/websockets/tests/test_listener.py 2015-11-02 20:28:44 +0000
580+++ src/maasserver/websockets/tests/test_listener.py 2015-11-05 00:32:58 +0000
581@@ -2915,6 +2915,7 @@
582 def test__calls_handler_with_update_on_create(self):
583 yield deferToDatabase(register_all_triggers)
584 node = yield deferToDatabase(self.create_node, self.params)
585+ yield deferToDatabase(self.create_partitiontable, {'node': node})
586
587 listener = PostgresListener()
588 dv = DeferredValue()
589@@ -2932,6 +2933,7 @@
590 def test__calls_handler_with_update_on_delete(self):
591 yield deferToDatabase(register_all_triggers)
592 node = yield deferToDatabase(self.create_node, self.params)
593+ yield deferToDatabase(self.create_partitiontable, {'node': node})
594 filesystemgroup = yield deferToDatabase(
595 self.create_filesystemgroup, {
596 "node": node, "group_type": "raid-5"})
597@@ -2953,6 +2955,7 @@
598 def test__calls_handler_with_update_on_update(self):
599 yield deferToDatabase(register_all_triggers)
600 node = yield deferToDatabase(self.create_node, self.params)
601+ yield deferToDatabase(self.create_partitiontable, {'node': node})
602 filesystemgroup = yield deferToDatabase(
603 self.create_filesystemgroup, {
604 "node": node, "group_type": "raid-5"})