Merge lp:~gz/maas/populate_tags into lp:maas/trunk

Proposed by Martin Packman on 2012-09-21
Status: Superseded
Proposed branch: lp:~gz/maas/populate_tags
Merge into: lp:maas/trunk
Diff against target: 409 lines (+326/-3)
5 files modified
src/maasserver/migrations/0028_add_node_hardware_details.py (+203/-0)
src/maasserver/models/node.py (+54/-1)
src/maasserver/tests/test_node.py (+6/-0)
src/metadataserver/api.py (+5/-2)
src/metadataserver/tests/test_api.py (+58/-0)
To merge this branch: bzr merge lp:~gz/maas/populate_tags
Reviewer Review Type Date Requested Status
John A Meinel 2012-09-21 Approve on 2012-09-22
Review via email: mp+125720@code.launchpad.net

Description of the Change

When hardware_details is updated, fill in any matching tags and remove any that no longer match. Again, this means bypassing django, but all the messiness is contained in one function at least. There a few tidy ups to do still, but this should get us rolling with the basics needed from constraints.

To post a comment you must log in.
John A Meinel (jameinel) wrote :

330 for name, uploaded_file in request.FILES.items():
331 - contents = uploaded_file.read().decode('utf-8')
332 - NodeCommissionResult.objects.store_data(node, name, contents)
333 + if name == "01-lshw.out":
334 + node.set_hardware_details(uploaded_file.read())
335 + else:
336 + contents = uploaded_file.read().decode('utf-8')
337 + NodeCommissionResult.objects.store_data(node, name, contents)

I wonder if we should still read the file and store it. I don't feel there is a strong case for breaking existing habits.

It really feels like we should be using lxml rather than putting the data in psql just to pull it back out again. The one thing I do like from using psql is the 'new tag update 1M nodes' case. But for the 'new node update 20 tags' case, it feels better in python. I also thought we wanted to try to store something like cpu:size since that will give us a way to approximate ECU, etc.

Also for memory, you seem to be storying GB as an integer. However, that means you can't tell the difference between a machine with 1 GB and 1.5GB. I would probably recommend storing at least MB. Alternatively, we could use a 64-bit value, but I do think it is a bit silly to store down to the exact byte.

I think there are too many spaces here:
240 return processed_nodes
241
242
243 +
244 +def update_hardware_details(node, xmlbytes):

Make sure to run 'make lint' so the landing machine doesn't reject it.

The rest looks worthy to land (IMO) and we can do further cleanups from there.

review: Approve
lp:~gz/maas/populate_tags updated on 2012-09-25
1042. By Martin Packman on 2012-09-25

Merge changes suggested in review of prerequisite branch

1043. By Martin Packman on 2012-09-25

Add note to update_hardware_details docstring about intended usage as suggested by allenap in review

