Merge lp:~mpontillo/maas/migrate-dns-settings-1.7 into lp:maas/1.7

Proposed by Mike Pontillo
Status: Merged
Approved by: Mike Pontillo
Approved revision: no longer in the source branch.
Merged at revision: 3375
Proposed branch: lp:~mpontillo/maas/migrate-dns-settings-1.7
Merge into: lp:maas/1.7
Diff against target: 639 lines (+372/-31)
5 files modified
docs/changelog.rst (+11/-0)
src/maasserver/management/commands/edit_named_options.py (+96/-12)
src/maasserver/tests/test_commands_edit_named_options.py (+141/-9)
src/maasserver/utils/isc.py (+25/-4)
src/maasserver/utils/tests/test_isc.py (+99/-6)
To merge this branch: bzr merge lp:~mpontillo/maas/migrate-dns-settings-1.7
Reviewer Review Type Date Requested Status
Andres Rodriguez (community) Approve
Review via email: mp+264089@code.launchpad.net

This proposal supersedes a proposal from 2015-07-08.

Commit message

Backport DNS migration fixes (Merge revision 4077 from trunk)

To post a comment you must log in.
Revision history for this message
Andres Rodriguez (andreserl) wrote :

lgtm!

review: Approve

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'docs/changelog.rst'
2--- docs/changelog.rst 2015-05-26 16:36:50 +0000
3+++ docs/changelog.rst 2015-07-08 00:32:06 +0000
4@@ -2,6 +2,17 @@
5 Changelog
6 =========
7
8+1.7.6
9+=====
10+
11+Bug Fix Update
12+--------------
13+
14+#1470585 Accept list of forwarders for upstream_dns rather than just one.
15+
16+#1413388 Fix upgrade issue where it would remove custom DNS config,
17+ potentially breaking DNS.
18+
19 1.7.5
20 =====
21
22
23=== modified file 'src/maasserver/management/commands/edit_named_options.py'
24--- src/maasserver/management/commands/edit_named_options.py 2015-07-01 01:38:49 +0000
25+++ src/maasserver/management/commands/edit_named_options.py 2015-07-08 00:32:06 +0000
26@@ -11,6 +11,8 @@
27 print_function,
28 unicode_literals,
29 )
30+from collections import OrderedDict
31+
32
33 str = None
34
35@@ -19,16 +21,20 @@
36 'Command',
37 ]
38
39+from copy import deepcopy
40 from datetime import datetime
41 from optparse import make_option
42 import os
43 import shutil
44+import sys
45
46 from django.core.management.base import (
47 BaseCommand,
48 CommandError,
49 )
50+from maasserver.models import Config
51 from maasserver.utils.isc import (
52+ ISCParseException,
53 make_isc_string,
54 parse_isc_string,
55 )
56@@ -42,6 +48,24 @@
57 '--config-path', dest='config_path',
58 default="/etc/bind/named.conf.options",
59 help="Specify the configuration file to edit."),
60+ make_option(
61+ '--dry-run', dest='dry_run',
62+ default=False, action='store_true',
63+ help="Do not edit any configuration; instead, print to stdout the "
64+ "actions that would be performed, and/or the new "
65+ "configuration that would be written."),
66+ make_option(
67+ '--force', dest='force',
68+ default=False, action='store_true',
69+ help="Force the BIND configuration to be written, even if it "
70+ "appears as though nothing has changed."),
71+ make_option(
72+ '--migrate-conflicting-options', default=False,
73+ dest='migrate_conflicting_options', action='store_true',
74+ help="Causes any options that conflict with MAAS-managed options "
75+ "to be deleted from the BIND configuration and moved to the "
76+ "MAAS-managed configuration. Requires the MAAS database to "
77+ "be configured and running."),
78 )
79 help = (
80 "Edit the named.conf.options file so that it includes the "
81@@ -66,13 +90,12 @@
82 """
83 try:
84 config_dict = parse_isc_string(options_file)
85- except Exception as e:
86- # Yes, it throws bare exceptions :(
87+ except ISCParseException as e:
88 raise CommandError("Failed to parse %s: %s" % (
89 config_path, e.message))
90 options_block = config_dict.get("options", None)
91 if options_block is None:
92- # Something is horribly wrong with the file, bail out rather
93+ # Something is horribly wrong with the file; bail out rather
94 # than doing anything drastic.
95 raise CommandError(
96 "Can't find options {} block in %s, bailing out without "
97@@ -85,14 +108,37 @@
98 options_block['include'] = '"%s%s%s"' % (
99 dir, os.path.sep, MAAS_NAMED_CONF_OPTIONS_INSIDE_NAME)
100
101- def remove_forwarders(self, options_block):
102+ def migrate_forwarders(self, options_block, dry_run, stdout):
103 """Remove existing forwarders from the options block.
104
105 It's a syntax error to have more than one in the combined
106- configuration for named so we just remove whatever was there.
107- There is no data loss due to the backup file made later.
108+ configuration for named, so we just remove whatever was there.
109+
110+ Migrate any forwarders in the configuration file to the MAAS config.
111 """
112 if 'forwarders' in options_block:
113+ bind_forwarders = options_block['forwarders']
114+
115+ if not dry_run:
116+ config, created = Config.objects.get_or_create(
117+ name='upstream_dns',
118+ defaults={'value': ' '.join(bind_forwarders)})
119+ if not created:
120+ # A configuration value already exists, so add the
121+ # additional values we found in the configuration file to
122+ # MAAS.
123+ if config.value is None:
124+ config.value = ''
125+ maas_forwarders = OrderedDict.fromkeys(
126+ config.value.split())
127+ maas_forwarders.update(bind_forwarders)
128+ config.value = ' '.join(maas_forwarders)
129+ config.save()
130+ else:
131+ stdout.write(
132+ "// Append to MAAS forwarders: %s\n"
133+ % ' '.join(bind_forwarders))
134+
135 del options_block['forwarders']
136
137 def back_up_existing_file(self, config_path):
138@@ -104,21 +150,59 @@
139 raise CommandError(
140 "Failed to make a backup of %s, exiting: %s" % (
141 config_path, e.message))
142+ return backup_destination
143+
144+ def write_new_named_conf_options(self, fd, backup_filename, new_content):
145+ fd.write("""\
146+//
147+// This file is managed by MAAS. Although MAAS attempts to preserve changes
148+// made here, it is possible to create conflicts that MAAS can not resolve.
149+//
150+// DNS settings available in MAAS (for example, forwarders and
151+// dnssec-validation) should be managed only in MAAS.
152+//
153+// The previous configuration file was backed up at:
154+// %s
155+//
156+""" % backup_filename)
157+ fd.write(new_content)
158+ fd.write("\n")
159
160 def handle(self, *args, **options):
161 """Entry point for BaseCommand."""
162 # Read stuff in, validate.
163 config_path = options.get('config_path')
164+ dry_run = options.get('dry_run')
165+ force = options.get('force')
166+ stdout = options.get('stdout')
167+ if stdout is None:
168+ stdout = sys.stdout
169+ migrate_conflicting_options = options.get(
170+ 'migrate_conflicting_options')
171+
172 options_file = self.read_file(config_path)
173 config_dict = self.parse_file(config_path, options_file)
174+ original_config = deepcopy(config_dict)
175+
176 options_block = config_dict['options']
177
178- # Modify the config.
179+ # Modify the configuration (if necessary).
180 self.set_up_include_statement(options_block, config_path)
181- self.remove_forwarders(options_block)
182+
183+ if migrate_conflicting_options:
184+ self.migrate_forwarders(options_block, dry_run, stdout)
185+
186+ # Re-parse the new configuration, so we can detect any changes.
187 new_content = make_isc_string(config_dict)
188+ new_config = parse_isc_string(new_content)
189
190- # Back up and write new file.
191- self.back_up_existing_file(config_path)
192- with open(config_path, "wb") as fd:
193- fd.write(new_content)
194+ if original_config != new_config or force:
195+ # The configuration has changed. Back up and write new file.
196+ if dry_run:
197+ self.write_new_named_conf_options(
198+ stdout, config_path, new_content)
199+ else:
200+ backup_filename = self.back_up_existing_file(config_path)
201+ with open(config_path, "wb") as fd:
202+ self.write_new_named_conf_options(
203+ fd, backup_filename, new_content)
204
205=== modified file 'src/maasserver/tests/test_commands_edit_named_options.py'
206--- src/maasserver/tests/test_commands_edit_named_options.py 2014-07-18 17:05:57 +0000
207+++ src/maasserver/tests/test_commands_edit_named_options.py 2015-07-08 00:32:06 +0000
208@@ -15,6 +15,7 @@
209 __all__ = []
210
211 from codecs import getwriter
212+from collections import OrderedDict
213 from io import BytesIO
214 import os
215 import shutil
216@@ -22,11 +23,22 @@
217
218 from django.core.management import call_command
219 from django.core.management.base import CommandError
220+from maasserver.management.commands.edit_named_options import (
221+ Command as command_module,
222+ )
223+from maasserver.models import Config
224 from maasserver.testing.factory import factory
225 from maasserver.testing.testcase import MAASServerTestCase
226+from maasserver.utils import get_one
227+from maasserver.utils.isc import (
228+ make_isc_string,
229+ parse_isc_string,
230+ read_isc_file,
231+ )
232 from provisioningserver.dns.config import MAAS_NAMED_CONF_OPTIONS_INSIDE_NAME
233 from testtools.matchers import (
234 Contains,
235+ Equals,
236 FileContains,
237 Not,
238 )
239@@ -43,7 +55,27 @@
240 OPTIONS_FILE_WITH_FORWARDERS = textwrap.dedent("""\
241 options {
242 directory "/var/cache/bind";
243- forwarders { 192.168.1.1; };
244+ forwarders { 192.168.1.1; 192.168.1.2; };
245+ auth-nxdomain no; # conform to RFC1035
246+ listen-on-v6 { any; };
247+ };
248+""")
249+
250+OPTIONS_FILE_WITH_FORWARDERS_AND_DNSSEC = textwrap.dedent("""\
251+ options {
252+ directory "/var/cache/bind";
253+ forwarders { 192.168.1.1; 192.168.1.2; };
254+ dnssec-validation no;
255+ auth-nxdomain no; # conform to RFC1035
256+ listen-on-v6 { any; };
257+ };
258+""")
259+
260+OPTIONS_FILE_WITH_EXTRA_AND_DUP_FORWARDER = textwrap.dedent("""\
261+ options {
262+ directory "/var/cache/bind";
263+ forwarders { 192.168.1.2; 192.168.1.3; };
264+ dnssec-validation no;
265 auth-nxdomain no; # conform to RFC1035
266 listen-on-v6 { any; };
267 };
268@@ -90,18 +122,27 @@
269 self.assertContentFailsWithMessage(
270 OPTIONS_FILE, "Failed to make a backup")
271
272- def test_removes_existing_forwarders_config(self):
273+ def test_does_not_remove_existing_forwarders_config(self):
274 options_file = self.make_file(contents=OPTIONS_FILE_WITH_FORWARDERS)
275 call_command(
276 "edit_named_options", config_path=options_file,
277 stdout=self.stdout)
278
279+ options = read_isc_file(options_file)
280+ self.assertThat(make_isc_string(options), Contains('forwarders'))
281+
282+ def test_removes_existing_forwarders_config_if_migrate_set(self):
283+ options_file = self.make_file(contents=OPTIONS_FILE_WITH_FORWARDERS)
284+ call_command(
285+ "edit_named_options", config_path=options_file,
286+ migrate_conflicting_options=True, stdout=self.stdout)
287+
288 # Check that the file was re-written without forwarders (since
289 # that's now in the included file).
290+ options = read_isc_file(options_file)
291 self.assertThat(
292- options_file,
293- Not(FileContains(
294- matcher=Contains('forwarders'))))
295+ make_isc_string(options),
296+ Not(Contains('forwarders')))
297
298 def test_normal_operation(self):
299 options_file = self.make_file(contents=OPTIONS_FILE)
300@@ -114,11 +155,10 @@
301 MAAS_NAMED_CONF_OPTIONS_INSIDE_NAME)
302
303 # Check that the file was re-written with the include statement.
304+ options = read_isc_file(options_file)
305 self.assertThat(
306- options_file,
307- FileContains(
308- matcher=Contains(
309- 'include "%s";' % expected_path)))
310+ make_isc_string(options),
311+ Contains('include "%s";' % expected_path))
312
313 # Check that the backup was made.
314 options_file_base = os.path.dirname(options_file)
315@@ -128,3 +168,95 @@
316 [backup_file] = files
317 backup_file = os.path.join(options_file_base, backup_file)
318 self.assertThat(backup_file, FileContains(OPTIONS_FILE))
319+
320+ def test_migrates_bind_config_to_database(self):
321+ options_file = self.make_file(
322+ contents=OPTIONS_FILE_WITH_FORWARDERS_AND_DNSSEC)
323+ call_command(
324+ "edit_named_options", config_path=options_file,
325+ migrate_conflicting_options=True, stdout=self.stdout)
326+
327+ upstream_dns = get_one(Config.objects.filter(name="upstream_dns"))
328+ self.assertThat({'192.168.1.1', '192.168.1.2'},
329+ Equals(set(upstream_dns.value.split())))
330+
331+ def test_migrate_combines_with_existing_forwarders(self):
332+ options_file = self.make_file(
333+ contents=OPTIONS_FILE_WITH_FORWARDERS_AND_DNSSEC)
334+ call_command(
335+ "edit_named_options", config_path=options_file,
336+ migrate_conflicting_options=True, stdout=self.stdout)
337+
338+ upstream_dns = get_one(Config.objects.filter(name="upstream_dns"))
339+ self.assertThat(OrderedDict.fromkeys(['192.168.1.1', '192.168.1.2']),
340+ Equals(OrderedDict.fromkeys(
341+ upstream_dns.value.split())))
342+
343+ options_file = self.make_file(
344+ contents=OPTIONS_FILE_WITH_EXTRA_AND_DUP_FORWARDER)
345+
346+ call_command(
347+ "edit_named_options", config_path=options_file,
348+ migrate_conflicting_options=True, stdout=self.stdout)
349+
350+ upstream_dns = get_one(Config.objects.filter(name="upstream_dns"))
351+ self.assertThat(
352+ OrderedDict.fromkeys(
353+ ['192.168.1.1', '192.168.1.2', '192.168.1.3']),
354+ Equals(OrderedDict.fromkeys(upstream_dns.value.split())))
355+
356+ def test_dry_run_migrates_nothing_and_prints_config(self):
357+ options_file = self.make_file(
358+ contents=OPTIONS_FILE_WITH_FORWARDERS_AND_DNSSEC)
359+ call_command(
360+ "edit_named_options", config_path=options_file,
361+ migrate_conflicting_options=True, dry_run=True, stdout=self.stdout)
362+
363+ upstream_dns = get_one(Config.objects.filter(name="upstream_dns"))
364+ self.assertIsNone(upstream_dns)
365+
366+ # Check that a proper configuration was written to stdout.
367+ config = parse_isc_string(self.stdout.getvalue())
368+ self.assertIsNotNone(config)
369+
370+ def test_repeat_migrations_migrate_nothing(self):
371+ options_file = self.make_file(
372+ contents=OPTIONS_FILE_WITH_FORWARDERS_AND_DNSSEC)
373+ backup_mock = self.patch(command_module, "back_up_existing_file")
374+
375+ call_command(
376+ "edit_named_options", config_path=options_file,
377+ migrate_conflicting_options=True, stdout=self.stdout)
378+
379+ self.assertTrue(backup_mock.called)
380+ backup_mock.reset_mock()
381+
382+ write_mock = self.patch(command_module, "write_new_named_conf_options")
383+
384+ call_command(
385+ "edit_named_options", config_path=options_file,
386+ migrate_conflicting_options=True, stdout=self.stdout)
387+
388+ self.assertFalse(backup_mock.called)
389+ self.assertFalse(write_mock.called)
390+
391+ def test_repeat_forced_migrations_write_file_anyway(self):
392+ options_file = self.make_file(
393+ contents=OPTIONS_FILE_WITH_FORWARDERS_AND_DNSSEC)
394+ backup_mock = self.patch(command_module, "back_up_existing_file")
395+
396+ call_command(
397+ "edit_named_options", config_path=options_file,
398+ migrate_conflicting_options=True, stdout=self.stdout)
399+
400+ self.assertTrue(backup_mock.called)
401+ backup_mock.reset_mock()
402+
403+ write_mock = self.patch(command_module, "write_new_named_conf_options")
404+
405+ call_command(
406+ "edit_named_options", config_path=options_file,
407+ migrate_conflicting_options=True, force=True, stdout=self.stdout)
408+
409+ self.assertTrue(backup_mock.called)
410+ self.assertTrue(write_mock.called)
411
412=== modified file 'src/maasserver/utils/isc.py'
413--- src/maasserver/utils/isc.py 2015-07-01 01:42:07 +0000
414+++ src/maasserver/utils/isc.py 2015-07-08 00:32:06 +0000
415@@ -33,18 +33,26 @@
416 print_function,
417 unicode_literals,
418 )
419+from collections import OrderedDict
420+
421
422 str = None
423
424 __metaclass__ = type
425 __all__ = [
426+ 'ISCParseException',
427 'make_isc_string',
428 'parse_isc_string',
429+ 'read_isc_file',
430 ]
431
432 import copy
433
434
435+class ISCParseException(Exception):
436+ """Thrown when an ISC string cannot be parsed."""
437+
438+
439 def _clip(char_list):
440 """Clips char_list to individual stanza.
441
442@@ -64,7 +72,7 @@
443 return index, char_list[:index]
444 elif item == '}':
445 skip -= 1
446- raise Exception("Invalid brackets.")
447+ raise ISCParseException("Invalid brackets.")
448
449
450 def _parse_tokens(char_list):
451@@ -86,11 +94,11 @@
452 {'10.1.0/32': True, '10.1.1/32': True}}}
453 """
454 index = 0
455- dictionary_fragment = {}
456+ dictionary_fragment = OrderedDict()
457 new_char_list = copy.deepcopy(char_list)
458 if type(new_char_list) == str:
459 return new_char_list
460- if type(new_char_list) == dict:
461+ if type(new_char_list) == OrderedDict:
462 return new_char_list
463 last_open = None
464 continuous_line = False
465@@ -146,6 +154,8 @@
466 # fine)
467 elif new_char_list[index] not in ['{', ';', '}']:
468 key = new_char_list[index]
469+ if type(dictionary_fragment) == list:
470+ raise ISCParseException("Dictionary expected; got a list")
471 dictionary_fragment[key] = ''
472 index += 1
473 index += 1
474@@ -277,7 +287,18 @@
475 new_list[-1] = '%s%s' % (new_list[-1], terminator)
476 isc_list.append(
477 '%s { %s }%s' % (option, ' '.join(new_list), terminator))
478- elif type(isc_dict[option]) == dict:
479+ elif (type(isc_dict[option]) == OrderedDict or
480+ type(isc_dict[option]) == dict):
481 isc_list.append('%s { %s }%s' % (
482 option, make_isc_string(isc_dict[option]), terminator))
483 return '\n'.join(isc_list)
484+
485+
486+def read_isc_file(isc_file):
487+ """Given the specified filename, parses it to create a dictionary.
488+
489+ :param:isc_file: the filename to read
490+ :return:dict: dictionary of ISC file representation
491+ """
492+ with open(isc_file, "r") as f:
493+ return parse_isc_string(f.read())
494
495=== modified file 'src/maasserver/utils/tests/test_isc.py'
496--- src/maasserver/utils/tests/test_isc.py 2015-07-01 01:43:35 +0000
497+++ src/maasserver/utils/tests/test_isc.py 2015-07-08 00:32:06 +0000
498@@ -9,19 +9,23 @@
499 unicode_literals,
500 )
501
502+
503 str = None
504
505 __metaclass__ = type
506 __all__ = []
507
508-
509+from collections import OrderedDict
510 from textwrap import dedent
511
512 from maasserver.utils.isc import (
513+ ISCParseException,
514 make_isc_string,
515 parse_isc_string,
516+ read_isc_file,
517 )
518 from maastesting.testcase import MAASTestCase
519+from testtools import ExpectedException
520
521
522 class TestParseISCString(MAASTestCase):
523@@ -39,10 +43,11 @@
524 """)
525 options = parse_isc_string(testdata)
526 self.assertEqual(
527- {u'options': {u'auth-nxdomain': u'no',
528- u'directory': u'"/var/cache/bind"',
529- u'dnssec-validation': u'auto',
530- u'listen-on-v6': {u'any': True}}}, options)
531+ OrderedDict({u'options': OrderedDict({u'auth-nxdomain': u'no',
532+ u'directory': u'"/var/cache/bind"',
533+ u'dnssec-validation': u'auto',
534+ u'listen-on-v6': OrderedDict({u'any': True})})}),
535+ options)
536
537 def test_parses_bind_acl(self):
538 testdata = dedent("""\
539@@ -159,6 +164,94 @@
540 config_string = make_isc_string(config)
541 config = parse_isc_string(config_string)
542 self.assertEqual(
543+ OrderedDict(
544+ [(u'acl canonical-int-ns',
545+ OrderedDict(
546+ [(u'91.189.90.151', True), (u'91.189.89.192', True)])),
547+ (u'options', OrderedDict(
548+ [(u'directory', u'"/var/cache/bind"'),
549+ (u'forwarders', OrderedDict(
550+ [(u'91.189.94.2', True)])),
551+ (u'dnssec-validation', u'auto'),
552+ (u'auth-nxdomain', u'no'),
553+ (u'listen-on-v6', OrderedDict([(u'any', True)])),
554+ (u'allow-query', OrderedDict([(u'any', True)])),
555+ (u'allow-transfer', OrderedDict(
556+ [(u'10.222.64.1', True),
557+ (u'canonical-int-ns', True)])),
558+ (u'notify', u'explicit'),
559+ (u'also-notify', OrderedDict(
560+ [(u'91.189.90.151', True),
561+ (u'91.189.89.192', True)])),
562+ (u'allow-query-cache', OrderedDict(
563+ [(u'10.222.64.0/18', True)])),
564+ (u'recursion', u'yes')])),
565+ (u'zone "."', OrderedDict(
566+ [(u'type', u'master'),
567+ (u'file', u'"/etc/bind/db.special"')]))]),
568+ config)
569+
570+ def test_parser_preserves_order(self):
571+ testdata = dedent("""\
572+ forwarders {
573+ 9.9.9.9;
574+ 8.8.8.8;
575+ 7.7.7.7;
576+ 6.6.6.6;
577+ 5.5.5.5;
578+ 4.4.4.4;
579+ 3.3.3.3;
580+ 2.2.2.2;
581+ 1.1.1.1;
582+ };
583+ """)
584+ forwarders = parse_isc_string(testdata)
585+ self.assertEqual(OrderedDict([(u'forwarders', OrderedDict(
586+ [(u'9.9.9.9', True), (u'8.8.8.8', True), (u'7.7.7.7', True),
587+ (u'6.6.6.6', True), (u'5.5.5.5', True), (u'4.4.4.4', True),
588+ (u'3.3.3.3', True), (u'2.2.2.2', True), (u'1.1.1.1', True)]))]),
589+ forwarders)
590+
591+ def test_parse_unmatched_brackets_throws_iscparseexception(self):
592+ with ExpectedException(ISCParseException):
593+ parse_isc_string("forwarders {")
594+
595+ def test_parse_malformed_list_throws_iscparseexception(self):
596+ with ExpectedException(ISCParseException):
597+ parse_isc_string("forwarders {{}a;;b}")
598+
599+ def test_read_isc_file(self):
600+ testdata = dedent("""\
601+ acl canonical-int-ns { 91.189.90.151; 91.189.89.192; };
602+
603+ options {
604+ directory "/var/cache/bind";
605+
606+ forwarders {
607+ 91.189.94.2;
608+ 91.189.94.2;
609+ };
610+
611+ dnssec-validation auto;
612+
613+ auth-nxdomain no; # conform to RFC1035
614+ listen-on-v6 { any; };
615+
616+ allow-query { any; };
617+ allow-transfer { 10.222.64.1; canonical-int-ns; };
618+
619+ notify explicit;
620+ also-notify { 91.189.90.151; 91.189.89.192; };
621+
622+ allow-query-cache { 10.222.64.0/18; };
623+ recursion yes;
624+ };
625+
626+ zone "." { type master; file "/etc/bind/db.special"; };
627+ """)
628+ testfile = self.make_file(contents=testdata)
629+ parsed = read_isc_file(testfile)
630+ self.assertEqual(
631 {u'acl canonical-int-ns':
632 {u'91.189.89.192': True, u'91.189.90.151': True},
633 u'options': {u'allow-query': {u'any': True},
634@@ -176,4 +269,4 @@
635 u'recursion': u'yes'},
636 u'zone "."':
637 {u'file': u'"/etc/bind/db.special"', u'type': u'master'}},
638- config)
639+ parsed)

Subscribers

People subscribed via source and target branches

to all changes: