Merge lp:~mpontillo/maas/dns-dhcp-fixes--bug-1686234 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: 6038
Proposed branch: lp:~mpontillo/maas/dns-dhcp-fixes--bug-1686234
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 766 lines (+419/-69)
11 files modified
src/maasserver/models/dnsresource.py (+69/-0)
src/maasserver/models/signals/staticipaddress.py (+22/-4)
src/maasserver/models/signals/tests/test_staticipaddress.py (+5/-2)
src/maasserver/models/tests/test_dnsresource.py (+148/-3)
src/maasserver/rpc/leases.py (+39/-14)
src/maasserver/rpc/tests/test_leases.py (+84/-4)
src/maasserver/testing/factory.py (+11/-2)
src/provisioningserver/rpc/tests/test_utils.py (+0/-24)
src/provisioningserver/rpc/utils.py (+1/-16)
src/provisioningserver/utils/network.py (+16/-0)
src/provisioningserver/utils/tests/test_network.py (+24/-0)
To merge this branch: bzr merge lp:~mpontillo/maas/dns-dhcp-fixes--bug-1686234
Reviewer Review Type Date Requested Status
Blake Rouse (community) Approve
Andres Rodriguez (community) Needs Information
Review via email: mp+323198@code.launchpad.net

Commit message

Fix automatic addition and removal of hostnames provided by DHCP.

 * Hostnames belonging to a Node can no longer be eclipsed by a dynamic hostname.
 * DNS resources already containing a static IP address can no longer be modified by DHCP lease events.
 * DNS resources are now properly removed when the last dynamic IP address associated with them is deleted.
 * Logging has been added when adding or removing a dynamic hostname.
 * Dynamic IP addresses are no longer deleted and recreated when not necessary.
 * Move the coerce_to_valid_hostname() function to the provisioningserver.utils.network module.

To post a comment you must log in.
Revision history for this message
Andres Rodriguez (andreserl) wrote :

overall it looks good, I just have a few questions inline.

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

Thanks for taking a look. See replies below.

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

