Merge lp:~allenap/maas/dhcp-ntp-publish-external into lp:~maas-committers/maas/trunk
- dhcp-ntp-publish-external
- Merge into trunk
Proposed by
Gavin Panella
Status: | Merged |
---|---|
Approved by: | Gavin Panella |
Approved revision: | no longer in the source branch. |
Merged at revision: | 5388 |
Proposed branch: | lp:~allenap/maas/dhcp-ntp-publish-external |
Merge into: | lp:~maas-committers/maas/trunk |
Prerequisite: | lp:~allenap/maas/dhcp-configuration-tuple |
Diff against target: |
357 lines (+253/-9) 2 files modified
src/maasserver/dhcp.py (+68/-8) src/maasserver/tests/test_dhcp.py (+185/-1) |
To merge this branch: | bzr merge lp:~allenap/maas/dhcp-ntp-publish-external |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Blake Rouse (community) | Approve | ||
Review via email: mp+306456@code.launchpad.net |
Commit message
Configure NTP servers per subnet when generating DHCP configurations.
Description of the change
To post a comment you must log in.
Revision history for this message
Gavin Panella (allenap) wrote : | # |
Thanks for the review. I have a question for you in the diff.
Preview Diff
[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1 | === modified file 'src/maasserver/dhcp.py' |
2 | --- src/maasserver/dhcp.py 2016-09-23 07:59:24 +0000 |
3 | +++ src/maasserver/dhcp.py 2016-09-23 07:59:24 +0000 |
4 | @@ -12,8 +12,12 @@ |
5 | defaultdict, |
6 | namedtuple, |
7 | ) |
8 | +from itertools import groupby |
9 | from operator import itemgetter |
10 | -from typing import Iterable |
11 | +from typing import ( |
12 | + Iterable, |
13 | + Union, |
14 | +) |
15 | |
16 | from django.conf import settings |
17 | from django.core.exceptions import ValidationError |
18 | @@ -43,7 +47,10 @@ |
19 | ) |
20 | from maasserver.utils.orm import transactional |
21 | from maasserver.utils.threads import deferToDatabase |
22 | -from netaddr import IPAddress |
23 | +from netaddr import ( |
24 | + IPAddress, |
25 | + IPNetwork, |
26 | +) |
27 | from provisioningserver.dhcp.omshell import generate_omapi_key |
28 | from provisioningserver.rpc.cluster import ( |
29 | ConfigureDHCPv4, |
30 | @@ -229,6 +236,37 @@ |
31 | return get_ip_address_for_interface(interface, vlan) |
32 | |
33 | |
34 | +@typed |
35 | +def get_ntp_server_addresses_for_rack(rack: RackController) -> dict: |
36 | + """Return a map of rack IP addresses suitable for NTP. |
37 | + |
38 | + These are keyed by `(subnet-space-id, subnet-ip-address-family)`, e.g.:: |
39 | + |
40 | + {(73, 4): "192.168.1.1"} |
41 | + |
42 | + Only a single routable address for the rack will be returned in each |
43 | + space+family group even if there are multiple. |
44 | + """ |
45 | + rack_addresses = StaticIPAddress.objects.filter( |
46 | + interface__enabled=True, interface__node=rack, alloc_type__in={ |
47 | + IPADDRESS_TYPE.STICKY, IPADDRESS_TYPE.USER_RESERVED}) |
48 | + rack_addresses = rack_addresses.order_by( |
49 | + "subnet__space_id", "subnet__cidr", "ip") |
50 | + rack_addresses = rack_addresses.values_list( |
51 | + "subnet__space_id", "subnet__cidr", "ip") |
52 | + |
53 | + def get_space_id_and_family(record): |
54 | + space_id, cidr, ip = record |
55 | + return space_id, IPNetwork(cidr).version |
56 | + |
57 | + return { |
58 | + space_id_and_family: min( |
59 | + (ip for _, _, ip in group), key=IPAddress) |
60 | + for space_id_and_family, group in groupby( |
61 | + rack_addresses, get_space_id_and_family) |
62 | + } |
63 | + |
64 | + |
65 | def make_interface_hostname(interface): |
66 | """Return the host decleration name for DHCPD for this `interface`.""" |
67 | interface_name = interface.name.replace(".", "-") |
68 | @@ -332,9 +370,15 @@ |
69 | |
70 | @typed |
71 | def make_subnet_config( |
72 | - rack_controller, subnet, maas_dns_server, ntp_servers: list, |
73 | - default_domain, failover_peer=None, subnets_dhcp_snippets: list=None): |
74 | - """Return DHCP subnet configuration dict for a rack interface.""" |
75 | + rack_controller, subnet, maas_dns_server, |
76 | + ntp_servers: Union[list, dict], default_domain, |
77 | + failover_peer=None, subnets_dhcp_snippets: list=None): |
78 | + """Return DHCP subnet configuration dict for a rack interface. |
79 | + |
80 | + :param ntp_servers: Either a list of NTP server addresses or hostnames to |
81 | + include in DHCP responses, or a dict; if the latter, it ought to match |
82 | + the output from `get_ntp_server_addresses_for_rack`. |
83 | + """ |
84 | ip_network = subnet.get_ipnetwork() |
85 | if subnet.dns_servers is not None and len(subnet.dns_servers) > 0: |
86 | # Replace MAAS DNS with the servers defined on the subnet. |
87 | @@ -345,6 +389,15 @@ |
88 | dns_servers = [] |
89 | if subnets_dhcp_snippets is None: |
90 | subnets_dhcp_snippets = [] |
91 | + |
92 | + if isinstance(ntp_servers, dict): |
93 | + try: |
94 | + ntp_server = ntp_servers[subnet.space_id, subnet.get_ip_version()] |
95 | + except KeyError: |
96 | + ntp_servers = [] |
97 | + else: |
98 | + ntp_servers = [ntp_server] |
99 | + |
100 | return { |
101 | 'subnet': str(ip_network.network), |
102 | 'subnet_mask': str(ip_network.netmask), |
103 | @@ -388,7 +441,7 @@ |
104 | @typed |
105 | def get_dhcp_configure_for( |
106 | ip_version: int, rack_controller, vlan, subnets: list, |
107 | - ntp_servers: list, domain, dhcp_snippets: Iterable=None): |
108 | + ntp_servers: Union[list, dict], domain, dhcp_snippets: Iterable=None): |
109 | """Get the DHCP configuration for `ip_version`.""" |
110 | # Circular imports. |
111 | from maasserver.dns.zonegenerator import get_dns_server_address |
112 | @@ -493,8 +546,15 @@ |
113 | shared_networks_v6 = [] |
114 | hosts_v6 = [] |
115 | interfaces_v6 = set() |
116 | - ntp_servers = Config.objects.get_config("ntp_servers") |
117 | - ntp_servers = list(split_string_list(ntp_servers)) |
118 | + |
119 | + # NTP configuration can get tricky... |
120 | + ntp_external_only = Config.objects.get_config("ntp_external_only") |
121 | + if ntp_external_only: |
122 | + ntp_servers = Config.objects.get_config("ntp_servers") |
123 | + ntp_servers = list(split_string_list(ntp_servers)) |
124 | + else: |
125 | + ntp_servers = get_ntp_server_addresses_for_rack(rack_controller) |
126 | + |
127 | default_domain = Domain.objects.get_default_domain() |
128 | for vlan, (subnets_v4, subnets_v6) in vlan_subnets.items(): |
129 | # IPv4 |
130 | |
131 | === modified file 'src/maasserver/tests/test_dhcp.py' |
132 | --- src/maasserver/tests/test_dhcp.py 2016-09-23 07:59:24 +0000 |
133 | +++ src/maasserver/tests/test_dhcp.py 2016-09-23 07:59:24 +0000 |
134 | @@ -65,10 +65,15 @@ |
135 | from provisioningserver.rpc.exceptions import CannotConfigureDHCP |
136 | from provisioningserver.utils.twisted import synchronous |
137 | from testtools.matchers import ( |
138 | + AllMatch, |
139 | ContainsAll, |
140 | + ContainsDict, |
141 | Equals, |
142 | + HasLength, |
143 | IsInstance, |
144 | + MatchesAll, |
145 | MatchesStructure, |
146 | + Not, |
147 | ) |
148 | from twisted.internet import defer |
149 | from twisted.internet.defer import inlineCallbacks |
150 | @@ -604,6 +609,93 @@ |
151 | dhcp.get_ip_address_for_rack_controller(rack_controller, vlan)) |
152 | |
153 | |
154 | +class TestGetNTPServerAddressesForRack(MAASServerTestCase): |
155 | + """Tests for `get_ntp_server_addresses_for_rack`.""" |
156 | + |
157 | + def test__returns_empty_dict_for_unconnected_rack(self): |
158 | + rack = factory.make_RackController() |
159 | + self.assertThat( |
160 | + dhcp.get_ntp_server_addresses_for_rack(rack), |
161 | + Equals({})) |
162 | + |
163 | + def test__returns_dict_with_rack_addresses(self): |
164 | + rack = factory.make_RackController() |
165 | + space = factory.make_Space() |
166 | + subnet = factory.make_Subnet(space=space) |
167 | + interface = factory.make_Interface(node=rack) |
168 | + address = factory.make_StaticIPAddress( |
169 | + interface=interface, subnet=subnet, |
170 | + alloc_type=IPADDRESS_TYPE.STICKY) |
171 | + |
172 | + self.assertThat( |
173 | + dhcp.get_ntp_server_addresses_for_rack(rack), Equals({ |
174 | + (space.id, subnet.get_ipnetwork().version): address.ip, |
175 | + })) |
176 | + |
177 | + def test__returns_dict_grouped_by_space_and_address_family(self): |
178 | + rack = factory.make_RackController() |
179 | + space1 = factory.make_Space() |
180 | + space2 = factory.make_Space() |
181 | + subnet1 = factory.make_Subnet(space=space1) |
182 | + subnet2 = factory.make_Subnet(space=space2) |
183 | + interface = factory.make_Interface(node=rack) |
184 | + address1 = factory.make_StaticIPAddress( |
185 | + interface=interface, subnet=subnet1, |
186 | + alloc_type=IPADDRESS_TYPE.STICKY) |
187 | + address2 = factory.make_StaticIPAddress( |
188 | + interface=interface, subnet=subnet2, |
189 | + alloc_type=IPADDRESS_TYPE.STICKY) |
190 | + |
191 | + self.assertThat( |
192 | + dhcp.get_ntp_server_addresses_for_rack(rack), Equals({ |
193 | + (space1.id, subnet1.get_ipnetwork().version): address1.ip, |
194 | + (space2.id, subnet2.get_ipnetwork().version): address2.ip, |
195 | + })) |
196 | + |
197 | + def test__returned_dict_chooses_minimum_address(self): |
198 | + rack = factory.make_RackController() |
199 | + space = factory.make_Space() |
200 | + cidr = factory.make_ip4_or_6_network(host_bits=16) |
201 | + subnet = factory.make_Subnet(space=space, cidr=cidr) |
202 | + interface = factory.make_Interface(node=rack) |
203 | + addresses = { |
204 | + factory.make_StaticIPAddress( |
205 | + interface=interface, subnet=subnet, |
206 | + alloc_type=IPADDRESS_TYPE.STICKY) |
207 | + for _ in range(10) |
208 | + } |
209 | + |
210 | + self.assertThat( |
211 | + dhcp.get_ntp_server_addresses_for_rack(rack), Equals({ |
212 | + (space.id, subnet.get_ipnetwork().version): min( |
213 | + (address.ip for address in addresses), key=IPAddress), |
214 | + })) |
215 | + |
216 | + def test__constant_query_count(self): |
217 | + rack = factory.make_RackController() |
218 | + interface = factory.make_Interface(node=rack) |
219 | + |
220 | + count, result = count_queries( |
221 | + dhcp.get_ntp_server_addresses_for_rack, rack) |
222 | + self.assertThat(count, Equals(1)) |
223 | + self.assertThat(result, Equals({})) |
224 | + |
225 | + for _ in (1, 2): |
226 | + space = factory.make_Space() |
227 | + for family in (4, 6): |
228 | + cidr = factory.make_ip4_or_6_network(family, host_bits=8) |
229 | + subnet = factory.make_Subnet(space=space, cidr=cidr) |
230 | + for _ in (1, 2): |
231 | + factory.make_StaticIPAddress( |
232 | + interface=interface, subnet=subnet, |
233 | + alloc_type=IPADDRESS_TYPE.STICKY) |
234 | + |
235 | + count, result = count_queries( |
236 | + dhcp.get_ntp_server_addresses_for_rack, rack) |
237 | + self.assertThat(count, Equals(1)) |
238 | + self.assertThat(result, Not(Equals({}))) |
239 | + |
240 | + |
241 | class TestMakeSubnetConfig(MAASServerTestCase): |
242 | """Tests for `make_subnet_config`.""" |
243 | |
244 | @@ -659,7 +751,7 @@ |
245 | rack_controller, subnet, maas_dns, ntp_servers, default_domain) |
246 | self.assertThat(config['dns_servers'], Equals([IPAddress(maas_dns)])) |
247 | |
248 | - def test__sets_ntp_from_arguments(self): |
249 | + def test__sets_ntp_from_list_argument(self): |
250 | rack_controller = factory.make_RackController(interface=False) |
251 | vlan = factory.make_VLAN() |
252 | subnet = factory.make_Subnet(vlan=vlan, dns_servers=[]) |
253 | @@ -671,6 +763,34 @@ |
254 | rack_controller, subnet, "", ntp_servers, default_domain) |
255 | self.expectThat(config['ntp_servers'], Equals(ntp_servers)) |
256 | |
257 | + def test__sets_ntp_from_empty_dict_argument(self): |
258 | + rack_controller = factory.make_RackController(interface=False) |
259 | + vlan = factory.make_VLAN() |
260 | + subnet = factory.make_Subnet(vlan=vlan, dns_servers=[]) |
261 | + factory.make_Interface( |
262 | + INTERFACE_TYPE.PHYSICAL, vlan=vlan, node=rack_controller) |
263 | + default_domain = Domain.objects.get_default_domain() |
264 | + config = dhcp.make_subnet_config( |
265 | + rack_controller, subnet, "", {}, default_domain) |
266 | + self.expectThat(config['ntp_servers'], Equals([])) |
267 | + |
268 | + def test__sets_ntp_from_dict_argument(self): |
269 | + rack_controller = factory.make_RackController(interface=False) |
270 | + vlan = factory.make_VLAN() |
271 | + subnet = factory.make_Subnet(vlan=vlan, dns_servers=[]) |
272 | + interface = factory.make_Interface( |
273 | + INTERFACE_TYPE.PHYSICAL, vlan=vlan, node=rack_controller) |
274 | + address = factory.make_StaticIPAddress( |
275 | + interface=interface, subnet=subnet, |
276 | + alloc_type=IPADDRESS_TYPE.STICKY) |
277 | + ntp_servers = { |
278 | + (subnet.space_id, subnet.get_ipnetwork().version): address.ip, |
279 | + } |
280 | + default_domain = Domain.objects.get_default_domain() |
281 | + config = dhcp.make_subnet_config( |
282 | + rack_controller, subnet, "", ntp_servers, default_domain) |
283 | + self.expectThat(config['ntp_servers'], Equals([address.ip])) |
284 | + |
285 | def test__overrides_ipv4_dns_from_subnet(self): |
286 | rack_controller = factory.make_RackController(interface=False) |
287 | vlan = factory.make_VLAN() |
288 | @@ -1402,6 +1522,70 @@ |
289 | self.assertEqual(primary_interface.name, observed_interface) |
290 | |
291 | |
292 | +class TestGetDHCPConfiguration(MAASServerTestCase): |
293 | + """Tests for `get_dhcp_configuration`.""" |
294 | + |
295 | + def make_RackController_ready_for_DHCP(self): |
296 | + rack = factory.make_RackController() |
297 | + vlan = factory.make_VLAN(dhcp_on=True, primary_rack=rack) |
298 | + subnet4 = factory.make_Subnet( |
299 | + vlan=vlan, cidr="10.20.30.0/24") |
300 | + subnet6 = factory.make_Subnet( |
301 | + vlan=vlan, cidr="fd38:c341:27da:c831::/64") |
302 | + interface = factory.make_Interface( |
303 | + INTERFACE_TYPE.PHYSICAL, node=rack, vlan=vlan) |
304 | + address4 = factory.make_StaticIPAddress( |
305 | + alloc_type=IPADDRESS_TYPE.STICKY, subnet=subnet4, |
306 | + interface=interface) |
307 | + address6 = factory.make_StaticIPAddress( |
308 | + alloc_type=IPADDRESS_TYPE.STICKY, subnet=subnet6, |
309 | + interface=interface) |
310 | + return rack, (address4, address6) |
311 | + |
312 | + def assertHasConfigurationForNTP( |
313 | + self, shared_network, subnet, ntp_servers): |
314 | + self.assertThat( |
315 | + shared_network, MatchesAll( |
316 | + # Quick-n-dirty: match only one shared network. |
317 | + HasLength(1), AllMatch(ContainsDict({ |
318 | + "subnets": MatchesAll( |
319 | + # Quick-n-dirty: match only one subnet. |
320 | + HasLength(1), AllMatch(ContainsDict({ |
321 | + "subnet_cidr": Equals(subnet.cidr), |
322 | + "ntp_servers": Equals(ntp_servers), |
323 | + })), |
324 | + ), |
325 | + })), |
326 | + ) |
327 | + ) |
328 | + |
329 | + def test__uses_global_ntp_servers_when_ntp_external_only_is_set(self): |
330 | + ntp_servers = [factory.make_hostname(), factory.make_ip_address()] |
331 | + Config.objects.set_config("ntp_servers", ", ".join(ntp_servers)) |
332 | + Config.objects.set_config("ntp_external_only", True) |
333 | + |
334 | + rack, (addr4, addr6) = self.make_RackController_ready_for_DHCP() |
335 | + config = dhcp.get_dhcp_configuration(rack) |
336 | + |
337 | + self.assertHasConfigurationForNTP( |
338 | + config.shared_networks_v4, addr4.subnet, ntp_servers) |
339 | + self.assertHasConfigurationForNTP( |
340 | + config.shared_networks_v6, addr6.subnet, ntp_servers) |
341 | + |
342 | + def test__finds_per_subnet_addresses_when_ntp_external_only_not_set(self): |
343 | + ntp_servers = [factory.make_hostname(), factory.make_ip_address()] |
344 | + Config.objects.set_config("ntp_servers", ", ".join(ntp_servers)) |
345 | + Config.objects.set_config("ntp_external_only", False) |
346 | + |
347 | + rack, (addr4, addr6) = self.make_RackController_ready_for_DHCP() |
348 | + config = dhcp.get_dhcp_configuration(rack) |
349 | + |
350 | + self.assertHasConfigurationForNTP( |
351 | + config.shared_networks_v4, addr4.subnet, [addr4.ip]) |
352 | + self.assertHasConfigurationForNTP( |
353 | + config.shared_networks_v6, addr6.subnet, [addr6.ip]) |
354 | + |
355 | + |
356 | class TestConfigureDHCP(MAASTransactionServerTestCase): |
357 | """Tests for `configure_dhcp`.""" |
358 |
Overall looks good. Just a couple of comments. Be sure the CI the whole set of branches before landing.