Merge ~cgrabowski/maas:update_dns_via_nsupdate into maas:master

Proposed by Christian Grabowski
Status: Merged
Approved by: Christian Grabowski
Approved revision: a1306793412e4e2391470d984523214c06ed4544
Merge reported by: MAAS Lander
Merged at revision: not available
Proposed branch: ~cgrabowski/maas:update_dns_via_nsupdate
Merge into: maas:master
Diff against target: 2500 lines (+1626/-87)
22 files modified
debian/maas-region-api.dirs (+1/-0)
debian/maas-region-api.postinst (+5/-0)
src/maasserver/dns/config.py (+162/-10)
src/maasserver/dns/tests/test_config.py (+149/-1)
src/maasserver/dns/zonegenerator.py (+55/-2)
src/maasserver/region_controller.py (+37/-2)
src/maasserver/region_script.py (+1/-0)
src/maasserver/tests/test_region_controller.py (+22/-10)
src/maasserver/triggers/system.py (+240/-0)
src/maasserver/triggers/testing.py (+43/-2)
src/maasserver/triggers/tests/test_init.py (+13/-0)
src/maasserver/triggers/tests/test_system.py (+280/-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 (+106/-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+433050@code.launchpad.net

This proposal supersedes a proposal from 2022-08-31.

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 : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

some minor questions

Revision history for this message
MAAS Lander (maas-lander) wrote : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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) : Posted in a previous version of this proposal
Revision history for this message
MAAS Lander (maas-lander) wrote : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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
Revision history for this message
Alexsander de Souza (alexsander-souza) wrote : Posted in a previous version of this proposal

a few questions inline

review: Needs Information
Revision history for this message
MAAS Lander (maas-lander) wrote : Posted in a previous version of this proposal

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) : Posted in a previous version of this proposal
Revision history for this message
Alexsander de Souza (alexsander-souza) wrote : Posted in a previous version of this proposal

+1

review: Approve
Revision history for this message
MAAS Lander (maas-lander) wrote : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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
Revision history for this message
MAAS Lander (maas-lander) wrote : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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
Revision history for this message
Adam Collard (adam-collard) : Posted in a previous version of this proposal
Revision history for this message
MAAS Lander (maas-lander) wrote : Posted in a previous version of this proposal

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
Revision history for this message
MAAS Lander (maas-lander) wrote : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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
Revision history for this message
MAAS Lander (maas-lander) wrote : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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 : Posted in a previous version of this proposal

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
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/1362/consoleText
COMMIT: 4e44d9c55fa32e19ccc7b937aa0b75522e8b25d6

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/1366/consoleText
COMMIT: 8af81b7358c1c4caad6f61574410c0ef9991bafc

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/1367/consoleText
COMMIT: 56138ea436744ab571f9b187f7e6fd94f54f6e71

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/1368/consoleText
COMMIT: a42fc61d6c6030228b9f77957bd0bd6da608a009

review: Needs Fixing
Revision history for this message
Christian Grabowski (cgrabowski) wrote :

jenkins: !test

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: a42fc61d6c6030228b9f77957bd0bd6da608a009

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

+1, a few nits inline

review: Approve
Revision history for this message
Christian Grabowski (cgrabowski) :
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/1386/consoleText
COMMIT: a9b01be54adf1137008717c2b55118aa3b22e363

review: Needs Fixing
Revision history for this message
Christian Grabowski (cgrabowski) wrote :

jenkins: !test

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/1387/consoleText
COMMIT: a9b01be54adf1137008717c2b55118aa3b22e363

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/1388/consoleText
COMMIT: e94338c9ee9e34fe44662b901a4f904f0f411091

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/1389/consoleText
COMMIT: b4cf0e57bb27ea4a5e7e0ed880500b90b3ce2a92

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: a1306793412e4e2391470d984523214c06ed4544

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

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

STATUS: FAILED BUILD
LOG: http://maas-ci.internal:8080/job/maas-tester/1391/consoleText

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

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

STATUS: FAILED BUILD
LOG: http://maas-ci.internal:8080/job/maas-tester/1392/consoleText

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

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

STATUS: FAILED BUILD
LOG: http://maas-ci.internal:8080/job/maas-tester/1393/consoleText

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

Subscribers

People subscribed via source and target branches