Merge ~cgrabowski/maas:update_dns_via_nsupdate into maas:master

Proposed by Christian Grabowski
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)
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

To post a comment you must log in.
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/468/consoleText
COMMIT: c8bd1525bae3aff0a8d360bddbfdf0146e80077c

review: Needs Fixing
Revision history for this message
Jack Lloyd-Walters (lloydwaltersj) wrote :

some minor questions

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/482/consoleText
COMMIT: a5b053f45432fbc744aea674be38d2c31f9ad258

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/488/consoleText
COMMIT: 4b2b6ef4d224bea21a59050d17e084319f6fc3e0

review: Needs Fixing
Revision history for this message
Christian Grabowski (cgrabowski) :
f1ffb8a... by Christian Grabowski

allow ttls of 0 in dynamic updates

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/489/consoleText
COMMIT: 9d290212c760d6c33ea320aff61c9dc8277fe86e

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/490/consoleText
COMMIT: 425c4d2f3382e79c4b1721d6cb6c5d8b18b20e50

review: Needs Fixing
417dd73... by Christian Grabowski

update set of triggers to account for dynamic updates

Revision history for this message
Alexsander de Souza (alexsander-souza) wrote :

a few questions inline

review: Needs Information
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: fcd948f51f72e398ab6fa1b162b4c3381f14fd29

review: Approve
Revision history for this message
Christian Grabowski (cgrabowski) :
8be3a3e... by Christian Grabowski

clean up full reload flag

Revision history for this message
Alexsander de Souza (alexsander-souza) wrote :

+1

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/493/consoleText
COMMIT: 8021acff300d749f36c9724cc923bc2e14ac2837

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/514/consoleText
COMMIT: 3a9b652d51a05cb8e83fa4536d33212ea9fb5161

review: Needs Fixing
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

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/556/consoleText
COMMIT: 1defa9a5206bc2d223ed61597ec17fd836d6e11a

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/559/consoleText
COMMIT: 68c4616d4557b29c622eef4fe493e96691ee76a8

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/564/consoleText
COMMIT: 91834894c931d55620fd93e66e7a8903d5a0f63d

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/565/consoleText
COMMIT: f5f85a4eac33db34c8553e8ea6362bbb7a8c5d9a

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/566/consoleText
COMMIT: 64368c19977fd2b14d4a50d02cd002aba07dee7d

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/575/consoleText
COMMIT: 405b6868c73076743b0baa8b48bc7b332170279c

review: Needs Fixing
2021bea... by Christian Grabowski

add missing patch calls for tests

96fafca... by Christian Grabowski

add helper to test notify triggers

Revision history for this message
Adam Collard (adam-collard) :
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/1154/consoleText
COMMIT: 556b628e5dd110dee9c4ad5561d2a944cfc4059c

review: Needs Fixing
e314a01... by Christian Grabowski

add all trigger testcase

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/1193/consoleText
COMMIT: 09b92908a47c3ccb9c7210424fe31963078dcadf

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/1204/consoleText
COMMIT: e592b85a0b8c74f506d2e1ec448f22a3857fc9f7

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/1278/consoleText
COMMIT: 210616767ee57baf8976183f0bfeebbd6ec9deca

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/1281/consoleText
COMMIT: 85d59ce7f102b1b242847a2c171adfa2a2bb3c97

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/1282/consoleText
COMMIT: d479aede2dd0692029202e33f03325968905426b

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/1340/consoleText
COMMIT: e314a01b5cc04452f6783007a4a234037d9c2b45

review: Needs Fixing
4e44d9c... by Christian Grabowski

fix trigger registration tests

Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/1342/consoleText
COMMIT: 668bf3a42b042fcb9019af9d1c443f299da571b4

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/1343/consoleText
COMMIT: a3bb7ab6ba6724d12d3e342a46bbbcefde041e51

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/1344/consoleText
COMMIT: 939d94111b35d11541eab813c15fd59b8f57fa22

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: FAILED
LOG: http://maas-ci.internal:8080/job/maas-tester/1345/consoleText
COMMIT: 6fb564b57fe58a2c34ac80330e01dc7feec909de

review: Needs Fixing
Revision history for this message
MAAS Lander (maas-lander) wrote :

UNIT TESTS
-b update_dns_via_nsupdate lp:~cgrabowski/maas/+git/maas into -b master lp:~maas-committers/maas

STATUS: SUCCESS
COMMIT: 4e44d9c55fa32e19ccc7b937aa0b75522e8b25d6

review: Approve

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

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/debian/maas-region-api.dirs b/debian/maas-region-api.dirs
2index 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
9diff --git a/debian/maas-region-api.postinst b/debian/maas-region-api.postinst
10index 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() {
25diff --git a/src/maasserver/dns/config.py b/src/maasserver/dns/config.py
26index 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)
234diff --git a/src/maasserver/dns/tests/test_config.py b/src/maasserver/dns/tests/test_config.py
235index 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+ )
423diff --git a/src/maasserver/dns/zonegenerator.py b/src/maasserver/dns/zonegenerator.py
424index 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
574diff --git a/src/maasserver/region_controller.py b/src/maasserver/region_controller.py
575index 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
652diff --git a/src/maasserver/region_script.py b/src/maasserver/region_script.py
653index 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"
664diff --git a/src/maasserver/tests/test_region_controller.py b/src/maasserver/tests/test_region_controller.py
665index 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
750diff --git a/src/maasserver/triggers/system.py b/src/maasserver/triggers/system.py
751index 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+ )
1025diff --git a/src/maasserver/triggers/testing.py b/src/maasserver/triggers/testing.py
1026index 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()
1095diff --git a/src/maasserver/triggers/tests/test_init.py b/src/maasserver/triggers/tests/test_init.py
1096index 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",
1135diff --git a/src/maasserver/triggers/tests/test_system.py b/src/maasserver/triggers/tests/test_system.py
1136index 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()
1447diff --git a/src/provisioningserver/dns/actions.py b/src/provisioningserver/dns/actions.py
1448index 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
1522diff --git a/src/provisioningserver/dns/commands/setup_dns.py b/src/provisioningserver/dns/commands/setup_dns.py
1523index 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()
1546diff --git a/src/provisioningserver/dns/commands/tests/test_setup_dns.py b/src/provisioningserver/dns/commands/tests/test_setup_dns.py
1547index 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,
1578diff --git a/src/provisioningserver/dns/config.py b/src/provisioningserver/dns/config.py
1579index 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 }
1731diff --git a/src/provisioningserver/dns/testing.py b/src/provisioningserver/dns/testing.py
1732index 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
1752diff --git a/src/provisioningserver/dns/tests/test_actions.py b/src/provisioningserver/dns/tests/test_actions.py
1753index 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+ )
1909diff --git a/src/provisioningserver/dns/tests/test_config.py b/src/provisioningserver/dns/tests/test_config.py
1910index 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)
2006diff --git a/src/provisioningserver/dns/tests/test_zoneconfig.py b/src/provisioningserver/dns/tests/test_zoneconfig.py
2007index 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()`."""
2314diff --git a/src/provisioningserver/dns/zoneconfig.py b/src/provisioningserver/dns/zoneconfig.py
2315index 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+ )
2477diff --git a/src/provisioningserver/templates/dns/named.conf.template b/src/provisioningserver/templates/dns/named.conf.template
2478index 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}}

Subscribers

People subscribed via source and target branches