Hello,

Add wait_for_dns option to default.conf.

This option makes record changes in DNS tree synchronous.
IPA calls will wait until new data are visible over DNS protocol
or until timeout.

It is intended only for testing. It should prevent tests from
failing if there is bigger delay between changes in LDAP and DNS.

My personal recommendation is to use value "5" (for testing!).

--
Petr^2 Spacek
From 5509f954308b910a8b100aaf14239202f6635762 Mon Sep 17 00:00:00 2001
From: Petr Spacek <pspa...@redhat.com>
Date: Wed, 2 Apr 2014 11:04:07 +0200
Subject: [PATCH] Add wait_for_dns option to default.conf.

This option makes record changes in DNS tree synchronous.
IPA calls will wait until new data are visible over DNS protocol
or until timeout.

It is intended only for testing. It should prevent tests from
failing if there is bigger delay between changes in LDAP and DNS.
---
 ipa-client/man/default.conf.5 |   9 ++
 ipalib/constants.py           |   1 +
 ipalib/errors.py              |  18 ++++
 ipalib/plugins/dns.py         | 217 +++++++++++++++++++++++++++++++++++++++++-
 4 files changed, 241 insertions(+), 4 deletions(-)

diff --git a/ipa-client/man/default.conf.5 b/ipa-client/man/default.conf.5
index 5d5a48db62cb97e7424b42b6cb70d0c872b2bc34..c1ccf109e874907885fc3b51b63507c2b46b64ab 100644
--- a/ipa-client/man/default.conf.5
+++ b/ipa-client/man/default.conf.5
@@ -178,6 +178,15 @@ Used internally in the IPA source package to verify that the API has not changed
 .B verbose <boolean>
 When True provides more information. Specifically this sets the global log level to "info".
 .TP
+.B wait_for_dns <number of attempts>
+Controls whether the IPA commands dnsrecord\-{add,mod,del} work synchronously or not. The DNS commands will repeat DNS queries up to the specified number of attempts until the DNS server returns an up-to-date answer to a query for modified records. Delay between retries is one second.
+.IP
+The DNS commands will raise a DNSDataMismatch exception if the answer doesn't match the expected value even after the specified number of attempts.
+.IP
+The DNS queries will be sent to the resolver configured in /etc/resolv.conf on the IPA server.
+.IP
+Do not enable this in production! This will cause problems if the resolver on IPA server uses a caching server instead of a local authoritative server or e.g. if DNS answers are modified by DNS64. The default is disabled (the option is not present).
+.TP
 .B xmlrpc_uri <URI>
 Specifies the URI of the XML\-RPC server for a client. This may be used by IPA, and is used by some external tools, such as ipa\-getcert. Example: https://ipa.example.com/ipa/xml
 .TP
