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

Proposed by LaMont Jones
Status: Merged
Approved by: LaMont Jones
Approved revision: no longer in the source branch.
Merged at revision: 4881
Proposed branch: lp:~lamont/maas/bug-1543707
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 284 lines (+174/-15)
6 files modified
docs/changelog.rst (+4/-0)
src/maasserver/migrations/builtin/maasserver/0051_space_fabric_unique.py (+80/-0)
src/maasserver/models/fabric.py (+23/-8)
src/maasserver/models/space.py (+25/-7)
src/maasserver/models/tests/test_fabric.py (+21/-0)
src/maasserver/models/tests/test_space.py (+21/-0)
To merge this branch: bzr merge lp:~lamont/maas/bug-1543707
Reviewer Review Type Date Requested Status
Blake Rouse (community) Approve
Review via email: mp+291034@code.launchpad.net

Commit message

Disallow duplicate space/fabric names; disallow spaces; migrate as needed.

Description of the change

Disallow duplicate space/fabric names; disallow spaces; migrate as needed.

To post a comment you must log in.
Revision history for this message
LaMont Jones (lamont) wrote :

Testing this, I see errors logged because of duplicate space key 0, and 3 of 4 regiond's dead. I need to figure out the right way to deal with that.

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

Overall this looks good. I think the migration can be more DRY.

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

The attempt to merge lp:~lamont/maas/bug-1543707 into lp:maas failed. Below is the output from the failed tests.

Hit:1 http://security.ubuntu.com/ubuntu xenial-security InRelease
Get:2 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial InRelease [95.6 kB]
Hit:3 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-updates InRelease
Hit:4 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial-backports InRelease
Get:5 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/main Sources [1,108 kB]
Get:6 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/universe Sources [7,515 kB]
Get:7 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/main amd64 Packages [1,435 kB]
Get:8 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/main Translation-en [731 kB]
Get:9 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/universe amd64 Packages [7,242 kB]
Get:10 http://prodstack-zone-2.clouds.archive.ubuntu.com/ubuntu xenial/universe Translation-en [4,187 kB]
Fetched 22.3 MB in 4s (4,991 kB/s)
Reading package lists...
sudo DEBIAN_FRONTEND=noninteractive apt-get -y \
    --no-install-recommends install apache2 archdetect-deb authbind bash 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 postgresql pxelinux python3-all python3-apt python3-bson python3-convoy python3-coverage python3-crochet python3-cssselect python3-curtin python3-dev python3-distro-info python3-django python3-django-nose python3-django-piston3 python3-dnspython python3-docutils python3-formencode python3-hivex python3-httplib2 python3-jinja2 python3-jsonschema python3-lxml python3-mock python3-netaddr python3-netifaces python3-oauth python3-oauthlib python3-openssl python3-paramiko python3-petname python3-pexpect python3-psycopg2 python3-pyinotify python3-pyparsing python3-pyvmomi python3-requests python3-seamicroclient python3-setuptools python3-simplestreams python3-sphinx python3-tempita python3-twisted python3-txtftp python3-tz python3-yaml python3-zope.interface python-bson python-crochet python-django python-django-piston python-djorm-ext-pgarray python-formencode python-lxml python-netaddr python-netifaces python-pocket-lint python-psycopg2 python-tempita python-twisted python-yaml socat syslinux-common tgt ubuntu-cloudimage-keyring wget xvfb
