launchpad-reviewers team mailing list archive
-
launchpad-reviewers team
-
Mailing list archive
-
Message #12325
[Merge] lp:~gz/maas/populate_tags into lp:maas
Martin Packman has proposed merging lp:~gz/maas/populate_tags into lp:maas.
Requested reviews:
MAAS Maintainers (maas-maintainers)
For more details, see:
https://code.launchpad.net/~gz/maas/populate_tags/+merge/125720
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.
--
https://code.launchpad.net/~gz/maas/populate_tags/+merge/125720
Your team MAAS Maintainers is requested to review the proposed merge of lp:~gz/maas/populate_tags into lp:maas.
=== added file 'src/maasserver/migrations/0028_add_node_hardware_details.py'
--- src/maasserver/migrations/0028_add_node_hardware_details.py 1970-01-01 00:00:00 +0000
+++ src/maasserver/migrations/0028_add_node_hardware_details.py 2012-09-21 14:12:24 +0000
@@ -0,0 +1,203 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+
+ # Adding field 'Node.cpu_count'
+ db.add_column(u'maasserver_node', 'cpu_count', self.gf('django.db.models.fields.IntegerField')(default=0), keep_default=False)
+
+ # Adding field 'Node.memory'
+ db.add_column(u'maasserver_node', 'memory', self.gf('django.db.models.fields.IntegerField')(default=0), keep_default=False)
+
+ # Adding field 'Node.hardware_details'
+ db.add_column(u'maasserver_node', 'hardware_details', self.gf('maasserver.fields.XMLField')(default=None, null=True, blank=True), keep_default=False)
+
+
+ def backwards(self, orm):
+
+ # Deleting field 'Node.cpu_count'
+ db.delete_column(u'maasserver_node', 'cpu_count')
+
+ # Deleting field 'Node.memory'
+ db.delete_column(u'maasserver_node', 'memory')
+
+ # Deleting field 'Node.hardware_details'
+ db.delete_column(u'maasserver_node', 'hardware_details')
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ u'maasserver.bootimage': {
+ 'Meta': {'unique_together': "((u'architecture', u'subarchitecture', u'release', u'purpose'),)", 'object_name': 'BootImage'},
+ 'architecture': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'purpose': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'release': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'subarchitecture': ('django.db.models.fields.CharField', [], {'max_length': '255'})
+ },
+ u'maasserver.config': {
+ 'Meta': {'object_name': 'Config'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'value': ('maasserver.fields.JSONObjectField', [], {'null': 'True'})
+ },
+ u'maasserver.dhcplease': {
+ 'Meta': {'object_name': 'DHCPLease'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'ip': ('django.db.models.fields.IPAddressField', [], {'unique': 'True', 'max_length': '15'}),
+ 'mac': ('maasserver.fields.MACAddressField', [], {}),
+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"})
+ },
+ u'maasserver.filestorage': {
+ 'Meta': {'object_name': 'FileStorage'},
+ 'data': ('django.db.models.fields.files.FileField', [], {'max_length': '255'}),
+ 'filename': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '200'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'})
+ },
+ u'maasserver.macaddress': {
+ 'Meta': {'object_name': 'MACAddress'},
+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'mac_address': ('maasserver.fields.MACAddressField', [], {'unique': 'True'}),
+ 'node': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.Node']"}),
+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
+ },
+ u'maasserver.node': {
+ 'Meta': {'object_name': 'Node'},
+ 'after_commissioning_action': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+ 'architecture': ('django.db.models.fields.CharField', [], {'default': "u'i386'", 'max_length': '10'}),
+ 'cpu_count': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
+ 'distro_series': ('django.db.models.fields.CharField', [], {'default': 'None', 'max_length': '10', 'null': 'True', 'blank': 'True'}),
+ 'error': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
+ 'hardware_details': ('maasserver.fields.XMLField', [], {'default': 'None', 'null': 'True', 'blank': 'True'}),
+ 'hostname': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'memory': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+ 'netboot': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']", 'null': 'True'}),
+ 'owner': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}),
+ 'power_parameters': ('maasserver.fields.JSONObjectField', [], {'default': "u''", 'blank': 'True'}),
+ 'power_type': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '10', 'blank': 'True'}),
+ 'status': ('django.db.models.fields.IntegerField', [], {'default': '0', 'max_length': '10'}),
+ 'system_id': ('django.db.models.fields.CharField', [], {'default': "u'node-4f4bbb90-032d-11e2-8bc6-fa163e17f81b'", 'unique': 'True', 'max_length': '41'}),
+ 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['maasserver.Tag']", 'symmetrical': 'False'}),
+ 'token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'null': 'True'}),
+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
+ },
+ u'maasserver.nodegroup': {
+ 'Meta': {'object_name': 'NodeGroup'},
+ 'api_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '18'}),
+ 'api_token': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Token']", 'unique': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
+ 'dhcp_key': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'status': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
+ 'uuid': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '36'})
+ },
+ u'maasserver.nodegroupinterface': {
+ 'Meta': {'unique_together': "((u'nodegroup', u'interface'),)", 'object_name': 'NodeGroupInterface'},
+ 'broadcast_ip': ('django.db.models.fields.IPAddressField', [], {'default': 'None', 'max_length': '15', 'null': 'True', 'blank': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'interface': ('django.db.models.fields.CharField', [], {'default': "u''", 'max_length': '255', 'blank': 'True'}),
+ 'ip': ('django.db.models.fields.IPAddressField', [], {'max_length': '15'}),
+ 'ip_range_high': ('django.db.models.fields.IPAddressField', [], {'default': 'None', 'max_length': '15', 'unique': 'True', 'null': 'True', 'blank': 'True'}),
+ 'ip_range_low': ('django.db.models.fields.IPAddressField', [], {'default': 'None', 'max_length': '15', 'unique': 'True', 'null': 'True', 'blank': 'True'}),
+ 'management': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
+ 'nodegroup': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['maasserver.NodeGroup']"}),
+ 'router_ip': ('django.db.models.fields.IPAddressField', [], {'default': 'None', 'max_length': '15', 'null': 'True', 'blank': 'True'}),
+ 'subnet_mask': ('django.db.models.fields.IPAddressField', [], {'default': 'None', 'max_length': '15', 'null': 'True', 'blank': 'True'}),
+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
+ },
+ u'maasserver.sshkey': {
+ 'Meta': {'unique_together': "((u'user', u'key'),)", 'object_name': 'SSHKey'},
+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'key': ('django.db.models.fields.TextField', [], {}),
+ 'updated': ('django.db.models.fields.DateTimeField', [], {}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+ },
+ u'maasserver.tag': {
+ 'Meta': {'object_name': 'Tag'},
+ 'comment': ('django.db.models.fields.TextField', [], {'blank': 'True'}),
+ 'created': ('django.db.models.fields.DateTimeField', [], {}),
+ 'definition': ('django.db.models.fields.TextField', [], {}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '256'}),
+ 'updated': ('django.db.models.fields.DateTimeField', [], {})
+ },
+ u'maasserver.userprofile': {
+ 'Meta': {'object_name': 'UserProfile'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'})
+ },
+ 'piston.consumer': {
+ 'Meta': {'object_name': 'Consumer'},
+ 'description': ('django.db.models.fields.TextField', [], {}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+ 'status': ('django.db.models.fields.CharField', [], {'default': "'pending'", 'max_length': '16'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'consumers'", 'null': 'True', 'to': "orm['auth.User']"})
+ },
+ 'piston.token': {
+ 'Meta': {'object_name': 'Token'},
+ 'callback': ('django.db.models.fields.CharField', [], {'max_length': '255', 'null': 'True', 'blank': 'True'}),
+ 'callback_confirmed': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'consumer': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['piston.Consumer']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_approved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
+ 'timestamp': ('django.db.models.fields.IntegerField', [], {'default': '1348150391L'}),
+ 'token_type': ('django.db.models.fields.IntegerField', [], {}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'tokens'", 'null': 'True', 'to': "orm['auth.User']"}),
+ 'verifier': ('django.db.models.fields.CharField', [], {'max_length': '10'})
+ }
+ }
+
+ complete_apps = ['maasserver']
=== modified file 'src/maasserver/models/node.py'
--- src/maasserver/models/node.py 2012-09-21 07:37:56 +0000
+++ src/maasserver/models/node.py 2012-09-21 14:12:24 +0000
@@ -13,6 +13,7 @@
__all__ = [
"NODE_TRANSITIONS",
"Node",
+ "update_hardware_details",
]
import os
@@ -25,6 +26,9 @@
PermissionDenied,
ValidationError,
)
+from django.db import (
+ connection,
+ )
from django.db.models import (
BooleanField,
CharField,
@@ -49,7 +53,7 @@
NODE_STATUS_CHOICES_DICT,
)
from maasserver.exceptions import NodeStateViolation
-from maasserver.fields import JSONObjectField
+from maasserver.fields import JSONObjectField, XMLField
from maasserver.models.cleansave import CleanSave
from maasserver.models.config import Config
from maasserver.models.tag import Tag
@@ -318,6 +322,44 @@
return processed_nodes
+
+def update_hardware_details(node, xmlbytes):
+ """Set node hardware_details from lshw output and update related fields
+
+ There are a bunch of suboptimal things here:
+ * Is a function rather than method in hope south migration can reuse.
+ * Doing UPDATE then transaction.commit_unless_managed doesn't work?
+ * Scalar returns from xpath() work in postgres 9.2 or later only.
+ """
+ node.hardware_details = xmlbytes
+ node.save()
+ cursor = connection.cursor()
+ cursor.execute("SELECT"
+ " array_length(xpath(%s, hardware_details), 1) AS count"
+ ", (xpath(%s, hardware_details))[1]::text::bigint / 1073741824 AS mem"
+ " FROM maasserver_node"
+ " WHERE id = %s",
+ [
+ "//node[@id='core']/node[@class='processor']",
+ "//node[@id='memory']/size[@units='bytes']/text()",
+ node.id,
+ ])
+ cpu_count, memory = cursor.fetchone()
+ node.cpu_count = cpu_count or 0
+ node.memory = memory or 0
+ for tag in Tag.objects.all():
+ cursor.execute(
+ "SELECT xpath_exists(%s, hardware_details)"
+ " FROM maasserver_node WHERE id = %s",
+ [tag.definition, node.id])
+ has_tag, = cursor.fetchone()
+ if has_tag:
+ node.tags.add(tag)
+ else:
+ node.tags.remove(tag)
+ node.save()
+
+
class Node(CleanSave, TimestampedModel):
"""A `Node` represents a physical machine used by the MAAS Server.
@@ -370,6 +412,13 @@
max_length=10, choices=ARCHITECTURE_CHOICES, blank=False,
default=ARCHITECTURE.i386)
+ # Juju expects the following standard constraints, which are stored here
+ # as a basic optimisation over querying the hardware_details field.
+ cpu_count = IntegerField(default=0)
+ memory = IntegerField(default=0)
+
+ hardware_details = XMLField(default=None, blank=True, null=True)
+
# For strings, Django insists on abusing the empty string ("blank")
# to mean "none."
power_type = CharField(
@@ -625,3 +674,7 @@
"""Set netboot on or off."""
self.netboot = on
self.save()
+
+ def set_hardware_details(self, xmlbytes):
+ """Set the `lshw -xml` output"""
+ update_hardware_details(self, xmlbytes)
=== modified file 'src/maasserver/tests/test_node.py'
--- src/maasserver/tests/test_node.py 2012-09-21 06:37:02 +0000
+++ src/maasserver/tests/test_node.py 2012-09-21 14:12:24 +0000
@@ -447,6 +447,12 @@
node.nodegroup = None
self.assertRaises(ValidationError, node.save)
+ def test_set_hardware_details(self):
+ xmlbytes = "<test/>"
+ node = factory.make_node(owner=factory.make_user())
+ node.set_hardware_details(xmlbytes)
+ self.assertEqual(xmlbytes, node.hardware_details)
+
class NodeTransitionsTests(TestCase):
"""Test the structure of NODE_TRANSITIONS."""
=== modified file 'src/metadataserver/api.py'
--- src/metadataserver/api.py 2012-08-24 10:28:29 +0000
+++ src/metadataserver/api.py 2012-09-21 14:12:24 +0000
@@ -180,8 +180,11 @@
def _store_commissioning_results(self, node, request):
"""Store commissioning result files for `node`."""
for name, uploaded_file in request.FILES.items():
- contents = uploaded_file.read().decode('utf-8')
- NodeCommissionResult.objects.store_data(node, name, contents)
+ if name == "01-lshw.out":
+ node.set_hardware_details(uploaded_file.read())
+ else:
+ contents = uploaded_file.read().decode('utf-8')
+ NodeCommissionResult.objects.store_data(node, name, contents)
@api_exported('POST')
def signal(self, request, version=None, mac=None):
=== modified file 'src/metadataserver/tests/test_api.py'
--- src/metadataserver/tests/test_api.py 2012-08-16 10:34:56 +0000
+++ src/metadataserver/tests/test_api.py 2012-09-21 14:12:24 +0000
@@ -494,6 +494,64 @@
node, 'output.txt')
self.assertEqual(size_limit, len(stored_data))
+ def test_signal_stores_lshw_file_on_node(self):
+ node = factory.make_node(status=NODE_STATUS.COMMISSIONING)
+ client = self.make_node_client(node=node)
+ xmlbytes = "<t\xe9st/>".encode("utf-8")
+ response = self.call_signal(client, files={'01-lshw.out': xmlbytes})
+ self.assertEqual(httplib.OK, response.status_code)
+ node = reload_object(node)
+ self.assertEqual(xmlbytes, node.hardware_details)
+
+ def test_signal_stores_lshw_with_cpu_count(self):
+ node = factory.make_node(status=NODE_STATUS.COMMISSIONING)
+ client = self.make_node_client(node=node)
+ xmlbytes = (
+ '<node id="core">'
+ '<node id="cpu:0" class="processor"/>'
+ '<node id="cpu:1" class="processor"/>'
+ '</node>').encode("utf-8")
+ response = self.call_signal(client, files={'01-lshw.out': xmlbytes})
+ self.assertEqual(httplib.OK, response.status_code)
+ node = reload_object(node)
+ self.assertEqual(2, node.cpu_count)
+
+ def test_signal_stores_lshw_with_memory(self):
+ node = factory.make_node(status=NODE_STATUS.COMMISSIONING)
+ client = self.make_node_client(node=node)
+ xmlbytes = (
+ '<node id="memory">'
+ '<size units="bytes">4294967296</size>'
+ '</node>').encode("utf-8")
+ response = self.call_signal(client, files={'01-lshw.out': xmlbytes})
+ self.assertEqual(httplib.OK, response.status_code)
+ node = reload_object(node)
+ self.assertEqual(4, node.memory)
+
+ def test_signal_lshw_tags_match(self):
+ tag1 = factory.make_tag(factory.getRandomString(10), "/node")
+ tag2 = factory.make_tag(factory.getRandomString(10), "//node")
+ node = factory.make_node(status=NODE_STATUS.COMMISSIONING)
+ client = self.make_node_client(node=node)
+ xmlbytes = '<node/>'.encode("utf-8")
+ response = self.call_signal(client, files={'01-lshw.out': xmlbytes})
+ self.assertEqual(httplib.OK, response.status_code)
+ node = reload_object(node)
+ self.assertEqual([tag1, tag2], list(node.tags.all()))
+
+ def test_signal_lshw_tags_no_match(self):
+ tag1 = factory.make_tag(factory.getRandomString(10), "/missing")
+ tag2 = factory.make_tag(factory.getRandomString(10), "/nothing")
+ node = factory.make_node(status=NODE_STATUS.COMMISSIONING)
+ node.tags = [tag2]
+ node.save()
+ client = self.make_node_client(node=node)
+ xmlbytes = '<node/>'.encode("utf-8")
+ response = self.call_signal(client, files={'01-lshw.out': xmlbytes})
+ self.assertEqual(httplib.OK, response.status_code)
+ node = reload_object(node)
+ self.assertEqual([], list(node.tags.all()))
+
def test_api_retrieves_node_metadata_by_mac(self):
mac = factory.make_mac_address()
url = reverse(