diff --git a/ipalib/constants.py b/ipalib/constants.py
index 8fc04afcd5cb996830d81e2e9b9dcf3f58034ef2..6cc50eacf44678840ad0048a1ef60c05736879cb 100644
--- a/ipalib/constants.py
+++ b/ipalib/constants.py
@@ -139,6 +139,7 @@ DEFAULT_CONFIG = (
     ('debug', False),
     ('startup_traceback', False),
     ('mode', 'production'),
+    ('wait_for_dns', False),
 
     # CA plugin:
     ('ca_host', FQDN),  # Set in Env._finalize_core()
diff --git a/ipalib/errors.py b/ipalib/errors.py
index 716decb2b41baf5470a1dc23c0cfb5d1c995e5ff..311127f62e54017c85541d27276020a9f950ab0f 100644
--- a/ipalib/errors.py
+++ b/ipalib/errors.py
@@ -1512,6 +1512,24 @@ class DatabaseTimeout(DatabaseError):
     format = _('LDAP timeout')
 
 
+class DNSDataMismatch(ExecutionError):
+    """
+    **4212** Raised when an DNS query didn't return expected answer
+    in a configured time limit.
+
+    For example:
+
+    >>> raise DNSDataMismatch(expected="zone3.test. 86400 IN A 192.0.2.1", \
+                              got="zone3.test. 86400 IN A 192.168.1.1")
+    Traceback (most recent call last):
+      ...
+    DNSDataMismatch: DNS check failed: Expected {zone3.test. 86400 IN A 192.0.2.1} got {zone3.test. 86400 IN A 192.168.1.1}
+    """
+
+    errno = 4212
+    format = _('DNS check failed: Expected {%(expected)s} got {%(got)s}')
+
+
 class CertificateError(ExecutionError):
     """
     **4300** Base class for Certificate execution errors (*4300 - 4399*).
diff --git a/ipalib/plugins/dns.py b/ipalib/plugins/dns.py
index c1b1b643420b190897f9c925eaa17d51c5082348..876f376195c8026546c1160ba9a6678390a40f2e 100644
--- a/ipalib/plugins/dns.py
+++ b/ipalib/plugins/dns.py
@@ -24,6 +24,7 @@ import netaddr
 import time
 import re
 import dns.name
+import dns.resolver
 
 from ipalib.request import context
 from ipalib import api, errors, output
@@ -248,6 +249,12 @@ _record_attributes = [str('%srecord' % t.lower()) for t in _record_types]
 # supported DNS classes, IN = internet, rest is almost never used
 _record_classes = (u'IN', u'CS', u'CH', u'HS')
 
+# IN record class
+_IN = dns.rdataclass.IN
+
+# NS record type
+_NS = dns.rdatatype.from_text('NS')
+
 def _rname_validator(ugettext, zonemgr):
     try:
         validate_zonemgr(zonemgr)
@@ -2397,6 +2404,178 @@ class dnsrecord(LDAPObject):
                                   'NS record except when located in a zone root '
                                   'record (RFC 6672, section 2.3)'))
 
+    def _entry2rrsets(self, entry_attrs, dns_name, dns_domain):
+        '''Convert entry_attrs to a dictionary {rdtype: rrset}.
+
+        :returns:
+            None if entry_attrs is None
+            {rdtype: None} if RRset of given type is empty
+            {rdtype: RRset} if RRset of given type is non-empty
+        '''
+        record_attr_suf = 'record'
+        ldap_rrsets = {}
+
+        if not entry_attrs:
+            # all records were deleted => name should not exist in DNS
+            return None
+
+        for attr, value in entry_attrs.iteritems():
+            if not attr.endswith(record_attr_suf):
+                continue
+
+            rdtype = dns.rdatatype.from_text(attr[0:-len(record_attr_suf)])
+            if not value:
+                ldap_rrsets[rdtype] = None  # RRset is empty
+                continue
+
+            try:
+                # TTL here can be arbitrary value because it is ignored
+                # during comparison
+                ldap_rrset = dns.rrset.from_text(
+                    dns_name, 86400, dns.rdataclass.IN, rdtype,
+                    *map(str, value))
+
+                # make sure that all names are absolute so RRset
+                # comparison will work
+                for ldap_rr in ldap_rrset:
+                    ldap_rr.choose_relativity(origin=dns_domain,
+                                              relativize=False)
+                ldap_rrsets[rdtype] = ldap_rrset
+
+            except dns.exception.SyntaxError as e:
+                self.log.error('DNS syntax error: %s %s %s: %s', dns_name,
+                               dns.rdatatype.to_text(rdtype), value, e)
+                raise
+
+        return ldap_rrsets
+
+    def wait_for_modified_attr(self, ldap_rrset, rdtype, dns_name):
+        '''Wait until DNS resolver returns up-to-date answer for given RRset
+            or until the maximum number of attempts is reached.
+            Number of attempts is controlled by self.api.env['wait_for_dns'].
+
+        :param ldap_rrset:
+            None if given rdtype should not exist or
+            dns.rrset.RRset to match against data in DNS.
+        :param dns_name: FQDN to query
+        :type dns_name: dns.name.Name
+        :return: None if data in DNS and LDAP match
+        :raises errors.DNSDataMismatch: if data in DNS and LDAP doesn't match
+        :raises dns.exception.DNSException: if DNS resolution failed
+        '''
+        resolver = dns.resolver.Resolver()
+        resolver.set_flags(0)  # disable recursion (for NS RR checks)
+        max_attempts = int(self.api.env['wait_for_dns'])
+        warn_attempts = max_attempts / 2
+        period = 1  # second
+        attempt = 0
+        log_fn = self.log.debug
+        log_fn('querying DNS server: expecting answer {%s}', ldap_rrset)
+        wait_template = 'waiting for DNS answer {%s}: got {%s} (attempt %s); '\
+                        'waiting %s seconds before next try'
+
+        while attempt < max_attempts:
+            if attempt >= warn_attempts:
+                log_fn = self.log.warn
+            attempt += 1
+            try:
+                dns_answer = resolver.query(dns_name, rdtype,
+                                            dns.rdataclass.IN,
+                                            raise_on_no_answer=False)
+                dns_rrset = None
+                if rdtype == _NS:
+                    # NS records can be in Authority section (sometimes)
+                    dns_rrset = dns_answer.response.get_rrset(
+                        dns_answer.response.authority, dns_name, _IN, rdtype)
+
+                if not dns_rrset:
+                    # Look for NS and other data in Answer section
+                    dns_rrset = dns_answer.rrset
+
+                if dns_rrset == ldap_rrset:
+                    log_fn('DNS answer matches expectations (attempt %s)',
+                           attempt)
+                    return
+
+                log_msg = wait_template % (ldap_rrset, dns_answer.response,
+                                           attempt, period)
+
+            except (dns.resolver.NXDOMAIN,
+                    dns.resolver.YXDOMAIN,
+                    dns.resolver.NoNameservers,
+                    dns.resolver.Timeout) as e:
+                if attempt >= max_attempts:
+                    raise
+                else:
+                    log_msg = wait_template % (ldap_rrset, type(e), attempt,
+                                               period)
+
+            log_fn(log_msg)
+            time.sleep(period)
+
+        # Maximum number of attempts was reached
+        else:
+            raise errors.DNSDataMismatch(expected=ldap_rrset, got=dns_rrset)
+
+    def wait_for_modified_attrs(self, entry_attrs, dns_name, dns_domain):
+        '''Wait until DNS resolver returns up-to-date answer for given entry
+            or until the maximum number of attempts is reached.
+
+        :param entry_attrs:
+            None if the entry was deleted from LDAP or
+            LDAPEntry instance containing at least all modified attributes.
+        :param dns_name: FQDN
+        :type dns_name: dns.name.Name
+        :raises errors.DNSDataMismatch: if data in DNS and LDAP doesn't match
+        '''
+
+        # represent data in LDAP as dictionary rdtype => rrset
+        ldap_rrsets = self._entry2rrsets(entry_attrs, dns_name, dns_domain)
+        nxdomain = ldap_rrsets is None
+        if nxdomain:
+            # name should not exist => ask for A record and check result
+            ldap_rrsets = {dns.rdatatype.from_text('A'): None}
+
+        for rdtype, ldap_rrset in ldap_rrsets.iteritems():
+            try:
+                self.wait_for_modified_attr(ldap_rrset, rdtype, dns_name)
+
+            except dns.resolver.NXDOMAIN as e:
+                if nxdomain:
+                    continue
+                else:
+                    e = errors.DNSDataMismatch(expected=ldap_rrset,
+                                               got="NXDOMAIN")
+                    self.log.error(e)
+                    raise e
+
+            except dns.resolver.NoNameservers as e:
+                # Do not raise exception if we have got SERVFAILs.
+                # Maybe the user has created an invalid zone intentionally.
+                self.log.warn('waiting for DNS answer {%s}: got {%s}; '
+                              'ignoring', ldap_rrset, type(e))
+                continue
+
+            except dns.exception.DNSException as e:
+                err_desc = str(type(e))
+                err_str = str(e)
+                if err_str:
+                    err_desc += ": %s" % err_str
+                e = errors.DNSDataMismatch(expected=ldap_rrset, got=err_desc)
+                self.log.error(e)
+                raise e
+
+    def wait_for_modified_entries(self, entries):
+        '''Call wait_for_modified_attrs for all entries in given dict.
+
+        :param entries:
+            Dict {(dns_domain, dns_name): entry_for_wait_for_modified_attrs}
+        '''
+        for entry_name, entry in entries.iteritems():
+            dns_domain = dns.name.from_text(entry_name[0])
+            dns_name = dns.name.from_text(entry_name[1], origin=dns_domain)
+            self.wait_for_modified_attrs(entry, dns_name, dns_domain)
+
 api.register(dnsrecord)
 
 
@@ -2559,6 +2738,10 @@ class dnsrecord_add(LDAPCreate):
                 entry_attrs[attr] = list(set(old_entry.get(attr, []) + vals))
 
         self.obj.check_record_type_collisions(keys, old_entry, entry_attrs)
+        context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods',
+                                               {})
+        context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy()
+
         return dn
 
     def exc_callback(self, keys, options, exc, call_func, *call_args, **call_kwargs):
@@ -2586,6 +2769,8 @@ class dnsrecord_add(LDAPCreate):
 
         self.obj.postprocess_record(entry_attrs, **options)
 
+        if self.api.env['wait_for_dns']:
+            self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods)
         return dn
 
 api.register(dnsrecord_add)
@@ -2661,6 +2846,10 @@ class dnsrecord_mod(LDAPUpdate):
                 entry_attrs[attr] = list(set(old_entry[attr] + new_dnsvalue))
 
         self.obj.check_record_type_collisions(keys, old_entry, entry_attrs)
+
+        context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods',
+                                               {})
+        context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy()
         return dn
 
     def execute(self, *keys, **options):
@@ -2682,7 +2871,14 @@ class dnsrecord_mod(LDAPUpdate):
                     break
 
             if del_all:
-                return self.obj.methods.delentry(*keys, version=options['version'])
+                result = self.obj.methods.delentry(*keys,
+                                                   version=options['version'])
+                # indicate that entry was deleted
+                context.dnsrecord_entry_mods[(keys[0], keys[1])] = None
+
+        if self.api.env['wait_for_dns']:
+            self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods)
+
         return result
 
     def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
@@ -2826,7 +3022,10 @@ class dnsrecord_del(LDAPUpdate):
         # set del_all flag in context
         # when the flag is enabled, the entire DNS record object is deleted
         # in a post callback
-        setattr(context, 'del_all', del_all)
+        context.del_all = del_all
+        context.dnsrecord_entry_mods = getattr(context, 'dnsrecord_entry_mods',
+                                               {})
+        context.dnsrecord_entry_mods[(keys[0], keys[1])] = entry_attrs.copy()
 
         return dn
 
@@ -2838,13 +3037,23 @@ class dnsrecord_del(LDAPUpdate):
                         error=_('Zone record \'%s\' cannot be deleted') \
                                 % _dns_zone_record
                       )
-            return self.obj.methods.delentry(*keys, version=options['version'])
+            result = self.obj.methods.delentry(*keys,
+                                               version=options['version'])
+            if self.api.env['wait_for_dns']:
+                entries = {(keys[0], keys[1]): None}
+                self.obj.wait_for_modified_entries(entries)
+            return result
 
         result = super(dnsrecord_del, self).execute(*keys, **options)
 
         if getattr(context, 'del_all', False) and not \
                 self.obj.is_pkey_zone_record(*keys):
-            return self.obj.methods.delentry(*keys, version=options['version'])
+            result = self.obj.methods.delentry(*keys,
+                                               version=options['version'])
+            context.dnsrecord_entry_mods[(keys[0], keys[1])] = None
+
+        if self.api.env['wait_for_dns']:
+            self.obj.wait_for_modified_entries(context.dnsrecord_entry_mods)
         return result
 
     def post_callback(self, ldap, dn, entry_attrs, *keys, **options):
-- 
1.9.0

_______________________________________________
Freeipa-devel mailing list
Freeipa-devel@redhat.com
https://www.redhat.com/mailman/listinfo/freeipa-devel

Reply via email to