Merge lp:~gmb/maas/generate-dns-for-dynamic-pool-bug-1382190 into lp:~maas-committers/maas/trunk
- generate-dns-for-dynamic-pool-bug-1382190
- Merge into trunk
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 | ||||
Related bugs: |
|
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 NodeGroupInterf
Previously, hosts with addresses in the dynamic range were not resolvable through DNS, which caused problems with some Juju charms.
Description of the change
Graham Binns (gmb) wrote : | # |
Gavin Panella (allenap) wrote : | # |
Partial review. More after lunch!
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.
Graham Binns (gmb) wrote : | # |
On 29 October 2014 12:25, Gavin Panella <email address hidden> wrote:
> Partial review. More after lunch!
>
> Diff comments:
>
>> === modified file 'etc/maas/
>> --- etc/maas/
>> +++ etc/maas/
>> @@ -12,6 +12,12 @@
>> )
>>
>> IN NS {{domain}}.
>> +{{for type, directive in generate_
>> +{{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
>> --- src/maasserver/
>> +++ src/maasserver/
>> @@ -393,14 +393,28 @@
>> [interface] = nodegroup.
>> networks_dict = ZoneGenerator.
>> retrieved_interface = networks_
>> - self.assertEqua
>> + self.assertEqual(
>> + [
>> + (
>> + interface.network,
>> + (interface.
>
> Consider using an IPRange here. NodeGroupInterf
>
>
>> + )
>> + ],
>> + retrieved_
>>
>> def test_get_
>> nodegroups = [self.make_
>> networks_dict = ZoneGenerator.
>> for nodegroup in nodegroups:
>> [interface] = nodegroup.
>> - self.assertEqua
>> + self.assertEqual(
>> + [
>> + (
>> + interface.network,
>> + (interface.
>> + ),
>> + ],
>> + networks_
>>
>> def test_get_
>> nodegroups = [
>> @@ -415,7 +429,10 @@
>> self.assertEqual(
>> {
>> nodegroup: [
>> - interface.network
>> + (
>> + interface.network,
>> + (interface.
>> + )
>> for interface in nodegroup.
>> ]
>> for nodegroup in nodegroups
>>
>> === modified file 'src/maasserver
>> --- src/maasserver/
>> +++ src/maasserver/
>> @@ -29,7 +29,10 @@
>> from maasserver.
>> from maasserver.
Graham Binns (gmb) wrote : | # |
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/
>> --- etc/maas/
>> +++ etc/maas/
>> @@ -12,6 +12,12 @@
>> )
>>
>> IN NS {{domain}}.
>> +{{for type, directive in generate_
>> +{{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
>> --- src/maasserver/
>> +++ src/maasserver/
>> @@ -393,14 +393,28 @@
>> [interface] = nodegroup.
>> networks_dict = ZoneGenerator.
>> retrieved_interface = networks_
>> - self.assertEqua
>> + self.assertEqual(
>> + [
>> + (
>> + interface.network,
>> + (interface.
>> + )
>> + ],
>> + retrieved_
>>
>> def test_get_
>> nodegroups = [self.make_
>> networks_dict = ZoneGenerator.
>> for nodegroup in nodegroups:
>> [interface] = nodegroup.
>> - self.assertEqua
>> + self.assertEqual(
>> + [
>> + (
>> + interface.network,
>> + (interface.
>> + ),
>> + ],
>> + networks_
>>
>> def test_get_
>> nodegroups = [
>> @@ -415,7 +429,10 @@
>> self.assertEqual(
>> {
>> nodegroup: [
>> - interface.network
>> + (
>> + interface.network,
>> + (interface.
>> + )
>> for interface in nodegroup.
>> ]
>> for nodegroup in nodegroups
>>
>> === modified file 'src/maasserver
>> --- src/maasserver/
>> +++ src/maasserver/
>> @@ -29,7 +2...
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.
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 :)
Graham Binns (gmb) wrote : | # |
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/
>> --- etc/maas/
>> +++ etc/maas/
>> @@ -12,6 +12,12 @@
>> )
>>
>> IN NS {{domain}}.
>> +{{for type, directive in generate_
>> +{{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
>> --- src/maasserver/
>> +++ src/maasserver/
>> @@ -393,14 +393,28 @@
>> [interface] = nodegroup.
>> networks_dict = ZoneGenerator.
>> retrieved_interface = networks_
>> - self.assertEqua
>> + self.assertEqual(
>> + [
>> + (
>> + interface.network,
>> + (interface.
>> + )
>> + ],
>> + retrieved_
>>
>> def test_get_
>> nodegroups = [self.make_
>> networks_dict = ZoneGenerator.
>> for nodegroup in nodegroups:
>> [interface] = nodegroup.
...
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:/
> --
>
> https:/
> You are subscribed to branch lp:maas.
>
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
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 | ) |
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.