Merge lp:~mpontillo/maas/dns-dhcp-fixes--bug-1686234 into lp:~maas-committers/maas/trunk
- dns-dhcp-fixes--bug-1686234
- Merge into 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 |
Related bugs: |
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_
Description of the change
To post a comment you must log in.
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)) |
overall it looks good, I just have a few questions inline.