Merge ~cgrabowski/maas:update_dns_via_nsupdate into maas:master
- Git
- lp:~cgrabowski/maas
- update_dns_via_nsupdate
- Merge into master
Status: | Superseded |
---|---|
Proposed branch: | ~cgrabowski/maas:update_dns_via_nsupdate |
Merge into: | maas:master |
Diff against target: |
2496 lines (+1623/-87) 22 files modified
debian/maas-region-api.dirs (+1/-0) debian/maas-region-api.postinst (+5/-0) src/maasserver/dns/config.py (+143/-10) src/maasserver/dns/tests/test_config.py (+149/-1) src/maasserver/dns/zonegenerator.py (+55/-2) src/maasserver/region_controller.py (+34/-2) src/maasserver/region_script.py (+1/-0) src/maasserver/tests/test_region_controller.py (+22/-10) src/maasserver/triggers/system.py (+260/-0) src/maasserver/triggers/testing.py (+43/-2) src/maasserver/triggers/tests/test_init.py (+13/-0) src/maasserver/triggers/tests/test_system.py (+290/-1) src/provisioningserver/dns/actions.py (+52/-1) src/provisioningserver/dns/commands/setup_dns.py (+4/-0) src/provisioningserver/dns/commands/tests/test_setup_dns.py (+6/-1) src/provisioningserver/dns/config.py (+95/-2) src/provisioningserver/dns/testing.py (+10/-0) src/provisioningserver/dns/tests/test_actions.py (+121/-3) src/provisioningserver/dns/tests/test_config.py (+74/-1) src/provisioningserver/dns/tests/test_zoneconfig.py (+165/-17) src/provisioningserver/dns/zoneconfig.py (+76/-34) src/provisioningserver/templates/dns/named.conf.template (+4/-0) |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
MAAS Lander | Approve | ||
Alexsander de Souza | Approve | ||
Review via email: mp+429199@code.launchpad.net |
This proposal has been superseded by a proposal from 2022-11-15.
Commit message
cleanup dns updates triggers
add nsupdate key
add tsig to packaging
use nsupdate to send bulk commands
queue up diffs of dns changes and process them
queue up DNS updates
Description of the change
Jack Lloyd-Walters (lloydwaltersj) wrote : | # |
some minor questions
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: a5b053f45432fbc
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 4b2b6ef4d224bea
Christian Grabowski (cgrabowski) : | # |
- f1ffb8a... by Christian Grabowski
-
allow ttls of 0 in dynamic updates
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 9d290212c760d6c
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 425c4d2f3382e79
- 417dd73... by Christian Grabowski
-
update set of triggers to account for dynamic updates
Alexsander de Souza (alexsander-souza) wrote : | # |
a few questions inline
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: SUCCESS
COMMIT: fcd948f51f72e39
Christian Grabowski (cgrabowski) : | # |
- 8be3a3e... by Christian Grabowski
-
clean up full reload flag
Alexsander de Souza (alexsander-souza) wrote : | # |
+1
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 8021acff300d749
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 3a9b652d51a05cb
- 8ef5019... by Christian Grabowski
-
separate out zone file dir from the rest of the dns config so bind can write to these paths as well
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 1defa9a5206bc2d
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 68c4616d4557b29
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 91834894c931d55
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: f5f85a4eac33db3
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 64368c19977fd2b
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 405b6868c730767
- 2021bea... by Christian Grabowski
-
add missing patch calls for tests
- 96fafca... by Christian Grabowski
-
add helper to test notify triggers
Adam Collard (adam-collard) : | # |
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 556b628e5dd110d
- e314a01... by Christian Grabowski
-
add all trigger testcase
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 09b92908a47c3cc
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: e592b85a0b8c74f
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 210616767ee57ba
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 85d59ce7f102b1b
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: d479aede2dd0692
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: e314a01b5cc0445
- 4e44d9c... by Christian Grabowski
-
fix trigger registration tests
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 668bf3a42b042fc
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: a3bb7ab6ba6724d
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 939d94111b35d11
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: FAILED
LOG: http://
COMMIT: 6fb564b57fe58a2
MAAS Lander (maas-lander) wrote : | # |
UNIT TESTS
-b update_
STATUS: SUCCESS
COMMIT: 4e44d9c55fa32e1
Unmerged commits
- 4e44d9c... by Christian Grabowski
-
fix trigger registration tests
- e314a01... by Christian Grabowski
-
add all trigger testcase
- 96fafca... by Christian Grabowski
-
add helper to test notify triggers
- 2021bea... by Christian Grabowski
-
add missing patch calls for tests
- 8ef5019... by Christian Grabowski
-
separate out zone file dir from the rest of the dns config so bind can write to these paths as well
- 8be3a3e... by Christian Grabowski
-
clean up full reload flag
- 417dd73... by Christian Grabowski
-
update set of triggers to account for dynamic updates
- f1ffb8a... by Christian Grabowski
-
allow ttls of 0 in dynamic updates
- af28863... by Christian Grabowski
-
add test for reverse dynamic update
- e38c94b... by Christian Grabowski
-
cleanup dns updates triggers
Preview Diff
1 | diff --git a/debian/maas-region-api.dirs b/debian/maas-region-api.dirs |
2 | index d5ba327..1e65cfa 100644 |
3 | --- a/debian/maas-region-api.dirs |
4 | +++ b/debian/maas-region-api.dirs |
5 | @@ -1,2 +1,3 @@ |
6 | etc/bind/maas |
7 | +var/lib/bind/maas |
8 | var/lib/maas/prometheus |
9 | diff --git a/debian/maas-region-api.postinst b/debian/maas-region-api.postinst |
10 | index a1fb1a1..778504d 100644 |
11 | --- a/debian/maas-region-api.postinst |
12 | +++ b/debian/maas-region-api.postinst |
13 | @@ -30,6 +30,11 @@ configure_libdir() { |
14 | if [ -f /var/lib/maas/maas_id ]; then |
15 | chown maas:maas /var/lib/maas/maas_id |
16 | fi |
17 | + |
18 | + if [ -d /var/lib/maas/bind ]; then |
19 | + chown maas:bind /var/lib/bind/maas |
20 | + chmod 0775 /var/lib/bind/maas |
21 | + fi |
22 | } |
23 | |
24 | edit_named_options() { |
25 | diff --git a/src/maasserver/dns/config.py b/src/maasserver/dns/config.py |
26 | index 102e33d..97d0950 100644 |
27 | --- a/src/maasserver/dns/config.py |
28 | +++ b/src/maasserver/dns/config.py |
29 | @@ -18,6 +18,7 @@ from maasserver.dns.zonegenerator import ( |
30 | from maasserver.enum import IPADDRESS_TYPE, RDNS_MODE |
31 | from maasserver.models.config import Config |
32 | from maasserver.models.dnspublication import DNSPublication |
33 | +from maasserver.models.dnsresource import DNSResource |
34 | from maasserver.models.domain import Domain |
35 | from maasserver.models.node import RackController |
36 | from maasserver.models.subnet import Subnet |
37 | @@ -28,7 +29,9 @@ from provisioningserver.dns.actions import ( |
38 | bind_write_options, |
39 | bind_write_zones, |
40 | ) |
41 | +from provisioningserver.dns.config import DynamicDNSUpdate |
42 | from provisioningserver.logger import get_maas_logger |
43 | +from provisioningserver.utils.shell import ExternalProcessError |
44 | |
45 | maaslog = get_maas_logger("dns") |
46 | |
47 | @@ -61,7 +64,12 @@ def forward_domains_to_forwarded_zones(forward_domains): |
48 | ] |
49 | |
50 | |
51 | -def dns_update_all_zones(reload_retry=False, reload_timeout=2): |
52 | +def dns_update_all_zones( |
53 | + reload_retry=False, |
54 | + reload_timeout=2, |
55 | + dynamic_updates=None, |
56 | + requires_reload=False, |
57 | +): |
58 | """Update all zone files for all domains. |
59 | |
60 | Serving these zone files means updating BIND's configuration to include |
61 | @@ -70,10 +78,23 @@ def dns_update_all_zones(reload_retry=False, reload_timeout=2): |
62 | :param reload_retry: Should the DNS server reload be retried in case |
63 | of failure? Defaults to `False`. |
64 | :type reload_retry: bool |
65 | + |
66 | + :param reload_timeout: How many seconds to wait for BIND's reload to succeed |
67 | + :type reload_timeout: int |
68 | + |
69 | + :param dynamic_updates: A list of updates to send via nsupdate to BIND |
70 | + :type dynamic_updates: list[DynamicDNSUpdate] |
71 | + |
72 | + :param requires_reload: If true, dynamic updates are ignored and a full reload will occur |
73 | + :type requires_reload: bool |
74 | """ |
75 | if not is_dns_enabled(): |
76 | return |
77 | |
78 | + if not dynamic_updates: |
79 | + dynamic_updates = [] |
80 | + |
81 | + reloaded = True |
82 | domains = Domain.objects.filter(authoritative=True) |
83 | forwarded_zones = forward_domains_to_forwarded_zones( |
84 | Domain.objects.get_forward_domains() |
85 | @@ -87,8 +108,13 @@ def dns_update_all_zones(reload_retry=False, reload_timeout=2): |
86 | default_ttl, |
87 | serial, |
88 | internal_domains=[get_internal_domain()], |
89 | + dynamic_updates=dynamic_updates, |
90 | + force_config_write=requires_reload, |
91 | ).as_list() |
92 | - bind_write_zones(zones) |
93 | + try: |
94 | + bind_write_zones(zones) |
95 | + except ExternalProcessError: # dynamic update failed |
96 | + reloaded = False |
97 | |
98 | # We should not be calling bind_write_options() here; call-sites should be |
99 | # making a separate call. It's a historical legacy, where many sites now |
100 | @@ -110,14 +136,21 @@ def dns_update_all_zones(reload_retry=False, reload_timeout=2): |
101 | forwarded_zones=forwarded_zones, |
102 | ) |
103 | |
104 | - # Reloading with retries may be a legacy from Celery days, or it may be |
105 | - # necessary to recover from races during start-up. We're not sure if it is |
106 | - # actually needed but it seems safer to maintain this behaviour until we |
107 | - # have a better understanding. |
108 | - if reload_retry: |
109 | - reloaded = bind_reload_with_retries(timeout=reload_timeout) |
110 | - else: |
111 | - reloaded = bind_reload(timeout=reload_timeout) |
112 | + if not requires_reload: |
113 | + for zone in zones: |
114 | + if zone.requires_reload: |
115 | + requires_reload = True |
116 | + break |
117 | + |
118 | + if requires_reload: |
119 | + # Reloading with retries may be a legacy from Celery days, or it may be |
120 | + # necessary to recover from races during start-up. We're not sure if it is |
121 | + # actually needed but it seems safer to maintain this behaviour until we |
122 | + # have a better understanding. |
123 | + if reload_retry: |
124 | + reloaded = bind_reload_with_retries(timeout=reload_timeout) |
125 | + else: |
126 | + reloaded = bind_reload(timeout=reload_timeout) |
127 | |
128 | # Return the current serial and list of domain names. |
129 | return serial, reloaded, [domain.name for domain in domains] |
130 | @@ -251,3 +284,103 @@ def get_internal_domain(): |
131 | ttl=15, |
132 | resources=resources, |
133 | ) |
134 | + |
135 | + |
136 | +def process_dns_update_notify(message): |
137 | + updates = [] |
138 | + update_list = message.split(" ") |
139 | + op = update_list[0] |
140 | + if op == "RELOAD": |
141 | + return (updates, True) |
142 | + zone = update_list[1] |
143 | + name = f"{update_list[2]}.{zone}" |
144 | + rectype = update_list[3] |
145 | + if op == "UPDATE": |
146 | + updates.append( |
147 | + DynamicDNSUpdate.create_from_trigger( |
148 | + operation="DELETE", |
149 | + zone=zone, |
150 | + name=name, |
151 | + rectype=rectype, |
152 | + answer=update_list[-1], |
153 | + ) |
154 | + ) |
155 | + updates.append( |
156 | + DynamicDNSUpdate.create_from_trigger( |
157 | + operation="INSERT", |
158 | + zone=zone, |
159 | + name=name, |
160 | + rectype=rectype, |
161 | + ttl=int(update_list[-2]) if update_list[-2] else None, |
162 | + answer=update_list[-1], |
163 | + ) |
164 | + ) |
165 | + elif op == "INSERT": |
166 | + updates.append( |
167 | + DynamicDNSUpdate.create_from_trigger( |
168 | + operation=op, |
169 | + zone=zone, |
170 | + name=name, |
171 | + rectype=rectype, |
172 | + ttl=int(update_list[-2]) if update_list[-2] else None, |
173 | + answer=update_list[-1], |
174 | + ) |
175 | + ) |
176 | + else: |
177 | + # special case where we know an IP has been deleted but, we can't fetch the value |
178 | + # and the rrecord may still have other answers |
179 | + if op == "DELETE-IP": |
180 | + updates.append( |
181 | + DynamicDNSUpdate.create_from_trigger( |
182 | + operation="DELETE", |
183 | + zone=zone, |
184 | + name=name, |
185 | + rectype=rectype, |
186 | + ) |
187 | + ) |
188 | + if rectype == "A": |
189 | + updates.append( |
190 | + DynamicDNSUpdate.create_from_trigger( |
191 | + operation="DELETE", |
192 | + zone=zone, |
193 | + name=name, |
194 | + rectype="AAAA", |
195 | + ) |
196 | + ) |
197 | + resource = DNSResource.objects.get( |
198 | + name=update_list[2], domain__name=zone |
199 | + ) |
200 | + updates += [ |
201 | + DynamicDNSUpdate.create_from_trigger( |
202 | + operation="INSERT", |
203 | + zone=zone, |
204 | + name=name, |
205 | + rectype=rectype, |
206 | + ttl=int(resource.address_ttl) |
207 | + if resource.address_ttl |
208 | + else None, |
209 | + answer=ip.ip, |
210 | + ) |
211 | + for ip in resource.ip_addresses.all() |
212 | + ] |
213 | + |
214 | + elif len(update_list) > 4: # has an answer |
215 | + updates.append( |
216 | + DynamicDNSUpdate.create_from_trigger( |
217 | + operation=op, |
218 | + zone=zone, |
219 | + name=name, |
220 | + rectype=rectype, |
221 | + answer=update_list[-1], |
222 | + ) |
223 | + ) |
224 | + else: |
225 | + updates.append( |
226 | + DynamicDNSUpdate.create_from_trigger( |
227 | + operation=op, |
228 | + zone=zone, |
229 | + name=name, |
230 | + rectype=rectype, |
231 | + ) |
232 | + ) |
233 | + return (updates, False) |
234 | diff --git a/src/maasserver/dns/tests/test_config.py b/src/maasserver/dns/tests/test_config.py |
235 | index 1b2f15a..913c96e 100644 |
236 | --- a/src/maasserver/dns/tests/test_config.py |
237 | +++ b/src/maasserver/dns/tests/test_config.py |
238 | @@ -29,6 +29,7 @@ from maasserver.dns.config import ( |
239 | get_trusted_acls, |
240 | get_trusted_networks, |
241 | get_upstream_dns, |
242 | + process_dns_update_notify, |
243 | ) |
244 | from maasserver.dns.zonegenerator import InternalDomainResourseRecord |
245 | from maasserver.enum import IPADDRESS_TYPE, NODE_STATUS |
246 | @@ -40,10 +41,15 @@ from maasserver.testing.factory import factory |
247 | from maasserver.testing.testcase import MAASServerTestCase |
248 | from maastesting.matchers import MockCalledOnceWith |
249 | from provisioningserver.dns.commands import get_named_conf, setup_dns |
250 | -from provisioningserver.dns.config import compose_config_path, DNSConfig |
251 | +from provisioningserver.dns.config import ( |
252 | + compose_config_path, |
253 | + DNSConfig, |
254 | + DynamicDNSUpdate, |
255 | +) |
256 | from provisioningserver.dns.testing import ( |
257 | patch_dns_config_path, |
258 | patch_dns_rndc_port, |
259 | + patch_zone_file_config_path, |
260 | ) |
261 | from provisioningserver.testing.bindfixture import allocate_ports, BINDServer |
262 | from provisioningserver.testing.tests.test_bindfixture import dig_call |
263 | @@ -127,6 +133,7 @@ class TestDNSServer(MAASServerTestCase): |
264 | patch_dns_config_path(self, self.bind.config.homedir) |
265 | # Use a random port for rndc. |
266 | patch_dns_rndc_port(self, allocate_ports("localhost")[0]) |
267 | + patch_zone_file_config_path(self, config_dir=self.bind.config.homedir) |
268 | # This simulates what should happen when the package is |
269 | # installed: |
270 | # Create MAAS-specific DNS configuration files. |
271 | @@ -431,6 +438,28 @@ class TestDNSConfigModifications(TestDNSServer): |
272 | ), |
273 | ) |
274 | |
275 | + def test_dns_update_all_zones_does_not_reload_if_it_does_not_need_to(self): |
276 | + self.patch(settings, "DNS_CONNECT", True) |
277 | + domain = factory.make_Domain() |
278 | + # These domains should not show up. Just to test we create them. |
279 | + for _ in range(3): |
280 | + factory.make_Domain(authoritative=False) |
281 | + node, static = self.create_node_with_static_ip(domain=domain) |
282 | + fake_serial = random.randint(1, 1000) |
283 | + self.patch( |
284 | + dns_config_module, "current_zone_serial" |
285 | + ).return_value = fake_serial |
286 | + reload_call = self.patch(dns_config_module, "bind_reload") |
287 | + serial1, reloaded, _ = dns_update_all_zones( |
288 | + reload_timeout=RELOAD_TIMEOUT |
289 | + ) |
290 | + self.assertTrue(reloaded) |
291 | + factory.make_DNSResource(domain=domain) |
292 | + _, _, _ = dns_update_all_zones( # should be a dynamic update |
293 | + reload_timeout=RELOAD_TIMEOUT |
294 | + ) |
295 | + reload_call.assert_called_once() |
296 | + |
297 | |
298 | class TestDNSDynamicIPAddresses(TestDNSServer): |
299 | """Allocated nodes with IP addresses in the dynamic range get a DNS |
300 | @@ -754,3 +783,122 @@ class TestGetResourceNameForSubnet(MAASServerTestCase): |
301 | def test_returns_valid(self): |
302 | subnet = factory.make_Subnet(cidr=self.cidr) |
303 | self.assertEqual(self.result, get_resource_name_for_subnet(subnet)) |
304 | + |
305 | + |
306 | +class TestProcessDNSUpdateNotify(MAASServerTestCase): |
307 | + def test_insert(self): |
308 | + domain = factory.make_Domain() |
309 | + resource = factory.make_DNSResource(domain=domain) |
310 | + ip = resource.ip_addresses.first().ip |
311 | + message = f"INSERT {domain.name} {resource.name} A {resource.address_ttl if resource.address_ttl else 60} {ip}" |
312 | + result, _ = process_dns_update_notify(message) |
313 | + self.assertCountEqual( |
314 | + [ |
315 | + DynamicDNSUpdate( |
316 | + operation="INSERT", |
317 | + zone=domain.name, |
318 | + name=f"{resource.name}.{domain.name}", |
319 | + ttl=resource.address_ttl if resource.address_ttl else 60, |
320 | + answer=ip, |
321 | + rectype="A" if IPAddress(ip).version == 4 else "AAAA", |
322 | + ) |
323 | + ], |
324 | + result, |
325 | + ) |
326 | + |
327 | + def test_delete_without_ip(self): |
328 | + domain = factory.make_Domain() |
329 | + resource = factory.make_DNSResource(domain=domain) |
330 | + message = f"DELETE {domain.name} {resource.name} A" |
331 | + result, _ = process_dns_update_notify(message) |
332 | + self.assertCountEqual( |
333 | + [ |
334 | + DynamicDNSUpdate( |
335 | + operation="DELETE", |
336 | + zone=domain.name, |
337 | + name=f"{resource.name}.{domain.name}", |
338 | + rectype="A", |
339 | + ) |
340 | + ], |
341 | + result, |
342 | + ) |
343 | + |
344 | + def test_delete_with_ip(self): |
345 | + domain = factory.make_Domain() |
346 | + resource = factory.make_DNSResource(domain=domain) |
347 | + ip = resource.ip_addresses.first().ip |
348 | + message = f"DELETE {domain.name} {resource.name} A {ip}" |
349 | + result, _ = process_dns_update_notify(message) |
350 | + self.assertCountEqual( |
351 | + [ |
352 | + DynamicDNSUpdate( |
353 | + operation="DELETE", |
354 | + zone=domain.name, |
355 | + name=f"{resource.name}.{domain.name}", |
356 | + answer=ip, |
357 | + rectype="A" if IPAddress(ip).version == 4 else "AAAA", |
358 | + ) |
359 | + ], |
360 | + result, |
361 | + ) |
362 | + |
363 | + def test_update(self): |
364 | + domain = factory.make_Domain() |
365 | + resource = factory.make_DNSResource(domain=domain) |
366 | + ip = resource.ip_addresses.first().ip |
367 | + message = f"UPDATE {domain.name} {resource.name} A {resource.address_ttl if resource.address_ttl else 60} {ip}" |
368 | + result, _ = process_dns_update_notify(message) |
369 | + self.assertCountEqual( |
370 | + [ |
371 | + DynamicDNSUpdate( |
372 | + operation="DELETE", |
373 | + zone=domain.name, |
374 | + name=f"{resource.name}.{domain.name}", |
375 | + answer=ip, |
376 | + rectype="A" if IPAddress(ip).version == 4 else "AAAA", |
377 | + ), |
378 | + DynamicDNSUpdate( |
379 | + operation="INSERT", |
380 | + zone=domain.name, |
381 | + name=f"{resource.name}.{domain.name}", |
382 | + ttl=resource.address_ttl if resource.address_ttl else 60, |
383 | + answer=ip, |
384 | + rectype="A" if IPAddress(ip).version == 4 else "AAAA", |
385 | + ), |
386 | + ], |
387 | + result, |
388 | + ) |
389 | + |
390 | + def test_delete_ip(self): |
391 | + domain = factory.make_Domain() |
392 | + resource = factory.make_DNSResource(domain=domain) |
393 | + ip = resource.ip_addresses.first().ip |
394 | + ip2 = factory.make_StaticIPAddress() |
395 | + resource.ip_addresses.add(ip2) |
396 | + message = f"DELETE-IP {domain.name} {resource.name} A {resource.address_ttl if resource.address_ttl else 60} {ip}" |
397 | + resource.ip_addresses.first().delete() |
398 | + result, _ = process_dns_update_notify(message) |
399 | + self.assertCountEqual( |
400 | + [ |
401 | + DynamicDNSUpdate( |
402 | + operation="DELETE", |
403 | + zone=domain.name, |
404 | + name=f"{resource.name}.{domain.name}", |
405 | + rectype="A", |
406 | + ), |
407 | + DynamicDNSUpdate( |
408 | + operation="DELETE", |
409 | + zone=domain.name, |
410 | + name=f"{resource.name}.{domain.name}", |
411 | + rectype="AAAA", |
412 | + ), |
413 | + DynamicDNSUpdate( |
414 | + operation="INSERT", |
415 | + zone=domain.name, |
416 | + name=f"{resource.name}.{domain.name}", |
417 | + rectype="A" if IPAddress(ip2.ip).version == 4 else "AAAA", |
418 | + answer=ip2.ip, |
419 | + ), |
420 | + ], |
421 | + result, |
422 | + ) |
423 | diff --git a/src/maasserver/dns/zonegenerator.py b/src/maasserver/dns/zonegenerator.py |
424 | index 0e4c872..ef31cfa 100644 |
425 | --- a/src/maasserver/dns/zonegenerator.py |
426 | +++ b/src/maasserver/dns/zonegenerator.py |
427 | @@ -21,6 +21,7 @@ from maasserver.models.domain import Domain |
428 | from maasserver.models.staticipaddress import StaticIPAddress |
429 | from maasserver.models.subnet import Subnet |
430 | from maasserver.server_address import get_maas_facing_server_addresses |
431 | +from provisioningserver.dns.config import DynamicDNSUpdate |
432 | from provisioningserver.dns.zoneconfig import ( |
433 | DNSForwardZoneConfig, |
434 | DNSReverseZoneConfig, |
435 | @@ -200,6 +201,8 @@ class ZoneGenerator: |
436 | default_ttl=None, |
437 | serial=None, |
438 | internal_domains=None, |
439 | + dynamic_updates=None, |
440 | + force_config_write=False, |
441 | ): |
442 | """ |
443 | :param serial: A serial number to reuse when creating zones in bulk. |
444 | @@ -215,6 +218,10 @@ class ZoneGenerator: |
445 | self.internal_domains = internal_domains |
446 | if self.internal_domains is None: |
447 | self.internal_domains = [] |
448 | + self._dynamic_updates = dynamic_updates |
449 | + if self._dynamic_updates is None: |
450 | + self._dynamic_updates = [] |
451 | + self.force_config_write = force_config_write # some data changed that nsupdate cannot update if true |
452 | |
453 | @staticmethod |
454 | def _get_mappings(): |
455 | @@ -235,6 +242,8 @@ class ZoneGenerator: |
456 | rrset_mappings, |
457 | default_ttl, |
458 | internal_domains, |
459 | + dynamic_updates, |
460 | + force_config_write, |
461 | ): |
462 | """Generator of forward zones, collated by domain name.""" |
463 | dns_ip_list = get_dns_server_addresses(filter_allowed_dns=False) |
464 | @@ -290,6 +299,12 @@ class ZoneGenerator: |
465 | (ttl, "AAAA", dns_ip.format()) |
466 | ) |
467 | |
468 | + domain_updates = [ |
469 | + update |
470 | + for update in dynamic_updates |
471 | + if update.zone == domain.name |
472 | + ] |
473 | + |
474 | yield DNSForwardZoneConfig( |
475 | domain.name, |
476 | serial=serial, |
477 | @@ -301,6 +316,8 @@ class ZoneGenerator: |
478 | ns_host_name=ns_host_name, |
479 | other_mapping=other_mapping, |
480 | dynamic_ranges=dynamic_ranges, |
481 | + dynamic_updates=domain_updates, |
482 | + force_config_write=force_config_write, |
483 | ) |
484 | |
485 | # Create the forward zone config for the internal domains. |
486 | @@ -313,6 +330,13 @@ class ZoneGenerator: |
487 | resource_mapping.rrset.add( |
488 | (internal_domain.ttl, record.rrtype, record.rrdata) |
489 | ) |
490 | + |
491 | + domain_updates = [ |
492 | + update |
493 | + for update in dynamic_updates |
494 | + if update.zone == internal_domain.name |
495 | + ] |
496 | + |
497 | yield DNSForwardZoneConfig( |
498 | internal_domain.name, |
499 | serial=serial, |
500 | @@ -324,11 +348,19 @@ class ZoneGenerator: |
501 | ns_host_name=ns_host_name, |
502 | other_mapping=other_mapping, |
503 | dynamic_ranges=[], |
504 | + dynamic_updates=domain_updates, |
505 | + force_config_write=force_config_write, |
506 | ) |
507 | |
508 | @staticmethod |
509 | def _gen_reverse_zones( |
510 | - subnets, serial, ns_host_name, mappings, default_ttl |
511 | + subnets, |
512 | + serial, |
513 | + ns_host_name, |
514 | + mappings, |
515 | + default_ttl, |
516 | + dynamic_updates, |
517 | + force_config_write, |
518 | ): |
519 | """Generator of reverse zones, sorted by network.""" |
520 | |
521 | @@ -418,6 +450,15 @@ class ZoneGenerator: |
522 | del rfc2317_glue[network] |
523 | else: |
524 | glue = set() |
525 | + |
526 | + domain_updates = [ |
527 | + DynamicDNSUpdate.as_reverse_record_update(update, subnet) |
528 | + for update in dynamic_updates |
529 | + if update.answer |
530 | + and update.answer_is_ip |
531 | + and (IPAddress(update.answer) in IPNetwork(subnet.cidr)) |
532 | + ] |
533 | + |
534 | yield DNSReverseZoneConfig( |
535 | ns_host_name, |
536 | serial=serial, |
537 | @@ -430,6 +471,8 @@ class ZoneGenerator: |
538 | exclude={ |
539 | IPNetwork(s.cidr) for s in subnets if s is not subnet |
540 | }, |
541 | + dynamic_updates=domain_updates, |
542 | + force_config_write=force_config_write, |
543 | ) |
544 | # Now provide any remaining rfc2317 glue networks. |
545 | for network, ranges in rfc2317_glue.items(): |
546 | @@ -445,6 +488,8 @@ class ZoneGenerator: |
547 | for s in subnets |
548 | if network in IPNetwork(s.cidr) |
549 | }, |
550 | + dynamic_updates=domain_updates, |
551 | + force_config_write=force_config_write, |
552 | ) |
553 | |
554 | def __iter__(self): |
555 | @@ -470,9 +515,17 @@ class ZoneGenerator: |
556 | rrset_mappings, |
557 | default_ttl, |
558 | self.internal_domains, |
559 | + self._dynamic_updates, |
560 | + self.force_config_write, |
561 | ), |
562 | self._gen_reverse_zones( |
563 | - self.subnets, serial, ns_host_name, mappings, default_ttl |
564 | + self.subnets, |
565 | + serial, |
566 | + ns_host_name, |
567 | + mappings, |
568 | + default_ttl, |
569 | + self._dynamic_updates, |
570 | + self.force_config_write, |
571 | ), |
572 | ) |
573 | |
574 | diff --git a/src/maasserver/region_controller.py b/src/maasserver/region_controller.py |
575 | index e795afb..7b19149 100644 |
576 | --- a/src/maasserver/region_controller.py |
577 | +++ b/src/maasserver/region_controller.py |
578 | @@ -41,7 +41,10 @@ from twisted.internet.task import LoopingCall |
579 | from twisted.names.client import Resolver |
580 | |
581 | from maasserver import eventloop, locks |
582 | -from maasserver.dns.config import dns_update_all_zones |
583 | +from maasserver.dns.config import ( |
584 | + dns_update_all_zones, |
585 | + process_dns_update_notify, |
586 | +) |
587 | from maasserver.macaroon_auth import get_auth_info |
588 | from maasserver.models.dnspublication import DNSPublication |
589 | from maasserver.models.rbacsync import RBAC_ACTION, RBACLastSync, RBACSync |
590 | @@ -94,6 +97,8 @@ class RegionControllerService(Service): |
591 | self.needsDNSUpdate = False |
592 | self.needsProxyUpdate = False |
593 | self.needsRBACUpdate = False |
594 | + self._dns_updates = [] |
595 | + self._dns_requires_full_reload = False |
596 | self.postgresListener = postgresListener |
597 | self.dnsResolver = Resolver( |
598 | resolv=None, |
599 | @@ -110,6 +115,9 @@ class RegionControllerService(Service): |
600 | """Start listening for messages.""" |
601 | super().startService() |
602 | self.postgresListener.register("sys_dns", self.markDNSForUpdate) |
603 | + self.postgresListener.register( |
604 | + "sys_dns_updates", self.queueDynamicDNSUpdate |
605 | + ) |
606 | self.postgresListener.register("sys_proxy", self.markProxyForUpdate) |
607 | self.postgresListener.register("sys_rbac", self.markRBACForUpdate) |
608 | self.postgresListener.register( |
609 | @@ -164,6 +172,18 @@ class RegionControllerService(Service): |
610 | ) |
611 | eventloop.restart() |
612 | |
613 | + def queueDynamicDNSUpdate(self, channel, message): |
614 | + """ |
615 | + Called when the `sys_dns_update` message is received |
616 | + and queues updates for existing domains |
617 | + """ |
618 | + (new_updates, need_reload) = process_dns_update_notify(message) |
619 | + |
620 | + self._dns_requires_full_reload = ( |
621 | + self._dns_requires_full_reload or need_reload |
622 | + ) |
623 | + self._dns_updates += new_updates |
624 | + |
625 | def startProcessing(self): |
626 | """Start the process looping call.""" |
627 | if not self.processing.running: |
628 | @@ -193,10 +213,22 @@ class RegionControllerService(Service): |
629 | if delay: |
630 | return pause(delay) |
631 | |
632 | + def _clear_dynamic_dns_updates(d): |
633 | + self._dns_updates = [] |
634 | + self._dns_requires_full_reload = False |
635 | + return d |
636 | + |
637 | defers = [] |
638 | if self.needsDNSUpdate: |
639 | self.needsDNSUpdate = False |
640 | - d = deferToDatabase(transactional(dns_update_all_zones)) |
641 | + d = deferToDatabase( |
642 | + transactional( |
643 | + dns_update_all_zones, |
644 | + ), |
645 | + dynamic_updates=self._dns_updates, |
646 | + requires_reload=self._dns_requires_full_reload, |
647 | + ) |
648 | + d.addCallback(_clear_dynamic_dns_updates) |
649 | d.addCallback(self._checkSerial) |
650 | d.addCallback(self._logDNSReload) |
651 | # Order here matters, first needsDNSUpdate is set then pass the |
652 | diff --git a/src/maasserver/region_script.py b/src/maasserver/region_script.py |
653 | index 0c87c61..7894260 100644 |
654 | --- a/src/maasserver/region_script.py |
655 | +++ b/src/maasserver/region_script.py |
656 | @@ -42,6 +42,7 @@ def run_django(is_snap, is_devenv): |
657 | "MAAS_DNS_CONFIG_DIR": os.path.join(snap_data, "bind"), |
658 | "MAAS_PROXY_CONFIG_DIR": os.path.join(snap_data, "proxy"), |
659 | "MAAS_SYSLOG_CONFIG_DIR": os.path.join(snap_data, "syslog"), |
660 | + "MAAS_ZONE_FILE_CONFIG_DIR": os.path.join(snap_data, "bind"), |
661 | "MAAS_IMAGES_KEYRING_FILEPATH": ( |
662 | "/snap/maas/current/usr/share/keyrings/" |
663 | "ubuntu-cloudimage-keyring.gpg" |
664 | diff --git a/src/maasserver/tests/test_region_controller.py b/src/maasserver/tests/test_region_controller.py |
665 | index 0995258..4f40354 100644 |
666 | --- a/src/maasserver/tests/test_region_controller.py |
667 | +++ b/src/maasserver/tests/test_region_controller.py |
668 | @@ -69,6 +69,7 @@ class TestRegionControllerService(MAASServerTestCase): |
669 | listener.register, |
670 | MockCallsMatch( |
671 | call("sys_dns", service.markDNSForUpdate), |
672 | + call("sys_dns_updates", service.queueDynamicDNSUpdate), |
673 | call("sys_proxy", service.markProxyForUpdate), |
674 | call("sys_rbac", service.markRBACForUpdate), |
675 | call("sys_vault_migration", service.restartRegion), |
676 | @@ -216,7 +217,9 @@ class TestRegionControllerService(MAASServerTestCase): |
677 | mock_msg = self.patch(region_controller.log, "msg") |
678 | service.startProcessing() |
679 | yield service.processingDefer |
680 | - self.assertThat(mock_dns_update_all_zones, MockCalledOnceWith()) |
681 | + mock_dns_update_all_zones.assert_called_once_with( |
682 | + dynamic_updates=[], requires_reload=False |
683 | + ) |
684 | self.assertThat(mock_check_serial, MockCalledOnceWith(dns_result)) |
685 | self.assertThat( |
686 | mock_msg, |
687 | @@ -258,7 +261,11 @@ class TestRegionControllerService(MAASServerTestCase): |
688 | service.startProcessing() |
689 | yield service.processingDefer |
690 | self.assertThat( |
691 | - mock_dns_update_all_zones, MockCallsMatch(call(), call()) |
692 | + mock_dns_update_all_zones, |
693 | + MockCallsMatch( |
694 | + call(dynamic_updates=[], requires_reload=False), |
695 | + call(dynamic_updates=[], requires_reload=False), |
696 | + ), |
697 | ) |
698 | self.assertThat( |
699 | mock_check_serial, |
700 | @@ -313,7 +320,9 @@ class TestRegionControllerService(MAASServerTestCase): |
701 | mock_err = self.patch(region_controller.log, "err") |
702 | service.startProcessing() |
703 | yield service.processingDefer |
704 | - self.assertThat(mock_dns_update_all_zones, MockCalledOnceWith()) |
705 | + mock_dns_update_all_zones.assert_called_once_with( |
706 | + dynamic_updates=[], requires_reload=False |
707 | + ) |
708 | self.assertThat( |
709 | mock_err, MockCalledOnceWith(ANY, "Failed configuring DNS.") |
710 | ) |
711 | @@ -400,12 +409,11 @@ class TestRegionControllerService(MAASServerTestCase): |
712 | mock_rbacSync.return_value = None |
713 | service.startProcessing() |
714 | yield service.processingDefer |
715 | - self.assertThat(mock_dns_update_all_zones, MockCalledOnceWith()) |
716 | - self.assertThat(mock_check_serial, MockCalledOnceWith(dns_result)) |
717 | - self.assertThat( |
718 | - mock_proxy_update_config, MockCalledOnceWith(reload_proxy=True) |
719 | + mock_dns_update_all_zones.assert_called_once_with( |
720 | + dynamic_updates=[], requires_reload=False |
721 | ) |
722 | - self.assertThat(mock_rbacSync, MockCalledOnceWith()) |
723 | + mock_proxy_update_config.assert_called_once_with(reload_proxy=True) |
724 | + mock_rbacSync.assert_called_once() |
725 | |
726 | def make_soa_result(self, serial): |
727 | return RRHeader( |
728 | @@ -626,7 +634,9 @@ class TestRegionControllerServiceTransactional(MAASTransactionServerTestCase): |
729 | mock_msg = self.patch(region_controller.log, "msg") |
730 | service.startProcessing() |
731 | yield service.processingDefer |
732 | - self.assertThat(mock_dns_update_all_zones, MockCalledOnceWith()) |
733 | + mock_dns_update_all_zones.assert_called_once_with( |
734 | + dynamic_updates=[], requires_reload=False |
735 | + ) |
736 | self.assertThat(mock_check_serial, MockCalledOnceWith(dns_result)) |
737 | self.assertThat( |
738 | mock_msg, |
739 | @@ -670,7 +680,9 @@ class TestRegionControllerServiceTransactional(MAASTransactionServerTestCase): |
740 | " * %s" % publication.source |
741 | for publication in reversed(publications[1:]) |
742 | ) |
743 | - self.assertThat(mock_dns_update_all_zones, MockCalledOnceWith()) |
744 | + mock_dns_update_all_zones.assert_called_once_with( |
745 | + dynamic_updates=[], requires_reload=False |
746 | + ) |
747 | self.assertThat(mock_check_serial, MockCalledOnceWith(dns_result)) |
748 | self.assertThat(mock_msg, MockCalledOnceWith(expected_msg)) |
749 | |
750 | diff --git a/src/maasserver/triggers/system.py b/src/maasserver/triggers/system.py |
751 | index 0cf4e90..e1a60f1 100644 |
752 | --- a/src/maasserver/triggers/system.py |
753 | +++ b/src/maasserver/triggers/system.py |
754 | @@ -1901,6 +1901,179 @@ RBAC_RPOOL_DELETE = dedent( |
755 | ) |
756 | |
757 | |
758 | +# handles dynamic updates when a dnsresource is created |
759 | +# or a StaticIPAddres maps to a resource or was deleted |
760 | +def render_dns_dynamic_update_dnsresource_ip_addresses_procedure(op): |
761 | + return dedent( |
762 | + f"""\ |
763 | + CREATE OR REPLACE FUNCTION sys_dns_updates_dns_ip_{op}() |
764 | + RETURNS trigger as $$ |
765 | + DECLARE |
766 | + ip_addr text; |
767 | + rname text; |
768 | + rdomain_id bigint; |
769 | + domain text; |
770 | + ttl integer; |
771 | + BEGIN |
772 | + IF TG_WHEN <> 'AFTER' THEN |
773 | + RAISE EXCEPTION '[%] - May only run as an AFTER trigger', TG_NAME; |
774 | + ELSIF (TG_LEVEL = 'STATEMENT') THEN |
775 | + RAISE EXCEPTION '[%] - Should not be used as a STATEMENT level trigger', TG_NAME; |
776 | + END IF; |
777 | + IF (TG_OP = 'INSERT' AND TG_LEVEL = 'ROW') THEN |
778 | + SELECT host(ip) INTO ip_addr FROM maasserver_staticipaddress WHERE id=NEW.staticipaddress_id; |
779 | + SELECT name, domain_id, COALESCE(address_ttl, 0) INTO rname, rdomain_id, ttl FROM maasserver_dnsresource WHERE id=NEW.dnsresource_id; |
780 | + SELECT name INTO domain FROM maasserver_domain WHERE id=rdomain_id; |
781 | + PERFORM pg_notify('sys_dns_updates', 'INSERT ' || domain || ' ' || rname || ' A ' || ttl || ' ' || ip_addr); |
782 | + ELSIF (TG_OP = 'DELETE' AND TG_LEVEl = 'ROW') THEN |
783 | + IF EXISTS(SELECT id FROM maasserver_dnsresource WHERE id=OLD.dnsresource_id) THEN |
784 | + IF EXISTS(SELECT id FROM maasserver_staticipaddress WHERE id=OLD.staticipaddress_id) THEN |
785 | + SELECT host(ip) INTO ip_addr FROM maasserver_staticipaddress WHERE id=OLD.staticipaddress_id; |
786 | + SELECT name, domain_id INTO rname, rdomain_id FROM maasserver_dnsresource WHERE id=OLD.dnsresource_id; |
787 | + SELECT name INTO domain FROM maasserver_domain WHERE id=rdomain_id; |
788 | + PERFORM pg_notify('sys_dns_updates', 'DELETE ' || domain || ' ' || rname || ' A ' || ip_addr); |
789 | + ELSE |
790 | + SELECT name, domain_id INTO rname, rdomain_id FROM maasserver_dnsresource WHERE id=NEW.dnsresource_id; |
791 | + SELECT name INTO domain FROM maasserver_domain WHERE id=rdomain_id; |
792 | + PERFORM pg_notify('sys_dns_updates', 'DELETE-IP ' || domain || ' ' || rname || ' A'); |
793 | + PERFORM pg_notify('sys_dns_updates', 'DELETE-IP ' || domain || ' ' || rname || ' AAAA'); |
794 | + END IF; |
795 | + END IF; |
796 | + END IF; |
797 | + RETURN NULL; |
798 | + END; |
799 | + $$ LANGUAGE plpgsql; |
800 | + """ |
801 | + ) |
802 | + |
803 | + |
804 | +# handles when ttl or name is modified or resource is deleted, |
805 | +# DNS_DYNAMIC_UPDATE_DNSRESOURCE_STATICIPADDRESS covers the case of insert |
806 | +def render_dns_dynamic_update_dnsresource_procedure(op): |
807 | + return dedent( |
808 | + f"""\ |
809 | + CREATE OR REPLACE FUNCTION sys_dns_updates_maasserver_dnsresource_{op}() |
810 | + RETURNS trigger as $$ |
811 | + DECLARE |
812 | + ip_addr text; |
813 | + ips text[]; |
814 | + domain text; |
815 | + BEGIN |
816 | + IF TG_WHEN <> 'AFTER' THEN |
817 | + RAISE EXCEPTION '[%] - May only run as an AFTER trigger', TG_NAME; |
818 | + ELSIF (TG_LEVEL = 'STATEMENT') THEN |
819 | + RAISE EXCEPTION '[%] - Should not be used as a STATEMENT level trigger', TG_NAME; |
820 | + END IF; |
821 | + PERFORM pg_notify('sys_dns_updates', TG_OP || ' ' || OLD.name || ' ' || NEW.name || ' ' || OLD.address_ttl || ' ' || NEW.address_ttl); |
822 | + IF (TG_OP = 'UPDATE' AND TG_LEVEL = 'ROW') THEN |
823 | + IF NEW IS DISTINCT FROM OLD THEN |
824 | + SELECT array_agg(host(ip)) INTO ips FROM maasserver_dnsresource_ip_addresses m |
825 | + INNER JOIN maasserver_staticipaddress ON maasserver_staticipaddress.id=m.staticipaddress_id |
826 | + WHERE dnsresource_id=NEW.id; |
827 | + SELECT name INTO domain FROM maasserver_domain WHERE id=NEW.domain_id; |
828 | + IF array_length(ips, 1) > 0 THEN |
829 | + FOREACH ip_addr IN ARRAY ips |
830 | + LOOP |
831 | + IF OLD.name <> NEW.name THEN |
832 | + PERFORM pg_notify('sys_dns_updates', 'DELETE ' || domain || ' ' || OLD.name || ' A ' || ip_addr); |
833 | + PERFORM pg_notify('sys_dns_updates', 'INSERT ' || domain || ' ' || NEW.name || ' A ' || NEW.address_ttl || ' ' || ip_addr); |
834 | + ELSE |
835 | + PERFORM pg_notify('sys_dns_updates', 'UPDATE ' || domain || ' ' || NEW.name || ' A ' || NEW.address_ttl || ' ' || ip_addr); |
836 | + END IF; |
837 | + END LOOP; |
838 | + END IF; |
839 | + ELSE |
840 | + RETURN NULL; |
841 | + END IF; |
842 | + ELSIF (TG_OP = 'DELETE' AND TG_LEVEL = 'ROW') THEN |
843 | + SELECT name INTO domain FROM maasserver_domain WHERE id=NEW.domain_id; |
844 | + PERFORM pg_notify('sys_dns_updates', 'DELETE ' || domain || ' ' || OLD.name || ' A'); |
845 | + END IF; |
846 | + RETURN NULL; |
847 | + END; |
848 | + $$ LANGUAGE plpgsql; |
849 | + """ |
850 | + ) |
851 | + |
852 | + |
853 | +def render_dns_dynamic_update_dnsdata_procedure(op): |
854 | + return dedent( |
855 | + f"""\ |
856 | + CREATE OR REPLACE FUNCTION sys_dns_updates_maasserver_dnsdata_{op}() |
857 | + RETURNS trigger as $$ |
858 | + DECLARE |
859 | + rname text; |
860 | + rdomain_id bigint; |
861 | + domain text; |
862 | + ttl int; |
863 | + BEGIN |
864 | + IF TG_WHEN <> 'AFTER' THEN |
865 | + RAISE EXCEPTION '[%] - May only run as an AFTER trigger', TG_NAME; |
866 | + ELSIF (TG_LEVEL = 'STATEMENT') THEN |
867 | + RAISE EXCEPTION '[%] - Should not be used as a STATEMENT level trigger', TG_NAME; |
868 | + END IF; |
869 | + IF (TG_OP = 'UPDATE' AND TG_LEVEL = 'ROW') THEN |
870 | + IF NEW IS DISTINCT FROM OLD THEN |
871 | + SELECT name, domain_id, COALESCE(address_ttl, 0) INTO rname, rdomain_id, ttl from maasserver_dnsresource WHERE id=NEW.dnsresource_id; |
872 | + SELECT name INTO domain FROM maasserver_domain WHERE id=rdomain_id; |
873 | + PERFORM pg_notify('sys_dns_updates', 'UPDATE ' || domain || ' ' || rname || ' ' || NEW.rrtype || ' ' || COALESCE(NEW.ttl, ttl) || ' ' || NEW.rrdata); |
874 | + ELSE |
875 | + RETURN NULL; |
876 | + END IF; |
877 | + ELSIF (TG_OP = 'INSERT' AND TG_LEVEL = 'ROW') THEN |
878 | + SELECT name, domain_id, COALESCE(address_ttl, 0) INTO rname, rdomain_id, ttl from maasserver_dnsresource WHERE id=NEW.dnsresource_id; |
879 | + SELECT name INTO domain FROM maasserver_domain WHERE id=rdomain_id; |
880 | + PERFORM pg_notify('sys_dns_updates', 'INSERT ' || domain || ' ' || rname || ' ' || NEW.rrtype || ' ' || COALESCE(NEW.ttl, ttl) || ' ' || NEW.rrdata); |
881 | + ELSIF (TG_OP = 'DELETE' AND TG_LEVEL = 'ROW') THEN |
882 | + SELECT name, domain_id INTO rname, rdomain_id from maasserver_dnsresource WHERE id=OLD.dnsresource_id; |
883 | + SELECT name INTO domain FROM maasserver_domain WHERE id=rdomain_id; |
884 | + PERFORM pg_notify('sys_dns_updates', 'DELETE ' || domain || ' ' || rname || ' ' || OLD.rrtype); |
885 | + END IF; |
886 | + RETURN NULL; |
887 | + END; |
888 | + $$ LANGUAGE plpgsql; |
889 | + """ |
890 | + ) |
891 | + |
892 | + |
893 | +def render_dns_dynamic_update_domain_procedure(op): |
894 | + return dedent( |
895 | + f"""\ |
896 | + CREATE OR REPLACE FUNCTION sys_dns_updates_maasserver_domain_{op}() |
897 | + RETURNS trigger as $$ |
898 | + BEGIN |
899 | + IF TG_WHEN <> 'AFTER' THEN |
900 | + RAISE EXCEPTION '[%] - May only run as an AFTER trigger', TG_NAME; |
901 | + ELSIF (TG_LEVEL = 'STATEMENT') THEN |
902 | + RAISE EXCEPTION '[%] - Should not be used as a STATEMENT level trigger', TG_NAME; |
903 | + END IF; |
904 | + PERFORM pg_notify('sys_dns_updates', 'RELOAD'); |
905 | + RETURN NULL; |
906 | + END; |
907 | + $$ LANGUAGE plpgsql; |
908 | + """ |
909 | + ) |
910 | + |
911 | + |
912 | +def render_dns_dynamic_update_subnet_procedure(op): |
913 | + return dedent( |
914 | + f"""\ |
915 | + CREATE OR REPLACE FUNCTION sys_dns_updates_maasserver_subnet_{op}() |
916 | + RETURNS trigger as $$ |
917 | + BEGIN |
918 | + IF TG_WHEN <> 'AFTER' THEN |
919 | + RAISE EXCEPTION '[%] - May only run as an AFTER trigger', TG_NAME; |
920 | + ELSIF (TG_LEVEL = 'STATEMENT') THEN |
921 | + RAISE EXCEPTION '[%] - Should not be used as a STATEMENT level trigger', TG_NAME; |
922 | + END IF; |
923 | + PERFORM pg_notify('sys_dns_updates', 'RELOAD'); |
924 | + RETURN NULL; |
925 | + END; |
926 | + $$ LANGUAGE plpgsql; |
927 | + """ |
928 | + ) |
929 | + |
930 | + |
931 | def render_sys_proxy_procedure(proc_name, on_delete=False): |
932 | """Render a database procedure with name `proc_name` that notifies that a |
933 | proxy update is needed. |
934 | @@ -2163,3 +2336,90 @@ def register_system_triggers(): |
935 | register_trigger( |
936 | "maasserver_resourcepool", "sys_rbac_rpool_delete", "delete" |
937 | ) |
938 | + |
939 | + register_procedure( |
940 | + render_dns_dynamic_update_dnsresource_ip_addresses_procedure("insert") |
941 | + ) |
942 | + register_trigger( |
943 | + "maasserver_dnsresource_ip_addresses", |
944 | + "sys_dns_updates_dns_ip_insert", |
945 | + "insert", |
946 | + ) |
947 | + register_procedure( |
948 | + render_dns_dynamic_update_dnsresource_ip_addresses_procedure("delete") |
949 | + ) |
950 | + register_trigger( |
951 | + "maasserver_dnsresource_ip_addresses", |
952 | + "sys_dns_updates_dns_ip_delete", |
953 | + "delete", |
954 | + ) |
955 | + register_procedure( |
956 | + render_dns_dynamic_update_dnsresource_procedure("update") |
957 | + ) |
958 | + register_trigger( |
959 | + "maasserver_dnsresource", |
960 | + "sys_dns_updates_maasserver_dnsresource_update", |
961 | + "update", |
962 | + ) |
963 | + register_procedure( |
964 | + render_dns_dynamic_update_dnsresource_procedure("delete") |
965 | + ) |
966 | + register_trigger( |
967 | + "maasserver_dnsresource", |
968 | + "sys_dns_updates_maasserver_dnsresource_delete", |
969 | + "delete", |
970 | + ) |
971 | + register_procedure(render_dns_dynamic_update_dnsdata_procedure("insert")) |
972 | + register_trigger( |
973 | + "maasserver_dnsdata", |
974 | + "sys_dns_updates_maasserver_dnsdata_insert", |
975 | + "update", |
976 | + ) |
977 | + register_procedure(render_dns_dynamic_update_dnsdata_procedure("update")) |
978 | + register_trigger( |
979 | + "maasserver_dnsdata", |
980 | + "sys_dns_updates_maasserver_dnsdata_update", |
981 | + "update", |
982 | + ) |
983 | + register_procedure(render_dns_dynamic_update_dnsdata_procedure("delete")) |
984 | + register_trigger( |
985 | + "maasserver_dnsdata", |
986 | + "sys_dns_updates_maasserver_dnsdata_delete", |
987 | + "delete", |
988 | + ) |
989 | + register_procedure(render_dns_dynamic_update_domain_procedure("insert")) |
990 | + register_trigger( |
991 | + "maasserver_domain", |
992 | + "sys_dns_updates_maasserver_domain_insert", |
993 | + "insert", |
994 | + ) |
995 | + register_procedure(render_dns_dynamic_update_domain_procedure("update")) |
996 | + register_trigger( |
997 | + "maasserver_domain", |
998 | + "sys_dns_updates_maasserver_domain_update", |
999 | + "update", |
1000 | + ) |
1001 | + register_procedure(render_dns_dynamic_update_domain_procedure("delete")) |
1002 | + register_trigger( |
1003 | + "maasserver_domain", |
1004 | + "sys_dns_updates_maasserver_domain_delete", |
1005 | + "delete", |
1006 | + ) |
1007 | + register_procedure(render_dns_dynamic_update_subnet_procedure("insert")) |
1008 | + register_trigger( |
1009 | + "maasserver_subnet", |
1010 | + "sys_dns_updates_maasserver_subnet_insert", |
1011 | + "insert", |
1012 | + ) |
1013 | + register_procedure(render_dns_dynamic_update_subnet_procedure("update")) |
1014 | + register_trigger( |
1015 | + "maasserver_subnet", |
1016 | + "sys_dns_updates_maasserver_subnet_update", |
1017 | + "update", |
1018 | + ) |
1019 | + register_procedure(render_dns_dynamic_update_subnet_procedure("delete")) |
1020 | + register_trigger( |
1021 | + "maasserver_subnet", |
1022 | + "sys_dns_updates_maasserver_subnet_delete", |
1023 | + "delete", |
1024 | + ) |
1025 | diff --git a/src/maasserver/triggers/testing.py b/src/maasserver/triggers/testing.py |
1026 | index 538fbc4..551ef4d 100644 |
1027 | --- a/src/maasserver/triggers/testing.py |
1028 | +++ b/src/maasserver/triggers/testing.py |
1029 | @@ -4,11 +4,10 @@ |
1030 | """Helper class for all tests using the `PostgresListenerService` under |
1031 | `maasserver.triggers.tests`.""" |
1032 | |
1033 | - |
1034 | from django.contrib.auth.models import User |
1035 | from piston3.models import Token |
1036 | from testtools.matchers import GreaterThan, Is, Not |
1037 | -from twisted.internet.defer import inlineCallbacks, returnValue |
1038 | +from twisted.internet.defer import DeferredQueue, inlineCallbacks, returnValue |
1039 | |
1040 | from maasserver.enum import INTERFACE_TYPE, NODE_TYPE |
1041 | from maasserver.listener import PostgresListenerService |
1042 | @@ -53,6 +52,7 @@ from maasserver.models.virtualblockdevice import VirtualBlockDevice |
1043 | from maasserver.models.vlan import VLAN |
1044 | from maasserver.models.zone import Zone |
1045 | from maasserver.testing.factory import factory, RANDOM |
1046 | +from maasserver.triggers import register_trigger |
1047 | from maasserver.utils.orm import reload_object, transactional |
1048 | from maasserver.utils.threads import deferToDatabase |
1049 | from maastesting.crochet import wait_for |
1050 | @@ -942,3 +942,44 @@ class RBACHelpersMixin: |
1051 | GreaterThan(old.id), |
1052 | "RBAC sync tracking has not been modified again.", |
1053 | ) |
1054 | + |
1055 | + |
1056 | +class NotifyHelperMixin: |
1057 | + |
1058 | + channels = () |
1059 | + channel_queues = {} |
1060 | + postgres_listener_service = None |
1061 | + |
1062 | + @inlineCallbacks |
1063 | + def set_service(self, listener): |
1064 | + self.postgres_listener_service = listener |
1065 | + yield self.postgres_listener_service.startService() |
1066 | + |
1067 | + def register_trigger(self, table, channel, ops=(), trigger=None): |
1068 | + if channel not in self.channels: |
1069 | + self.postgres_listener_service.registerChannel(channel) |
1070 | + self.postgres_listener_service.register(channel, self.listen) |
1071 | + self.channels = self.channels + (channel,) |
1072 | + for op in ops: |
1073 | + trigger = trigger or f"{channel}_{table}_{op}" |
1074 | + register_trigger(table, trigger, op) |
1075 | + |
1076 | + @inlineCallbacks |
1077 | + def listen(self, channel, msg): |
1078 | + if msg: |
1079 | + yield self.channel_queues[channel].put(msg) |
1080 | + |
1081 | + @inlineCallbacks |
1082 | + def get_notify(self, channel): |
1083 | + msg = yield self.channel_queues[channel].get() |
1084 | + return msg |
1085 | + |
1086 | + def start_reading(self): |
1087 | + for channel in self.channels: |
1088 | + self.channel_queues[channel] = DeferredQueue() |
1089 | + self.postgres_listener_service.startReading() |
1090 | + |
1091 | + def stop_reading(self): |
1092 | + for channel in self.channels: |
1093 | + del self.channel_queues[channel] |
1094 | + self.postgres_listener_service.stopReading() |
1095 | diff --git a/src/maasserver/triggers/tests/test_init.py b/src/maasserver/triggers/tests/test_init.py |
1096 | index 7b6e8ee..e4fb3ed 100644 |
1097 | --- a/src/maasserver/triggers/tests/test_init.py |
1098 | +++ b/src/maasserver/triggers/tests/test_init.py |
1099 | @@ -70,15 +70,25 @@ class TestTriggersUsed(MAASServerTestCase): |
1100 | "dnsdata_sys_dns_dnsdata_delete", |
1101 | "dnsdata_sys_dns_dnsdata_insert", |
1102 | "dnsdata_sys_dns_dnsdata_update", |
1103 | + "dnsdata_sys_dns_updates_maasserver_dnsdata_delete", |
1104 | + "dnsdata_sys_dns_updates_maasserver_dnsdata_insert", |
1105 | + "dnsdata_sys_dns_updates_maasserver_dnsdata_update", |
1106 | "dnspublication_sys_dns_publish", |
1107 | "dnsresource_ip_addresses_sys_dns_dnsresource_ip_link", |
1108 | "dnsresource_ip_addresses_sys_dns_dnsresource_ip_unlink", |
1109 | "dnsresource_sys_dns_dnsresource_delete", |
1110 | "dnsresource_sys_dns_dnsresource_insert", |
1111 | "dnsresource_sys_dns_dnsresource_update", |
1112 | + "dnsresource_sys_dns_updates_maasserver_dnsresource_update", |
1113 | + "dnsresource_sys_dns_updates_maasserver_dnsresource_delete", |
1114 | + "dnsresource_ip_addresses_sys_dns_updates_dns_ip_insert", |
1115 | + "dnsresource_ip_addresses_sys_dns_updates_dns_ip_delete", |
1116 | "domain_sys_dns_domain_delete", |
1117 | "domain_sys_dns_domain_insert", |
1118 | "domain_sys_dns_domain_update", |
1119 | + "domain_sys_dns_updates_maasserver_domain_delete", |
1120 | + "domain_sys_dns_updates_maasserver_domain_insert", |
1121 | + "domain_sys_dns_updates_maasserver_domain_update", |
1122 | "interface_ip_addresses_sys_dns_nic_ip_link", |
1123 | "interface_ip_addresses_sys_dns_nic_ip_unlink", |
1124 | "interface_sys_dhcp_interface_update", |
1125 | @@ -99,6 +109,9 @@ class TestTriggersUsed(MAASServerTestCase): |
1126 | "staticipaddress_sys_dhcp_staticipaddress_insert", |
1127 | "staticipaddress_sys_dhcp_staticipaddress_update", |
1128 | "staticipaddress_sys_dns_staticipaddress_update", |
1129 | + "subnet_sys_dns_updates_maasserver_subnet_delete", |
1130 | + "subnet_sys_dns_updates_maasserver_subnet_insert", |
1131 | + "subnet_sys_dns_updates_maasserver_subnet_update", |
1132 | "subnet_sys_dhcp_subnet_delete", |
1133 | "subnet_sys_dhcp_subnet_update", |
1134 | "subnet_sys_dns_subnet_delete", |
1135 | diff --git a/src/maasserver/triggers/tests/test_system.py b/src/maasserver/triggers/tests/test_system.py |
1136 | index 7d646ae..f43b014 100644 |
1137 | --- a/src/maasserver/triggers/tests/test_system.py |
1138 | +++ b/src/maasserver/triggers/tests/test_system.py |
1139 | @@ -5,13 +5,26 @@ |
1140 | from contextlib import closing |
1141 | |
1142 | from django.db import connection |
1143 | +from twisted.internet.defer import inlineCallbacks |
1144 | |
1145 | from maasserver.models.dnspublication import zone_serial |
1146 | -from maasserver.testing.testcase import MAASServerTestCase |
1147 | +from maasserver.testing.factory import factory |
1148 | +from maasserver.testing.testcase import ( |
1149 | + MAASServerTestCase, |
1150 | + MAASTransactionServerTestCase, |
1151 | +) |
1152 | from maasserver.triggers.system import register_system_triggers |
1153 | +from maasserver.triggers.testing import ( |
1154 | + NotifyHelperMixin, |
1155 | + TransactionalHelpersMixin, |
1156 | +) |
1157 | from maasserver.utils.orm import psql_array |
1158 | +from maasserver.utils.threads import deferToDatabase |
1159 | +from maastesting.crochet import wait_for |
1160 | from maastesting.matchers import MockCalledOnceWith |
1161 | |
1162 | +wait_for_reactor = wait_for() |
1163 | + |
1164 | |
1165 | class TestTriggers(MAASServerTestCase): |
1166 | def test_register_system_triggers(self): |
1167 | @@ -88,3 +101,279 @@ class TestTriggers(MAASServerTestCase): |
1168 | mock_create = self.patch(zone_serial, "create_if_not_exists") |
1169 | register_system_triggers() |
1170 | self.assertThat(mock_create, MockCalledOnceWith()) |
1171 | + |
1172 | + |
1173 | +class TestSysDNSUpdates( |
1174 | + MAASTransactionServerTestCase, TransactionalHelpersMixin, NotifyHelperMixin |
1175 | +): |
1176 | + @wait_for_reactor |
1177 | + @inlineCallbacks |
1178 | + def test_dns_dynamic_update_dnsresource_ip_addresses_insert(self): |
1179 | + listener = self.make_listener_without_delay() |
1180 | + yield self.set_service(listener) |
1181 | + domain = yield deferToDatabase(self.create_domain) |
1182 | + yield deferToDatabase( |
1183 | + self.register_trigger, |
1184 | + "maasserver_dnsresource_ip_addresses", |
1185 | + "sys_dns_updates", |
1186 | + ops=("insert",), |
1187 | + trigger="sys_dns_updates_dns_ip_insert", |
1188 | + ) |
1189 | + self.start_reading() |
1190 | + try: |
1191 | + static_ip = yield deferToDatabase(self.create_staticipaddress) |
1192 | + rec = yield deferToDatabase( |
1193 | + self.create_dnsresource, |
1194 | + params={"domain": domain, "ip_addresses": [static_ip]}, |
1195 | + ) |
1196 | + yield self.get_notify( |
1197 | + "sys_dns_updates" |
1198 | + ) # ignore RELOAD from domain creation |
1199 | + msg = yield self.get_notify("sys_dns_updates") |
1200 | + self.assertEqual( |
1201 | + msg, |
1202 | + f"INSERT {domain.name} {rec.name} A {domain.ttl if domain.ttl else 0} {static_ip.ip}", |
1203 | + ) |
1204 | + finally: |
1205 | + self.stop_reading() |
1206 | + yield self.postgres_listener_service.stopService() |
1207 | + |
1208 | + @wait_for_reactor |
1209 | + @inlineCallbacks |
1210 | + def test_dns_dynamic_update_dnsresource_ip_addresses_delete(self): |
1211 | + listener = self.make_listener_without_delay() |
1212 | + yield self.set_service(listener) |
1213 | + domain = yield deferToDatabase(self.create_domain) |
1214 | + static_ip = yield deferToDatabase(self.create_staticipaddress) |
1215 | + rec = yield deferToDatabase( |
1216 | + self.create_dnsresource, |
1217 | + params={"domain": domain, "ip_addresses": [static_ip]}, |
1218 | + ) |
1219 | + yield deferToDatabase( |
1220 | + self.register_trigger, |
1221 | + "maasserver_dnsresource_ip_addresses", |
1222 | + "sys_dns_updates", |
1223 | + ops=("delete",), |
1224 | + trigger="sys_dns_updates_dns_ip_delete", |
1225 | + ) |
1226 | + self.start_reading() |
1227 | + try: |
1228 | + yield deferToDatabase(rec.delete) |
1229 | + msg = yield self.get_notify("sys_dns_updates") |
1230 | + self.assertEqual( |
1231 | + msg, f"DELETE {domain.name} {rec.name} A {static_ip.ip}" |
1232 | + ) |
1233 | + finally: |
1234 | + self.stop_reading() |
1235 | + yield self.postgres_listener_service.stopService() |
1236 | + |
1237 | + @wait_for_reactor |
1238 | + @inlineCallbacks |
1239 | + def test_dns_dynamic_update_dnsresource_update(self): |
1240 | + listener = self.make_listener_without_delay() |
1241 | + yield self.set_service(listener) |
1242 | + domain = yield deferToDatabase(self.create_domain) |
1243 | + static_ip = yield deferToDatabase(self.create_staticipaddress) |
1244 | + rec = yield deferToDatabase( |
1245 | + self.create_dnsresource, |
1246 | + params={"domain": domain, "ip_addresses": [static_ip]}, |
1247 | + ) |
1248 | + yield deferToDatabase( |
1249 | + self.register_trigger, |
1250 | + "maasserver_dnsresource", |
1251 | + "sys_dns_updates", |
1252 | + ops=("update",), |
1253 | + ) |
1254 | + self.start_reading() |
1255 | + try: |
1256 | + rec.address_ttl = 30 |
1257 | + yield deferToDatabase(rec.save) |
1258 | + msg = yield self.get_notify("sys_dns_updates") |
1259 | + self.assertEqual( |
1260 | + msg, f"UPDATE {domain.name} {rec.name} A 30 {static_ip.ip}" |
1261 | + ) |
1262 | + finally: |
1263 | + self.stop_reading() |
1264 | + yield self.postgres_listener_service.stopService() |
1265 | + |
1266 | + @wait_for_reactor |
1267 | + @inlineCallbacks |
1268 | + def test_dns_dynamic_update_dnsresource_delete(self): |
1269 | + listener = self.make_listener_without_delay() |
1270 | + yield self.set_service(listener) |
1271 | + domain = yield deferToDatabase(self.create_domain) |
1272 | + static_ip = yield deferToDatabase(self.create_staticipaddress) |
1273 | + rec = yield deferToDatabase( |
1274 | + self.create_dnsresource, |
1275 | + params={"domain": domain, "ip_addresses": [static_ip]}, |
1276 | + ) |
1277 | + yield deferToDatabase( |
1278 | + self.register_trigger, |
1279 | + "maasserver_dnsresource", |
1280 | + "sys_dns_updates", |
1281 | + ops=("delete",), |
1282 | + ) |
1283 | + self.start_reading() |
1284 | + try: |
1285 | + yield deferToDatabase(rec.delete) |
1286 | + msg = yield self.get_notify("sys_dns_updates") |
1287 | + self.assertEqual( |
1288 | + msg, f"DELETE {domain.name} {rec.name} A {static_ip.ip}" |
1289 | + ) |
1290 | + finally: |
1291 | + self.stop_reading() |
1292 | + yield self.postgres_listener_service.stopService() |
1293 | + |
1294 | + @wait_for_reactor |
1295 | + @inlineCallbacks |
1296 | + def test_dns_dynamic_update_dnsdata_update(self): |
1297 | + listener = self.make_listener_without_delay() |
1298 | + yield self.set_service(listener) |
1299 | + domain = yield deferToDatabase(self.create_domain) |
1300 | + rec = yield deferToDatabase( |
1301 | + self.create_dnsresource, params={"domain": domain} |
1302 | + ) |
1303 | + yield deferToDatabase( |
1304 | + self.register_trigger, |
1305 | + "maasserver_dnsdata", |
1306 | + "sys_dns_updates", |
1307 | + ops=("update",), |
1308 | + ) |
1309 | + self.start_reading() |
1310 | + try: |
1311 | + dnsdata = yield deferToDatabase( |
1312 | + self.create_dnsdata, |
1313 | + params={ |
1314 | + "dnsresource": rec, |
1315 | + "rrtype": "TXT", |
1316 | + "rrdata": factory.make_name(), |
1317 | + }, |
1318 | + ) |
1319 | + dnsdata.rrdata = factory.make_name() |
1320 | + yield deferToDatabase(dnsdata.save) |
1321 | + msg = yield self.get_notify("sys_dns_updates") |
1322 | + expected_ttl = 0 |
1323 | + if dnsdata.ttl: |
1324 | + expected_ttl = dnsdata.ttl |
1325 | + elif rec.address_ttl: |
1326 | + expected_ttl = rec.address_ttl |
1327 | + self.assertEqual( |
1328 | + msg, |
1329 | + f"UPDATE {domain.name} {rec.name} {dnsdata.rrtype} {expected_ttl} {dnsdata.rrdata}", |
1330 | + ) |
1331 | + finally: |
1332 | + self.stop_reading() |
1333 | + yield self.postgres_listener_service.stopService() |
1334 | + |
1335 | + @wait_for_reactor |
1336 | + @inlineCallbacks |
1337 | + def test_dns_dynamic_update_dnsdata_insert(self): |
1338 | + listener = self.make_listener_without_delay() |
1339 | + yield self.set_service(listener) |
1340 | + domain = yield deferToDatabase(self.create_domain) |
1341 | + rec = yield deferToDatabase( |
1342 | + self.create_dnsresource, {"domain": domain} |
1343 | + ) |
1344 | + yield deferToDatabase( |
1345 | + self.register_trigger, |
1346 | + "maasserver_dnsdata", |
1347 | + "sys_dns_updates", |
1348 | + ops=("insert",), |
1349 | + ) |
1350 | + self.start_reading() |
1351 | + try: |
1352 | + dnsdata = yield deferToDatabase( |
1353 | + self.create_dnsdata, |
1354 | + params={ |
1355 | + "dnsresource": rec, |
1356 | + "rrtype": "TXT", |
1357 | + "rrdata": factory.make_name(), |
1358 | + }, |
1359 | + ) |
1360 | + msg = yield self.get_notify("sys_dns_updates") |
1361 | + expected_ttl = 0 |
1362 | + if dnsdata.ttl: |
1363 | + expected_ttl = dnsdata.ttl |
1364 | + elif rec.address_ttl: |
1365 | + expected_ttl = rec.address_ttl |
1366 | + self.assertEqual( |
1367 | + msg, |
1368 | + f"INSERT {domain.name} {rec.name} {dnsdata.rrtype} {expected_ttl} {dnsdata.rrdata}", |
1369 | + ) |
1370 | + finally: |
1371 | + self.stop_reading() |
1372 | + yield self.postgres_listener_service.stopService() |
1373 | + |
1374 | + @wait_for_reactor |
1375 | + @inlineCallbacks |
1376 | + def test_dns_dynamic_update_dnsdata_delete(self): |
1377 | + listener = self.make_listener_without_delay() |
1378 | + yield self.set_service(listener) |
1379 | + domain = yield deferToDatabase(self.create_domain) |
1380 | + rec = yield deferToDatabase( |
1381 | + self.create_dnsresource, params={"domain": domain} |
1382 | + ) |
1383 | + dnsdata = yield deferToDatabase( |
1384 | + self.create_dnsdata, |
1385 | + params={ |
1386 | + "dnsresource": rec, |
1387 | + "rrtype": "TXT", |
1388 | + "rrdata": factory.make_name(), |
1389 | + }, |
1390 | + ) |
1391 | + yield deferToDatabase( |
1392 | + self.register_trigger, |
1393 | + "maasserver_dnsdata", |
1394 | + "sys_dns_updates", |
1395 | + ops=("delete",), |
1396 | + ) |
1397 | + self.start_reading() |
1398 | + try: |
1399 | + yield deferToDatabase(dnsdata.delete) |
1400 | + msg = yield self.get_notify("sys_dns_updates") |
1401 | + self.assertEqual( |
1402 | + msg, f"DELETE {domain.name} {rec.name} {dnsdata.rrtype}" |
1403 | + ) |
1404 | + finally: |
1405 | + self.stop_reading() |
1406 | + yield self.postgres_listener_service.stopService() |
1407 | + |
1408 | + @wait_for_reactor |
1409 | + @inlineCallbacks |
1410 | + def test_dns_dynamic_update_domain_reload(self): |
1411 | + listener = self.make_listener_without_delay() |
1412 | + yield self.set_service(listener) |
1413 | + yield deferToDatabase( |
1414 | + self.register_trigger, |
1415 | + "maasserver_domain", |
1416 | + "sys_dns_updates", |
1417 | + ops=("insert",), |
1418 | + ) |
1419 | + self.start_reading() |
1420 | + try: |
1421 | + yield deferToDatabase(self.create_domain) |
1422 | + msg = yield self.get_notify("sys_dns_updates") |
1423 | + self.assertEqual(msg, "RELOAD") |
1424 | + finally: |
1425 | + self.stop_reading() |
1426 | + yield self.postgres_listener_service.stopService() |
1427 | + |
1428 | + @wait_for_reactor |
1429 | + @inlineCallbacks |
1430 | + def test_dns_dynamic_update_subnet_reload(self): |
1431 | + listener = self.make_listener_without_delay() |
1432 | + yield self.set_service(listener) |
1433 | + yield deferToDatabase( |
1434 | + self.register_trigger, |
1435 | + "maasserver_subnet", |
1436 | + "sys_dns_updates", |
1437 | + ops=("insert",), |
1438 | + ) |
1439 | + self.start_reading() |
1440 | + try: |
1441 | + yield deferToDatabase(self.create_subnet) |
1442 | + msg = yield self.get_notify("sys_dns_updates") |
1443 | + self.assertEqual(msg, "RELOAD") |
1444 | + finally: |
1445 | + self.stop_reading() |
1446 | + yield self.postgres_listener_service.stopService() |
1447 | diff --git a/src/provisioningserver/dns/actions.py b/src/provisioningserver/dns/actions.py |
1448 | index 7b8b1ca..bafbac9 100644 |
1449 | --- a/src/provisioningserver/dns/actions.py |
1450 | +++ b/src/provisioningserver/dns/actions.py |
1451 | @@ -10,14 +10,18 @@ from time import sleep |
1452 | from provisioningserver.dns.config import ( |
1453 | DNSConfig, |
1454 | execute_rndc_command, |
1455 | + get_nsupdate_key_path, |
1456 | set_up_options_conf, |
1457 | ) |
1458 | from provisioningserver.logger import get_maas_logger |
1459 | -from provisioningserver.utils.shell import ExternalProcessError |
1460 | +from provisioningserver.utils.shell import ExternalProcessError, run_command |
1461 | |
1462 | maaslog = get_maas_logger("dns") |
1463 | |
1464 | |
1465 | +MAAS_NSUPDATE_HOST = "localhost" |
1466 | + |
1467 | + |
1468 | def bind_reconfigure(): |
1469 | """Ask BIND to reload its configuration and *new* zone files. |
1470 | |
1471 | @@ -135,3 +139,50 @@ def bind_write_zones(zones): |
1472 | """ |
1473 | for zone in zones: |
1474 | zone.write_config() |
1475 | + |
1476 | + |
1477 | +class NSUpdateCommand: |
1478 | + executable = "nsupdate" |
1479 | + |
1480 | + def __init__(self, zone, updates, **kwargs): |
1481 | + self._zone = zone |
1482 | + self._updates = updates |
1483 | + self._serial = kwargs.get("serial") |
1484 | + self._zone_ttl = kwargs["ttl"] |
1485 | + |
1486 | + def _format_update(self, update): |
1487 | + if update.operation == "DELETE": |
1488 | + if update.answer: |
1489 | + return f"update delete {update.name} {update.rectype} {update.answer}" |
1490 | + return f"update delete {update.name} {update.rectype}" |
1491 | + ttl = update.ttl |
1492 | + if ttl is None: |
1493 | + ttl = self._zone_ttl |
1494 | + return ( |
1495 | + f"update add {update.name} {ttl} {update.rectype} {update.answer}" |
1496 | + ) |
1497 | + |
1498 | + def update(self, server_address=MAAS_NSUPDATE_HOST): |
1499 | + stdin = [f"zone {self._zone}"] + [ |
1500 | + self._format_update(update) for update in self._updates |
1501 | + ] |
1502 | + if server_address: |
1503 | + stdin = [f"server {server_address}"] + stdin |
1504 | + |
1505 | + if self._serial: |
1506 | + stdin.append( |
1507 | + f"update add {self._zone} {self._zone_ttl} SOA {self._zone}. nobody.example.com. {self._serial} 600 1800 604800 {self._zone_ttl}" |
1508 | + ) |
1509 | + |
1510 | + stdin.append("send\n") |
1511 | + |
1512 | + cmd = [self.executable, "-k", get_nsupdate_key_path()] |
1513 | + if len(self._updates) > 1: |
1514 | + cmd.append("-v") # use TCP for bulk payloads |
1515 | + |
1516 | + try: |
1517 | + run_command(*cmd, stdin="\n".join(stdin).encode("ascii")) |
1518 | + except CalledProcessError as exc: |
1519 | + maaslog.error(f"dynamic update of DNS failed: {exc}") |
1520 | + ExternalProcessError.upgrade(exc) |
1521 | + raise |
1522 | diff --git a/src/provisioningserver/dns/commands/setup_dns.py b/src/provisioningserver/dns/commands/setup_dns.py |
1523 | index 65b1166..cdf4e38 100644 |
1524 | --- a/src/provisioningserver/dns/commands/setup_dns.py |
1525 | +++ b/src/provisioningserver/dns/commands/setup_dns.py |
1526 | @@ -16,8 +16,10 @@ from textwrap import dedent |
1527 | |
1528 | from provisioningserver.dns.config import ( |
1529 | DNSConfig, |
1530 | + set_up_nsupdate_key, |
1531 | set_up_options_conf, |
1532 | set_up_rndc, |
1533 | + set_up_zone_file_dir, |
1534 | ) |
1535 | |
1536 | |
1537 | @@ -49,6 +51,8 @@ def run(args, stdout=sys.stdout, stderr=sys.stderr): |
1538 | :param stdout: Standard output stream to write to. |
1539 | :param stderr: Standard error stream to write to. |
1540 | """ |
1541 | + set_up_nsupdate_key() |
1542 | + set_up_zone_file_dir() |
1543 | set_up_rndc() |
1544 | set_up_options_conf(overwrite=not args.no_clobber) |
1545 | config = DNSConfig() |
1546 | diff --git a/src/provisioningserver/dns/commands/tests/test_setup_dns.py b/src/provisioningserver/dns/commands/tests/test_setup_dns.py |
1547 | index dc73242..e720cff 100644 |
1548 | --- a/src/provisioningserver/dns/commands/tests/test_setup_dns.py |
1549 | +++ b/src/provisioningserver/dns/commands/tests/test_setup_dns.py |
1550 | @@ -17,7 +17,10 @@ from provisioningserver.dns.config import ( |
1551 | MAAS_NAMED_CONF_NAME, |
1552 | MAAS_RNDC_CONF_NAME, |
1553 | ) |
1554 | -from provisioningserver.dns.testing import patch_dns_config_path |
1555 | +from provisioningserver.dns.testing import ( |
1556 | + patch_dns_config_path, |
1557 | + patch_zone_file_config_path, |
1558 | +) |
1559 | |
1560 | |
1561 | class TestSetupCommand(MAASTestCase): |
1562 | @@ -35,6 +38,7 @@ class TestSetupCommand(MAASTestCase): |
1563 | def test_writes_configuration(self): |
1564 | dns_conf_dir = self.make_dir() |
1565 | patch_dns_config_path(self, dns_conf_dir) |
1566 | + patch_zone_file_config_path(self, dns_conf_dir) |
1567 | self.run_command() |
1568 | named_config = os.path.join(dns_conf_dir, MAAS_NAMED_CONF_NAME) |
1569 | rndc_conf_path = os.path.join(dns_conf_dir, MAAS_RNDC_CONF_NAME) |
1570 | @@ -43,6 +47,7 @@ class TestSetupCommand(MAASTestCase): |
1571 | def test_does_not_overwrite_config(self): |
1572 | dns_conf_dir = self.make_dir() |
1573 | patch_dns_config_path(self, dns_conf_dir) |
1574 | + patch_zone_file_config_path(self, dns_conf_dir) |
1575 | random_content = factory.make_string() |
1576 | factory.make_file( |
1577 | location=dns_conf_dir, |
1578 | diff --git a/src/provisioningserver/dns/config.py b/src/provisioningserver/dns/config.py |
1579 | index d5409e8..6ebc906 100644 |
1580 | --- a/src/provisioningserver/dns/config.py |
1581 | +++ b/src/provisioningserver/dns/config.py |
1582 | @@ -6,12 +6,16 @@ |
1583 | |
1584 | from collections import namedtuple |
1585 | from contextlib import contextmanager |
1586 | +from dataclasses import dataclass |
1587 | from datetime import datetime |
1588 | import errno |
1589 | import os |
1590 | import os.path |
1591 | import re |
1592 | import sys |
1593 | +from typing import Optional |
1594 | + |
1595 | +from netaddr import AddrFormatError, IPAddress |
1596 | |
1597 | from provisioningserver.logger import get_maas_logger |
1598 | from provisioningserver.utils import load_template, locate_config |
1599 | @@ -26,6 +30,62 @@ MAAS_NAMED_CONF_NAME = "named.conf.maas" |
1600 | MAAS_NAMED_CONF_OPTIONS_INSIDE_NAME = "named.conf.options.inside.maas" |
1601 | MAAS_NAMED_RNDC_CONF_NAME = "named.conf.rndc.maas" |
1602 | MAAS_RNDC_CONF_NAME = "rndc.conf.maas" |
1603 | +MAAS_NSUPDATE_KEY_NAME = "keys.conf.maas" |
1604 | +MAAS_ZONE_FILE_DIR = "/var/lib/bind/maas" |
1605 | + |
1606 | + |
1607 | +@dataclass |
1608 | +class DynamicDNSUpdate: |
1609 | + operation: str |
1610 | + name: str |
1611 | + zone: str |
1612 | + rectype: str |
1613 | + ttl: Optional[int] = None |
1614 | + subnet: Optional[str] = None # for reverse updates |
1615 | + answer: Optional[str] = None |
1616 | + |
1617 | + @classmethod |
1618 | + def _is_ip(cls, answer): |
1619 | + if not answer: |
1620 | + return False |
1621 | + try: |
1622 | + IPAddress(answer) |
1623 | + except AddrFormatError: |
1624 | + return False |
1625 | + else: |
1626 | + return True |
1627 | + |
1628 | + @classmethod |
1629 | + def create_from_trigger(cls, **kwargs): |
1630 | + answer = kwargs.get("answer") |
1631 | + rectype = kwargs.pop("rectype") |
1632 | + if answer: |
1633 | + del kwargs["answer"] |
1634 | + # the DB trigger is unable to figure out if an IP is v6, so we do it here instead |
1635 | + if cls._is_ip(answer): |
1636 | + ip = IPAddress(answer) |
1637 | + if ip.version == 6: |
1638 | + rectype = "AAAA" |
1639 | + return cls(answer=answer, rectype=rectype, **kwargs) |
1640 | + |
1641 | + @classmethod |
1642 | + def as_reverse_record_update(cls, fwd_update, subnet): |
1643 | + if not fwd_update.answer_is_ip: |
1644 | + return None |
1645 | + ip = IPAddress(fwd_update.answer) |
1646 | + return cls( |
1647 | + operation=fwd_update.operation, |
1648 | + name=ip.reverse_dns, |
1649 | + zone=fwd_update.zone, |
1650 | + subnet=subnet, |
1651 | + ttl=fwd_update.ttl, |
1652 | + answer=fwd_update.name, |
1653 | + rectype="PTR", |
1654 | + ) |
1655 | + |
1656 | + @property |
1657 | + def answer_is_ip(self): |
1658 | + return DynamicDNSUpdate._is_ip(self.answer) |
1659 | |
1660 | |
1661 | def get_dns_config_dir(): |
1662 | @@ -40,6 +100,19 @@ def get_dns_config_dir(): |
1663 | return setting |
1664 | |
1665 | |
1666 | +def get_zone_file_config_dir(): |
1667 | + """ |
1668 | + Location of MAAS' zone files, separate from config files |
1669 | + so that bind can write to the location as well |
1670 | + """ |
1671 | + setting = os.getenv("MAAS_ZONE_FILE_CONFIG_DIR", MAAS_ZONE_FILE_DIR) |
1672 | + if isinstance(setting, bytes): |
1673 | + fsenc = sys.getfilesystemencoding() |
1674 | + return setting.decode(fsenc) |
1675 | + else: |
1676 | + return setting |
1677 | + |
1678 | + |
1679 | def get_bind_config_dir(): |
1680 | """Location of bind configuration files.""" |
1681 | setting = os.getenv( |
1682 | @@ -155,6 +228,21 @@ def get_rndc_conf_path(): |
1683 | return compose_config_path(MAAS_RNDC_CONF_NAME) |
1684 | |
1685 | |
1686 | +def get_nsupdate_key_path(): |
1687 | + return compose_config_path(MAAS_NSUPDATE_KEY_NAME) |
1688 | + |
1689 | + |
1690 | +def set_up_nsupdate_key(): |
1691 | + tsig = call_and_check(["tsig-keygen", "-a", "HMAC-SHA512", "maas."]) |
1692 | + atomic_write(tsig, get_nsupdate_key_path(), overwrite=True, mode=0o644) |
1693 | + |
1694 | + |
1695 | +def set_up_zone_file_dir(): |
1696 | + p = get_zone_file_config_dir() |
1697 | + if not os.path.exists(p): |
1698 | + os.mkdir(p) |
1699 | + |
1700 | + |
1701 | def set_up_rndc(): |
1702 | """Writes out the two files needed to enable MAAS to use rndc commands: |
1703 | MAAS_RNDC_CONF_NAME and MAAS_NAMED_RNDC_CONF_NAME. |
1704 | @@ -232,12 +320,16 @@ def set_up_options_conf(overwrite=True, **kwargs): |
1705 | |
1706 | |
1707 | def compose_config_path(filename): |
1708 | - """Return the full path for a DNS config or zone file.""" |
1709 | + """Return the full path for a DNS config""" |
1710 | return os.path.join(get_dns_config_dir(), filename) |
1711 | |
1712 | |
1713 | +def compose_zone_file_config_path(filename): |
1714 | + return os.path.join(get_zone_file_config_dir(), filename) |
1715 | + |
1716 | + |
1717 | def compose_bind_config_path(filename): |
1718 | - """Return the full path for a DNS config or zone file.""" |
1719 | + """Return the full path for a DNS config""" |
1720 | return os.path.join(get_bind_config_dir(), filename) |
1721 | |
1722 | |
1723 | @@ -309,6 +401,7 @@ class DNSConfig: |
1724 | "forwarded_zones": self.forwarded_zones, |
1725 | "DNS_CONFIG_DIR": get_dns_config_dir(), |
1726 | "named_rndc_conf_path": get_named_rndc_conf_path(), |
1727 | + "nsupdate_keys_conf_path": get_nsupdate_key_path(), |
1728 | "trusted_networks": trusted_networks, |
1729 | "modified": str(datetime.today()), |
1730 | } |
1731 | diff --git a/src/provisioningserver/dns/testing.py b/src/provisioningserver/dns/testing.py |
1732 | index 2415e0b..4892f7a 100644 |
1733 | --- a/src/provisioningserver/dns/testing.py |
1734 | +++ b/src/provisioningserver/dns/testing.py |
1735 | @@ -17,6 +17,16 @@ def patch_dns_config_path(testcase, config_dir=None): |
1736 | return config_dir |
1737 | |
1738 | |
1739 | +def patch_zone_file_config_path(testcase, config_dir=None): |
1740 | + """Set the DNS config dir to a temporary directory, and return its path.""" |
1741 | + if config_dir is None: |
1742 | + config_dir = testcase.make_dir() |
1743 | + testcase.useFixture( |
1744 | + EnvironmentVariable("MAAS_ZONE_FILE_CONFIG_DIR", config_dir) |
1745 | + ) |
1746 | + return config_dir |
1747 | + |
1748 | + |
1749 | def patch_dns_rndc_port(testcase, port): |
1750 | testcase.useFixture(EnvironmentVariable("MAAS_DNS_RNDC_PORT", "%d" % port)) |
1751 | |
1752 | diff --git a/src/provisioningserver/dns/tests/test_actions.py b/src/provisioningserver/dns/tests/test_actions.py |
1753 | index 72c5b5a..1087bc0 100644 |
1754 | --- a/src/provisioningserver/dns/tests/test_actions.py |
1755 | +++ b/src/provisioningserver/dns/tests/test_actions.py |
1756 | @@ -20,11 +20,19 @@ from maastesting.factory import factory |
1757 | from maastesting.matchers import MockCalledOnceWith, MockCallsMatch |
1758 | from maastesting.testcase import MAASTestCase |
1759 | from provisioningserver.dns import actions |
1760 | +from provisioningserver.dns.actions import ( |
1761 | + get_nsupdate_key_path, |
1762 | + NSUpdateCommand, |
1763 | +) |
1764 | from provisioningserver.dns.config import ( |
1765 | + DynamicDNSUpdate, |
1766 | MAAS_NAMED_CONF_NAME, |
1767 | MAAS_NAMED_CONF_OPTIONS_INSIDE_NAME, |
1768 | ) |
1769 | -from provisioningserver.dns.testing import patch_dns_config_path |
1770 | +from provisioningserver.dns.testing import ( |
1771 | + patch_dns_config_path, |
1772 | + patch_zone_file_config_path, |
1773 | +) |
1774 | from provisioningserver.dns.tests.test_zoneconfig import HostnameIPMapping |
1775 | from provisioningserver.dns.zoneconfig import ( |
1776 | DNSForwardZoneConfig, |
1777 | @@ -208,6 +216,7 @@ class TestConfiguration(MAASTestCase): |
1778 | ) |
1779 | |
1780 | def test_bind_write_zones_writes_file(self): |
1781 | + zone_file_dir = patch_zone_file_config_path(self) |
1782 | domain = factory.make_string() |
1783 | network = IPNetwork("192.168.0.3/24") |
1784 | dns_ip_list = [factory.pick_ip_in_network(network)] |
1785 | @@ -229,8 +238,8 @@ class TestConfiguration(MAASTestCase): |
1786 | forward_file_name = "zone.%s" % domain |
1787 | reverse_file_name = "zone.0.168.192.in-addr.arpa" |
1788 | expected_files = [ |
1789 | - join(self.dns_conf_dir, forward_file_name), |
1790 | - join(self.dns_conf_dir, reverse_file_name), |
1791 | + join(zone_file_dir, forward_file_name), |
1792 | + join(zone_file_dir, reverse_file_name), |
1793 | ] |
1794 | self.assertThat(expected_files, AllMatch(FileExists())) |
1795 | |
1796 | @@ -270,3 +279,112 @@ class TestConfiguration(MAASTestCase): |
1797 | self.assertThat( |
1798 | expected_options_file, FileContains(expected_options_content) |
1799 | ) |
1800 | + |
1801 | + |
1802 | +class TestNSUpdateCommand(MAASTestCase): |
1803 | + def test_format_update_deletion(self): |
1804 | + domain = factory.make_name() |
1805 | + update = DynamicDNSUpdate( |
1806 | + operation="DELETE", |
1807 | + zone=domain, |
1808 | + name=f"{factory.make_name()}.{domain}", |
1809 | + rectype="A", |
1810 | + ) |
1811 | + cmd = NSUpdateCommand( |
1812 | + domain, |
1813 | + [update], |
1814 | + serial=random.randint(1, 100), |
1815 | + ttl=random.randint(1, 100), |
1816 | + ) |
1817 | + self.assertEqual( |
1818 | + f"update delete {update.name} A", cmd._format_update(update) |
1819 | + ) |
1820 | + |
1821 | + def test_format_update_addition(self): |
1822 | + domain = factory.make_name() |
1823 | + update = DynamicDNSUpdate( |
1824 | + operation="INSERT", |
1825 | + zone=domain, |
1826 | + name=f"{factory.make_name()}.{domain}", |
1827 | + rectype="A", |
1828 | + answer=factory.make_ip_address(), |
1829 | + ) |
1830 | + ttl = random.randint(1, 100) |
1831 | + cmd = NSUpdateCommand( |
1832 | + domain, [update], serial=random.randint(1, 100), ttl=ttl |
1833 | + ) |
1834 | + self.assertEqual( |
1835 | + f"update add {update.name} {ttl} A {update.answer}", |
1836 | + cmd._format_update(update), |
1837 | + ) |
1838 | + |
1839 | + def test_nsupdate_sends_a_single_update(self): |
1840 | + domain = factory.make_name() |
1841 | + update = DynamicDNSUpdate( |
1842 | + operation="INSERT", |
1843 | + zone=domain, |
1844 | + name=f"{factory.make_name()}.{domain}", |
1845 | + rectype="A", |
1846 | + answer=factory.make_ip_address(), |
1847 | + ) |
1848 | + serial = random.randint(1, 100) |
1849 | + ttl = random.randint(1, 100) |
1850 | + cmd = NSUpdateCommand(domain, [update], serial=serial, ttl=ttl) |
1851 | + run_command = self.patch(actions, "run_command") |
1852 | + cmd.update() |
1853 | + run_command.assert_called_once_with( |
1854 | + "nsupdate", |
1855 | + "-k", |
1856 | + get_nsupdate_key_path(), |
1857 | + stdin="\n".join( |
1858 | + [ |
1859 | + "server localhost", |
1860 | + f"zone {domain}", |
1861 | + f"update add {update.name} {ttl} A {update.answer}", |
1862 | + f"update add {domain} {ttl} SOA {domain}. nobody.example.com. {serial} 600 1800 604800 {ttl}", |
1863 | + "send\n", |
1864 | + ] |
1865 | + ).encode("ascii"), |
1866 | + ) |
1867 | + |
1868 | + def test_nsupdate_sends_a_bulk_update(self): |
1869 | + domain = factory.make_name() |
1870 | + deletions = [ |
1871 | + DynamicDNSUpdate( |
1872 | + operation="DELETE", |
1873 | + zone=domain, |
1874 | + name=f"{factory.make_name()}.{domain}", |
1875 | + rectype="A", |
1876 | + ) |
1877 | + for _ in range(2) |
1878 | + ] |
1879 | + additions = [ |
1880 | + DynamicDNSUpdate( |
1881 | + operation="INSERT", |
1882 | + zone=domain, |
1883 | + name=f"{factory.make_name()}.{domain}", |
1884 | + rectype="A", |
1885 | + answer=factory.make_ip_address(), |
1886 | + ) |
1887 | + for _ in range(2) |
1888 | + ] |
1889 | + ttl = random.randint(1, 100) |
1890 | + cmd = NSUpdateCommand(domain, deletions + additions, ttl=ttl) |
1891 | + run_command = self.patch(actions, "run_command") |
1892 | + cmd.update() |
1893 | + expected_stdin = [ |
1894 | + "server localhost", |
1895 | + f"zone {domain}", |
1896 | + f"update delete {deletions[0].name} {deletions[0].rectype}", |
1897 | + f"update delete {deletions[1].name} {deletions[1].rectype}", |
1898 | + f"update add {additions[0].name} {ttl} {additions[0].rectype} {additions[0].answer}", |
1899 | + f"update add {additions[1].name} {ttl} {additions[1].rectype} {additions[1].answer}", |
1900 | + "send\n", |
1901 | + ] |
1902 | + run_command.assert_called_once_with( |
1903 | + "nsupdate", |
1904 | + "-k", |
1905 | + get_nsupdate_key_path(), |
1906 | + "-v", |
1907 | + stdin="\n".join(expected_stdin).encode("ascii"), |
1908 | + ) |
1909 | diff --git a/src/provisioningserver/dns/tests/test_config.py b/src/provisioningserver/dns/tests/test_config.py |
1910 | index c29c3ca..1e02498 100644 |
1911 | --- a/src/provisioningserver/dns/tests/test_config.py |
1912 | +++ b/src/provisioningserver/dns/tests/test_config.py |
1913 | @@ -11,7 +11,7 @@ from textwrap import dedent |
1914 | from unittest.mock import Mock, sentinel |
1915 | |
1916 | from fixtures import EnvironmentVariable |
1917 | -from netaddr import IPNetwork |
1918 | +from netaddr import IPAddress, IPNetwork |
1919 | from testtools.matchers import ( |
1920 | AllMatch, |
1921 | Contains, |
1922 | @@ -40,6 +40,7 @@ from provisioningserver.dns.config import ( |
1923 | DNSConfig, |
1924 | DNSConfigDirectoryMissing, |
1925 | DNSConfigFail, |
1926 | + DynamicDNSUpdate, |
1927 | execute_rndc_command, |
1928 | extract_suggested_named_conf, |
1929 | generate_rndc, |
1930 | @@ -593,3 +594,75 @@ class TestDNSConfig(MAASTestCase): |
1931 | ), |
1932 | ), |
1933 | ) |
1934 | + |
1935 | + |
1936 | +class TestDynamicDNSUpdate(MAASTestCase): |
1937 | + def test_create_from_trigger_v4(self): |
1938 | + domain = factory.make_name() |
1939 | + update = DynamicDNSUpdate.create_from_trigger( |
1940 | + operation="INSERT", |
1941 | + zone=domain, |
1942 | + name=f"{factory.make_name()}.{domain}", |
1943 | + rectype="A", |
1944 | + answer=factory.make_ip_address(ipv6=False), |
1945 | + ) |
1946 | + self.assertEqual(update.rectype, "A") |
1947 | + |
1948 | + def test_create_from_trigger_v6(self): |
1949 | + domain = factory.make_name() |
1950 | + update = DynamicDNSUpdate.create_from_trigger( |
1951 | + operation="INSERT", |
1952 | + zone=domain, |
1953 | + name=f"{factory.make_name()}.{domain}", |
1954 | + rectype="A", |
1955 | + answer=factory.make_ip_address(ipv6=True), |
1956 | + ) |
1957 | + self.assertEqual(update.rectype, "AAAA") |
1958 | + |
1959 | + def test_answer_is_ip_returns_true_when_answer_is_an_ip(self): |
1960 | + domain = factory.make_name() |
1961 | + update = DynamicDNSUpdate( |
1962 | + operation="INSERT", |
1963 | + zone=domain, |
1964 | + name=f"{factory.make_name()}.{domain}", |
1965 | + rectype="A", |
1966 | + answer=factory.make_ip_address(), |
1967 | + ) |
1968 | + self.assertTrue(update.answer_is_ip) |
1969 | + |
1970 | + def test_answer_is_ip_returns_false_when_answer_is_not_an_ip(self): |
1971 | + domain = factory.make_name() |
1972 | + update = DynamicDNSUpdate( |
1973 | + operation="INSERT", |
1974 | + zone=domain, |
1975 | + name=f"{factory.make_name()}.{domain}", |
1976 | + rectype="CNAME", |
1977 | + answer=factory.make_name(), |
1978 | + ) |
1979 | + self.assertFalse(update.answer_is_ip) |
1980 | + |
1981 | + def test_as_reverse_record_update(self): |
1982 | + domain = factory.make_name() |
1983 | + subnet = factory.make_ip4_or_6_network() |
1984 | + fwd_update = DynamicDNSUpdate( |
1985 | + operation="INSERT", |
1986 | + zone=domain, |
1987 | + name=f"{factory.make_name()}.{domain}", |
1988 | + rectype="A", |
1989 | + answer=str(IPAddress(subnet.next())), |
1990 | + ) |
1991 | + expected_rev_update = DynamicDNSUpdate( |
1992 | + operation="INSERT", |
1993 | + zone=domain, |
1994 | + name=IPAddress(fwd_update.answer).reverse_dns, |
1995 | + rectype="PTR", |
1996 | + ttl=fwd_update.ttl, |
1997 | + subnet=str(subnet), |
1998 | + answer=fwd_update.name, |
1999 | + ) |
2000 | + rev_update = DynamicDNSUpdate.as_reverse_record_update( |
2001 | + fwd_update, str(subnet) |
2002 | + ) |
2003 | + self.assertEqual(expected_rev_update.name, rev_update.name) |
2004 | + self.assertEqual(expected_rev_update.rectype, rev_update.rectype) |
2005 | + self.assertEqual(expected_rev_update.answer, rev_update.answer) |
2006 | diff --git a/src/provisioningserver/dns/tests/test_zoneconfig.py b/src/provisioningserver/dns/tests/test_zoneconfig.py |
2007 | index ba84efb..f97adfd 100644 |
2008 | --- a/src/provisioningserver/dns/tests/test_zoneconfig.py |
2009 | +++ b/src/provisioningserver/dns/tests/test_zoneconfig.py |
2010 | @@ -22,8 +22,13 @@ from twisted.python.filepath import FilePath |
2011 | from maastesting.factory import factory |
2012 | from maastesting.matchers import MockNotCalled |
2013 | from maastesting.testcase import MAASTestCase |
2014 | -from provisioningserver.dns.config import get_dns_config_dir |
2015 | -from provisioningserver.dns.testing import patch_dns_config_path |
2016 | +from provisioningserver.dns import actions |
2017 | +from provisioningserver.dns.config import ( |
2018 | + DynamicDNSUpdate, |
2019 | + get_nsupdate_key_path, |
2020 | + get_zone_file_config_dir, |
2021 | +) |
2022 | +from provisioningserver.dns.testing import patch_zone_file_config_path |
2023 | from provisioningserver.dns.zoneconfig import ( |
2024 | DNSForwardZoneConfig, |
2025 | DNSReverseZoneConfig, |
2026 | @@ -100,7 +105,7 @@ class TestDNSForwardZoneConfig(MAASTestCase): |
2027 | domain = factory.make_name("zone") |
2028 | dns_zone_config = DNSForwardZoneConfig(domain) |
2029 | self.assertEqual( |
2030 | - os.path.join(get_dns_config_dir(), "zone.%s" % domain), |
2031 | + os.path.join(get_zone_file_config_dir(), "zone.%s" % domain), |
2032 | dns_zone_config.zone_info[0].target_path, |
2033 | ) |
2034 | |
2035 | @@ -173,7 +178,7 @@ class TestDNSForwardZoneConfig(MAASTestCase): |
2036 | ) |
2037 | |
2038 | def test_handles_slash_32_dynamic_range(self): |
2039 | - target_dir = patch_dns_config_path(self) |
2040 | + target_dir = patch_zone_file_config_path(self) |
2041 | domain = factory.make_string() |
2042 | network = factory.make_ipv4_network() |
2043 | ipv4_hostname = factory.make_name("host") |
2044 | @@ -222,7 +227,7 @@ class TestDNSForwardZoneConfig(MAASTestCase): |
2045 | ) |
2046 | |
2047 | def test_writes_dns_zone_config(self): |
2048 | - target_dir = patch_dns_config_path(self) |
2049 | + target_dir = patch_zone_file_config_path(self) |
2050 | domain = factory.make_string() |
2051 | network = factory.make_ipv4_network() |
2052 | ipv4_hostname = factory.make_name("host") |
2053 | @@ -269,7 +274,7 @@ class TestDNSForwardZoneConfig(MAASTestCase): |
2054 | ) |
2055 | |
2056 | def test_writes_dns_zone_config_with_NS_record(self): |
2057 | - target_dir = patch_dns_config_path(self) |
2058 | + target_dir = patch_zone_file_config_path(self) |
2059 | addr_ttl = random.randint(10, 100) |
2060 | ns_host_name = factory.make_name("ns") |
2061 | dns_zone_config = DNSForwardZoneConfig( |
2062 | @@ -286,7 +291,7 @@ class TestDNSForwardZoneConfig(MAASTestCase): |
2063 | ) |
2064 | |
2065 | def test_ignores_generate_directives_for_v6_dynamic_ranges(self): |
2066 | - patch_dns_config_path(self) |
2067 | + patch_zone_file_config_path(self) |
2068 | domain = factory.make_string() |
2069 | network = factory.make_ipv4_network() |
2070 | ipv4_hostname = factory.make_name("host") |
2071 | @@ -314,7 +319,7 @@ class TestDNSForwardZoneConfig(MAASTestCase): |
2072 | self.assertThat(get_generate_directives, MockNotCalled()) |
2073 | |
2074 | def test_config_file_is_world_readable(self): |
2075 | - patch_dns_config_path(self) |
2076 | + patch_zone_file_config_path(self) |
2077 | dns_zone_config = DNSForwardZoneConfig( |
2078 | factory.make_string(), serial=random.randint(1, 100) |
2079 | ) |
2080 | @@ -322,6 +327,94 @@ class TestDNSForwardZoneConfig(MAASTestCase): |
2081 | filepath = FilePath(dns_zone_config.zone_info[0].target_path) |
2082 | self.assertTrue(filepath.getPermissions().other.read) |
2083 | |
2084 | + def test_zone_file_exists(self): |
2085 | + patch_zone_file_config_path(self) |
2086 | + domain = factory.make_string() |
2087 | + network = factory.make_ipv4_network() |
2088 | + ipv4_hostname = factory.make_name("host") |
2089 | + ipv4_ip = factory.pick_ip_in_network(network) |
2090 | + ipv6_hostname = factory.make_name("host") |
2091 | + ipv6_ip = factory.make_ipv6_address() |
2092 | + ipv6_network = factory.make_ipv6_network() |
2093 | + dynamic_range = IPRange(ipv6_network.first, ipv6_network.last) |
2094 | + ttl = random.randint(10, 300) |
2095 | + mapping = { |
2096 | + ipv4_hostname: HostnameIPMapping(None, ttl, {ipv4_ip}), |
2097 | + ipv6_hostname: HostnameIPMapping(None, ttl, {ipv6_ip}), |
2098 | + } |
2099 | + dns_zone_config = DNSForwardZoneConfig( |
2100 | + domain, |
2101 | + serial=random.randint(1, 100), |
2102 | + mapping=mapping, |
2103 | + default_ttl=ttl, |
2104 | + dynamic_ranges=[dynamic_range], |
2105 | + ) |
2106 | + self.patch(dns_zone_config, "get_GENERATE_directives") |
2107 | + self.assertFalse( |
2108 | + dns_zone_config.zone_file_exists(dns_zone_config.zone_info[0]) |
2109 | + ) |
2110 | + dns_zone_config.write_config() |
2111 | + self.assertTrue( |
2112 | + dns_zone_config.zone_file_exists(dns_zone_config.zone_info[0]) |
2113 | + ) |
2114 | + |
2115 | + def test_uses_dynamic_update_when_zone_has_been_configured_once(self): |
2116 | + patch_zone_file_config_path(self) |
2117 | + domain = factory.make_string() |
2118 | + network = factory.make_ipv4_network() |
2119 | + ipv4_hostname = factory.make_name("host") |
2120 | + ipv4_ip = factory.pick_ip_in_network(network) |
2121 | + ipv6_hostname = factory.make_name("host") |
2122 | + ipv6_ip = factory.make_ipv6_address() |
2123 | + ipv6_network = factory.make_ipv6_network() |
2124 | + dynamic_range = IPRange(ipv6_network.first, ipv6_network.last) |
2125 | + ttl = random.randint(10, 300) |
2126 | + mapping = { |
2127 | + ipv4_hostname: HostnameIPMapping(None, ttl, {ipv4_ip}), |
2128 | + ipv6_hostname: HostnameIPMapping(None, ttl, {ipv6_ip}), |
2129 | + } |
2130 | + dns_zone_config = DNSForwardZoneConfig( |
2131 | + domain, |
2132 | + serial=random.randint(1, 100), |
2133 | + mapping=mapping, |
2134 | + default_ttl=ttl, |
2135 | + dynamic_ranges=[dynamic_range], |
2136 | + ) |
2137 | + self.patch(dns_zone_config, "get_GENERATE_directives") |
2138 | + run_command = self.patch(actions, "run_command") |
2139 | + dns_zone_config.write_config() |
2140 | + update = DynamicDNSUpdate.create_from_trigger( |
2141 | + operation="INSERT", |
2142 | + zone=domain, |
2143 | + name=f"{factory.make_name()}.{domain}", |
2144 | + rectype="A", |
2145 | + answer=factory.make_ip_address(), |
2146 | + ) |
2147 | + new_dns_zone_config = DNSForwardZoneConfig( |
2148 | + domain, |
2149 | + serial=random.randint(1, 100), |
2150 | + mapping=mapping, |
2151 | + default_ttl=ttl, |
2152 | + dynamic_ranges=[dynamic_range], |
2153 | + dynamic_updates=[update], |
2154 | + ) |
2155 | + new_dns_zone_config.write_config() |
2156 | + expected_stdin = "\n".join( |
2157 | + [ |
2158 | + "server localhost", |
2159 | + f"zone {domain}", |
2160 | + f"update add {update.name} {ttl} {'A' if IPAddress(update.answer).version == 4 else 'AAAA'} {update.answer}", |
2161 | + f"update add {domain} {new_dns_zone_config.default_ttl} SOA {domain}. nobody.example.com. {new_dns_zone_config.serial} 600 1800 604800 {new_dns_zone_config.default_ttl}", |
2162 | + "send\n", |
2163 | + ] |
2164 | + ) |
2165 | + run_command.assert_called_once_with( |
2166 | + "nsupdate", |
2167 | + "-k", |
2168 | + get_nsupdate_key_path(), |
2169 | + stdin=expected_stdin.encode("ascii"), |
2170 | + ) |
2171 | + |
2172 | |
2173 | class TestDNSReverseZoneConfig(MAASTestCase): |
2174 | """Tests for DNSReverseZoneConfig.""" |
2175 | @@ -340,7 +433,7 @@ class TestDNSReverseZoneConfig(MAASTestCase): |
2176 | ), |
2177 | ) |
2178 | |
2179 | - def test_computes_dns_config_file_paths(self): |
2180 | + def test_computes_zone_file_config_file_paths(self): |
2181 | domain = factory.make_name("zone") |
2182 | reverse_file_name = [ |
2183 | "zone.%d.168.192.in-addr.arpa" % i for i in range(4) |
2184 | @@ -350,11 +443,11 @@ class TestDNSReverseZoneConfig(MAASTestCase): |
2185 | ) |
2186 | for i in range(4): |
2187 | self.assertEqual( |
2188 | - os.path.join(get_dns_config_dir(), reverse_file_name[i]), |
2189 | + os.path.join(get_zone_file_config_dir(), reverse_file_name[i]), |
2190 | dns_zone_config.zone_info[i].target_path, |
2191 | ) |
2192 | |
2193 | - def test_computes_dns_config_file_paths_for_small_network(self): |
2194 | + def test_computes_zone_file_config_file_paths_for_small_network(self): |
2195 | domain = factory.make_name("zone") |
2196 | reverse_file_name = "zone.192-27.0.168.192.in-addr.arpa" |
2197 | dns_zone_config = DNSReverseZoneConfig( |
2198 | @@ -362,7 +455,7 @@ class TestDNSReverseZoneConfig(MAASTestCase): |
2199 | ) |
2200 | self.assertEqual(1, len(dns_zone_config.zone_info)) |
2201 | self.assertEqual( |
2202 | - os.path.join(get_dns_config_dir(), reverse_file_name), |
2203 | + os.path.join(get_zone_file_config_dir(), reverse_file_name), |
2204 | dns_zone_config.zone_info[0].target_path, |
2205 | ) |
2206 | |
2207 | @@ -620,7 +713,7 @@ class TestDNSReverseZoneConfig(MAASTestCase): |
2208 | ) |
2209 | |
2210 | def test_writes_dns_zone_config_with_NS_record(self): |
2211 | - target_dir = patch_dns_config_path(self) |
2212 | + target_dir = patch_zone_file_config_path(self) |
2213 | network = factory.make_ipv4_network() |
2214 | ns_host_name = factory.make_name("ns") |
2215 | dns_zone_config = DNSReverseZoneConfig( |
2216 | @@ -637,7 +730,7 @@ class TestDNSReverseZoneConfig(MAASTestCase): |
2217 | ) |
2218 | |
2219 | def test_writes_reverse_dns_zone_config(self): |
2220 | - target_dir = patch_dns_config_path(self) |
2221 | + target_dir = patch_zone_file_config_path(self) |
2222 | domain = factory.make_string() |
2223 | ns_host_name = factory.make_name("ns") |
2224 | network = IPNetwork("192.168.0.1/22") |
2225 | @@ -676,7 +769,7 @@ class TestDNSReverseZoneConfig(MAASTestCase): |
2226 | ) |
2227 | |
2228 | def test_writes_reverse_dns_zone_config_for_small_network(self): |
2229 | - target_dir = patch_dns_config_path(self) |
2230 | + target_dir = patch_zone_file_config_path(self) |
2231 | domain = factory.make_string() |
2232 | ns_host_name = factory.make_name("ns") |
2233 | network = IPNetwork("192.168.0.1/26") |
2234 | @@ -710,7 +803,7 @@ class TestDNSReverseZoneConfig(MAASTestCase): |
2235 | ) |
2236 | |
2237 | def test_ignores_generate_directives_for_v6_dynamic_ranges(self): |
2238 | - patch_dns_config_path(self) |
2239 | + patch_zone_file_config_path(self) |
2240 | domain = factory.make_string() |
2241 | network = IPNetwork("192.168.0.1/22") |
2242 | dynamic_network = IPNetwork("%s/64" % factory.make_ipv6_address()) |
2243 | @@ -729,7 +822,7 @@ class TestDNSReverseZoneConfig(MAASTestCase): |
2244 | self.assertThat(get_generate_directives, MockNotCalled()) |
2245 | |
2246 | def test_reverse_config_file_is_world_readable(self): |
2247 | - patch_dns_config_path(self) |
2248 | + patch_zone_file_config_path(self) |
2249 | dns_zone_config = DNSReverseZoneConfig( |
2250 | factory.make_string(), |
2251 | serial=random.randint(1, 100), |
2252 | @@ -740,6 +833,61 @@ class TestDNSReverseZoneConfig(MAASTestCase): |
2253 | filepath = FilePath(tgt) |
2254 | self.assertTrue(filepath.getPermissions().other.read) |
2255 | |
2256 | + def test_dynamic_update_when_zone_file_exists(self): |
2257 | + patch_zone_file_config_path(self) |
2258 | + domain = factory.make_string() |
2259 | + network = IPNetwork("10.0.0.0/24") |
2260 | + ip1 = factory.pick_ip_in_network(network) |
2261 | + ip2 = factory.pick_ip_in_network(network) |
2262 | + hostname1 = f"{factory.make_string()}.{domain}" |
2263 | + hostname2 = f"{factory.make_string()}.{domain}" |
2264 | + fwd_updates = [ |
2265 | + DynamicDNSUpdate( |
2266 | + operation="INSERT", |
2267 | + zone=domain, |
2268 | + name=hostname1, |
2269 | + rectype="A", |
2270 | + answer=ip1, |
2271 | + ), |
2272 | + DynamicDNSUpdate( |
2273 | + operation="INSERT", |
2274 | + zone=domain, |
2275 | + name=hostname2, |
2276 | + rectype="A", |
2277 | + answer=ip2, |
2278 | + ), |
2279 | + ] |
2280 | + rev_updates = [ |
2281 | + DynamicDNSUpdate.as_reverse_record_update(update, str(network)) |
2282 | + for update in fwd_updates |
2283 | + ] |
2284 | + zone = DNSReverseZoneConfig( |
2285 | + domain, |
2286 | + serial=random.randint(1, 100), |
2287 | + network=network, |
2288 | + dynamic_updates=rev_updates, |
2289 | + ) |
2290 | + run_command = self.patch(actions, "run_command") |
2291 | + zone.write_config() |
2292 | + zone.write_config() |
2293 | + expected_stdin = "\n".join( |
2294 | + [ |
2295 | + "server localhost", |
2296 | + "zone 0.0.10.in-addr.arpa", |
2297 | + f"update add {IPAddress(ip1).reverse_dns} {zone.default_ttl} PTR {hostname1}", |
2298 | + f"update add {IPAddress(ip2).reverse_dns} {zone.default_ttl} PTR {hostname2}", |
2299 | + f"update add 0.0.10.in-addr.arpa {zone.default_ttl} SOA 0.0.10.in-addr.arpa. nobody.example.com. {zone.serial} 600 1800 604800 {zone.default_ttl}", |
2300 | + "send\n", |
2301 | + ] |
2302 | + ) |
2303 | + run_command.assert_called_once_with( |
2304 | + "nsupdate", |
2305 | + "-k", |
2306 | + get_nsupdate_key_path(), |
2307 | + "-v", |
2308 | + stdin=expected_stdin.encode("ascii"), |
2309 | + ) |
2310 | + |
2311 | |
2312 | class TestDNSReverseZoneConfig_GetGenerateDirectives(MAASTestCase): |
2313 | """Tests for `DNSReverseZoneConfig.get_GENERATE_directives()`.""" |
2314 | diff --git a/src/provisioningserver/dns/zoneconfig.py b/src/provisioningserver/dns/zoneconfig.py |
2315 | index d393818..eb28986 100644 |
2316 | --- a/src/provisioningserver/dns/zoneconfig.py |
2317 | +++ b/src/provisioningserver/dns/zoneconfig.py |
2318 | @@ -6,12 +6,14 @@ |
2319 | |
2320 | from datetime import datetime |
2321 | from itertools import chain |
2322 | +import os |
2323 | |
2324 | from netaddr import IPAddress, IPNetwork, spanning_cidr |
2325 | from netaddr.core import AddrFormatError |
2326 | |
2327 | +from provisioningserver.dns.actions import NSUpdateCommand |
2328 | from provisioningserver.dns.config import ( |
2329 | - compose_config_path, |
2330 | + compose_zone_file_config_path, |
2331 | render_dns_template, |
2332 | report_missing_config_dir, |
2333 | ) |
2334 | @@ -115,7 +117,9 @@ class DomainInfo: |
2335 | self.subnetwork = subnetwork |
2336 | self.zone_name = zone_name |
2337 | if target_path is None: |
2338 | - self.target_path = compose_config_path("zone.%s" % zone_name) |
2339 | + self.target_path = compose_zone_file_config_path( |
2340 | + "zone.%s" % zone_name |
2341 | + ) |
2342 | else: |
2343 | self.target_path = target_path |
2344 | |
2345 | @@ -140,9 +144,12 @@ class DomainConfigBase: |
2346 | self.ns_host_name = kwargs.pop("ns_host_name", None) |
2347 | self.serial = serial |
2348 | self.zone_info = zone_info |
2349 | - self.target_base = compose_config_path("zone") |
2350 | + self.target_base = compose_zone_file_config_path("zone") |
2351 | self.default_ttl = kwargs.pop("default_ttl", 30) |
2352 | self.ns_ttl = kwargs.pop("ns_ttl", self.default_ttl) |
2353 | + self.requires_reload = False |
2354 | + self._dynamic_updates = kwargs.pop("dynamic_updates", []) |
2355 | + self.force_config_write = kwargs.pop("force_config_write", False) |
2356 | |
2357 | def make_parameters(self): |
2358 | """Return a dict of the common template parameters.""" |
2359 | @@ -155,6 +162,27 @@ class DomainConfigBase: |
2360 | "ns_host_name": self.ns_host_name, |
2361 | } |
2362 | |
2363 | + def zone_file_exists(self, zone_info): |
2364 | + try: |
2365 | + os.stat(zone_info.target_path) |
2366 | + except FileNotFoundError: |
2367 | + return False |
2368 | + else: |
2369 | + return True |
2370 | + |
2371 | + def dynamic_update(self, zone_info): |
2372 | + nsupdate = NSUpdateCommand( |
2373 | + zone_info.zone_name, |
2374 | + [ |
2375 | + update |
2376 | + for update in self._dynamic_updates |
2377 | + if update.zone == zone_info.zone_name or update.subnet |
2378 | + ], |
2379 | + serial=self.serial, |
2380 | + ttl=self.default_ttl, |
2381 | + ) |
2382 | + nsupdate.update() |
2383 | + |
2384 | @classmethod |
2385 | def write_zone_file(cls, output_file, *parameters): |
2386 | """Write a zone file based on the zone file template. |
2387 | @@ -292,22 +320,29 @@ class DNSForwardZoneConfig(DomainConfigBase): |
2388 | if dynamic_range.version == 4 |
2389 | ) |
2390 | ) |
2391 | - self.write_zone_file( |
2392 | - zi.target_path, |
2393 | - self.make_parameters(), |
2394 | - { |
2395 | - "mappings": { |
2396 | - "A": self.get_A_mapping(self._mapping, self._ipv4_ttl), |
2397 | - "AAAA": self.get_AAAA_mapping( |
2398 | - self._mapping, self._ipv6_ttl |
2399 | + if not self.force_config_write and self.zone_file_exists(zi): |
2400 | + if len(self._dynamic_updates) > 0: |
2401 | + self.dynamic_update(zi) |
2402 | + else: |
2403 | + self.requires_reload = True |
2404 | + self.write_zone_file( |
2405 | + zi.target_path, |
2406 | + self.make_parameters(), |
2407 | + { |
2408 | + "mappings": { |
2409 | + "A": self.get_A_mapping( |
2410 | + self._mapping, self._ipv4_ttl |
2411 | + ), |
2412 | + "AAAA": self.get_AAAA_mapping( |
2413 | + self._mapping, self._ipv6_ttl |
2414 | + ), |
2415 | + }, |
2416 | + "other_mapping": enumerate_rrset_mapping( |
2417 | + self._other_mapping |
2418 | ), |
2419 | + "generate_directives": {"A": generate_directives}, |
2420 | }, |
2421 | - "other_mapping": enumerate_rrset_mapping( |
2422 | - self._other_mapping |
2423 | - ), |
2424 | - "generate_directives": {"A": generate_directives}, |
2425 | - }, |
2426 | - ) |
2427 | + ) |
2428 | |
2429 | |
2430 | class DNSReverseZoneConfig(DomainConfigBase): |
2431 | @@ -575,21 +610,28 @@ class DNSReverseZoneConfig(DomainConfigBase): |
2432 | if dynamic_range.version == 4 |
2433 | ) |
2434 | ) |
2435 | - self.write_zone_file( |
2436 | - zi.target_path, |
2437 | - self.make_parameters(), |
2438 | - { |
2439 | - "mappings": { |
2440 | - "PTR": self.get_PTR_mapping( |
2441 | - self._mapping, zi.subnetwork |
2442 | - ) |
2443 | - }, |
2444 | - "other_mapping": [], |
2445 | - "generate_directives": { |
2446 | - "PTR": generate_directives, |
2447 | - "CNAME": self.get_rfc2317_GENERATE_directives( |
2448 | - zi.subnetwork, self._rfc2317_ranges, self.domain |
2449 | - ), |
2450 | + if not self.force_config_write and self.zone_file_exists(zi): |
2451 | + if len(self._dynamic_updates) > 0: |
2452 | + self.dynamic_update(zi) |
2453 | + else: |
2454 | + self.requires_reload = True |
2455 | + self.write_zone_file( |
2456 | + zi.target_path, |
2457 | + self.make_parameters(), |
2458 | + { |
2459 | + "mappings": { |
2460 | + "PTR": self.get_PTR_mapping( |
2461 | + self._mapping, zi.subnetwork |
2462 | + ) |
2463 | + }, |
2464 | + "other_mapping": [], |
2465 | + "generate_directives": { |
2466 | + "PTR": generate_directives, |
2467 | + "CNAME": self.get_rfc2317_GENERATE_directives( |
2468 | + zi.subnetwork, |
2469 | + self._rfc2317_ranges, |
2470 | + self.domain, |
2471 | + ), |
2472 | + }, |
2473 | }, |
2474 | - }, |
2475 | - ) |
2476 | + ) |
2477 | diff --git a/src/provisioningserver/templates/dns/named.conf.template b/src/provisioningserver/templates/dns/named.conf.template |
2478 | index f0f9841..7217ca8 100644 |
2479 | --- a/src/provisioningserver/templates/dns/named.conf.template |
2480 | +++ b/src/provisioningserver/templates/dns/named.conf.template |
2481 | @@ -1,4 +1,5 @@ |
2482 | include "{{named_rndc_conf_path}}"; |
2483 | +include "{{nsupdate_keys_conf_path}}"; |
2484 | |
2485 | # Authoritative Zone declarations. |
2486 | {{for zone in zones}} |
2487 | @@ -6,6 +7,9 @@ include "{{named_rndc_conf_path}}"; |
2488 | zone "{{zoneinfo.zone_name}}" { |
2489 | type master; |
2490 | file "{{zoneinfo.target_path}}"; |
2491 | + allow-update { |
2492 | + key maas.; |
2493 | + }; |
2494 | }; |
2495 | {{endfor}} |
2496 | {{endfor}} |
UNIT TESTS dns_via_ nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas
-b update_
STATUS: FAILED maas-ci. internal: 8080/job/ maas-tester/ 468/consoleText 0a8d360bddbfdf0 146e80077c
LOG: http://
COMMIT: c8bd1525bae3aff