Merge lp:~gmb/maas/generate-dns-for-dynamic-pool-bug-1382190 into lp:~maas-committers/maas/trunk

Proposed by Graham Binns
Status: Merged
Approved by: Graham Binns
Approved revision: no longer in the source branch.
Merged at revision: 3308
Proposed branch: lp:~gmb/maas/generate-dns-for-dynamic-pool-bug-1382190
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 802 lines (+551/-20)
6 files modified
etc/maas/templates/dns/zone.template (+6/-0)
src/maasserver/dns/tests/test_config.py (+4/-4)
src/maasserver/dns/tests/test_zonegenerator.py (+20/-3)
src/maasserver/dns/zonegenerator.py (+26/-7)
src/provisioningserver/dns/tests/test_zoneconfig.py (+353/-4)
src/provisioningserver/dns/zoneconfig.py (+142/-2)
To merge this branch: bzr merge lp:~gmb/maas/generate-dns-for-dynamic-pool-bug-1382190
Reviewer Review Type Date Requested Status
Raphaël Badin (community) Approve
Gavin Panella (community) Approve
Review via email: mp+239912@code.launchpad.net

Commit message

Using $GENERATE directives, generate DNS A and PTR records for all addresses in each managed NodeGroupInterface's dynamic range, as long as that dynamic range uses IPv4.

Previously, hosts with addresses in the dynamic range were not resolvable through DNS, which caused problems with some Juju charms.

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) wrote :

Marking this work-in-progress because I think I see several ways that I can improve this branch before it lands — the advantage of a couple of hours of not looking at it.

Revision history for this message
Gavin Panella (allenap) wrote :

Partial review. More after lunch!

Revision history for this message
Gavin Panella (allenap) wrote :

Looks good. I have a lot of comments, and there's a 255/256 issue in several places that needs your attention (I may be wrong), but +1 anyway. I'm happy to look again before landing if you want.

review: Approve
Revision history for this message
Graham Binns (gmb) wrote :
Download full text (34.1 KiB)

