Merge lp:~allenap/maas/dhcp-ntp-publish-external into lp:~maas-committers/maas/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
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.

To post a comment you must log in.
Revision history for this message
Blake Rouse (blake-rouse) wrote :

Overall looks good. Just a couple of comments. Be sure the CI the whole set of branches before landing.

review: Approve
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