Looks good. Just fix the typo.

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'src/maasserver/models/dnsresource.py'
2--- src/maasserver/models/dnsresource.py 2016-07-07 22:42:56 +0000
3+++ src/maasserver/models/dnsresource.py 2017-05-05 13:38:34 +0000
4@@ -28,12 +28,18 @@
5 )
6 from django.db.models.query import QuerySet
7 from maasserver import DefaultMeta
8+from maasserver.enum import IPADDRESS_TYPE
9 from maasserver.models import domain
10 from maasserver.models.cleansave import CleanSave
11 from maasserver.models.domain import Domain
12 from maasserver.models.node import Node
13 from maasserver.models.timestampedmodel import TimestampedModel
14 from maasserver.utils.orm import MAASQueriesMixin
15+from provisioningserver.logger import LegacyLogger
16+from provisioningserver.utils.network import coerce_to_valid_hostname
17+
18+
19+log = LegacyLogger()
20
21
22 LABEL = r'[a-zA-Z0-9]([-a-zA-Z0-9]{0,62}[a-zA-Z0-9]){0,1}'
23@@ -154,6 +160,65 @@
24 else:
25 raise PermissionDenied()
26
27+ def update_dynamic_hostname(self, sip, hostname):
28+ """Creates or updates the DHCP hostname for a StaticIPAddress.
29+
30+ The hostname will be coerced into a valid hostname before being saved,
31+ since DHCP clients may report hostnames with embedded spaces, etc.
32+
33+ :param sip: a StaticIPAddress of alloc_type DISCOVERED
34+ :param hostname: the hostname provided by the DHCP client.
35+ """
36+ assert sip.ip is not None
37+ assert sip.alloc_type == IPADDRESS_TYPE.DISCOVERED
38+ hostname = coerce_to_valid_hostname(hostname)
39+ self.release_dynamic_hostname(sip, but_not_for=hostname)
40+ dnsrr, created = self.get_or_create(name=hostname)
41+ if created:
42+ dnsrr.ip_addresses.add(sip)
43+ log.msg("Added dynamic hostname '%s' for IP address '%s'." % (
44+ dnsrr.fqdn, sip.ip))
45+ else:
46+ if dnsrr.has_static_ip():
47+ log.msg(
48+ "Skipped adding dynamic hostname '%s' for IP address "
49+ "'%s': already exists in DNS with a static IP." % (
50+ dnsrr.fqdn, sip.ip))
51+ else:
52+ if dnsrr.ip_addresses.count() == 1:
53+ # Don't bother updating if it's already the same address.
54+ if set(dnsrr.ip_addresses.all()) == set([sip]):
55+ return
56+ # One or more dynamic IPs for this hostname. Replace them.
57+ dnsrr.ip_addresses.clear()
58+ dnsrr.ip_addresses.add(sip)
59+ log.msg(
60+ "Updated dynamic hostname '%s' for IP address '%s'." % (
61+ dnsrr.fqdn, sip.ip))
62+
63+ def release_dynamic_hostname(self, sip, but_not_for=None):
64+ """
65+ Releases the DHCP hostname for the specified StaticIPAddress.
66+
67+ :param sip: a StaticIPAddress of alloc_type DISCOVERED.
68+ """
69+ assert sip.ip is not None
70+ assert sip.alloc_type == IPADDRESS_TYPE.DISCOVERED
71+ resources = self.filter(
72+ domain=get_default_domain(), ip_addresses__in=[sip])
73+ if but_not_for is not None:
74+ resources = resources.exclude(name=but_not_for)
75+ for dnsrr in resources:
76+ dynamic_ip = dnsrr.ip_addresses.filter(
77+ alloc_type=IPADDRESS_TYPE.DISCOVERED, ip=sip.ip).first()
78+ if dynamic_ip is not None:
79+ dnsrr.ip_addresses.remove(dynamic_ip)
80+ if dnsrr.ip_addresses.count() == 0:
81+ dnsrr.delete()
82+ log.msg(
83+ "Deleted dynamic hostname '%s' for IP address '%s'." % (
84+ dnsrr.fqdn, sip.ip))
85+
86
87 class DNSResource(CleanSave, TimestampedModel):
88 """A `DNSResource`.
89@@ -217,6 +282,10 @@
90 else:
91 return "%s.%s" % (self.name, self.domain.name)
92
93+ def has_static_ip(self):
94+ return self.ip_addresses.exclude(
95+ alloc_type=IPADDRESS_TYPE.DISCOVERED).exists()
96+
97 def get_addresses(self):
98 """Return all addresses associated with this FQDN."""
99 # Since Node.hostname is unique, this will be at most 1 node.
100
101=== modified file 'src/maasserver/models/signals/staticipaddress.py'
102--- src/maasserver/models/signals/staticipaddress.py 2017-04-07 21:52:35 +0000
103+++ src/maasserver/models/signals/staticipaddress.py 2017-05-05 13:38:34 +0000
104@@ -16,12 +16,14 @@
105 )
106 from maasserver.models import StaticIPAddress
107 from maasserver.utils.signals import SignalsManager
108-
109-
110+from provisioningserver.logger import LegacyLogger
111+
112+
113+log = LegacyLogger()
114 signals = SignalsManager()
115
116
117-def pre_delete_record_bmcs_on_delete(sender, instance, **kwargs):
118+def pre_delete_record_relations_on_delete(sender, instance, **kwargs):
119 """Store the instance's bmc_set for use in post_delete.
120
121 It is coerced to a set to force it to be evaluated here in the pre_delete
122@@ -33,9 +35,10 @@
123 can be called on in post_delete to make their own StaticIPAddresses.
124 """
125 instance.__previous_bmcs = set(instance.bmc_set.all())
126+ instance.__previous_dnsresources = set(instance.dnsresource_set.all())
127
128 signals.watch(
129- pre_delete, pre_delete_record_bmcs_on_delete, sender=StaticIPAddress)
130+ pre_delete, pre_delete_record_relations_on_delete, sender=StaticIPAddress)
131
132
133 def post_delete_remake_sip_for_bmc(sender, instance, **kwargs):
134@@ -59,6 +62,21 @@
135 post_delete, post_delete_remake_sip_for_bmc, sender=StaticIPAddress)
136
137
138+def post_delete_clean_up_dns(sender, instance, **kwargs):
139+ """Now that the StaticIPAddress instance is gone, check if any related
140+ DNS records should be deleted.
141+ """
142+ for dnsrr in instance.__previous_dnsresources:
143+ if dnsrr.ip_addresses.count() == 0:
144+ dnsrr.delete()
145+ log.msg(
146+ "Removed orphan DNS record '%s' for deleted IP address '%s'."
147+ % (dnsrr.fqdn, instance.ip))
148+
149+signals.watch(
150+ post_delete, post_delete_clean_up_dns, sender=StaticIPAddress)
151+
152+
153 def post_init_store_previous_ip(sender, instance, **kwargs):
154 """Store the pre_save IP address of the instance.
155
156
157=== modified file 'src/maasserver/models/signals/tests/test_staticipaddress.py'
158--- src/maasserver/models/signals/tests/test_staticipaddress.py 2016-03-10 17:54:26 +0000
159+++ src/maasserver/models/signals/tests/test_staticipaddress.py 2017-05-05 13:38:34 +0000
160@@ -4,8 +4,11 @@
161 """
162 Test the behaviour of staticipaddress signals.
163
164-These signals are tested in src/maasserver/models/tests/test_bmc.py. They
165-handle IP sharing issues between BMCs and Machines.
166+Tests for sharing IP addresses between BMCs and machines are in:
167+ src/maasserver/models/tests/test_bmc.py
168+
169+Tests related to DNS resources are in:
170+ src/maasserver/models/tests/test_dnsresource.py
171 """
172
173 __all__ = []
174
175=== modified file 'src/maasserver/models/tests/test_dnsresource.py'
176--- src/maasserver/models/tests/test_dnsresource.py 2016-07-07 22:42:56 +0000
177+++ src/maasserver/models/tests/test_dnsresource.py 2017-05-05 13:38:34 +0000
178@@ -5,22 +5,36 @@
179
180 __all__ = []
181
182-
183+from datetime import (
184+ datetime,
185+ timedelta,
186+)
187 import re
188
189 from django.core.exceptions import (
190 PermissionDenied,
191 ValidationError,
192 )
193-from maasserver.enum import NODE_PERMISSION
194+from maasserver.enum import (
195+ IPADDRESS_TYPE,
196+ NODE_PERMISSION,
197+)
198+from maasserver.models import StaticIPAddress
199 from maasserver.models.dnsresource import (
200 DNSResource,
201 separate_fqdn,
202 )
203 from maasserver.testing.factory import factory
204 from maasserver.testing.testcase import MAASServerTestCase
205+from maasserver.utils.orm import reload_object
206 from testtools import ExpectedException
207-from testtools.matchers import MatchesStructure
208+from testtools.matchers import (
209+ Contains,
210+ Equals,
211+ Is,
212+ MatchesStructure,
213+ Not,
214+)
215
216
217 class TestDNSResourceManagerGetDNSResourceOr404(MAASServerTestCase):
218@@ -241,3 +255,134 @@
219 self.assertItemsEqual(
220 (sip1.get_ip(), sip2.get_ip()),
221 dnsresource.get_addresses())
222+
223+
224+class TestUpdateDynamicHostname(MAASServerTestCase):
225+
226+ def test__adds_new_hostname(self):
227+ sip = factory.make_StaticIPAddress(
228+ ip='10.0.0.1',
229+ alloc_type=IPADDRESS_TYPE.DISCOVERED)
230+ hostname = factory.make_name().lower()
231+ DNSResource.objects.update_dynamic_hostname(sip, hostname)
232+ dnsrr = DNSResource.objects.get(name=hostname)
233+ self.assertThat(dnsrr.ip_addresses.all(), Contains(sip))
234+
235+ def test__coerces_to_valid_hostname(self):
236+ sip = factory.make_StaticIPAddress(
237+ ip='10.0.0.1',
238+ alloc_type=IPADDRESS_TYPE.DISCOVERED)
239+ hostname = "no tea"
240+ DNSResource.objects.update_dynamic_hostname(sip, hostname)
241+ dnsrr = DNSResource.objects.get(name="no-tea")
242+ self.assertThat(dnsrr.ip_addresses.all(), Contains(sip))
243+
244+ def test__does_not_modify_existing_non_dynamic_records(self):
245+ sip_reserved = factory.make_StaticIPAddress(
246+ ip='10.0.0.1',
247+ alloc_type=IPADDRESS_TYPE.USER_RESERVED)
248+ hostname = factory.make_name().lower()
249+ factory.make_DNSResource(name=hostname, ip_addresses=[sip_reserved])
250+ sip_dynamic = factory.make_StaticIPAddress(
251+ ip='10.0.0.2',
252+ alloc_type=IPADDRESS_TYPE.DISCOVERED)
253+ DNSResource.objects.update_dynamic_hostname(sip_dynamic, hostname)
254+ dnsrr = DNSResource.objects.get(name=hostname)
255+ self.assertThat(dnsrr.ip_addresses.all(), Contains(sip_reserved))
256+ self.assertThat(dnsrr.ip_addresses.all(), Not(Contains(sip_dynamic)))
257+
258+ def test__updates_existing_dynamic_record(self):
259+ sip_before = factory.make_StaticIPAddress(
260+ ip='10.0.0.1',
261+ alloc_type=IPADDRESS_TYPE.DISCOVERED)
262+ hostname = factory.make_name().lower()
263+ DNSResource.objects.update_dynamic_hostname(sip_before, hostname)
264+ sip_after = factory.make_StaticIPAddress(
265+ ip='10.0.0.2',
266+ alloc_type=IPADDRESS_TYPE.DISCOVERED)
267+ DNSResource.objects.update_dynamic_hostname(sip_after, hostname)
268+ dnsrr = DNSResource.objects.get(name=hostname)
269+ self.assertThat(dnsrr.ip_addresses.all(), Contains(sip_after))
270+ self.assertThat(dnsrr.ip_addresses.all(), Not(Contains(sip_before)))
271+
272+ def test__skips_updating_identical_ip(self):
273+ sip = factory.make_StaticIPAddress(
274+ ip='10.0.0.1',
275+ alloc_type=IPADDRESS_TYPE.DISCOVERED)
276+ hostname = factory.make_name().lower()
277+ DNSResource.objects.update_dynamic_hostname(sip, hostname)
278+ dnsrr = DNSResource.objects.get(name=hostname)
279+ # Create a date object for one week ago.
280+ before = datetime.fromtimestamp(
281+ datetime.now().timestamp() - timedelta(days=7).total_seconds())
282+ dnsrr.save(_created=before, _updated=before)
283+ dnsrr = DNSResource.objects.get(name=hostname)
284+ self.assertThat(dnsrr.updated, Equals(before))
285+ self.assertThat(dnsrr.created, Equals(before))
286+ # Test that the timestamps weren't updated after updating again.
287+ DNSResource.objects.update_dynamic_hostname(sip, hostname)
288+ dnsrr = DNSResource.objects.get(name=hostname)
289+ self.assertThat(dnsrr.updated, Equals(before))
290+ self.assertThat(dnsrr.created, Equals(before))
291+
292+ def test__update_releases_obsolete_hostnames(self):
293+ sip = factory.make_StaticIPAddress(
294+ ip='10.0.0.1',
295+ alloc_type=IPADDRESS_TYPE.DISCOVERED)
296+ hostname_old = factory.make_name().lower()
297+ DNSResource.objects.update_dynamic_hostname(sip, hostname_old)
298+ hostname_new = factory.make_name().lower()
299+ DNSResource.objects.update_dynamic_hostname(sip, hostname_new)
300+ dnsrr = DNSResource.objects.get(name=hostname_new)
301+ self.assertThat(dnsrr.ip_addresses.all(), Contains(sip))
302+ self.assertThat(
303+ DNSResource.objects.filter(name=hostname_old).first(),
304+ Equals(None))
305+
306+
307+class TestReleaseDynamicHostname(MAASServerTestCase):
308+
309+ def test__releases_dynamic_hostname(self):
310+ sip = factory.make_StaticIPAddress(
311+ ip='10.0.0.1',
312+ alloc_type=IPADDRESS_TYPE.DISCOVERED)
313+ hostname = factory.make_name().lower()
314+ DNSResource.objects.update_dynamic_hostname(sip, hostname)
315+ DNSResource.objects.release_dynamic_hostname(sip)
316+ self.assertThat(
317+ DNSResource.objects.filter(name=hostname).first(),
318+ Equals(None))
319+
320+ def test__leaves_static_hostnames_untouched(self):
321+ sip = factory.make_StaticIPAddress(
322+ ip='10.0.0.1',
323+ alloc_type=IPADDRESS_TYPE.DISCOVERED)
324+ hostname = factory.make_name().lower()
325+ DNSResource.objects.update_dynamic_hostname(sip, hostname)
326+ sip_reserved = factory.make_StaticIPAddress(
327+ ip='10.0.0.2',
328+ alloc_type=IPADDRESS_TYPE.USER_RESERVED)
329+ dnsrr = DNSResource.objects.get(name=hostname)
330+ dnsrr.ip_addresses.add(sip_reserved)
331+ DNSResource.objects.release_dynamic_hostname(sip)
332+ dnsrr = reload_object(dnsrr)
333+ self.assertThat(dnsrr.ip_addresses.all(), Contains(sip_reserved))
334+ self.assertThat(dnsrr.ip_addresses.all(), Not(Contains(sip)))
335+
336+
337+class TestStaticIPAddressSignals(MAASServerTestCase):
338+ """Tests the signals signals/staticipaddress.py."""
339+
340+ def test__deletes_orphaned_record(self):
341+ dnsrr = factory.make_DNSResource()
342+ StaticIPAddress.objects.all().delete()
343+ dnsrr = reload_object(dnsrr)
344+ self.assertThat(dnsrr, Is(None))
345+
346+ def test__non_orphaned_record_not_deleted(self):
347+ dnsrr = factory.make_DNSResource(ip_addresses=['8.8.8.8', '8.8.4.4'])
348+ sip = StaticIPAddress.objects.get(ip='8.8.4.4')
349+ sip.delete()
350+ dnsrr = reload_object(dnsrr)
351+ sip = StaticIPAddress.objects.get(ip='8.8.8.8')
352+ self.assertThat(dnsrr.ip_addresses.all(), Contains(sip))
353
354=== modified file 'src/maasserver/rpc/leases.py'
355--- src/maasserver/rpc/leases.py 2016-03-28 13:54:47 +0000
356+++ src/maasserver/rpc/leases.py 2017-05-05 13:38:34 +0000
357@@ -13,15 +13,17 @@
358 IPADDRESS_FAMILY,
359 IPADDRESS_TYPE,
360 )
361-from maasserver.models.dnsresource import DNSResource
362-from maasserver.models.interface import (
363+from maasserver.models import (
364+ DNSResource,
365 Interface,
366+ Node,
367+ StaticIPAddress,
368+ Subnet,
369 UnknownInterface,
370 )
371-from maasserver.models.staticipaddress import StaticIPAddress
372-from maasserver.models.subnet import Subnet
373 from maasserver.utils.orm import transactional
374 from netaddr import IPAddress
375+from provisioningserver.utils.network import coerce_to_valid_hostname
376 from provisioningserver.utils.twisted import synchronous
377
378
379@@ -102,6 +104,7 @@
380 # No interfaces and not commit action so nothing needs to be done.
381 return {}
382
383+ sip = None
384 # Delete all discovered IP addresses attached to all interfaces of the same
385 # IP address family.
386 old_family_addresses = StaticIPAddress.objects.filter_by_ip_family(
387@@ -109,7 +112,15 @@
388 old_family_addresses = old_family_addresses.filter(
389 alloc_type=IPADDRESS_TYPE.DISCOVERED,
390 interface__in=interfaces)
391- old_family_addresses.delete()
392+ for address in old_family_addresses:
393+ # Release old DHCP hostnames, but only for obsolete dynamic addresses.
394+ if address.ip != ip:
395+ if address.ip is not None:
396+ DNSResource.objects.release_dynamic_hostname(address)
397+ address.delete()
398+ else:
399+ # Avoid recreating a new StaticIPAddress later.
400+ sip = address
401
402 # Create the new StaticIPAddress object based on the action.
403 if action == "commit":
404@@ -129,21 +140,35 @@
405 # object. That will make sure that the lease_time is correct from
406 # the created time.
407 created = datetime.fromtimestamp(timestamp)
408- sip = StaticIPAddress.objects.create(
409- alloc_type=IPADDRESS_TYPE.DISCOVERED, ip=ip, subnet=subnet,
410- lease_time=lease_time, created=created, updated=created)
411+ sip, _ = StaticIPAddress.objects.update_or_create(
412+ defaults=dict(
413+ subnet=subnet, lease_time=lease_time,
414+ created=created, updated=created),
415+ alloc_type=IPADDRESS_TYPE.DISCOVERED, ip=ip)
416 for interface in interfaces:
417 interface.ip_addresses.add(sip)
418 if sip_hostname is not None:
419- dnsrr, created = DNSResource.objects.get_or_create(
420- name=sip_hostname)
421- dnsrr.ip_addresses.add(sip)
422+ # MAAS automatically manages DNS for node hostnames, so we cannot
423+ # allow a DHCP client to override that.
424+ hostname_belongs_to_a_node = (
425+ Node.objects.filter(
426+ hostname=coerce_to_valid_hostname(sip_hostname)).exists()
427+ )
428+ if hostname_belongs_to_a_node:
429+ # Ensure we don't allow a DHCP hostname to override a node
430+ # hostname.
431+ DNSResource.objects.release_dynamic_hostname(sip)
432+ else:
433+ DNSResource.objects.update_dynamic_hostname(sip, sip_hostname)
434 elif action == "expiry" or action == "release":
435 # Interfaces no longer holds an active lease. Create the new object
436 # to show that it used to be connected to this subnet.
437- sip = StaticIPAddress.objects.create(
438- alloc_type=IPADDRESS_TYPE.DISCOVERED, ip=None, subnet=subnet)
439+ if sip is None:
440+ sip = StaticIPAddress.objects.create(
441+ alloc_type=IPADDRESS_TYPE.DISCOVERED, ip=None, subnet=subnet)
442+ else:
443+ sip.ip = None
444+ sip.save()
445 for interface in interfaces:
446 interface.ip_addresses.add(sip)
447-
448 return {}
449
450=== modified file 'src/maasserver/rpc/tests/test_leases.py'
451--- src/maasserver/rpc/tests/test_leases.py 2016-03-28 13:54:47 +0000
452+++ src/maasserver/rpc/tests/test_leases.py 2017-05-05 13:38:34 +0000
453@@ -14,6 +14,7 @@
454 IPADDRESS_FAMILY,
455 IPADDRESS_TYPE,
456 )
457+from maasserver.models import DNSResource
458 from maasserver.models.interface import UnknownInterface
459 from maasserver.models.staticipaddress import StaticIPAddress
460 from maasserver.rpc.leases import (
461@@ -22,9 +23,17 @@
462 )
463 from maasserver.testing.factory import factory
464 from maasserver.testing.testcase import MAASServerTestCase
465-from maasserver.utils.orm import reload_object
466+from maasserver.utils.orm import (
467+ get_one,
468+ reload_object,
469+)
470 from netaddr import IPAddress
471-from testtools.matchers import MatchesStructure
472+from testtools.matchers import (
473+ Contains,
474+ Equals,
475+ MatchesStructure,
476+ Not,
477+)
478
479
480 class TestUpdateLease(MAASServerTestCase):
481@@ -140,13 +149,14 @@
482 updated=datetime.fromtimestamp(kwargs["timestamp"]),
483 ))
484
485- def test_creates_ignores_none_hostname(self):
486+ def test_create_ignores_none_hostname(self):
487 subnet = factory.make_ipv4_Subnet_with_IPRanges(
488 with_static_range=False, dhcp_on=True)
489 dynamic_range = subnet.get_dynamic_ranges()[0]
490 ip = factory.pick_ip_in_IPRange(dynamic_range)
491+ hostname = "(none)"
492 kwargs = self.make_kwargs(
493- action="commit", ip=ip, hostname="(none)")
494+ action="commit", ip=ip, hostname=hostname)
495 update_lease(**kwargs)
496 unknown_interface = UnknownInterface.objects.filter(
497 mac_address=kwargs["mac"]).first()
498@@ -162,6 +172,76 @@
499 created=datetime.fromtimestamp(kwargs["timestamp"]),
500 updated=datetime.fromtimestamp(kwargs["timestamp"]),
501 ))
502+ # No DNS record should have been crated.
503+ self.assertThat(DNSResource.objects.count(), Equals(0))
504+
505+ def test_creates_dns_record_for_hostname(self):
506+ subnet = factory.make_ipv4_Subnet_with_IPRanges(
507+ with_static_range=False, dhcp_on=True)
508+ dynamic_range = subnet.get_dynamic_ranges()[0]
509+ ip = factory.pick_ip_in_IPRange(dynamic_range)
510+ hostname = factory.make_name().lower()
511+ kwargs = self.make_kwargs(
512+ action="commit", ip=ip, hostname=hostname)
513+ update_lease(**kwargs)
514+ unknown_interface = UnknownInterface.objects.filter(
515+ mac_address=kwargs["mac"]).first()
516+ self.assertIsNotNone(unknown_interface)
517+ self.assertEquals(subnet.vlan, unknown_interface.vlan)
518+ sip = unknown_interface.ip_addresses.first()
519+ self.assertIsNotNone(sip)
520+ dnsrr = get_one(DNSResource.objects.filter(name=hostname))
521+ self.assertThat(sip.dnsresource_set.all(), Contains(dnsrr))
522+
523+ def test_mutiple_calls_reuse_existing_staticipaddress_records(self):
524+ subnet = factory.make_ipv4_Subnet_with_IPRanges(
525+ with_static_range=False, dhcp_on=True)
526+ dynamic_range = subnet.get_dynamic_ranges()[0]
527+ ip = factory.pick_ip_in_IPRange(dynamic_range)
528+ hostname = factory.make_name().lower()
529+ kwargs = self.make_kwargs(
530+ action="commit", ip=ip, hostname=hostname)
531+ update_lease(**kwargs)
532+ sip1 = StaticIPAddress.objects.get(ip=ip)
533+ update_lease(**kwargs)
534+ sip2 = StaticIPAddress.objects.get(ip=ip)
535+ self.assertThat(sip1.id, Equals(sip2.id))
536+
537+ def test_skips_dns_record_for_hostname_from_existing_node(self):
538+ subnet = factory.make_ipv4_Subnet_with_IPRanges(
539+ with_static_range=False, dhcp_on=True)
540+ dynamic_range = subnet.get_dynamic_ranges()[0]
541+ ip = factory.pick_ip_in_IPRange(dynamic_range)
542+ hostname = factory.make_name().lower()
543+ factory.make_Node(hostname=hostname)
544+ kwargs = self.make_kwargs(
545+ action="commit", ip=ip, hostname=hostname)
546+ update_lease(**kwargs)
547+ unknown_interface = UnknownInterface.objects.filter(
548+ mac_address=kwargs["mac"]).first()
549+ self.assertIsNotNone(unknown_interface)
550+ self.assertEquals(subnet.vlan, unknown_interface.vlan)
551+ sip = unknown_interface.ip_addresses.first()
552+ self.assertIsNotNone(sip)
553+ self.assertThat(sip.dnsresource_set.all(), Not(Contains(sip)))
554+
555+ def test_skips_dns_record_for_coerced_hostname_from_existing_node(self):
556+ subnet = factory.make_ipv4_Subnet_with_IPRanges(
557+ with_static_range=False, dhcp_on=True)
558+ dynamic_range = subnet.get_dynamic_ranges()[0]
559+ ip = factory.pick_ip_in_IPRange(dynamic_range)
560+ hostname = "gaming device"
561+ factory.make_Node(hostname="gaming-device")
562+ kwargs = self.make_kwargs(
563+ action="commit", ip=ip, hostname=hostname)
564+ update_lease(**kwargs)
565+ unknown_interface = UnknownInterface.objects.filter(
566+ mac_address=kwargs["mac"]).first()
567+ self.assertIsNotNone(unknown_interface)
568+ self.assertEquals(subnet.vlan, unknown_interface.vlan)
569+ sip = unknown_interface.ip_addresses.first()
570+ self.assertIsNotNone(sip)
571+ self.assertThat(sip.dnsresource_set.all(), Not(Contains(sip)))
572
573 def test_creates_lease_for_physical_interface(self):
574 subnet = factory.make_ipv4_Subnet_with_IPRanges(
575
576=== modified file 'src/maasserver/testing/factory.py'
577--- src/maasserver/testing/factory.py 2017-04-11 16:49:44 +0000
578+++ src/maasserver/testing/factory.py 2017-05-05 13:38:34 +0000
579@@ -647,6 +647,14 @@
580 name = self.make_name('label')
581 if ip_addresses is None and not no_ip_addresses:
582 ip_addresses = [self.make_StaticIPAddress(**kwargs)]
583+ elif isinstance(ip_addresses, list):
584+ ip_addresses = [
585+ self.make_StaticIPAddress(
586+ ip=ip, alloc_type=IPADDRESS_TYPE.USER_RESERVED)
587+ if not isinstance(ip, StaticIPAddress)
588+ else ip
589+ for ip in ip_addresses
590+ ]
591 dnsrr = DNSResource(
592 name=name, address_ttl=address_ttl,
593 domain=domain)
594@@ -857,7 +865,8 @@
595 # that in ages past (?) MAAS did not require a network to be
596 # modelled in order to add a USER_RESERVED address, and there are
597 # most likely test cases that depend on this behavior.
598- if subnet is None and alloc_type != IPADDRESS_TYPE.USER_RESERVED:
599+ if (ip is undefined and subnet is None and
600+ alloc_type != IPADDRESS_TYPE.USER_RESERVED):
601 subnet = self.make_Subnet(vlan=vlan)
602 else:
603 if vlan is None:
604@@ -872,7 +881,7 @@
605 # hints as to why we think this behaviour exists.
606 if not subnet and alloc_type == IPADDRESS_TYPE.USER_RESERVED:
607 ip = self.make_ip_address()
608- else:
609+ elif subnet is not None:
610 ip = self.pick_ip_in_network(
611 IPNetwork(subnet.cidr),
612 but_not=self._get_exclude_list(subnet))
613
614=== modified file 'src/provisioningserver/rpc/tests/test_utils.py'
615--- src/provisioningserver/rpc/tests/test_utils.py 2016-10-18 11:55:44 +0000
616+++ src/provisioningserver/rpc/tests/test_utils.py 2017-05-05 13:38:34 +0000
617@@ -24,7 +24,6 @@
618 from provisioningserver.rpc.testing import MockLiveClusterToRegionRPCFixture
619 import provisioningserver.rpc.utils
620 from provisioningserver.rpc.utils import (
621- coerce_to_valid_hostname,
622 commission_node,
623 create_node,
624 )
625@@ -32,29 +31,6 @@
626 from twisted.internet import defer
627
628
629-class TestCoerceHostname(MAASTestCase):
630-
631- def test_replaces_international_characters(self):
632- self.assertEqual("abc-123", coerce_to_valid_hostname("abc青い空123"))
633-
634- def test_removes_illegal_dashes(self):
635- self.assertEqual("abc123", coerce_to_valid_hostname("-abc123-"))
636-
637- def test_replaces_whitespace_and_special_characters(self):
638- self.assertEqual(
639- "abc123-ubuntu", coerce_to_valid_hostname("abc123 (ubuntu)"))
640-
641- def test_makes_hostname_lowercase(self):
642- self.assertEqual(
643- "ubunturocks", coerce_to_valid_hostname("UbuntuRocks"))
644-
645- def test_returns_none_if_result_empty(self):
646- self.assertIsNone(coerce_to_valid_hostname("-人間性-"))
647-
648- def test_returns_none_if_result_too_large(self):
649- self.assertIsNone(coerce_to_valid_hostname('a' * 65))
650-
651-
652 class TestCreateNode(MAASTestCase):
653
654 run_tests_with = MAASTwistedRunTest.make_factory(timeout=5)
655
656=== modified file 'src/provisioningserver/rpc/utils.py'
657--- src/provisioningserver/rpc/utils.py 2016-10-25 13:57:02 +0000
658+++ src/provisioningserver/rpc/utils.py 2017-05-05 13:38:34 +0000
659@@ -9,7 +9,6 @@
660 ]
661
662 import json
663-import re
664
665 from provisioningserver.logger import get_maas_logger
666 from provisioningserver.rpc import getRegionClient
667@@ -19,6 +18,7 @@
668 NodeAlreadyExists,
669 )
670 from provisioningserver.rpc.region import CreateNode
671+from provisioningserver.utils.network import coerce_to_valid_hostname
672 from provisioningserver.utils.twisted import (
673 asynchronous,
674 pause,
675@@ -38,21 +38,6 @@
676 maaslog = get_maas_logger("region")
677
678
679-def coerce_to_valid_hostname(hostname):
680- """Given a server name that may contain spaces and special characters,
681- attempts to derive a valid hostname.
682-
683- :param hostname: the specified (possibly invalid) hostname
684- :return: the resulting string, or None if the hostname could not be coerced
685- """
686- hostname = hostname.lower()
687- hostname = re.sub(r'[^a-z0-9-]+', '-', hostname)
688- hostname = hostname.strip('-')
689- if hostname == '' or len(hostname) > 64:
690- return None
691- return hostname
692-
693-
694 @asynchronous
695 @inlineCallbacks
696 def create_node(
697
698=== modified file 'src/provisioningserver/utils/network.py'
699--- src/provisioningserver/utils/network.py 2017-04-17 23:17:25 +0000
700+++ src/provisioningserver/utils/network.py 2017-05-05 13:38:34 +0000
701@@ -22,6 +22,7 @@
702 import codecs
703 from collections import namedtuple
704 from operator import attrgetter
705+import re
706 import socket
707 from socket import (
708 AF_INET,
709@@ -1305,3 +1306,18 @@
710 else:
711 return results
712 return None
713+
714+
715+def coerce_to_valid_hostname(hostname):
716+ """Given a server name that may contain spaces and special characters,
717+ attempts to derive a valid hostname.
718+
719+ :param hostname: the specified (possibly invalid) hostname
720+ :return: the resulting string, or None if the hostname could not be coerced
721+ """
722+ hostname = hostname.lower()
723+ hostname = re.sub(r'[^a-z0-9-]+', '-', hostname)
724+ hostname = hostname.strip('-')
725+ if hostname == '' or len(hostname) > 64:
726+ return None
727+ return hostname
728
729=== modified file 'src/provisioningserver/utils/tests/test_network.py'
730--- src/provisioningserver/utils/tests/test_network.py 2017-04-17 23:17:25 +0000
731+++ src/provisioningserver/utils/tests/test_network.py 2017-05-05 13:38:34 +0000
732@@ -45,6 +45,7 @@
733 bytes_to_hex,
734 bytes_to_int,
735 clean_up_netifaces_address,
736+ coerce_to_valid_hostname,
737 find_ip_via_arp,
738 find_mac_via_arp,
739 format_eui,
740@@ -2095,3 +2096,26 @@
741 self.lookupPointer.side_effect = TypeError
742 with ExpectedException(TypeError):
743 yield reverseResolve(factory.make_ip_address())
744+
745+
746+class TestCoerceHostname(MAASTestCase):
747+
748+ def test_replaces_international_characters(self):
749+ self.assertEqual("abc-123", coerce_to_valid_hostname("abc青い空123"))
750+
751+ def test_removes_illegal_dashes(self):
752+ self.assertEqual("abc123", coerce_to_valid_hostname("-abc123-"))
753+
754+ def test_replaces_whitespace_and_special_characters(self):
755+ self.assertEqual(
756+ "abc123-ubuntu", coerce_to_valid_hostname("abc123 (ubuntu)"))
757+
758+ def test_makes_hostname_lowercase(self):
759+ self.assertEqual(
760+ "ubunturocks", coerce_to_valid_hostname("UbuntuRocks"))
761+
762+ def test_returns_none_if_result_empty(self):
763+ self.assertIsNone(coerce_to_valid_hostname("-人間性-"))
764+
765+ def test_returns_none_if_result_too_large(self):
766+ self.assertIsNone(coerce_to_valid_hostname('a' * 65))