On 29 October 2014 12:25, Gavin Panella <email address hidden> wrote:
> Partial review. More after lunch!
>
> Diff comments:
>
>> === modified file 'etc/maas/templates/dns/zone.template'
>> --- etc/maas/templates/dns/zone.template 2012-10-04 15:59:56 +0000
>> +++ etc/maas/templates/dns/zone.template 2014-10-29 11:33:01 +0000
>> @@ -12,6 +12,12 @@
>> )
>>
>> IN NS {{domain}}.
>> +{{for type, directive in generate_directives.items()}}
>> +{{for iterator_values, rdns, hostname in directive}}
>> +$GENERATE {{iterator_values}} {{rdns}} IN {{type}} {{hostname}}
>> +{{endfor}}
>> +{{endfor}}
>> +
>> {{for type, mapping in mappings.items()}}
>> {{for item_from, item_to in mapping}}
>> {{item_from}} IN {{type}} {{item_to}}
>>
>> === modified file 'src/maasserver/dns/tests/test_zonegenerator.py'
>> --- src/maasserver/dns/tests/test_zonegenerator.py 2014-09-15 14:28:28 +0000
>> +++ src/maasserver/dns/tests/test_zonegenerator.py 2014-10-29 11:33:01 +0000
>> @@ -393,14 +393,28 @@
>> [interface] = nodegroup.get_managed_interfaces()
>> networks_dict = ZoneGenerator._get_networks()
>> retrieved_interface = networks_dict[nodegroup]
>> - self.assertEqual([interface.network], retrieved_interface)
>> + self.assertEqual(
>> + [
>> + (
>> + interface.network,
>> + (interface.ip_range_low, interface.ip_range_high)
>
> Consider using an IPRange here. NodeGroupInterface.get_dynamic_ip_range() returns one.
>
>
>> + )
>> + ],
>> + retrieved_interface)
>>
>> def test_get_networks_returns_multiple_networks(self):
>> nodegroups = [self.make_node_group() for _ in range(3)]
>> networks_dict = ZoneGenerator._get_networks()
>> for nodegroup in nodegroups:
>> [interface] = nodegroup.get_managed_interfaces()
>> - self.assertEqual([interface.network], networks_dict[nodegroup])
>> + self.assertEqual(
>> + [
>> + (
>> + interface.network,
>> + (interface.ip_range_low, interface.ip_range_high),
>> + ),
>> + ],
>> + networks_dict[nodegroup])
>>
>> def test_get_networks_returns_managed_networks(self):
>> nodegroups = [
>> @@ -415,7 +429,10 @@
>> self.assertEqual(
>> {
>> nodegroup: [
>> - interface.network
>> + (
>> + interface.network,
>> + (interface.ip_range_low, interface.ip_range_high),
>> + )
>> for interface in nodegroup.get_managed_interfaces()
>> ]
>> for nodegroup in nodegroups
>>
>> === modified file 'src/maasserver/dns/zonegenerator.py'
>> --- src/maasserver/dns/zonegenerator.py 2014-08-28 12:00:15 +0000
>> +++ src/maasserver/dns/zonegenerator.py 2014-10-29 11:33:01 +0000
>> @@ -29,7 +29,10 @@
>> from maasserver.models.config import Config
>> from maasserver.models.nod...

Revision history for this message
Graham Binns (gmb) wrote :
Download full text (34.7 KiB)

On 29 October 2014 13:54, Gavin Panella <email address hidden> wrote:
> Review: Approve
>
> Looks good. I have a lot of comments, and there's a 255/256 issue in several places that needs your attention (I may be wrong), but +1 anyway. I'm happy to look again before landing if you want.
>
> Diff comments:
>
>> === modified file 'etc/maas/templates/dns/zone.template'
>> --- etc/maas/templates/dns/zone.template 2012-10-04 15:59:56 +0000
>> +++ etc/maas/templates/dns/zone.template 2014-10-29 11:33:01 +0000
>> @@ -12,6 +12,12 @@
>> )
>>
>> IN NS {{domain}}.
>> +{{for type, directive in generate_directives.items()}}
>> +{{for iterator_values, rdns, hostname in directive}}
>> +$GENERATE {{iterator_values}} {{rdns}} IN {{type}} {{hostname}}
>> +{{endfor}}
>> +{{endfor}}
>> +
>> {{for type, mapping in mappings.items()}}
>> {{for item_from, item_to in mapping}}
>> {{item_from}} IN {{type}} {{item_to}}
>>
>> === modified file 'src/maasserver/dns/tests/test_zonegenerator.py'
>> --- src/maasserver/dns/tests/test_zonegenerator.py 2014-09-15 14:28:28 +0000
>> +++ src/maasserver/dns/tests/test_zonegenerator.py 2014-10-29 11:33:01 +0000
>> @@ -393,14 +393,28 @@
>> [interface] = nodegroup.get_managed_interfaces()
>> networks_dict = ZoneGenerator._get_networks()
>> retrieved_interface = networks_dict[nodegroup]
>> - self.assertEqual([interface.network], retrieved_interface)
>> + self.assertEqual(
>> + [
>> + (
>> + interface.network,
>> + (interface.ip_range_low, interface.ip_range_high)
>> + )
>> + ],
>> + retrieved_interface)
>>
>> def test_get_networks_returns_multiple_networks(self):
>> nodegroups = [self.make_node_group() for _ in range(3)]
>> networks_dict = ZoneGenerator._get_networks()
>> for nodegroup in nodegroups:
>> [interface] = nodegroup.get_managed_interfaces()
>> - self.assertEqual([interface.network], networks_dict[nodegroup])
>> + self.assertEqual(
>> + [
>> + (
>> + interface.network,
>> + (interface.ip_range_low, interface.ip_range_high),
>> + ),
>> + ],
>> + networks_dict[nodegroup])
>>
>> def test_get_networks_returns_managed_networks(self):
>> nodegroups = [
>> @@ -415,7 +429,10 @@
>> self.assertEqual(
>> {
>> nodegroup: [
>> - interface.network
>> + (
>> + interface.network,
>> + (interface.ip_range_low, interface.ip_range_high),
>> + )
>> for interface in nodegroup.get_managed_interfaces()
>> ]
>> for nodegroup in nodegroups
>>
>> === modified file 'src/maasserver/dns/zonegenerator.py'
>> --- src/maasserver/dns/zonegenerator.py 2014-08-28 12:00:15 +0000
>> +++ src/maasserver/dns/zonegenerator.py 2014-10-29 11:33:01 +0000
>> @@ -29,7 +2...

Revision history for this message
Raphaël Badin (rvb) wrote :

I added a bunch of comments on top of what Gavin already said.

I've tested that this works as expected on my NUCs and testing went great (but I didn't test the corner cases, just that this addresses the main issue); The main thing I suggested was the removal of the 'dynamic' prefix in the GENERATE records to preserve exactly the hostnames we had before in order to avoid upgrade nightmares; thanks for addressing this quickly gmb.

fwiw, I really wonder if this isn't a bit overly complicated. I understand this was done this way to limit the number of GENERATE records as much as possible but the end result is code that will be a bit painful to maintain.

Same as Gavin (in his comment that starts with "There's a *lot* of machinery in here to come up with expected output."), I think that the testing would benefit from using the expected string output when possible; additionally, this will help us be confident that the corner cases are covered.

review: Approve
Revision history for this message
Graham Binns (gmb) wrote :

On 29 October 2014 16:47, Raphaël Badin <email address hidden> wrote:
> All I'm saying is that we need the proper limitations in place at the form level. What do you think?

I think I'm going to do that in a separate branch, and I'm already
working on it :)

Revision history for this message
Graham Binns (gmb) wrote :
Download full text (35.9 KiB)

On 29 October 2014 16:47, Raphaël Badin <email address hidden> wrote:
> Review: Approve
>
> I added a bunch of comments on top of what Gavin already said.
>
> I've tested that this works as expected on my NUCs and testing went great (but I didn't test the corner cases, just that this addresses the main issue); The main thing I suggested was the removal of the 'dynamic' prefix in the GENERATE records to preserve exactly the hostnames we had before in order to avoid upgrade nightmares; thanks for addressing this quickly gmb.
>
> fwiw, I really wonder if this isn't a bit overly complicated. I understand this was done this way to limit the number of GENERATE records as much as possible but the end result is code that will be a bit painful to maintain.

I agree. I would have been happy to land the older version too, but
now that we're here…

> Same as Gavin (in his comment that starts with "There's a *lot* of machinery in here to come up with expected output."), I think that the testing would benefit from using the expected string output when possible; additionally, this will help us be confident that the corner cases are covered.

ISWYM. I think I'm channelling Jeroen's "Don't let it look like you're
relying on specific values" approach a bit too much :). I've fixed
that a bit by adding explicit tests which actually cover an odd IP
range. I don't want to block landing this for 1.7 on the tests being
readable, though. I'll file a tech-debt bug to re-work them.

>
>> === modified file 'etc/maas/templates/dns/zone.template'
>> --- etc/maas/templates/dns/zone.template 2012-10-04 15:59:56 +0000
>> +++ etc/maas/templates/dns/zone.template 2014-10-29 11:33:01 +0000
>> @@ -12,6 +12,12 @@
>> )
>>
>> IN NS {{domain}}.
>> +{{for type, directive in generate_directives.items()}}
>> +{{for iterator_values, rdns, hostname in directive}}
>> +$GENERATE {{iterator_values}} {{rdns}} IN {{type}} {{hostname}}
>> +{{endfor}}
>> +{{endfor}}
>> +
>> {{for type, mapping in mappings.items()}}
>> {{for item_from, item_to in mapping}}
>> {{item_from}} IN {{type}} {{item_to}}
>>
>> === modified file 'src/maasserver/dns/tests/test_zonegenerator.py'
>> --- src/maasserver/dns/tests/test_zonegenerator.py 2014-09-15 14:28:28 +0000
>> +++ src/maasserver/dns/tests/test_zonegenerator.py 2014-10-29 11:33:01 +0000
>> @@ -393,14 +393,28 @@
>> [interface] = nodegroup.get_managed_interfaces()
>> networks_dict = ZoneGenerator._get_networks()
>> retrieved_interface = networks_dict[nodegroup]
>> - self.assertEqual([interface.network], retrieved_interface)
>> + self.assertEqual(
>> + [
>> + (
>> + interface.network,
>> + (interface.ip_range_low, interface.ip_range_high)
>> + )
>> + ],
>> + retrieved_interface)
>>
>> def test_get_networks_returns_multiple_networks(self):
>> nodegroups = [self.make_node_group() for _ in range(3)]
>> networks_dict = ZoneGenerator._get_networks()
>> for nodegroup in nodegroups:
>> [interface] = nodegroup.get_managed_interfaces()
...

Revision history for this message
Andres Rodriguez (andreserl) wrote :

I'll produce packages later today q
So we can start testing this immediately and ensure we have no regressions.
On Oct 29, 2014 4:15 PM, "Graham Binns" <email address hidden> wrote:

> The proposal to merge
> lp:~gmb/maas/generate-dns-for-dynamic-pool-bug-1382190 into lp:maas has
> been updated.
>
> Status: Needs review => Approved
>
> For more details, see:
>
> https://code.launchpad.net/~gmb/maas/generate-dns-for-dynamic-pool-bug-1382190/+merge/239912
> --
>
> https://code.launchpad.net/~gmb/maas/generate-dns-for-dynamic-pool-bug-1382190/+merge/239912
> You are subscribed to branch lp:maas.
>

Revision history for this message
Graham Binns (gmb) wrote :

On 29 October 2014 20:18, Andres Rodriguez <email address hidden> wrote:
> I'll produce packages later today q
> So we can start testing this immediately and ensure we have no regressions.

Cool. I'll run up a branch for 1.7, too.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'etc/maas/templates/dns/zone.template'
2--- etc/maas/templates/dns/zone.template 2012-10-04 15:59:56 +0000
3+++ etc/maas/templates/dns/zone.template 2014-10-29 19:33:27 +0000
4@@ -12,6 +12,12 @@
5 )
6
7 IN NS {{domain}}.
8+{{for type, directive in generate_directives.items()}}
9+{{for iterator_values, rdns, hostname in directive}}
10+$GENERATE {{iterator_values}} {{rdns}} IN {{type}} {{hostname}}
11+{{endfor}}
12+{{endfor}}
13+
14 {{for type, mapping in mappings.items()}}
15 {{for item_from, item_to in mapping}}
16 {{item_from}} IN {{type}} {{item_to}}
17
18=== modified file 'src/maasserver/dns/tests/test_config.py'
19--- src/maasserver/dns/tests/test_config.py 2014-10-15 11:22:22 +0000
20+++ src/maasserver/dns/tests/test_config.py 2014-10-29 19:33:27 +0000
21@@ -165,15 +165,15 @@
22 # A forward lookup on the hostname returns the IP address.
23 fqdn = "%s.%s" % (hostname, domain)
24 forward_lookup_result = self.dig_resolve(fqdn, version=version)
25- self.assertEqual(
26- [ip], forward_lookup_result,
27+ self.expectThat(
28+ forward_lookup_result, Contains(ip),
29 "Failed to resolve '%s' (results: '%s')." % (
30 fqdn, ','.join(forward_lookup_result)))
31 # A reverse lookup on the IP address returns the hostname.
32 reverse_lookup_result = self.dig_reverse_resolve(
33 ip, version=version)
34- self.assertEqual(
35- ["%s." % fqdn], reverse_lookup_result,
36+ self.expectThat(
37+ reverse_lookup_result, Contains("%s." % fqdn),
38 "Failed to reverse resolve '%s' (results: '%s')." % (
39 fqdn, ','.join(reverse_lookup_result)))
40
41
42=== modified file 'src/maasserver/dns/tests/test_zonegenerator.py'
43--- src/maasserver/dns/tests/test_zonegenerator.py 2014-09-15 14:28:28 +0000
44+++ src/maasserver/dns/tests/test_zonegenerator.py 2014-10-29 19:33:27 +0000
45@@ -393,14 +393,28 @@
46 [interface] = nodegroup.get_managed_interfaces()
47 networks_dict = ZoneGenerator._get_networks()
48 retrieved_interface = networks_dict[nodegroup]
49- self.assertEqual([interface.network], retrieved_interface)
50+ self.assertEqual(
51+ [
52+ (
53+ interface.network,
54+ (interface.ip_range_low, interface.ip_range_high)
55+ )
56+ ],
57+ retrieved_interface)
58
59 def test_get_networks_returns_multiple_networks(self):
60 nodegroups = [self.make_node_group() for _ in range(3)]
61 networks_dict = ZoneGenerator._get_networks()
62 for nodegroup in nodegroups:
63 [interface] = nodegroup.get_managed_interfaces()
64- self.assertEqual([interface.network], networks_dict[nodegroup])
65+ self.assertEqual(
66+ [
67+ (
68+ interface.network,
69+ (interface.ip_range_low, interface.ip_range_high),
70+ ),
71+ ],
72+ networks_dict[nodegroup])
73
74 def test_get_networks_returns_managed_networks(self):
75 nodegroups = [
76@@ -415,7 +429,10 @@
77 self.assertEqual(
78 {
79 nodegroup: [
80- interface.network
81+ (
82+ interface.network,
83+ (interface.ip_range_low, interface.ip_range_high),
84+ )
85 for interface in nodegroup.get_managed_interfaces()
86 ]
87 for nodegroup in nodegroups
88
89=== modified file 'src/maasserver/dns/zonegenerator.py'
90--- src/maasserver/dns/zonegenerator.py 2014-08-28 12:00:15 +0000
91+++ src/maasserver/dns/zonegenerator.py 2014-10-29 19:33:27 +0000
92@@ -29,7 +29,10 @@
93 from maasserver.models.config import Config
94 from maasserver.models.nodegroup import NodeGroup
95 from maasserver.server_address import get_maas_facing_server_address
96-from netaddr import IPAddress
97+from netaddr import (
98+ IPAddress,
99+ IPRange,
100+ )
101 from provisioningserver.dns.zoneconfig import (
102 DNSForwardZoneConfig,
103 DNSReverseZoneConfig,
104@@ -180,9 +183,18 @@
105
106 @staticmethod
107 def _get_networks():
108- """Return a lazily evaluated nodegroup:network dict."""
109- return lazydict(lambda ng: [
110- interface.network for interface in ng.get_managed_interfaces()])
111+ """Return a lazily evaluated nodegroup:network_details dict.
112+
113+ network_details takes the form of a tuple of (network,
114+ (network.ip_range_low, network.ip_range_high)).
115+ """
116+
117+ def get_network(nodegroup):
118+ return [
119+ (iface.network, (iface.ip_range_low, iface.ip_range_high))
120+ for iface in nodegroup.get_managed_interfaces()
121+ ]
122+ return lazydict(get_network)
123
124 @staticmethod
125 def _get_srv_mappings():
126@@ -209,6 +221,12 @@
127 forward_nodegroups = sorted(nodegroups, key=get_domain)
128 for domain, nodegroups in groupby(forward_nodegroups, get_domain):
129 nodegroups = list(nodegroups)
130+ dynamic_ranges = [
131+ interface.get_dynamic_ip_range()
132+ for nodegroup in nodegroups
133+ for interface in nodegroup.get_managed_interfaces()
134+ ]
135+
136 # A forward zone encompassing all nodes in the same domain.
137 yield DNSForwardZoneConfig(
138 domain, serial=serial, dns_ip=dns_ip,
139@@ -217,7 +235,8 @@
140 for nodegroup in nodegroups
141 for hostname, ip in mappings[nodegroup].items()
142 },
143- srv_mapping=set(srv_mappings)
144+ srv_mapping=set(srv_mappings),
145+ dynamic_ranges=dynamic_ranges,
146 )
147
148 @staticmethod
149@@ -226,11 +245,11 @@
150 get_domain = lambda nodegroup: nodegroup.name
151 reverse_nodegroups = sorted(nodegroups, key=networks.get)
152 for nodegroup in reverse_nodegroups:
153- for network in networks[nodegroup]:
154+ for network, dynamic_range in networks[nodegroup]:
155 mapping = mappings[nodegroup]
156 yield DNSReverseZoneConfig(
157 get_domain(nodegroup), serial=serial, mapping=mapping,
158- network=network
159+ network=network, dynamic_ranges=[IPRange(*dynamic_range)]
160 )
161
162 def __iter__(self):
163
164=== modified file 'src/provisioningserver/dns/tests/test_zoneconfig.py'
165--- src/provisioningserver/dns/tests/test_zoneconfig.py 2014-09-23 21:43:27 +0000
166+++ src/provisioningserver/dns/tests/test_zoneconfig.py 2014-10-29 19:33:27 +0000
167@@ -22,10 +22,12 @@
168 import random
169
170 from maastesting.factory import factory
171+from maastesting.matchers import MockNotCalled
172 from maastesting.testcase import MAASTestCase
173 from netaddr import (
174 IPAddress,
175 IPNetwork,
176+ IPRange,
177 )
178 from provisioningserver.dns.config import (
179 get_dns_config_dir,
180@@ -35,11 +37,14 @@
181 from provisioningserver.dns.zoneconfig import (
182 DNSForwardZoneConfig,
183 DNSReverseZoneConfig,
184+ intersect_iprange,
185 )
186 from testtools.matchers import (
187 Contains,
188 ContainsAll,
189+ Equals,
190 FileContains,
191+ HasLength,
192 IsInstance,
193 MatchesAll,
194 MatchesStructure,
195@@ -187,10 +192,13 @@
196 ipv4_hostname: [ipv4_ip],
197 ipv6_hostname: [ipv6_ip],
198 }
199+ expected_generate_directives = (
200+ DNSForwardZoneConfig.get_GENERATE_directives(network))
201 srv = self.make_srv_record()
202 dns_zone_config = DNSForwardZoneConfig(
203 domain, serial=random.randint(1, 100),
204- mapping=mapping, dns_ip=dns_ip, srv_mapping=[srv])
205+ mapping=mapping, dns_ip=dns_ip, srv_mapping=[srv],
206+ dynamic_ranges=[IPRange(network.first, network.last)])
207 dns_zone_config.write_config()
208 self.assertThat(
209 os.path.join(target_dir, 'zone.%s' % domain),
210@@ -201,6 +209,12 @@
211 srv.service, self.get_srv_item_output(srv)),
212 '%s IN A %s' % (ipv4_hostname, ipv4_ip),
213 '%s IN AAAA %s' % (ipv6_hostname, ipv6_ip),
214+ ] +
215+ [
216+ '$GENERATE %s %s IN A %s' % (
217+ iterator_values, reverse_dns, hostname)
218+ for iterator_values, reverse_dns, hostname in
219+ expected_generate_directives
220 ]
221 )
222 )
223@@ -222,6 +236,31 @@
224 '%s. IN A %s' % (dns_zone_config.domain, dns_ip),
225 ])))
226
227+ def test_ignores_generate_directives_for_v6_dynamic_ranges(self):
228+ patch_dns_config_path(self)
229+ domain = factory.make_string()
230+ network = factory.make_ipv4_network()
231+ dns_ip = factory.pick_ip_in_network(network)
232+ ipv4_hostname = factory.make_name('host')
233+ ipv4_ip = factory.pick_ip_in_network(network)
234+ ipv6_hostname = factory.make_name('host')
235+ ipv6_ip = factory.make_ipv6_address()
236+ ipv6_network = factory.make_ipv6_network()
237+ dynamic_range = IPRange(ipv6_network.first, ipv6_network.last)
238+ mapping = {
239+ ipv4_hostname: [ipv4_ip],
240+ ipv6_hostname: [ipv6_ip],
241+ }
242+ srv = self.make_srv_record()
243+ dns_zone_config = DNSForwardZoneConfig(
244+ domain, serial=random.randint(1, 100),
245+ mapping=mapping, dns_ip=dns_ip, srv_mapping=[srv],
246+ dynamic_ranges=[dynamic_range])
247+ get_generate_directives = self.patch(
248+ dns_zone_config, 'get_GENERATE_directives')
249+ dns_zone_config.write_config()
250+ self.assertThat(get_generate_directives, MockNotCalled())
251+
252 def test_config_file_is_world_readable(self):
253 patch_dns_config_path(self)
254 dns_zone_config = DNSForwardZoneConfig(
255@@ -350,16 +389,43 @@
256 target_dir = patch_dns_config_path(self)
257 domain = factory.make_string()
258 network = IPNetwork('192.168.0.1/22')
259+ dynamic_network = IPNetwork('192.168.0.1/28')
260 dns_zone_config = DNSReverseZoneConfig(
261- domain, serial=random.randint(1, 100), network=network)
262+ domain, serial=random.randint(1, 100), network=network,
263+ dynamic_ranges=[
264+ IPRange(dynamic_network.first, dynamic_network.last)])
265 dns_zone_config.write_config()
266 reverse_file_name = 'zone.168.192.in-addr.arpa'
267- expected = Contains(
268- 'IN NS %s' % domain)
269+ expected_generate_directives = dns_zone_config.get_GENERATE_directives(
270+ dynamic_network, domain)
271+ expected = ContainsAll(
272+ [
273+ 'IN NS %s' % domain
274+ ] +
275+ [
276+ '$GENERATE %s %s IN PTR %s' % (
277+ iterator_values, reverse_dns, hostname)
278+ for iterator_values, reverse_dns, hostname in
279+ expected_generate_directives
280+ ])
281 self.assertThat(
282 os.path.join(target_dir, reverse_file_name),
283 FileContains(matcher=expected))
284
285+ def test_ignores_generate_directives_for_v6_dynamic_ranges(self):
286+ patch_dns_config_path(self)
287+ domain = factory.make_string()
288+ network = IPNetwork('192.168.0.1/22')
289+ dynamic_network = IPNetwork("%s/64" % factory.make_ipv6_address())
290+ dns_zone_config = DNSReverseZoneConfig(
291+ domain, serial=random.randint(1, 100), network=network,
292+ dynamic_ranges=[
293+ IPRange(dynamic_network.first, dynamic_network.last)])
294+ get_generate_directives = self.patch(
295+ dns_zone_config, 'get_GENERATE_directives')
296+ dns_zone_config.write_config()
297+ self.assertThat(get_generate_directives, MockNotCalled())
298+
299 def test_reverse_config_file_is_world_readable(self):
300 patch_dns_config_path(self)
301 dns_zone_config = DNSReverseZoneConfig(
302@@ -368,3 +434,286 @@
303 dns_zone_config.write_config()
304 filepath = FilePath(dns_zone_config.target_path)
305 self.assertTrue(filepath.getPermissions().other.read)
306+
307+
308+class TestDNSReverseZoneConfig_GetGenerateDirectives(MAASTestCase):
309+ """Tests for `DNSReverseZoneConfig.get_GENERATE_directives()`."""
310+
311+ def test_excplicitly(self):
312+ # The other tests in this TestCase rely on
313+ # get_expected_generate_directives(), which is quite dense. Here
314+ # we test get_GENERATE_directives() explicitly.
315+ ip_range = IPRange('192.168.0.1', '192.168.2.128')
316+ expected_directives = [
317+ ("1-255", "$.0.168.192.in-addr.arpa.", "192-168-0-$.domain."),
318+ ("0-255", "$.1.168.192.in-addr.arpa.", "192-168-1-$.domain."),
319+ ("0-128", "$.2.168.192.in-addr.arpa.", "192-168-2-$.domain."),
320+ ]
321+ self.assertItemsEqual(
322+ expected_directives,
323+ DNSReverseZoneConfig.get_GENERATE_directives(
324+ ip_range, domain="domain"))
325+
326+ def get_expected_generate_directives(self, network, domain):
327+ ip_parts = network.network.format().split('.')
328+ relevant_ip_parts = ip_parts[:-2]
329+
330+ first_address = IPAddress(network.first).format()
331+ first_address_parts = first_address.split(".")
332+
333+ if network.size < 256:
334+ last_address = IPAddress(network.last).format()
335+ iterator_low = int(first_address_parts[-1])
336+ iterator_high = last_address.split('.')[-1]
337+ else:
338+ iterator_low = 0
339+ iterator_high = 255
340+
341+ second_octet_offset = int(first_address_parts[-2])
342+ expected_generate_directives = []
343+ directives_needed = network.size / 256
344+
345+ if directives_needed == 0:
346+ directives_needed = 1
347+ for num in range(directives_needed):
348+ expected_address_base = "%s-%s" % tuple(relevant_ip_parts)
349+ expected_address = "%s-%s-$" % (
350+ expected_address_base, num + second_octet_offset)
351+ relevant_ip_parts.reverse()
352+ expected_rdns_base = (
353+ "%s.%s.in-addr.arpa." % tuple(relevant_ip_parts))
354+ expected_rdns_template = "$.%s.%s" % (
355+ num + second_octet_offset, expected_rdns_base)
356+ expected_generate_directives.append(
357+ (
358+ "%s-%s" % (iterator_low, iterator_high),
359+ expected_rdns_template,
360+ "%s.%s." % (expected_address, domain)
361+ ))
362+ relevant_ip_parts.reverse()
363+ return expected_generate_directives
364+
365+ def test_returns_single_entry_for_slash_24_network(self):
366+ network = IPNetwork("%s/24" % factory.make_ipv4_address())
367+ domain = factory.make_string()
368+ expected_generate_directives = self.get_expected_generate_directives(
369+ network, domain)
370+ directives = DNSReverseZoneConfig.get_GENERATE_directives(
371+ network, domain)
372+ self.expectThat(directives, HasLength(1))
373+ self.assertItemsEqual(expected_generate_directives, directives)
374+
375+ def test_returns_single_entry_for_tiny_network(self):
376+ network = IPNetwork("%s/28" % factory.make_ipv4_address())
377+ domain = factory.make_string()
378+
379+ expected_generate_directives = self.get_expected_generate_directives(
380+ network, domain)
381+ directives = DNSReverseZoneConfig.get_GENERATE_directives(
382+ network, domain)
383+ self.expectThat(directives, HasLength(1))
384+ self.assertItemsEqual(expected_generate_directives, directives)
385+
386+ def test_returns_single_entry_for_weird_small_range(self):
387+ ip_range = IPRange('10.0.0.1', '10.0.0.255')
388+ domain = factory.make_string()
389+ directives = DNSReverseZoneConfig.get_GENERATE_directives(
390+ ip_range, domain)
391+ self.expectThat(directives, HasLength(1))
392+
393+ def test_dtrt_for_larger_networks(self):
394+ # For every other network size that we're not explicitly
395+ # testing here,
396+ # DNSReverseZoneConfig.get_GENERATE_directives() will return
397+ # one GENERATE directive for every 255 addresses in the network.
398+ for prefixlen in range(23, 17):
399+ network = IPNetwork(
400+ "%s/%s" % (factory.make_ipv4_address(), prefixlen))
401+ domain = factory.make_string()
402+ directives = DNSReverseZoneConfig.get_GENERATE_directives(
403+ network, domain)
404+ self.expectThat(directives, HasLength(network.size / 256))
405+
406+ def test_returns_two_entries_for_slash_23_network(self):
407+ network = IPNetwork(factory.make_ipv4_network(slash=23))
408+ domain = factory.make_string()
409+
410+ expected_generate_directives = self.get_expected_generate_directives(
411+ network, domain)
412+ directives = DNSReverseZoneConfig.get_GENERATE_directives(
413+ network, domain)
414+ self.expectThat(directives, HasLength(2))
415+ self.assertItemsEqual(expected_generate_directives, directives)
416+
417+ def test_rejects_network_larger_than_slash_16(self):
418+ network = IPNetwork("%s/15" % factory.make_ipv4_address())
419+ error = self.assertRaises(
420+ AssertionError, DNSReverseZoneConfig.get_GENERATE_directives,
421+ network, None)
422+ self.assertEqual(
423+ "Cannot generate reverse zone mapping for any network larger "
424+ "than /16.",
425+ unicode(error))
426+
427+ def test_sorts_output_by_hostname(self):
428+ network = IPNetwork("10.0.0.1/23")
429+ domain = factory.make_string()
430+
431+ expected_hostname = "10-0-%s-$." + domain + "."
432+ expected_rdns = "$.%s.0.10.in-addr.arpa."
433+
434+ directives = list(DNSReverseZoneConfig.get_GENERATE_directives(
435+ network, domain))
436+ self.expectThat(
437+ directives[0], Equals(
438+ ("0-255", expected_rdns % "0", expected_hostname % "0")))
439+ self.expectThat(
440+ directives[1], Equals(
441+ ("0-255", expected_rdns % "1", expected_hostname % "1")))
442+
443+
444+class TestDNSForwardZoneConfig_GetGenerateDirectives(MAASTestCase):
445+ """Tests for `DNSForwardZoneConfig.get_GENERATE_directives()`."""
446+
447+ def test_excplicitly(self):
448+ # The other tests in this TestCase rely on
449+ # get_expected_generate_directives(), which is quite dense. Here
450+ # we test get_GENERATE_directives() explicitly.
451+ ip_range = IPRange('192.168.0.1', '192.168.2.128')
452+ expected_directives = [
453+ ("1-255", "192-168-0-$", "192.168.0.$"),
454+ ("0-255", "192-168-1-$", "192.168.1.$"),
455+ ("0-128", "192-168-2-$", "192.168.2.$"),
456+ ]
457+ self.assertItemsEqual(
458+ expected_directives,
459+ DNSForwardZoneConfig.get_GENERATE_directives(ip_range))
460+
461+ def get_expected_generate_directives(self, network):
462+ ip_parts = network.network.format().split('.')
463+ ip_parts[-1] = "$"
464+ expected_hostname = "%s" % "-".join(ip_parts)
465+ expected_address = ".".join(ip_parts)
466+
467+ first_address = IPAddress(network.first).format()
468+ first_address_parts = first_address.split(".")
469+ last_address = IPAddress(network.last).format()
470+ last_address_parts = last_address.split(".")
471+
472+ if network.size < 256:
473+ iterator_low = int(first_address_parts[-1])
474+ if iterator_low == 0:
475+ iterator_low = 1
476+ iterator_high = last_address_parts[-1]
477+ else:
478+ iterator_low = 0
479+ iterator_high = 255
480+
481+ expected_iterator_values = "%s-%s" % (iterator_low, iterator_high)
482+
483+ directives_needed = network.size / 256
484+ if directives_needed == 0:
485+ directives_needed = 1
486+ expected_directives = []
487+ for num in range(directives_needed):
488+ ip_parts[-2] = unicode(num + int(ip_parts[-2]))
489+ expected_address = ".".join(ip_parts)
490+ expected_hostname = "%s" % "-".join(ip_parts)
491+ expected_directives.append(
492+ (
493+ expected_iterator_values,
494+ expected_hostname,
495+ expected_address
496+ ))
497+ return expected_directives
498+
499+ def test_returns_single_entry_for_slash_24_network(self):
500+ network = IPNetwork("%s/24" % factory.make_ipv4_address())
501+ expected_directives = self.get_expected_generate_directives(network)
502+ directives = DNSForwardZoneConfig.get_GENERATE_directives(
503+ network)
504+ self.expectThat(directives, HasLength(1))
505+ self.assertItemsEqual(expected_directives, directives)
506+
507+ def test_returns_single_entry_for_tiny_network(self):
508+ network = IPNetwork("%s/31" % factory.make_ipv4_address())
509+
510+ expected_directives = self.get_expected_generate_directives(network)
511+ directives = DNSForwardZoneConfig.get_GENERATE_directives(
512+ network)
513+ self.assertEqual(1, len(expected_directives))
514+ self.assertItemsEqual(expected_directives, directives)
515+
516+ def test_returns_two_entries_for_slash_23_network(self):
517+ network = IPNetwork("%s/23" % factory.make_ipv4_address())
518+
519+ expected_directives = self.get_expected_generate_directives(network)
520+ directives = DNSForwardZoneConfig.get_GENERATE_directives(
521+ network)
522+ self.assertEqual(2, len(expected_directives))
523+ self.assertItemsEqual(expected_directives, directives)
524+
525+ def test_dtrt_for_larger_networks(self):
526+ # For every other network size that we're not explicitly
527+ # testing here,
528+ # DNSForwardZoneConfig.get_GENERATE_directives() will return
529+ # one GENERATE directive for every 255 addresses in the network.
530+ for prefixlen in range(23, 16):
531+ network = IPNetwork(
532+ "%s/%s" % (factory.make_ipv4_address(), prefixlen))
533+ directives = DNSForwardZoneConfig.get_GENERATE_directives(
534+ network)
535+ self.assertIsEqual(network.size / 256, len(directives))
536+
537+ def test_rejects_network_larger_than_slash_16(self):
538+ network = IPNetwork("%s/15" % factory.make_ipv4_address())
539+ error = self.assertRaises(
540+ AssertionError, DNSForwardZoneConfig.get_GENERATE_directives,
541+ network)
542+ self.assertEqual(
543+ "Cannot generate reverse zone mapping for any network larger "
544+ "than /16.",
545+ unicode(error))
546+
547+ def test_sorts_output(self):
548+ network = IPNetwork("10.0.0.0/23")
549+
550+ expected_hostname = "10-0-%s-$"
551+ expected_address = "10.0.%s.$"
552+
553+ directives = list(DNSForwardZoneConfig.get_GENERATE_directives(
554+ network))
555+ self.expectThat(len(directives), Equals(2))
556+ self.expectThat(
557+ directives[0], Equals(
558+ ("0-255", expected_hostname % "0", expected_address % "0")))
559+ self.expectThat(
560+ directives[1], Equals(
561+ ("0-255", expected_hostname % "1", expected_address % "1")))
562+
563+
564+class TestIntersectIPRange(MAASTestCase):
565+ """Tests for `intersect_iprange()`."""
566+
567+ def test_finds_intersection_between_two_ranges(self):
568+ range_1 = IPRange('10.0.0.1', '10.0.0.255')
569+ range_2 = IPRange('10.0.0.128', '10.0.0.200')
570+ intersect = intersect_iprange(range_1, range_2)
571+ self.expectThat(
572+ IPAddress(intersect.first), Equals(IPAddress('10.0.0.128')))
573+ self.expectThat(
574+ IPAddress(intersect.last), Equals(IPAddress('10.0.0.200')))
575+
576+ def test_ignores_non_intersecting_ranges(self):
577+ range_1 = IPRange('10.0.0.1', '10.0.0.255')
578+ range_2 = IPRange('10.0.1.128', '10.0.1.200')
579+ self.assertIsNone(intersect_iprange(range_1, range_2))
580+
581+ def test_finds_partial_intersection(self):
582+ range_1 = IPRange('10.0.0.1', '10.0.0.128')
583+ range_2 = IPRange('10.0.0.64', '10.0.0.200')
584+ intersect = intersect_iprange(range_1, range_2)
585+ self.expectThat(
586+ IPAddress(intersect.first), Equals(IPAddress('10.0.0.64')))
587+ self.expectThat(
588+ IPAddress(intersect.last), Equals(IPAddress('10.0.0.128')))
589
590=== modified file 'src/provisioningserver/dns/zoneconfig.py'
591--- src/provisioningserver/dns/zoneconfig.py 2014-08-15 11:10:09 +0000
592+++ src/provisioningserver/dns/zoneconfig.py 2014-10-29 19:33:27 +0000
593@@ -23,7 +23,11 @@
594 from itertools import chain
595 import math
596
597-from netaddr import IPAddress
598+from netaddr import (
599+ IPAddress,
600+ IPRange,
601+ spanning_cidr,
602+ )
603 from netaddr.core import AddrFormatError
604 from provisioningserver.dns.config import (
605 compose_config_path,
606@@ -33,6 +37,19 @@
607 from provisioningserver.utils.fs import incremental_write
608
609
610+def intersect_iprange(network, iprange):
611+ """Return the intersection between two IPNetworks or IPRanges.
612+
613+ IPSet is notoriously inefficient so we intersect ourselves here.
614+ """
615+ if network.last >= iprange.first and network.first <= iprange.last:
616+ first = max(network.first, iprange.first)
617+ last = min(network.last, iprange.last)
618+ return IPRange(first, last)
619+ else:
620+ return None
621+
622+
623 def get_fqdn_or_ip_address(target):
624 """Returns the ip address is target is a valid ip address, otherwise
625 returns the target with appended '.' if missing."""
626@@ -52,6 +69,52 @@
627 yield hostname, ip
628
629
630+def get_details_for_ip_range(ip_range):
631+ """For a given IPRange, return all subnets, a useable prefix and the
632+ reverse DNS suffix calculated from that IP range.
633+
634+ :return: A tuple of:
635+ All subnets of /24 (or smaller if there is no /24 subnet to be
636+ found) in `ip_range`.
637+ A prefix made from the first two octets in the range.
638+ A RDNS suffix calculated from the first two octets in the range.
639+ """
640+ # Calculate a spanning network for the range above. There are
641+ # 256 /24 networks in a /16, so that's the most /24s we're going
642+ # to have to deal with; this matters later on when we iterate
643+ # through the /24s within this network.
644+ cidr = spanning_cidr(ip_range)
645+ subnets = cidr.subnet(max(24, cidr.prefixlen))
646+
647+ # Split the spanning network into /24 subnets, then see if they fall
648+ # entirely within the original network range, partially, or not at
649+ # all.
650+ intersecting_subnets = []
651+ for subnet in subnets:
652+ intersect = intersect_iprange(subnet, ip_range)
653+ if intersect is None:
654+ # The subnet does not fall within the original network.
655+ pass
656+ else:
657+ # The subnet falls partially within the original network, so print
658+ # out a $GENERATE expression for a subset of the /24.
659+ intersecting_subnets.append(intersect)
660+
661+ octet_one = (cidr.value & 0xff000000) >> 24
662+ octet_two = (cidr.value & 0x00ff0000) >> 16
663+
664+ # The first two octets of the network range formatted in the
665+ # usual dotted-quad style. We can precalculate the start of any IP
666+ # address in the range because we're only ever dealing with /16
667+ # networks and smaller.
668+ prefix = "%d.%d" % (octet_one, octet_two)
669+
670+ # Similarly, we can calculate what the reverse DNS suffix is going
671+ # to look like.
672+ rdns_suffix = "%d.%d.in-addr.arpa." % (octet_two, octet_one)
673+ return intersecting_subnets, prefix, rdns_suffix
674+
675+
676 class DNSZoneConfigBase:
677 """Base class for zone writers."""
678
679@@ -117,7 +180,8 @@
680 """
681 self._dns_ip = kwargs.pop('dns_ip', None)
682 self._mapping = kwargs.pop('mapping', {})
683- self._network = None
684+ self._network = kwargs.pop('network', None)
685+ self._dynamic_ranges = kwargs.pop('dynamic_ranges', [])
686 self._srv_mapping = kwargs.pop('srv_mapping', [])
687 super(DNSForwardZoneConfig, self).__init__(
688 domain, zone_name=domain, **kwargs)
689@@ -187,8 +251,45 @@
690 target)
691 yield (record.service, item)
692
693+ @classmethod
694+ def get_GENERATE_directives(cls, dynamic_range):
695+ """Return the GENERATE directives for the forward zone of a network.
696+ """
697+ assert dynamic_range.size <= 256 ** 2, (
698+ "Cannot generate reverse zone mapping for any network larger than "
699+ "/16.")
700+
701+ generate_directives = set()
702+ subnets, prefix, _ = get_details_for_ip_range(dynamic_range)
703+ for subnet in subnets:
704+ iterator = "%d-%d" % (
705+ (subnet.first & 0x000000ff),
706+ (subnet.last & 0x000000ff))
707+
708+ hostname = "%s-%d-$" % (
709+ prefix.replace('.', '-'),
710+ # Calculate what the third quad (i.e. 10.0.X.1) value should
711+ # be for this subnet.
712+ (subnet.first & 0x0000ff00) >> 8,
713+ )
714+
715+ ip_address = "%s.%d.$" % (
716+ prefix,
717+ (subnet.first & 0x0000ff00) >> 8)
718+ generate_directives.add((iterator, hostname, ip_address))
719+
720+ return sorted(
721+ generate_directives, key=lambda directive: directive[2])
722+
723 def write_config(self):
724 """Write the zone file."""
725+ # Create GENERATE directives for IPv4 ranges.
726+ generate_directives = list(
727+ chain.from_iterable(
728+ self.get_GENERATE_directives(dynamic_range)
729+ for dynamic_range in self._dynamic_ranges
730+ if dynamic_range.version == 4
731+ ))
732 self.write_zone_file(
733 self.target_path, self.make_parameters(),
734 {
735@@ -200,6 +301,9 @@
736 'AAAA': self.get_AAAA_mapping(
737 self._mapping, self.domain, self._dns_ip),
738 },
739+ 'generate_directives': {
740+ 'A': generate_directives,
741+ }
742 })
743
744
745@@ -225,6 +329,7 @@
746 """
747 self._mapping = kwargs.pop('mapping', {})
748 self._network = kwargs.pop("network", None)
749+ self._dynamic_ranges = kwargs.pop('dynamic_ranges', [])
750 zone_name = self.compose_zone_name(self._network)
751 super(DNSReverseZoneConfig, self).__init__(
752 domain, zone_name=zone_name, **kwargs)
753@@ -279,8 +384,40 @@
754 if IPAddress(ip) in network
755 )
756
757+ @classmethod
758+ def get_GENERATE_directives(cls, dynamic_range, domain):
759+ """Return the GENERATE directives for the reverse zone of a network."""
760+ assert dynamic_range.size <= 256 ** 2, (
761+ "Cannot generate reverse zone mapping for any network larger than "
762+ "/16.")
763+
764+ generate_directives = set()
765+ subnets, prefix, rdns_suffix = get_details_for_ip_range(dynamic_range)
766+ for subnet in subnets:
767+ iterator = "%d-%d" % (
768+ (subnet.first & 0x000000ff),
769+ (subnet.last & 0x000000ff))
770+ hostname = "%s-%d-$" % (
771+ prefix.replace('.', '-'),
772+ (subnet.first & 0x0000ff00) >> 8)
773+ rdns = "$.%d.%s" % (
774+ (subnet.first & 0x0000ff00) >> 8,
775+ rdns_suffix)
776+ generate_directives.add(
777+ (iterator, rdns, "%s.%s." % (hostname, domain)))
778+
779+ return sorted(
780+ generate_directives, key=lambda directive: directive[2])
781+
782 def write_config(self):
783 """Write the zone file."""
784+ # Create GENERATE directives for IPv4 ranges.
785+ generate_directives = list(
786+ chain.from_iterable(
787+ self.get_GENERATE_directives(dynamic_range, self.domain)
788+ for dynamic_range in self._dynamic_ranges
789+ if dynamic_range.version == 4
790+ ))
791 self.write_zone_file(
792 self.target_path, self.make_parameters(),
793 {
794@@ -288,5 +425,8 @@
795 'PTR': self.get_PTR_mapping(
796 self._mapping, self.domain, self._network),
797 },
798+ 'generate_directives': {
799+ 'PTR': generate_directives,
800+ }
801 }
802 )