Reading package lists...
Building dependency tree...
Reading state information...
apache2 is already the newest version (2.4.18-2ubuntu1).
archdetect-deb is already the newest version (1.117ubuntu1).
authbind is already the newest version (2.1.1+nmu1).
bash is already the newest version (4.3-14ubuntu1).
bind9 is already the newest version (1:9.10.3.dfsg.P4-5).
bind9utils is already the newest version (1:9.10.3.dfsg.P4-5).
build-essential is already the newest version (12.1ubuntu2).
curl is already the newest version (7.47.0-1ubuntu2).
debhelper is already the newest version (9.20160115ub...

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'docs/changelog.rst'
2--- docs/changelog.rst 2016-04-06 13:45:19 +0000
3+++ docs/changelog.rst 2016-04-06 17:51:57 +0000
4@@ -13,6 +13,10 @@
5
6 LP: #1561733 [2.0a3] MAAS no longer detects external DHCP servers
7
8+LP: #1543707 MAAS 1.9+ should not allow whitespace characters in space names
9+
10+LP: #1543968 MAAS 1.9.0 allows non-unique space names
11+
12
13 2.0.0 (beta1)
14 =============
15
16=== added file 'src/maasserver/migrations/builtin/maasserver/0051_space_fabric_unique.py'
17--- src/maasserver/migrations/builtin/maasserver/0051_space_fabric_unique.py 1970-01-01 00:00:00 +0000
18+++ src/maasserver/migrations/builtin/maasserver/0051_space_fabric_unique.py 2016-04-06 17:51:57 +0000
19@@ -0,0 +1,80 @@
20+# -*- coding: utf-8 -*-
21+from __future__ import unicode_literals
22+
23+from random import randint
24+import re
25+
26+from django.db import (
27+ migrations,
28+ models,
29+)
30+import maasserver.models.fabric
31+import maasserver.models.space
32+
33+
34+def fix_model(apps, schema_editor, model_name, prefix):
35+ cls = apps.get_model("maasserver", model_name)
36+ # Pass 1: assign values to all of the null/empty names, and identify the
37+ # duplicates.
38+ duplicates = set()
39+ for instance in cls.objects.all():
40+ if instance.name is None or instance.name == '':
41+ instance.name = '%s-%d' % (prefix, instance.id)
42+ instance.save()
43+ elif cls.objects.filter(name=instance.name).count() > 1:
44+ # We know that anything we would be renaming None to is reserved to
45+ # only our instance. That means that we don't have to do all the
46+ # renmaes before we check for duplicates.
47+ duplicates.add(instance)
48+ # Pass 2: Rename anything that was a duplicate. Because blanks are now
49+ # illegal in the name (and should always have been) any instance that we
50+ # need to rename to fix duplication will need to have blanks replaced with
51+ # hypens.
52+ # No preference is given to any instance: all duplicates will get a new
53+ # name.
54+ for instance in duplicates:
55+ # Get rid of the instance, if any.
56+ instance.name = re.sub(' ', '-', instance.name)
57+ # First, try ${NAME}-${ID}, then go with ${NAME}-${ID}-${RANDOM}
58+ name = '%s-%d' % (instance.name, instance.id)
59+ if cls.objects.filter(name=name).exists():
60+ retries = 20
61+ while retries > 0 and cls.objects.filter(name=name).exists():
62+ name = '%s-%d-%d' % (
63+ instance.name, instance.id, randint(1, 100000))
64+ retries -= 1
65+ # We tried, and we give up. Just change it to the default name.
66+ # Note that to get here, we tried ${NAME}-${ID} and 20 other
67+ # randomly related names and they _ALL_ had existing collisions in
68+ # the data base.
69+ if retries == 0:
70+ name = '%s-%d' % (prefix, instance.id)
71+ instance.name = name
72+ instance.save()
73+
74+def fix_space_names(apps, schema_editor):
75+ fix_model(apps, schema_editor, "Space", "space")
76+
77+def fix_fabric_names(apps, schema_editor):
78+ fix_model(apps, schema_editor, "Fabric", "fabric")
79+
80+class Migration(migrations.Migration):
81+
82+ dependencies = [
83+ ('maasserver', '0050_modify_external_dhcp_on_vlan'),
84+ ]
85+
86+ operations = [
87+ migrations.RunPython(fix_space_names),
88+ migrations.RunPython(fix_fabric_names),
89+ migrations.AlterField(
90+ model_name='space',
91+ name='name',
92+ field=models.CharField(null=True, blank=True, unique=True, validators=[maasserver.models.space.validate_space_name], max_length=256),
93+ ),
94+ migrations.AlterField(
95+ model_name='fabric',
96+ name='name',
97+ field=models.CharField(null=True, blank=True, unique=True, validators=[maasserver.models.fabric.validate_fabric_name], max_length=256),
98+ ),
99+ ]
100
101=== modified file 'src/maasserver/models/fabric.py'
102--- src/maasserver/models/fabric.py 2016-03-28 13:54:47 +0000
103+++ src/maasserver/models/fabric.py 2016-04-06 17:51:57 +0000
104@@ -152,8 +152,10 @@
105
106 objects = FabricManager()
107
108+ # We don't actually allow blank or null name, but that is enforced in
109+ # clean() and save().
110 name = CharField(
111- max_length=256, editable=True, null=True, blank=True, unique=False,
112+ max_length=256, editable=True, null=True, blank=True, unique=True,
113 validators=[validate_fabric_name])
114
115 class_type = CharField(
116@@ -196,19 +198,32 @@
117 name=DEFAULT_VLAN_NAME, vid=DEFAULT_VID, fabric=self)
118
119 def save(self, *args, **kwargs):
120+ # Name will get set by clean_name() if None or empty, and there is an
121+ # id. We just need to handle names here for creation.
122 created = self.id is None
123- super(Fabric, self).save(*args, **kwargs)
124+ super().save(*args, **kwargs)
125+ if self.name is None or self.name == '':
126+ # If we got here, then we have a newly created fabric that needs a
127+ # default name.
128+ self.name = "fabric-%d" % self.id
129+ fabric = Fabric.objects.get(id=self.id)
130+ fabric.save()
131 # Create default VLAN if this is a fabric creation.
132 if created:
133 self._create_default_vlan()
134
135 def clean_name(self):
136- reserved = re.compile('fabric-\d+$')
137- if self.name is not None and reserved.search(self.name):
138- if (self.id is None or self.name != 'fabric-%d' % self.id):
139- raise ValidationError(
140- {'name': ["Reserved fabric name."]})
141+ reserved = re.compile('^fabric-\d+$')
142+ if self.name is not None and self.name != '':
143+ if reserved.search(self.name):
144+ if (self.id is None or self.name != 'fabric-%d' % self.id):
145+ raise ValidationError(
146+ {'name': ["Reserved fabric name."]})
147+ elif self.id is not None:
148+ # Since we are not creating the fabric, force the (null or empty)
149+ # name to be the default name.
150+ self.name = "fabric-%d" % self.id
151
152 def clean(self, *args, **kwargs):
153- super(Fabric, self).clean(*args, **kwargs)
154+ super().clean(*args, **kwargs)
155 self.clean_name()
156
157=== modified file 'src/maasserver/models/space.py'
158--- src/maasserver/models/space.py 2015-12-01 18:12:59 +0000
159+++ src/maasserver/models/space.py 2016-04-06 17:51:57 +0000
160@@ -30,7 +30,7 @@
161 """Django validator: `value` must be either `None`, or valid."""
162 if value is None:
163 return
164- namespec = re.compile('^[ \w-]+$')
165+ namespec = re.compile('^[\w-]+$')
166 if not namespec.search(value):
167 raise ValidationError("Invalid space name: %s." % value)
168
169@@ -125,8 +125,10 @@
170
171 objects = SpaceManager()
172
173+ # We don't actually allow blank or null name, but that is enforced in
174+ # clean() and save().
175 name = CharField(
176- max_length=256, editable=True, null=True, blank=True, unique=False,
177+ max_length=256, editable=True, null=True, blank=True, unique=True,
178 validators=[validate_space_name])
179
180 def __str__(self):
181@@ -144,11 +146,27 @@
182 return "space-%s" % self.id
183
184 def clean_name(self):
185- reserved = re.compile('space-\d+$')
186- if self.name is not None and reserved.search(self.name):
187- if (self.id is None or self.name != 'space-%d' % self.id):
188- raise ValidationError(
189- {'name': ["Reserved space name."]})
190+ reserved = re.compile('^space-\d+$')
191+ if self.name is not None and self.name != '':
192+ if reserved.search(self.name):
193+ if (self.id is None or self.name != 'space-%d' % self.id):
194+ raise ValidationError(
195+ {'name': ["Reserved space name."]})
196+ elif self.id is not None:
197+ # Since we are not creating the space, force the (null or empty)
198+ # name to be the default name.
199+ self.name = "space-%d" % self.id
200+
201+ def save(self, *args, **kwargs):
202+ # Name will get set by clean_name() if None or empty, and there is an
203+ # id. We just need to handle names here for creation.
204+ super().save(*args, **kwargs)
205+ if self.name is None or self.name == '':
206+ # If we got here, then we have a newly created space that needs a
207+ # default name.
208+ self.name = "space-%d" % self.id
209+ space = Space.objects.get(id=self.id)
210+ space.save()
211
212 def clean(self, *args, **kwargs):
213 super(Space, self).clean(*args, **kwargs)
214
215=== modified file 'src/maasserver/models/tests/test_fabric.py'
216--- src/maasserver/models/tests/test_fabric.py 2016-03-28 13:54:47 +0000
217+++ src/maasserver/models/tests/test_fabric.py 2016-04-06 17:51:57 +0000
218@@ -164,6 +164,27 @@
219 default_fabric = Fabric.objects.get_default_fabric()
220 self.assertEqual(0, default_fabric.id)
221 self.assertEqual(DEFAULT_FABRIC_NAME, default_fabric.get_name())
222+ self.assertEqual(DEFAULT_FABRIC_NAME, default_fabric.name)
223+
224+ def test_create_sets_name(self):
225+ fabric = Fabric.objects.create(name=None)
226+ self.assertEqual("fabric-%d" % fabric.id, fabric.name)
227+
228+ def test_create_does_not_override_name(self):
229+ name = factory.make_name()
230+ fabric = factory.make_Fabric(name=name)
231+ self.assertEqual(name, fabric.name)
232+
233+ def test_nonreserved_name_does_not_raise_exception(self):
234+ fabric = factory.make_Fabric(name='myfabric-33')
235+ self.assertEqual("myfabric-33", fabric.name)
236+
237+ def test_rejects_duplicate_names(self):
238+ fabric1 = factory.make_Fabric()
239+ self.assertRaises(
240+ ValidationError,
241+ factory.make_Fabric,
242+ name=fabric1.name)
243
244 def test_get_default_fabric_is_idempotent(self):
245 default_fabric = Fabric.objects.get_default_fabric()
246
247=== modified file 'src/maasserver/models/tests/test_space.py'
248--- src/maasserver/models/tests/test_space.py 2015-12-01 18:12:59 +0000
249+++ src/maasserver/models/tests/test_space.py 2016-04-06 17:51:57 +0000
250@@ -139,6 +139,7 @@
251 default_space = Space.objects.get_default_space()
252 self.assertEqual(0, default_space.id)
253 self.assertEqual(DEFAULT_SPACE_NAME, default_space.get_name())
254+ self.assertEqual(DEFAULT_SPACE_NAME, default_space.name)
255
256 def test_invalid_name_raises_exception(self):
257 self.assertRaises(
258@@ -152,6 +153,26 @@
259 factory.make_Space,
260 name='space-1999')
261
262+ def test_create_sets_name(self):
263+ space = Space.objects.create(name=None)
264+ self.assertEqual("space-%d" % space.id, space.name)
265+
266+ def test_create_does_not_override_name(self):
267+ name = factory.make_name()
268+ space = factory.make_Space(name=name)
269+ self.assertEqual(name, space.name)
270+
271+ def test_nonreserved_name_does_not_raise_exception(self):
272+ space = factory.make_Space(name='myspace-1999')
273+ self.assertEqual("myspace-1999", space.name)
274+
275+ def test_rejects_duplicate_names(self):
276+ space1 = factory.make_Space()
277+ self.assertRaises(
278+ ValidationError,
279+ factory.make_Space,
280+ name=space1.name)
281+
282 def test_get_default_space_is_idempotent(self):
283 default_space = Space.objects.get_default_space()
284 default_space2 = Space.objects.get_default_space()