Unmerged revisions

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== added file 'src/maasserver/migrations/0028_add_node_hardware_details.py'
2--- src/maasserver/migrations/0028_add_node_hardware_details.py 1970-01-01 00:00:00 +0000
3+++ src/maasserver/migrations/0028_add_node_hardware_details.py 2012-09-21 14:14:18 +0000
4@@ -0,0 +1,203 @@
5+# encoding: utf-8
6+import datetime
7+from south.db import db
8+from south.v2 import SchemaMigration
9+from django.db import models
10+
11+class Migration(SchemaMigration):
12+
13+ def forwards(self, orm):
14+
15+ # Adding field 'Node.cpu_count'
16+ db.add_column(u'maasserver_node', 'cpu_count', self.gf('django.db.models.fields.IntegerField')(default=0), keep_default=False)
17+
18+ # Adding field 'Node.memory'
19+ db.add_column(u'maasserver_node', 'memory', self.gf('django.db.models.fields.IntegerField')(default=0), keep_default=False)
20+
21+ # Adding field 'Node.hardware_details'
22+ db.add_column(u'maasserver_node', 'hardware_details', self.gf('maasserver.fields.XMLField')(default=None, null=True, blank=True), keep_default=False)
23+
24+
25+ def backwards(self, orm):
26+
27+ # Deleting field 'Node.cpu_count'
28+ db.delete_column(u'maasserver_node', 'cpu_count')
29+
30+ # Deleting field 'Node.memory'
31+ db.delete_column(u'maasserver_node', 'memory')
32+
33+ # Deleting field 'Node.hardware_details'
34+ db.delete_column(u'maasserver_node', 'hardware_details')
35+
36+
37+ models = {
38+ 'auth.group': {
39+ 'Meta': {'object_name': 'Group'},
40+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
41+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
42+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
43+ },
44+ 'auth.permission': {
45+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
46+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
47+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
48+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
49+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
50+ },
51+ 'auth.user': {
52+ 'Meta': {'object_name': 'User'},
53+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
54+ 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75', 'blank': 'True'}),
55+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
56+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
57+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
58+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
59+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
60+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
61+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
62+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
63+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
64+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
65+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
66+ },
67+ 'contenttypes.contenttype': {
68+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
69+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
70+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
71+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
72+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
73+ },
74+ u'maasserver.bootimage': {
75+ 'Meta': {'unique_together': "((u'architecture', u'subarchitecture', u'release', u'purpose'),)", 'object_name': 'BootImage'},
76+ 'architecture': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
77+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
78+ 'purpose': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
79+ 'release': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
80+ 'subarchitecture': ('django.db.models.fields.CharField', [], {'max_length': '255'})
81+ },
82+ u'maasserver.config': {
83+ 'Meta': {'object_name': 'Config'},
84+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
85+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
86+ 'value': ('maasserver.fields.JSONObjectField', [], {'null': 'True'})
87+ },
88+ u'maasserver.dhcplease': {
89+ 'Meta': {'object_name': 'DHCPLease'},
90+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
91+ 'ip': ('django.db.models.fields.IPAddressField', [], {'unique': 'True', 'max_length': '15'}),
92+ 'mac': ('maasserver.fields.MACAddressField', [], {}),
93+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"})
94+ },
95+ u'maasserver.filestorage': {
96+ 'Meta': {'object_name': 'FileStorage'},
97+ 'data': ('django.db.models.fields.files.FileField', [], {'max_length': '255'}),
98+ 'filename': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '200'}),
99+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
100+ },
101+ u'maasserver.macaddress': {
102+ 'Meta': {'object_name': 'MACAddress'},
103+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
104+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
105+ 'mac_address': ('maasserver.fields.MACAddressField', [], {'unique': 'True'}),
106+ 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Node']"}),
107+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
108+ },
109+ u'maasserver.node': {
110+ 'Meta': {'object_name': 'Node'},
111+ 'after_commissioning_action': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
112+ 'architecture': ('django.db.models.fields.CharField', [], {'default': "u'i386'", 'max_length': '10'}),
113+ 'cpu_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
114+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
115+ 'distro_series': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '10', 'null': 'True', 'blank': 'True'}),
116+ 'error': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
117+ 'hardware_details': ('maasserver.fields.XMLField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
118+ 'hostname': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
119+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
120+ 'memory': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
121+ 'netboot': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
122+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']", 'null': 'True'}),
123+ 'owner': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}),
124+ 'power_parameters': ('maasserver.fields.JSONObjectField', [], {'default': "u''", 'blank': 'True'}),
125+ 'power_type': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '10', 'blank': 'True'}),
126+ 'status': ('django.db.models.fields.IntegerField', [], {'default': '0', 'max_length': '10'}),
127+ 'system_id': ('django.db.models.fields.CharField', [], {'default': "u'node-4f4bbb90-032d-11e2-8bc6-fa163e17f81b'", 'unique': 'True', 'max_length': '41'}),
128+ 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['maasserver.Tag']", 'symmetrical': 'False'}),
129+ 'token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'null': 'True'}),
130+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
131+ },
132+ u'maasserver.nodegroup': {
133+ 'Meta': {'object_name': 'NodeGroup'},
134+ 'api_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '18'}),
135+ 'api_token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'unique': 'True'}),
136+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
137+ 'dhcp_key': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
138+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
139+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
140+ 'status': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
141+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
142+ 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '36'})
143+ },
144+ u'maasserver.nodegroupinterface': {
145+ 'Meta': {'unique_together': "((u'nodegroup', u'interface'),)", 'object_name': 'NodeGroupInterface'},
146+ 'broadcast_ip': ('django.db.models.fields.IPAddressField', [], {'default': 'None', 'max_length': '15', 'null': 'True', 'blank': 'True'}),
147+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
148+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
149+ 'interface': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
150+ 'ip': ('django.db.models.fields.IPAddressField', [], {'max_length': '15'}),
151+ 'ip_range_high': ('django.db.models.fields.IPAddressField', [], {'default': 'None', 'max_length': '15', 'unique': 'True', 'null': 'True', 'blank': 'True'}),
152+ 'ip_range_low': ('django.db.models.fields.IPAddressField', [], {'default': 'None', 'max_length': '15', 'unique': 'True', 'null': 'True', 'blank': 'True'}),
153+ 'management': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
154+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"}),
155+ 'router_ip': ('django.db.models.fields.IPAddressField', [], {'default': 'None', 'max_length': '15', 'null': 'True', 'blank': 'True'}),
156+ 'subnet_mask': ('django.db.models.fields.IPAddressField', [], {'default': 'None', 'max_length': '15', 'null': 'True', 'blank': 'True'}),
157+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
158+ },
159+ u'maasserver.sshkey': {
160+ 'Meta': {'unique_together': "((u'user', u'key'),)", 'object_name': 'SSHKey'},
161+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
162+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
163+ 'key': ('django.db.models.fields.TextField', [], {}),
164+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
165+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
166+ },
167+ u'maasserver.tag': {
168+ 'Meta': {'object_name': 'Tag'},
169+ 'comment': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
170+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
171+ 'definition': ('django.db.models.fields.TextField', [], {}),
172+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
173+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '256'}),
174+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
175+ },
176+ u'maasserver.userprofile': {
177+ 'Meta': {'object_name': 'UserProfile'},
178+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
179+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
180+ },
181+ 'piston.consumer': {
182+ 'Meta': {'object_name': 'Consumer'},
183+ 'description': ('django.db.models.fields.TextField', [], {}),
184+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
185+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
186+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
187+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
188+ 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '16'}),
189+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'consumers'", 'null': 'True', 'to': "orm['auth.User']"})
190+ },
191+ 'piston.token': {
192+ 'Meta': {'object_name': 'Token'},
193+ 'callback': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
194+ 'callback_confirmed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
195+ 'consumer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Consumer']"}),
196+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
197+ 'is_approved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
198+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
199+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
200+ 'timestamp': ('django.db.models.fields.IntegerField', [], {'default': '1348150391L'}),
201+ 'token_type': ('django.db.models.fields.IntegerField', [], {}),
202+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'tokens'", 'null': 'True', 'to': "orm['auth.User']"}),
203+ 'verifier': ('django.db.models.fields.CharField', [], {'max_length': '10'})
204+ }
205+ }
206+
207+ complete_apps = ['maasserver']
208
209=== modified file 'src/maasserver/models/node.py'
210--- src/maasserver/models/node.py 2012-09-21 07:37:56 +0000
211+++ src/maasserver/models/node.py 2012-09-21 14:14:18 +0000
212@@ -13,6 +13,7 @@
213 __all__ = [
214 "NODE_TRANSITIONS",
215 "Node",
216+ "update_hardware_details",
217 ]
218
219 import os
220@@ -25,6 +26,9 @@
221 PermissionDenied,
222 ValidationError,
223 )
224+from django.db import (
225+ connection,
226+ )
227 from django.db.models import (
228 BooleanField,
229 CharField,
230@@ -49,7 +53,7 @@
231 NODE_STATUS_CHOICES_DICT,
232 )
233 from maasserver.exceptions import NodeStateViolation
234-from maasserver.fields import JSONObjectField
235+from maasserver.fields import JSONObjectField, XMLField
236 from maasserver.models.cleansave import CleanSave
237 from maasserver.models.config import Config
238 from maasserver.models.tag import Tag
239@@ -318,6 +322,44 @@
240 return processed_nodes
241
242
243+
244+def update_hardware_details(node, xmlbytes):
245+ """Set node hardware_details from lshw output and update related fields
246+
247+ There are a bunch of suboptimal things here:
248+ * Is a function rather than method in hope south migration can reuse.
249+ * Doing UPDATE then transaction.commit_unless_managed doesn't work?
250+ * Scalar returns from xpath() work in postgres 9.2 or later only.
251+ """
252+ node.hardware_details = xmlbytes
253+ node.save()
254+ cursor = connection.cursor()
255+ cursor.execute("SELECT"
256+ " array_length(xpath(%s, hardware_details), 1) AS count"
257+ ", (xpath(%s, hardware_details))[1]::text::bigint / 1073741824 AS mem"
258+ " FROM maasserver_node"
259+ " WHERE id = %s",
260+ [
261+ "//node[@id='core']/node[@class='processor']",
262+ "//node[@id='memory']/size[@units='bytes']/text()",
263+ node.id,
264+ ])
265+ cpu_count, memory = cursor.fetchone()
266+ node.cpu_count = cpu_count or 0
267+ node.memory = memory or 0
268+ for tag in Tag.objects.all():
269+ cursor.execute(
270+ "SELECT xpath_exists(%s, hardware_details)"
271+ " FROM maasserver_node WHERE id = %s",
272+ [tag.definition, node.id])
273+ has_tag, = cursor.fetchone()
274+ if has_tag:
275+ node.tags.add(tag)
276+ else:
277+ node.tags.remove(tag)
278+ node.save()
279+
280+
281 class Node(CleanSave, TimestampedModel):
282 """A `Node` represents a physical machine used by the MAAS Server.
283
284@@ -370,6 +412,13 @@
285 max_length=10, choices=ARCHITECTURE_CHOICES, blank=False,
286 default=ARCHITECTURE.i386)
287
288+ # Juju expects the following standard constraints, which are stored here
289+ # as a basic optimisation over querying the hardware_details field.
290+ cpu_count = IntegerField(default=0)
291+ memory = IntegerField(default=0)
292+
293+ hardware_details = XMLField(default=None, blank=True, null=True)
294+
295 # For strings, Django insists on abusing the empty string ("blank")
296 # to mean "none."
297 power_type = CharField(
298@@ -625,3 +674,7 @@
299 """Set netboot on or off."""
300 self.netboot = on
301 self.save()
302+
303+ def set_hardware_details(self, xmlbytes):
304+ """Set the `lshw -xml` output"""
305+ update_hardware_details(self, xmlbytes)
306
307=== modified file 'src/maasserver/tests/test_node.py'
308--- src/maasserver/tests/test_node.py 2012-09-21 06:37:02 +0000
309+++ src/maasserver/tests/test_node.py 2012-09-21 14:14:18 +0000
310@@ -447,6 +447,12 @@
311 node.nodegroup = None
312 self.assertRaises(ValidationError, node.save)
313
314+ def test_set_hardware_details(self):
315+ xmlbytes = "<test/>"
316+ node = factory.make_node(owner=factory.make_user())
317+ node.set_hardware_details(xmlbytes)
318+ self.assertEqual(xmlbytes, node.hardware_details)
319+
320
321 class NodeTransitionsTests(TestCase):
322 """Test the structure of NODE_TRANSITIONS."""
323
324=== modified file 'src/metadataserver/api.py'
325--- src/metadataserver/api.py 2012-08-24 10:28:29 +0000
326+++ src/metadataserver/api.py 2012-09-21 14:14:18 +0000
327@@ -180,8 +180,11 @@
328 def _store_commissioning_results(self, node, request):
329 """Store commissioning result files for `node`."""
330 for name, uploaded_file in request.FILES.items():
331- contents = uploaded_file.read().decode('utf-8')
332- NodeCommissionResult.objects.store_data(node, name, contents)
333+ if name == "01-lshw.out":
334+ node.set_hardware_details(uploaded_file.read())
335+ else:
336+ contents = uploaded_file.read().decode('utf-8')
337+ NodeCommissionResult.objects.store_data(node, name, contents)
338
339 @api_exported('POST')
340 def signal(self, request, version=None, mac=None):
341
342=== modified file 'src/metadataserver/tests/test_api.py'
343--- src/metadataserver/tests/test_api.py 2012-08-16 10:34:56 +0000
344+++ src/metadataserver/tests/test_api.py 2012-09-21 14:14:18 +0000
345@@ -494,6 +494,64 @@
346 node, 'output.txt')
347 self.assertEqual(size_limit, len(stored_data))
348
349+ def test_signal_stores_lshw_file_on_node(self):
350+ node = factory.make_node(status=NODE_STATUS.COMMISSIONING)
351+ client = self.make_node_client(node=node)
352+ xmlbytes = "<t\xe9st/>".encode("utf-8")
353+ response = self.call_signal(client, files={'01-lshw.out': xmlbytes})
354+ self.assertEqual(httplib.OK, response.status_code)
355+ node = reload_object(node)
356+ self.assertEqual(xmlbytes, node.hardware_details)
357+
358+ def test_signal_stores_lshw_with_cpu_count(self):
359+ node = factory.make_node(status=NODE_STATUS.COMMISSIONING)
360+ client = self.make_node_client(node=node)
361+ xmlbytes = (
362+ '<node id="core">'
363+ '<node id="cpu:0" class="processor"/>'
364+ '<node id="cpu:1" class="processor"/>'
365+ '</node>').encode("utf-8")
366+ response = self.call_signal(client, files={'01-lshw.out': xmlbytes})
367+ self.assertEqual(httplib.OK, response.status_code)
368+ node = reload_object(node)
369+ self.assertEqual(2, node.cpu_count)
370+
371+ def test_signal_stores_lshw_with_memory(self):
372+ node = factory.make_node(status=NODE_STATUS.COMMISSIONING)
373+ client = self.make_node_client(node=node)
374+ xmlbytes = (
375+ '<node id="memory">'
376+ '<size units="bytes">4294967296</size>'
377+ '</node>').encode("utf-8")
378+ response = self.call_signal(client, files={'01-lshw.out': xmlbytes})
379+ self.assertEqual(httplib.OK, response.status_code)
380+ node = reload_object(node)
381+ self.assertEqual(4, node.memory)
382+
383+ def test_signal_lshw_tags_match(self):
384+ tag1 = factory.make_tag(factory.getRandomString(10), "/node")
385+ tag2 = factory.make_tag(factory.getRandomString(10), "//node")
386+ node = factory.make_node(status=NODE_STATUS.COMMISSIONING)
387+ client = self.make_node_client(node=node)
388+ xmlbytes = '<node/>'.encode("utf-8")
389+ response = self.call_signal(client, files={'01-lshw.out': xmlbytes})
390+ self.assertEqual(httplib.OK, response.status_code)
391+ node = reload_object(node)
392+ self.assertEqual([tag1, tag2], list(node.tags.all()))
393+
394+ def test_signal_lshw_tags_no_match(self):
395+ tag1 = factory.make_tag(factory.getRandomString(10), "/missing")
396+ tag2 = factory.make_tag(factory.getRandomString(10), "/nothing")
397+ node = factory.make_node(status=NODE_STATUS.COMMISSIONING)
398+ node.tags = [tag2]
399+ node.save()
400+ client = self.make_node_client(node=node)
401+ xmlbytes = '<node/>'.encode("utf-8")
402+ response = self.call_signal(client, files={'01-lshw.out': xmlbytes})
403+ self.assertEqual(httplib.OK, response.status_code)
404+ node = reload_object(node)
405+ self.assertEqual([], list(node.tags.all()))
406+
407 def test_api_retrieves_node_metadata_by_mac(self):
408 mac = factory.make_mac_address()
409 url = reverse(