Merge lp:~jtv/maas/zero-vlan-tag-is-not-unique into lp:~maas-committers/maas/trunk

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: 1892
Proposed branch: lp:~jtv/maas/zero-vlan-tag-is-not-unique
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 223 lines (+57/-17)
4 files modified
src/maasserver/migrations/0066_rename_vlan_add_link_node_network.py (+9/-10)
src/maasserver/models/network.py (+10/-2)
src/maasserver/models/tests/test_network.py (+28/-4)
src/maasserver/testing/factory.py (+10/-1)
To merge this branch: bzr merge lp:~jtv/maas/zero-vlan-tag-is-not-unique
Reviewer Review Type Date Requested Status
Julian Edwards (community) Approve
Review via email: mp+205073@code.launchpad.net

Commit message

Zero VLAN tag should not be unique.

Description of the change

We have a "unique" constraint on Network.vlan_tag, but this should not apply to the zero tag! Django does not support conditional unique constraints (except in model code, which can't detect paradoxes that may occur at lower database isolation levels) so there was only one thing for it. Allow the field to be null, and normalise zero to None. I thought I was able to avoid this, but forgot about the database constraint. :(

Jeroen

To post a comment you must log in.
Revision history for this message
Julian Edwards (julian-edwards) wrote :

Looks great, I especially like the thought put into the random tag in the factory func.

Can I persuade you to go and edit the old migration though please, having multiple migrations for the same new model is ugly, embarrassing and inefficient. It's not been released to trusty yet so it's fine.

review: Approve
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Thanks. I've updated the migration. I was more cautious about this after the recent incident where a release unbeknownst to us hit juuuust the wrong migration number.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/maasserver/migrations/0066_rename_vlan_add_link_node_network.py'
--- src/maasserver/migrations/0066_rename_vlan_add_link_node_network.py 2014-02-04 09:08:13 +0000
+++ src/maasserver/migrations/0066_rename_vlan_add_link_node_network.py 2014-02-06 04:08:29 +0000
@@ -1,9 +1,8 @@
1# -*- coding: utf-8 -*-1# -*- coding: utf-8 -*-
2import datetime2from south.utils import datetime_utils as datetime
3
4from django.db import models
5from south.db import db3from south.db import db
6from south.v2 import SchemaMigration4from south.v2 import SchemaMigration
5from django.db import models
76
87
9class Migration(SchemaMigration):8class Migration(SchemaMigration):
@@ -18,7 +17,7 @@
18 ('name', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255)),17 ('name', self.gf('django.db.models.fields.CharField')(unique=True, max_length=255)),
19 ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(unique=True, max_length=39)),18 ('ip', self.gf('django.db.models.fields.GenericIPAddressField')(unique=True, max_length=39)),
20 ('netmask', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),19 ('netmask', self.gf('django.db.models.fields.GenericIPAddressField')(max_length=39)),
21 ('vlan_tag', self.gf('django.db.models.fields.PositiveSmallIntegerField')(unique=True)),20 ('vlan_tag', self.gf('django.db.models.fields.PositiveSmallIntegerField')(unique=True, null=True, blank=True)),
22 ('description', self.gf('django.db.models.fields.CharField')(default=u'', max_length=255, blank=True)),21 ('description', self.gf('django.db.models.fields.CharField')(default=u'', max_length=255, blank=True)),
23 ))22 ))
24 db.send_create_signal(u'maasserver', ['Network'])23 db.send_create_signal(u'maasserver', ['Network'])
@@ -67,7 +66,7 @@
67 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),66 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
68 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75', 'blank': 'True'}),67 'email': ('django.db.models.fields.EmailField', [], {'unique': 'True', 'max_length': '75', 'blank': 'True'}),
69 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),68 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
70 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),69 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Group']"}),
71 u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),70 u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
72 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),71 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
73 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),72 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
@@ -75,7 +74,7 @@
75 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),74 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}),
76 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),75 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
77 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),76 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
78 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),77 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'symmetrical': 'False', 'related_name': "u'user_set'", 'blank': 'True', 'to': u"orm['auth.Permission']"}),
79 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})78 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
80 },79 },
81 u'contenttypes.contenttype': {80 u'contenttypes.contenttype': {
@@ -131,7 +130,7 @@
131 'content': ('metadataserver.fields.BinaryField', [], {'blank': 'True'}),130 'content': ('metadataserver.fields.BinaryField', [], {'blank': 'True'}),
132 'filename': ('django.db.models.fields.CharField', [], {'max_length': '255'}),131 'filename': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
133 u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),132 u'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
134 'key': ('django.db.models.fields.CharField', [], {'default': "u'c9791474-8d7b-11e3-8453-9c4e363b1c94'", 'unique': 'True', 'max_length': '36'}),133 'key': ('django.db.models.fields.CharField', [], {'default': "u'1cef8bac-8ee3-11e3-8252-94de80b61466'", 'unique': 'True', 'max_length': '36'}),
135 'owner': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'})134 'owner': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': u"orm['auth.User']", 'null': 'True', 'blank': 'True'})
136 },135 },
137 u'maasserver.macaddress': {136 u'maasserver.macaddress': {
@@ -149,7 +148,7 @@
149 'ip': ('django.db.models.fields.GenericIPAddressField', [], {'unique': 'True', 'max_length': '39'}),148 'ip': ('django.db.models.fields.GenericIPAddressField', [], {'unique': 'True', 'max_length': '39'}),
150 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),149 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '255'}),
151 'netmask': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),150 'netmask': ('django.db.models.fields.GenericIPAddressField', [], {'max_length': '39'}),
152 'vlan_tag': ('django.db.models.fields.PositiveSmallIntegerField', [], {'unique': 'True'})151 'vlan_tag': ('django.db.models.fields.PositiveSmallIntegerField', [], {'unique': 'True', 'null': 'True', 'blank': 'True'})
153 },152 },
154 u'maasserver.node': {153 u'maasserver.node': {
155 'Meta': {'object_name': 'Node'},154 'Meta': {'object_name': 'Node'},
@@ -172,7 +171,7 @@
172 'routers': ('djorm_pgarray.fields.ArrayField', [], {'default': 'None', 'dbtype': "u'macaddr'", 'null': 'True', 'blank': 'True'}),171 'routers': ('djorm_pgarray.fields.ArrayField', [], {'default': 'None', 'dbtype': "u'macaddr'", 'null': 'True', 'blank': 'True'}),
173 'status': ('django.db.models.fields.IntegerField', [], {'default': '0', 'max_length': '10'}),172 'status': ('django.db.models.fields.IntegerField', [], {'default': '0', 'max_length': '10'}),
174 'storage': ('django.db.models.fields.IntegerField', [], {'default': '0'}),173 'storage': ('django.db.models.fields.IntegerField', [], {'default': '0'}),
175 'system_id': ('django.db.models.fields.CharField', [], {'default': "u'node-c97b5392-8d7b-11e3-8453-9c4e363b1c94'", 'unique': 'True', 'max_length': '41'}),174 'system_id': ('django.db.models.fields.CharField', [], {'default': "u'node-1cf030fc-8ee3-11e3-8252-94de80b61466'", 'unique': 'True', 'max_length': '41'}),
176 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['maasserver.Tag']", 'symmetrical': 'False'}),175 'tags': ('django.db.models.fields.related.ManyToManyField', [], {'to': u"orm['maasserver.Tag']", 'symmetrical': 'False'}),
177 'token': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['piston.Token']", 'null': 'True'}),176 'token': ('django.db.models.fields.related.ForeignKey', [], {'to': u"orm['piston.Token']", 'null': 'True'}),
178 'updated': ('django.db.models.fields.DateTimeField', [], {}),177 'updated': ('django.db.models.fields.DateTimeField', [], {}),
@@ -258,7 +257,7 @@
258 'is_approved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),257 'is_approved': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
259 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),258 'key': ('django.db.models.fields.CharField', [], {'max_length': '18'}),
260 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),259 'secret': ('django.db.models.fields.CharField', [], {'max_length': '32'}),
261 'timestamp': ('django.db.models.fields.IntegerField', [], {'default': '1391504854L'}),260 'timestamp': ('django.db.models.fields.IntegerField', [], {'default': '1391659184L'}),
262 'token_type': ('django.db.models.fields.IntegerField', [], {}),261 'token_type': ('django.db.models.fields.IntegerField', [], {}),
263 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'tokens'", 'null': 'True', 'to': u"orm['auth.User']"}),262 'user': ('django.db.models.fields.related.ForeignKey', [], {'blank': 'True', 'related_name': "'tokens'", 'null': 'True', 'to': u"orm['auth.User']"}),
264 'verifier': ('django.db.models.fields.CharField', [], {'max_length': '10'})263 'verifier': ('django.db.models.fields.CharField', [], {'max_length': '10'})
265264
=== modified file 'src/maasserver/models/network.py'
--- src/maasserver/models/network.py 2014-02-05 14:12:38 +0000
+++ src/maasserver/models/network.py 2014-02-06 04:08:29 +0000
@@ -54,7 +54,7 @@
54 help_text="Network mask (e.g. 255.255.255.0).")54 help_text="Network mask (e.g. 255.255.255.0).")
5555
56 vlan_tag = PositiveSmallIntegerField(56 vlan_tag = PositiveSmallIntegerField(
57 editable=True, blank=False, unique=True,57 editable=True, null=True, blank=True, unique=True,
58 help_text="A 12-bit field specifying the VLAN to which the frame "58 help_text="A 12-bit field specifying the VLAN to which the frame "
59 "belongs. The hexadecimal values of 0x000 and 0xFFF "59 "belongs. The hexadecimal values of 0x000 and 0xFFF "
60 "are reserved. All other values may be used as VLAN "60 "are reserved. All other values may be used as VLAN "
@@ -85,13 +85,18 @@
8585
86 def __unicode__(self):86 def __unicode__(self):
87 net = unicode(self.get_network().cidr)87 net = unicode(self.get_network().cidr)
88 if self.vlan_tag == 0:88 # A vlan_tag of zero normalises to None. But __unicode__ may be
89 # called while we're not in a clean state, so handle zero as well.
90 if self.vlan_tag is None or self.vlan_tag == 0:
89 return net91 return net
90 else:92 else:
91 return "%s(tag:%x)" % (net, self.vlan_tag)93 return "%s(tag:%x)" % (net, self.vlan_tag)
9294
93 def clean_vlan_tag(self):95 def clean_vlan_tag(self):
94 """Validator for `vlan_tag`."""96 """Validator for `vlan_tag`."""
97 if self.vlan_tag is None:
98 # Always OK.
99 return
95 if self.vlan_tag == 0xFFF:100 if self.vlan_tag == 0xFFF:
96 raise ValidationError(101 raise ValidationError(
97 {'tag': ["Cannot use reserved value 0xFFF."]})102 {'tag': ["Cannot use reserved value 0xFFF."]})
@@ -133,6 +138,9 @@
133 raise ValidationError("Invalid network address: %s" % e)138 raise ValidationError("Invalid network address: %s" % e)
134 # Normalise self.ip. This strips off any host bits from the address.139 # Normalise self.ip. This strips off any host bits from the address.
135 self.ip = unicode(net.cidr.ip)140 self.ip = unicode(net.cidr.ip)
141 # Normalise self.vlan_tag. A zero value ("not a VLAN") becomes None.
142 if self.vlan_tag == 0:
143 self.vlan_tag = None
136144
137 def validate_unique(self, exclude=None):145 def validate_unique(self, exclude=None):
138 super(Network, self).validate_unique(exclude=exclude)146 super(Network, self).validate_unique(exclude=exclude)
139147
=== modified file 'src/maasserver/models/tests/test_network.py'
--- src/maasserver/models/tests/test_network.py 2014-02-05 10:46:49 +0000
+++ src/maasserver/models/tests/test_network.py 2014-02-06 04:08:29 +0000
@@ -14,9 +14,11 @@
14__metaclass__ = type14__metaclass__ = type
15__all__ = []15__all__ = []
1616
17from operator import attrgetter
17from random import randint18from random import randint
1819
19from django.core.exceptions import ValidationError20from django.core.exceptions import ValidationError
21from maasserver.models import Network
20from maasserver.testing.factory import factory22from maasserver.testing.factory import factory
21from maasserver.testing.testcase import MAASServerTestCase23from maasserver.testing.testcase import MAASServerTestCase
22from netaddr import IPNetwork24from netaddr import IPNetwork
@@ -52,11 +54,10 @@
52 self.assertEqual('10.9.0.0', network.ip)54 self.assertEqual('10.9.0.0', network.ip)
5355
54 def test_vlan_tag_can_be_zero_through_hex_ffe(self):56 def test_vlan_tag_can_be_zero_through_hex_ffe(self):
55 min_tag = 057 self.assertIsNone(factory.make_network(vlan_tag=0).vlan_tag)
58 self.assertEqual(1, factory.make_network(vlan_tag=1).vlan_tag)
56 max_tag = 0xfff - 159 max_tag = 0xfff - 1
57 self.assertEqual(60 self.assertEqual(
58 min_tag, factory.make_network(vlan_tag=min_tag).vlan_tag)
59 self.assertEqual(
60 max_tag, factory.make_network(vlan_tag=max_tag).vlan_tag)61 max_tag, factory.make_network(vlan_tag=max_tag).vlan_tag)
6162
62 def test_reserved_vlan_tag_does_not_validate(self):63 def test_reserved_vlan_tag_does_not_validate(self):
@@ -69,6 +70,20 @@
69 self.assertRaises(70 self.assertRaises(
70 ValidationError, factory.make_network, vlan_tag=-1)71 ValidationError, factory.make_network, vlan_tag=-1)
7172
73 def test_vlan_tag_normalises_zero_to_None(self):
74 self.assertIsNone(factory.make_network(vlan_tag=0).vlan_tag)
75
76 def test_nonzero_vlan_tag_is_unique(self):
77 tag = randint(1, 0xffe)
78 factory.make_network(vlan_tag=tag)
79 self.assertRaises(ValidationError, factory.make_network, vlan_tag=tag)
80
81 def test_zero_vlan_tag_is_not_unique(self):
82 networks = [factory.make_network(vlan_tag=0) for _ in range(3)]
83 self.assertEqual(
84 sorted(networks, key=attrgetter('id')),
85 list(Network.objects.filter(vlan_tag=None).order_by('id')))
86
72 def test_get_network_returns_network(self):87 def test_get_network_returns_network(self):
73 net = factory.getRandomNetwork()88 net = factory.getRandomNetwork()
74 self.assertEqual(net, factory.make_network(network=net).get_network())89 self.assertEqual(net, factory.make_network(network=net).get_network())
@@ -112,11 +127,20 @@
112 network = factory.make_network(network=IPNetwork(cidr), vlan_tag=0)127 network = factory.make_network(network=IPNetwork(cidr), vlan_tag=0)
113 self.assertEqual(cidr, unicode(network))128 self.assertEqual(cidr, unicode(network))
114129
115 def test_unicode_includes_tag_if_nonzero(self):130 def test_unicode_includes_tag_if_set(self):
116 cidr = '10.9.0.0/16'131 cidr = '10.9.0.0/16'
117 network = factory.make_network(network=IPNetwork(cidr), vlan_tag=0xabc)132 network = factory.make_network(network=IPNetwork(cidr), vlan_tag=0xabc)
118 self.assertEqual("%s(tag:abc)" % cidr, unicode(network))133 self.assertEqual("%s(tag:abc)" % cidr, unicode(network))
119134
135 def test_unicode_treats_unclean_zero_tag_as_unset(self):
136 net = IPNetwork('10.1.1.0/24')
137 network = factory.make_network(network=net)
138 network.vlan_tag = None
139 proper_unicode = unicode(network)
140 network.vlan_tag = 0
141 unclean_unicode = unicode(network)
142 self.assertEqual(proper_unicode, unclean_unicode)
143
120 def test_disallows_identical_networks_with_same_netmask(self):144 def test_disallows_identical_networks_with_same_netmask(self):
121 existing_network = factory.make_network()145 existing_network = factory.make_network()
122 self.assertRaises(146 self.assertRaises(
123147
=== modified file 'src/maasserver/testing/factory.py'
--- src/maasserver/testing/factory.py 2014-02-04 17:28:30 +0000
+++ src/maasserver/testing/factory.py 2014-02-06 04:08:29 +0000
@@ -543,6 +543,9 @@
543543
544 :param network: An `IPNetwork`. If given, the `ip` and `netmask`544 :param network: An `IPNetwork`. If given, the `ip` and `netmask`
545 fields will be taken from this.545 fields will be taken from this.
546 :param vlan_tag: A number between 1 and 0xffe inclusive to create a
547 VLAN, or 0 to create a non-VLAN network, or None to make a random
548 choice.
546 """549 """
547 if name is None:550 if name is None:
548 name = factory.make_name()551 name = factory.make_name()
@@ -553,7 +556,13 @@
553 if description is None:556 if description is None:
554 description = self.getRandomString()557 description = self.getRandomString()
555 if vlan_tag is None:558 if vlan_tag is None:
556 vlan_tag = random.randint(0, 0xFFE)559 # Caller wants it random. To avoid hiding once-in-4094 bugs when
560 # it comes out zero, skew the odds in favour of zero: fifty-fifty
561 # choice between a zero and a nonzero tag.
562 if random.randint(0, 1) == 1:
563 vlan_tag = random.randint(1, 0xFFE)
564 else:
565 vlan_tag = 0
557 network = Network(566 network = Network(
558 name=name, ip=ip, netmask=netmask, vlan_tag=vlan_tag,567 name=name, ip=ip, netmask=netmask, vlan_tag=vlan_tag,
559 description=description)568 description=description)