Browse Source

[master] Merge branch 'trac2158'

Conflicts:
	ChangeLog
Naoki Kambe 12 years ago
parent
commit
e68c127fed

+ 7 - 0
ChangeLog

@@ -1,3 +1,10 @@
+475.	[func]		naokikambe
+	Added Xfrout statistics counters: notifyoutv4, notifyoutv6, xfrrej, and
+	xfrreqdone. These are per-zone type counters. The value of these
+	counters can be seen with zone name by invoking "Stats show Xfrout" via
+	bindctl.
+	(Trac #2158, git TBD)
+
 474.	[func]      stephen
 	DHCP servers now use the BIND 10 logging system for messages.
 	(Trac #1545, git de69a92613b36bd3944cb061e1b7c611c3c85506)

+ 48 - 0
src/bin/xfrout/b10-xfrout.xml

@@ -153,6 +153,54 @@
 
   </refsect1>
 
+  <refsect1>
+    <title>STATISTICS DATA</title>
+
+    <para>
+      The statistics data collected by the <command>b10-xfrout</command>
+      daemon for <quote>Xfrout</quote> include:
+    </para>
+
+    <variablelist>
+
+      <varlistentry>
+        <term>notifyoutv4</term>
+        <listitem><simpara>
+	 Number of IPv4 notifies per zone name sent out from Xfrout
+	</simpara></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term>notifyoutv6</term>
+        <listitem><simpara>
+	 Number of IPv6 notifies per zone name sent out from Xfrout
+	</simpara></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term>xfrrej</term>
+        <listitem><simpara>
+         Number of XFR requests per zone name rejected by Xfrout
+        </simpara></listitem>
+      </varlistentry>
+
+      <varlistentry>
+        <term>xfrreqdone</term>
+        <listitem><simpara>
+	 Number of requested zone transfers per zone name completed
+        </simpara></listitem>
+      </varlistentry>
+
+    </variablelist>
+
+    <para>
+      In per-zone counters the special zone name '_SERVER_' exists. It doesn't
+      mean a specific zone. It represents an entire server and its value means
+      a total count of all zones.
+    </para>
+
+  </refsect1>
+
 <!--
   <refsect1>
     <title>OPTIONS</title>

+ 146 - 1
src/bin/xfrout/tests/xfrout_test.py.in

@@ -277,13 +277,23 @@ class TestXfroutSessionBase(unittest.TestCase):
                                        # When not testing ACLs, simply accept
                                        isc.acl.dns.REQUEST_LOADER.load(
                                            [{"action": "ACCEPT"}]),
-                                       {})
+                                       {},
+                                       counter_xfrrej=self._counter_xfrrej,
+                                       counter_xfrreqdone=self._counter_xfrreqdone)
         self.set_request_type(RRType.AXFR()) # test AXFR by default
         self.mdata = self.create_request_data()
         self.soa_rrset = create_soa(SOA_CURRENT_VERSION)
         # some test replaces a module-wide function.  We should ensure the
         # original is used elsewhere.
         self.orig_get_rrset_len = xfrout.get_rrset_len
+        self._zone_name_xfrrej = None
+        self._zone_name_xfrreqdone = None
+
+    def _counter_xfrrej(self, zone_name):
+        self._zone_name_xfrrej = zone_name
+
+    def _counter_xfrreqdone(self, zone_name):
+        self._zone_name_xfrreqdone = zone_name
 
     def tearDown(self):
         xfrout.get_rrset_len = self.orig_get_rrset_len
@@ -458,7 +468,28 @@ class TestXfroutSession(TestXfroutSessionBase):
         # ACL checks only with the default ACL
         def acl_setter(acl):
             self.xfrsess._acl = acl
+        self.assertIsNone(self._zone_name_xfrrej)
+        self.check_transfer_acl(acl_setter)
+        self.assertEqual(self._zone_name_xfrrej, TEST_ZONE_NAME_STR)
+
+    def test_transfer_acl_with_nonetype_xfrrej(self):
+        # ACL checks only with the default ACL and NoneType xfrrej
+        # counter
+        def acl_setter(acl):
+            self.xfrsess._acl = acl
+        self.xfrsess._counter_xfrrej = None
+        self.assertIsNone(self._zone_name_xfrrej)
         self.check_transfer_acl(acl_setter)
+        self.assertIsNone(self._zone_name_xfrrej)
+
+    def test_transfer_acl_with_notcallable_xfrrej(self):
+        # ACL checks only with the default ACL and not callable xfrrej
+        # counter
+        def acl_setter(acl):
+            self.xfrsess._acl = acl
+        self.xfrsess._counter_xfrrej = 'NOT CALLABLE'
+        self.assertRaises(TypeError,
+                          self.check_transfer_acl, acl_setter)
 
     def test_transfer_zoneacl(self):
         # ACL check with a per zone ACL + default ACL.  The per zone ACL
@@ -469,7 +500,9 @@ class TestXfroutSession(TestXfroutSessionBase):
             self.xfrsess._zone_config[zone_key]['transfer_acl'] = acl
             self.xfrsess._acl = isc.acl.dns.REQUEST_LOADER.load([
                     {"from": "127.0.0.1", "action": "DROP"}])
+        self.assertIsNone(self._zone_name_xfrrej)
         self.check_transfer_acl(acl_setter)
+        self.assertEqual(self._zone_name_xfrrej, TEST_ZONE_NAME_STR)
 
     def test_transfer_zoneacl_nomatch(self):
         # similar to the previous one, but the per zone doesn't match the
@@ -481,7 +514,9 @@ class TestXfroutSession(TestXfroutSessionBase):
                 isc.acl.dns.REQUEST_LOADER.load([
                     {"from": "127.0.0.1", "action": "DROP"}])
             self.xfrsess._acl = acl
+        self.assertIsNone(self._zone_name_xfrrej)
         self.check_transfer_acl(acl_setter)
+        self.assertEqual(self._zone_name_xfrrej, TEST_ZONE_NAME_STR)
 
     def test_get_transfer_acl(self):
         # set the default ACL.  If there's no specific zone ACL, this one
@@ -831,9 +866,39 @@ class TestXfroutSession(TestXfroutSessionBase):
         def myreply(msg, sock):
             self.sock.send(b"success")
 
+        self.assertIsNone(self._zone_name_xfrreqdone)
         self.xfrsess._reply_xfrout_query = myreply
         self.xfrsess.dns_xfrout_start(self.sock, self.mdata)
         self.assertEqual(self.sock.readsent(), b"success")
+        self.assertEqual(self._zone_name_xfrreqdone, TEST_ZONE_NAME_STR)
+
+    def test_dns_xfrout_start_with_nonetype_xfrreqdone(self):
+        def noerror(msg, name, rrclass):
+            return Rcode.NOERROR()
+        self.xfrsess._xfrout_setup = noerror
+
+        def myreply(msg, sock):
+            self.sock.send(b"success")
+
+        self.assertIsNone(self._zone_name_xfrreqdone)
+        self.xfrsess._reply_xfrout_query = myreply
+        self.xfrsess._counter_xfrreqdone = None
+        self.xfrsess.dns_xfrout_start(self.sock, self.mdata)
+        self.assertIsNone(self._zone_name_xfrreqdone)
+
+    def test_dns_xfrout_start_with_notcallable_xfrreqdone(self):
+        def noerror(msg, name, rrclass):
+            return Rcode.NOERROR()
+        self.xfrsess._xfrout_setup = noerror
+
+        def myreply(msg, sock):
+            self.sock.send(b"success")
+
+        self.xfrsess._reply_xfrout_query = myreply
+        self.xfrsess._counter_xfrreqdone = 'NOT CALLABLE'
+        self.assertRaises(TypeError,
+                          self.xfrsess.dns_xfrout_start, self.sock,
+                          self.mdata)
 
     def test_reply_xfrout_query_axfr(self):
         self.xfrsess._soa = self.soa_rrset
@@ -1153,6 +1218,7 @@ class MyUnixSockServer(UnixSockServer):
         self._common_init()
         self._cc = MyCCSession()
         self.update_config_data(self._cc.get_full_config())
+        self._counters = {}
 
 class TestUnixSockServer(unittest.TestCase):
     def setUp(self):
@@ -1504,6 +1570,80 @@ class MyXfroutServer(XfroutServer):
         self._unix_socket_server = None
         # Disable the wait for threads
         self._wait_for_threads = lambda : None
+        self._cc.get_module_spec = lambda:\
+            isc.config.module_spec_from_file(xfrout.SPECFILE_LOCATION)
+        # setup an XfroutCount object
+        self._counter = XfroutCounter(
+            self._cc.get_module_spec().get_statistics_spec())
+
+class TestXfroutCounter(unittest.TestCase):
+    def setUp(self):
+        statistics_spec = \
+            isc.config.module_spec_from_file(\
+            xfrout.SPECFILE_LOCATION).get_statistics_spec()
+        self.xfrout_counter = XfroutCounter(statistics_spec)
+        self._counters = isc.config.spec_name_list(\
+            isc.config.find_spec_part(\
+                statistics_spec, XfroutCounter.perzone_prefix)\
+                ['named_set_item_spec']['map_item_spec'])
+        self._started = threading.Event()
+        self._number = 3 # number of the threads
+        self._cycle = 10000 # number of counting per thread
+
+    def test_get_default_statistics_data(self):
+        self.assertEqual(self.xfrout_counter._get_default_statistics_data(),
+                         {XfroutCounter.perzone_prefix: {
+                            XfroutCounter.entire_server: \
+                              dict([(cnt, 0) for cnt in self._counters])
+                         }})
+
+    def setup_incrementer(self, incrementer):
+        self._started.wait()
+        for i in range(self._cycle): incrementer(TEST_ZONE_NAME_STR)
+
+    def start_incrementer(self, incrementer):
+        threads = []
+        for i in range(self._number):
+            threads.append(threading.Thread(\
+                    target=self.setup_incrementer,\
+                        args=(incrementer,)\
+                        ))
+        for th in threads: th.start()
+        self._started.set()
+        for th in threads: th.join()
+
+    def get_count(self, zone_name, counter_name):
+        return isc.cc.data.find(\
+            self.xfrout_counter.get_statistics(),\
+                '%s/%s/%s' % (XfroutCounter.perzone_prefix,\
+                                  zone_name, counter_name))
+
+    def test_incrementers(self):
+        result = { XfroutCounter.entire_server: {},
+                   TEST_ZONE_NAME_STR: {} }
+        for counter_name in self._counters:
+                incrementer = getattr(self.xfrout_counter, 'inc_%s' % counter_name)
+                self.start_incrementer(incrementer)
+                self.assertEqual(self.get_count(\
+                            TEST_ZONE_NAME_STR, counter_name), \
+                                     self._number * self._cycle)
+                self.assertEqual(self.get_count(\
+                        XfroutCounter.entire_server, counter_name), \
+                                     self._number * self._cycle)
+                result[XfroutCounter.entire_server][counter_name] = \
+                    result[TEST_ZONE_NAME_STR][counter_name] = \
+                    self._number * self._cycle
+        self.assertEqual(
+            self.xfrout_counter.get_statistics(),
+            {XfroutCounter.perzone_prefix: result})
+
+    def test_add_perzone_counter(self):
+        for counter_name in self._counters:
+            self.assertRaises(isc.cc.data.DataNotFoundError,\
+                                  self.get_count, TEST_ZONE_NAME_STR, counter_name)
+        self.xfrout_counter._add_perzone_counter(TEST_ZONE_NAME_STR)
+        for counter_name in self._counters:
+            self.assertEqual(self.get_count(TEST_ZONE_NAME_STR, counter_name), 0)
 
 class TestXfroutServer(unittest.TestCase):
     def setUp(self):
@@ -1514,6 +1654,11 @@ class TestXfroutServer(unittest.TestCase):
         self.assertTrue(self.xfrout_server._notifier.shutdown_called)
         self.assertTrue(self.xfrout_server._cc.stopped)
 
+    def test_getstats(self):
+        self.assertEqual(
+            self.xfrout_server.command_handler('getstats', None), \
+                create_answer(0,  {}))
+
 if __name__== "__main__":
     isc.log.resetUnitTestRootLogger()
     unittest.main()

+ 143 - 9
src/bin/xfrout/xfrout.py.in

@@ -153,7 +153,8 @@ def get_soa_serial(soa_rdata):
 
 class XfroutSession():
     def __init__(self, sock_fd, request_data, server, tsig_key_ring, remote,
-                 default_acl, zone_config, client_class=DataSourceClient):
+                 default_acl, zone_config, client_class=DataSourceClient,
+                 counter_xfrrej=None, counter_xfrreqdone=None):
         self._sock_fd = sock_fd
         self._request_data = request_data
         self._server = server
@@ -168,6 +169,10 @@ class XfroutSession():
         self.ClientClass = client_class # parameterize this for testing
         self._soa = None # will be set in _xfrout_setup or in tests
         self._jnl_reader = None # will be set to a reader for IXFR
+        # Set counter handlers for counting Xfr requests. An argument
+        # is required for zone name.
+        self._counter_xfrrej = counter_xfrrej
+        self._counter_xfrreqdone = counter_xfrreqdone
         self._handle()
 
     def create_tsig_ctx(self, tsig_record, tsig_key_ring):
@@ -270,6 +275,9 @@ class XfroutSession():
                          format_zone_str(zone_name, zone_class))
             return None, None
         elif acl_result == REJECT:
+            if self._counter_xfrrej is not None:
+                # count rejected Xfr request by each zone name
+                self._counter_xfrrej(zone_name.to_text())
             logger.debug(DBG_XFROUT_TRACE, XFROUT_QUERY_REJECTED,
                          self._request_type, format_addrinfo(self._remote),
                          format_zone_str(zone_name, zone_class))
@@ -525,6 +533,9 @@ class XfroutSession():
         except Exception as err:
             logger.error(XFROUT_XFR_TRANSFER_ERROR, self._request_typestr,
                     format_addrinfo(self._remote), zone_str, err)
+        if self._counter_xfrreqdone is not None:
+            # count done Xfr requests by each zone name
+            self._counter_xfrreqdone(zone_name.to_text())
         logger.info(XFROUT_XFR_TRANSFER_DONE, self._request_typestr,
                     format_addrinfo(self._remote), zone_str)
 
@@ -634,7 +645,7 @@ class UnixSockServer(socketserver_mixin.NoPollMixIn,
     '''The unix domain socket server which accept xfr query sent from auth server.'''
 
     def __init__(self, sock_file, handle_class, shutdown_event, config_data,
-                 cc):
+                 cc, **counters):
         self._remove_unused_sock_file(sock_file)
         self._sock_file = sock_file
         socketserver_mixin.NoPollMixIn.__init__(self)
@@ -644,6 +655,8 @@ class UnixSockServer(socketserver_mixin.NoPollMixIn,
         self._common_init()
         self._cc = cc
         self.update_config_data(config_data)
+        # handlers for statistics use
+        self._counters = counters
 
     def _common_init(self):
         '''Initialization shared with the mock server class used for tests'''
@@ -798,7 +811,8 @@ class UnixSockServer(socketserver_mixin.NoPollMixIn,
         self._lock.release()
         self.RequestHandlerClass(sock_fd, request_data, self,
                                  isc.server_common.tsig_keyring.get_keyring(),
-                                 self._guess_remote(sock_fd), acl, zone_config)
+                                 self._guess_remote(sock_fd), acl, zone_config,
+                                 **self._counters)
 
     def _remove_unused_sock_file(self, sock_file):
         '''Try to remove the socket file. If the file is being used
@@ -926,6 +940,107 @@ class UnixSockServer(socketserver_mixin.NoPollMixIn,
         self._transfers_counter -= 1
         self._lock.release()
 
+class XfroutCounter:
+    """A class for handling all statistics counters of Xfrout.  In
+    this class, the structure of per-zone counters is assumed to be
+    like this:
+        zones/example.com./notifyoutv4
+        zones/example.com./notifyoutv6
+        zones/example.com./xfrrej
+        zones/example.com./xfrreqdone
+    """
+    # '_SERVER_' is a special zone name representing an entire
+    # count. It doesn't mean a specific zone, but it means an
+    # entire count in the server.
+    entire_server = '_SERVER_'
+    # zone names are contained under this dirname in the spec file.
+    perzone_prefix = 'zones'
+    def __init__(self, statistics_spec):
+        self._statistics_spec = statistics_spec
+        # holding statistics data for Xfrout module
+        self._statistics_data = {}
+        self._lock = threading.RLock()
+        self._create_perzone_incrementers()
+
+    def get_statistics(self):
+        """Calculates an entire server counts, and returns statistics
+        data format to send out the stats module including each
+        counter. If there is no counts, then it returns an empty
+        dictionary. Locks the thread because it is considered to be
+        invoked by a multi-threading caller."""
+        # If self._statistics_data contains nothing of zone name, it
+        # returns an empty dict.
+        if len(self._statistics_data) == 0: return {}
+        zones = {}
+        with self._lock:
+            zones = self._statistics_data[self.perzone_prefix].copy()
+        # Start calculation for '_SERVER_' counts
+        attrs = self._get_default_statistics_data()[self.perzone_prefix][self.entire_server]
+        statistics_data = {self.perzone_prefix: {}}
+        for attr in attrs:
+            sum_ = 0
+            for name in zones:
+                if name == self.entire_server: continue
+                if attr in zones[name]:
+                    if  name not in statistics_data[self.perzone_prefix]:
+                        statistics_data[self.perzone_prefix][name] = {}
+                    statistics_data[self.perzone_prefix][name].update(
+                        {attr: zones[name][attr]}
+                        )
+                    sum_ += zones[name][attr]
+            if  sum_ > 0:
+                if self.entire_server not in statistics_data[self.perzone_prefix]:
+                    statistics_data[self.perzone_prefix][self.entire_server] = {}
+                statistics_data[self.perzone_prefix][self.entire_server].update({attr: sum_})
+        return statistics_data
+
+    def _get_default_statistics_data(self):
+        """Returns default statistics data from the spec file"""
+        statistics_data = {}
+        for id_ in isc.config.spec_name_list(self._statistics_spec):
+            spec = isc.config.find_spec_part(self._statistics_spec, id_)
+            statistics_data.update({id_: spec['item_default']})
+        return statistics_data
+
+    def _create_perzone_incrementers(self):
+        """Creates increment method of each per-zone counter based on
+        the spec file. Incrementer can be accessed by name
+        "inc_${item_name}".Incrementers are passed to the
+        XfroutSession and NotifyOut class as counter handlers."""
+        # add a new element under the named_set item for the zone
+        zones_spec = isc.config.find_spec_part(
+            self._statistics_spec, self.perzone_prefix)
+        item_list =  isc.config.spec_name_list(\
+            zones_spec['named_set_item_spec']['map_item_spec'])
+        # can be accessed by the name 'inc_xxx'
+        for item in item_list:
+            def __perzone_incrementer(zone_name, counter_name=item, step=1):
+                """A per-zone incrementer for counter_name. Locks the thread
+                because it is considered to be invoked by a multi-threading
+                caller."""
+                with self._lock:
+                    self._add_perzone_counter(zone_name)
+                    self._statistics_data[self.perzone_prefix][zone_name][counter_name] += step
+            setattr(self, 'inc_%s' % item, __perzone_incrementer)
+
+
+    def _add_perzone_counter(self, zone):
+        """Adds named_set-type counter for each zone name"""
+        try:
+            self._statistics_data[self.perzone_prefix][zone]
+        except KeyError:
+            # add a new element under the named_set item for the zone
+            map_spec = isc.config.find_spec_part(
+                self._statistics_spec, '%s/%s' % \
+                    (self.perzone_prefix, zone))['map_item_spec']
+            id_list =  isc.config.spec_name_list(map_spec)
+            for id_ in id_list:
+                spec = isc.config.find_spec_part(map_spec, id_)
+                isc.cc.data.set(self._statistics_data,
+                                '%s/%s/%s' % \
+                                    (self.perzone_prefix, zone, id_),
+                                spec['item_default'])
+
 class XfroutServer:
     def __init__(self):
         self._unix_socket_server = None
@@ -933,6 +1048,8 @@ class XfroutServer:
         self._shutdown_event = threading.Event()
         self._cc = isc.config.ModuleCCSession(SPECFILE_LOCATION, self.config_handler, self.command_handler)
         self._config_data = self._cc.get_full_config()
+        self._counter = XfroutCounter(
+            self._cc.get_module_spec().get_statistics_spec())
         self._cc.start()
         self._cc.add_remote_config(AUTH_SPECFILE_LOCATION)
         isc.server_common.tsig_keyring.init_keyring(self._cc)
@@ -941,17 +1058,25 @@ class XfroutServer:
 
     def _start_xfr_query_listener(self):
         '''Start a new thread to accept xfr query. '''
-        self._unix_socket_server = UnixSockServer(self._listen_sock_file,
-                                                  XfroutSession,
-                                                  self._shutdown_event,
-                                                  self._config_data,
-                                                  self._cc)
+        self._unix_socket_server = UnixSockServer(
+            self._listen_sock_file,
+            XfroutSession,
+            self._shutdown_event,
+            self._config_data,
+            self._cc,
+            counter_xfrrej=self._counter.inc_xfrrej,
+            counter_xfrreqdone=self._counter.inc_xfrreqdone
+            )
         listener = threading.Thread(target=self._unix_socket_server.serve_forever)
         listener.start()
 
     def _start_notifier(self):
         datasrc = self._unix_socket_server.get_db_file()
-        self._notifier = notify_out.NotifyOut(datasrc)
+        self._notifier = notify_out.NotifyOut(
+            datasrc,
+            counter_notifyoutv4=self._counter.inc_notifyoutv4,
+            counter_notifyoutv6=self._counter.inc_notifyoutv6
+            )
         if 'also_notify' in self._config_data:
             for slave in self._config_data['also_notify']:
                 self._notifier.add_slave(slave['address'], slave['port'])
@@ -1027,6 +1152,15 @@ class XfroutServer:
             else:
                 answer = create_answer(1, "Bad command parameter:" + str(args))
 
+        # return statistics data to the stats daemon
+        elif cmd == "getstats":
+            # The log level is here set to debug in order to avoid
+            # that a log becomes too verbose. Because the b10-stats
+            # daemon is periodically asking to the b10-xfrout daemon.
+            logger.debug(DBG_XFROUT_TRACE, \
+                             XFROUT_RECEIVED_GETSTATS_COMMAND)
+            answer = create_answer(0, self._counter.get_statistics())
+
         else:
             answer = create_answer(1, "Unknown command:" + str(cmd))
 

+ 59 - 0
src/bin/xfrout/xfrout.spec.pre.in

@@ -114,6 +114,65 @@
             "item_default": "IN"
           } ]
         }
+      ],
+      "statistics": [
+        {
+          "item_name": "zones",
+          "item_type": "named_set",
+          "item_optional": false,
+          "item_default": {
+            "_SERVER_" : {
+              "notifyoutv4" : 0,
+              "notifyoutv6" : 0,
+              "xfrrej" : 0,
+              "xfrreqdone" : 0
+            }
+          },
+          "item_title": "Zone names",
+          "item_description": "Zone names for Xfrout statistics",
+          "named_set_item_spec": {
+            "item_name": "zonename",
+            "item_type": "map",
+            "item_optional": false,
+            "item_default": {},
+            "item_title": "Zone name",
+            "item_description": "Zone name for Xfrout statistics",
+            "map_item_spec": [
+              {
+                "item_name": "notifyoutv4",
+                "item_type": "integer",
+                "item_optional": false,
+                "item_default": 0,
+                "item_title": "IPv4 notifies",
+                "item_description": "Number of IPv4 notifies per zone name sent out from Xfrout"
+              },
+              {
+                "item_name": "notifyoutv6",
+                "item_type": "integer",
+                "item_optional": false,
+                "item_default": 0,
+                "item_title": "IPv6 notifies",
+                "item_description": "Number of IPv6 notifies per zone name sent out from Xfrout"
+              },
+              {
+                "item_name": "xfrrej",
+                "item_type": "integer",
+                "item_optional": false,
+                "item_default": 0,
+                "item_title": "XFR rejected requests",
+                "item_description": "Number of XFR requests per zone name rejected by Xfrout"
+              },
+              {
+                "item_name": "xfrreqdone",
+                "item_type": "integer",
+                "item_optional": false,
+                "item_default": 0,
+                "item_title": "Requested zone transfers",
+                "item_description": "Number of requested zone transfers completed per zone name"
+              }
+            ]
+          }
+        }
       ]
   }
 }

+ 4 - 0
src/bin/xfrout/xfrout_messages.mes

@@ -107,6 +107,10 @@ received from the configuration manager.
 The xfrout daemon received a command on the command channel that
 NOTIFY packets should be sent for the given zone.
 
+% XFROUT_RECEIVED_GETSTATS_COMMAND received command to get statistics data
+The xfrout daemon received a command on the command channel that
+statistics data should be sent to the stats daemon.
+
 % XFROUT_PARSE_QUERY_ERROR error parsing query: %1
 There was a parse error while reading an incoming query. The parse
 error is shown in the log message. A remote client sent a packet we

+ 16 - 2
src/lib/python/isc/notify/notify_out.py

@@ -125,9 +125,10 @@ class ZoneNotifyInfo:
 class NotifyOut:
     '''This class is used to handle notify logic for all zones(sending
     notify message to its slaves). notify service can be started by
-    calling  dispatcher(), and it can be stoped by calling shutdown()
+    calling  dispatcher(), and it can be stopped by calling shutdown()
     in another thread. '''
-    def __init__(self, datasrc_file, verbose=True):
+    def __init__(self, datasrc_file, counter_handler=None, verbose=True,
+                 counter_notifyoutv4=None, counter_notifyoutv6=None):
         self._notify_infos = {} # key is (zone_name, zone_class)
         self._waiting_zones = []
         self._notifying_zones = []
@@ -142,6 +143,10 @@ class NotifyOut:
         # Use nonblock event to eliminate busy loop
         # If there are no notifying zones, clear the event bit and wait.
         self._nonblock_event = threading.Event()
+        # Set counter handlers for counting notifies. An argument is
+        # required for zone name.
+        self._counter_notifyoutv4 = counter_notifyoutv4
+        self._counter_notifyoutv6 = counter_notifyoutv6
 
     def _init_notify_out(self, datasrc_file):
         '''Get all the zones name and its notify target's address.
@@ -478,6 +483,15 @@ class NotifyOut:
         try:
             sock = zone_notify_info.create_socket(addrinfo[0])
             sock.sendto(render.get_data(), 0, addrinfo)
+            # count notifying by IPv4 or IPv6 for statistics
+            if zone_notify_info.get_socket().family \
+                    == socket.AF_INET \
+                    and self._counter_notifyoutv4 is not None:
+                self._counter_notifyoutv4(zone_notify_info.zone_name)
+            elif zone_notify_info.get_socket().family \
+                    == socket.AF_INET6 \
+                    and self._counter_notifyoutv6 is not None:
+                self._counter_notifyoutv6(zone_notify_info.zone_name)
             logger.info(NOTIFY_OUT_SENDING_NOTIFY, addrinfo[0],
                         addrinfo[1])
         except (socket.error, addr.InvalidAddress) as err:

+ 56 - 1
src/lib/python/isc/notify/tests/notify_out_test.py

@@ -61,6 +61,7 @@ class MockZoneNotifyInfo(notify_out.ZoneNotifyInfo):
         self.sock_family = self._sock.family
         self._sock.close()
         self._sock = MockSocket()
+        self._sock.family = self.sock_family
         return self._sock
 
 class TestZoneNotifyInfo(unittest.TestCase):
@@ -95,7 +96,13 @@ class TestZoneNotifyInfo(unittest.TestCase):
 class TestNotifyOut(unittest.TestCase):
     def setUp(self):
         self._db_file = TESTDATA_SRCDIR + '/test.sqlite3'
-        self._notify = notify_out.NotifyOut(self._db_file)
+        self._notifiedv4_zone_name = None
+        def _dummy_counter_notifyoutv4(z): self._notifiedv4_zone_name = z
+        self._notifiedv6_zone_name = None
+        def _dummy_counter_notifyoutv6(z): self._notifiedv6_zone_name = z
+        self._notify = notify_out.NotifyOut(self._db_file,
+                                            counter_notifyoutv4=_dummy_counter_notifyoutv4,
+                                            counter_notifyoutv6=_dummy_counter_notifyoutv6)
         self._notify._notify_infos[('example.com.', 'IN')] = MockZoneNotifyInfo('example.com.', 'IN')
         self._notify._notify_infos[('example.com.', 'CH')] = MockZoneNotifyInfo('example.com.', 'CH')
         self._notify._notify_infos[('example.net.', 'IN')] = MockZoneNotifyInfo('example.net.', 'IN')
@@ -262,17 +269,61 @@ class TestNotifyOut(unittest.TestCase):
     def test_send_notify_message_udp_ipv4(self):
         example_com_info = self._notify._notify_infos[('example.net.', 'IN')]
         example_com_info.prepare_notify_out()
+        self.assertIsNone(self._notifiedv4_zone_name)
+        self.assertIsNone(self._notifiedv6_zone_name)
         ret = self._notify._send_notify_message_udp(example_com_info,
                                                     ('192.0.2.1', 53))
         self.assertTrue(ret)
         self.assertEqual(socket.AF_INET, example_com_info.sock_family)
+        self.assertEqual(self._notifiedv4_zone_name, 'example.net.')
+        self.assertIsNone(self._notifiedv6_zone_name)
 
     def test_send_notify_message_udp_ipv6(self):
         example_com_info = self._notify._notify_infos[('example.net.', 'IN')]
+        self.assertIsNone(self._notifiedv4_zone_name)
+        self.assertIsNone(self._notifiedv6_zone_name)
         ret = self._notify._send_notify_message_udp(example_com_info,
                                                     ('2001:db8::53', 53))
         self.assertTrue(ret)
         self.assertEqual(socket.AF_INET6, example_com_info.sock_family)
+        self.assertIsNone(self._notifiedv4_zone_name)
+        self.assertEqual(self._notifiedv6_zone_name, 'example.net.')
+
+    def test_send_notify_message_udp_ipv4_with_nonetype_notifyoutv4(self):
+        example_com_info = self._notify._notify_infos[('example.net.', 'IN')]
+        example_com_info.prepare_notify_out()
+        self.assertIsNone(self._notifiedv4_zone_name)
+        self.assertIsNone(self._notifiedv6_zone_name)
+        self._notify._counter_notifyoutv4 = None
+        self._notify._send_notify_message_udp(example_com_info,
+                                              ('192.0.2.1', 53))
+        self.assertIsNone(self._notifiedv4_zone_name)
+        self.assertIsNone(self._notifiedv6_zone_name)
+
+    def test_send_notify_message_udp_ipv4_with_notcallable_notifyoutv4(self):
+        example_com_info = self._notify._notify_infos[('example.net.', 'IN')]
+        example_com_info.prepare_notify_out()
+        self._notify._counter_notifyoutv4 = 'NOT CALLABLE'
+        self.assertRaises(TypeError,
+                          self._notify._send_notify_message_udp,
+                          example_com_info, ('192.0.2.1', 53))
+
+    def test_send_notify_message_udp_ipv6_with_nonetype_notifyoutv6(self):
+        example_com_info = self._notify._notify_infos[('example.net.', 'IN')]
+        self.assertIsNone(self._notifiedv4_zone_name)
+        self.assertIsNone(self._notifiedv6_zone_name)
+        self._notify._counter_notifyoutv6 = None
+        self._notify._send_notify_message_udp(example_com_info,
+                                              ('2001:db8::53', 53))
+        self.assertIsNone(self._notifiedv4_zone_name)
+        self.assertIsNone(self._notifiedv6_zone_name)
+
+    def test_send_notify_message_udp_ipv6_with_notcallable_notifyoutv6(self):
+        example_com_info = self._notify._notify_infos[('example.net.', 'IN')]
+        self._notify._counter_notifyoutv6 = 'NOT CALLABLE'
+        self.assertRaises(TypeError,
+                          self._notify._send_notify_message_udp,
+                          example_com_info, ('2001:db8::53', 53))
 
     def test_send_notify_message_with_bogus_address(self):
         example_com_info = self._notify._notify_infos[('example.net.', 'IN')]
@@ -281,9 +332,13 @@ class TestNotifyOut(unittest.TestCase):
         # happen, but right now it's not actually the case.  Even if the
         # data source does its job, it's prudent to confirm the behavior for
         # an unexpected case.
+        self.assertIsNone(self._notifiedv4_zone_name)
+        self.assertIsNone(self._notifiedv6_zone_name)
         ret = self._notify._send_notify_message_udp(example_com_info,
                                                     ('invalid', 53))
         self.assertFalse(ret)
+        self.assertIsNone(self._notifiedv4_zone_name)
+        self.assertIsNone(self._notifiedv6_zone_name)
 
     def test_zone_notify_handler(self):
         old_send_msg = self._notify._send_notify_message_udp

+ 1 - 0
tests/lettuce/configurations/xfrin/.gitignore

@@ -0,0 +1 @@
+/retransfer_master.conf

+ 1 - 0
tests/lettuce/configurations/xfrin/retransfer_master.conf

@@ -38,6 +38,7 @@
             "b10-auth": { "kind": "needed", "special": "auth" },
             "b10-xfrout": { "address": "Xfrout", "kind": "dispensable" },
             "b10-zonemgr": { "address": "Zonemgr", "kind": "dispensable" },
+            "b10-stats": { "address": "Stats", "kind": "dispensable" },
             "b10-cmdctl": { "special": "cmdctl", "kind": "needed" }
         }
     }

+ 61 - 0
tests/lettuce/features/terrain/bind10_control.py

@@ -362,3 +362,64 @@ def configure_ddns_off(step):
         config commit
         \"\"\"
     """)
+
+@step('query statistics(?: (\S+))? of bind10 module (\S+)(?: with cmdctl port (\d+))?')
+def query_statistics(step, statistics, name, cmdctl_port):
+    """
+    query statistics data via bindctl.
+    Parameters:
+    statistics  ('statistics <statistics>', optional) : The queried statistics name.
+    name ('module <name>'): The name of the module (case sensitive!)
+    cmdctl_port ('with cmdctl port <portnr>', optional): cmdctl port to send
+                the command to.
+    """
+    port_str = ' with cmdctl port %s' % cmdctl_port \
+        if cmdctl_port else ''
+    step.given('send bind10%s the command Stats show owner=%s%s'\
+        % (port_str, name,\
+               ' name=%s' % statistics if statistics else ''))
+
+def find_value(dictionary, key):
+    """A helper method. Recursively find a value corresponding to the
+    key of the dictionary and returns it. Returns None if the
+    dictionary is not dict type."""
+    if type(dictionary) is not dict:
+        return
+    if key in dictionary:
+        return dictionary[key]
+    else:
+        for v in dictionary.values():
+            return find_value(v, key)
+
+@step('The counter (\S+)(?: for the zone (\S+))? should be' + \
+          '(?:( greater than| less than))? (\d+)')
+def check_statistics(step, counter, zone, gtlt, number):
+    """
+    check the output of bindctl for statistics of specified counter
+    and zone.
+    Parameters:
+    counter ('counter <counter>'): The counter name of statistics.
+    zone ('zone <zone>', optional): The zone name.
+    gtlt (' greater than'|' less than', optional): greater than
+          <number> or less than <number>.
+    number ('<number>): The expect counter number. <number> is assumed
+          to be an unsigned integer.
+    """
+    output = parse_bindctl_output_as_data_structure()
+    found = None
+    zone_str = ""
+    if zone:
+        found = find_value(find_value(output, zone), counter)
+        zone_str = " for zone %s" % zone
+    else:
+        found = find_value(output, counter)
+    assert found is not None, \
+        'Not found statistics counter %s%s' % (counter, zone_str)
+    msg = "Got %s, expected%s %s as counter %s%s" % \
+        (found, gtlt, number, counter, zone_str)
+    if gtlt and 'greater' in gtlt:
+        assert int(found) > int(number), msg
+    elif gtlt and 'less' in gtlt:
+        assert int(found) < int(number), msg
+    else:
+        assert int(found) == int(number), msg

+ 2 - 0
tests/lettuce/features/terrain/terrain.py

@@ -57,6 +57,8 @@ copylist = [
      "configurations/ddns/ddns.config"],
     ["configurations/ddns/noddns.config.orig",
      "configurations/ddns/noddns.config"],
+    ["configurations/xfrin/retransfer_master.conf.orig",
+     "configurations/xfrin/retransfer_master.conf"],
     ["data/inmem-xfrin.sqlite3.orig",
      "data/inmem-xfrin.sqlite3"],
     ["data/xfrin-notify.sqlite3.orig",

+ 110 - 0
tests/lettuce/features/xfrin_notify_handling.feature

@@ -8,6 +8,7 @@ Feature: Xfrin incoming notify handling
     And wait for master stderr message AUTH_SERVER_STARTED
     And wait for master stderr message XFROUT_STARTED
     And wait for master stderr message ZONEMGR_STARTED
+    And wait for master stderr message STATS_STARTING
 
     And I have bind10 running with configuration xfrin/retransfer_slave_notify.conf
     And wait for bind10 stderr message BIND10_STARTED_CC
@@ -18,6 +19,19 @@ Feature: Xfrin incoming notify handling
 
     A query for www.example.org to [::1]:47806 should have rcode NXDOMAIN
 
+    #
+    # Test for statistics
+    #
+    # check for initial statistics
+    #
+    When I query statistics zones of bind10 module Xfrout with cmdctl port 47804
+    last bindctl output should not contain "error"
+    last bindctl output should not contain "example.org."
+    The counter notifyoutv4 for the zone _SERVER_ should be 0
+    The counter notifyoutv6 for the zone _SERVER_ should be 0
+    The counter xfrrej for the zone _SERVER_ should be 0
+    The counter xfrreqdone for the zone _SERVER_ should be 0
+
     When I send bind10 with cmdctl port 47804 the command Xfrout notify example.org IN
     Then wait for new master stderr message XFROUT_NOTIFY_COMMAND
     Then wait for new bind10 stderr message AUTH_RECEIVED_NOTIFY
@@ -25,5 +39,101 @@ Feature: Xfrin incoming notify handling
     Then wait for new bind10 stderr message XFRIN_XFR_TRANSFER_STARTED
     Then wait for new bind10 stderr message XFRIN_TRANSFER_SUCCESS not XFRIN_XFR_PROCESS_FAILURE
     Then wait for new bind10 stderr message ZONEMGR_RECEIVE_XFRIN_SUCCESS
+    Then wait 5 times for new master stderr message NOTIFY_OUT_SENDING_NOTIFY
+    Then wait for new master stderr message NOTIFY_OUT_RETRY_EXCEEDED
 
     A query for www.example.org to [::1]:47806 should have rcode NOERROR
+
+    #
+    # Test for statistics
+    #
+    # check for statistics change
+    #
+    When I query statistics zones of bind10 module Xfrout with cmdctl port 47804
+    last bindctl output should not contain "error"
+    Then wait for new master stderr message XFROUT_RECEIVED_GETSTATS_COMMAND
+    The counter notifyoutv4 for the zone _SERVER_ should be 0
+    The counter notifyoutv4 for the zone example.org. should be 0
+    The counter notifyoutv6 for the zone _SERVER_ should be 5
+    The counter notifyoutv6 for the zone example.org. should be 5
+    The counter xfrrej for the zone _SERVER_ should be 0
+    The counter xfrrej for the zone example.org. should be 0
+    The counter xfrreqdone for the zone _SERVER_ should be 1
+    The counter xfrreqdone for the zone example.org. should be 1
+
+    #
+    # Test for Xfr request rejected
+    #
+    Scenario: Handle incoming notify (XFR request rejected)
+    Given I have bind10 running with configuration xfrin/retransfer_master.conf with cmdctl port 47804 as master
+    And wait for master stderr message BIND10_STARTED_CC
+    And wait for master stderr message CMDCTL_STARTED
+    And wait for master stderr message AUTH_SERVER_STARTED
+    And wait for master stderr message XFROUT_STARTED
+    And wait for master stderr message ZONEMGR_STARTED
+    And wait for master stderr message STATS_STARTING
+
+    And I have bind10 running with configuration xfrin/retransfer_slave_notify.conf
+    And wait for bind10 stderr message BIND10_STARTED_CC
+    And wait for bind10 stderr message CMDCTL_STARTED
+    And wait for bind10 stderr message AUTH_SERVER_STARTED
+    And wait for bind10 stderr message XFRIN_STARTED
+    And wait for bind10 stderr message ZONEMGR_STARTED
+
+    A query for www.example.org to [::1]:47806 should have rcode NXDOMAIN
+
+    #
+    # Test1 for statistics
+    #
+    # check for initial statistics
+    #
+    When I query statistics zones of bind10 module Xfrout with cmdctl port 47804
+    last bindctl output should not contain "error"
+    last bindctl output should not contain "example.org."
+    The counter notifyoutv4 for the zone _SERVER_ should be 0
+    The counter notifyoutv6 for the zone _SERVER_ should be 0
+    The counter xfrrej for the zone _SERVER_ should be 0
+    The counter xfrreqdone for the zone _SERVER_ should be 0
+
+    #
+    # set transfer_acl rejection
+    # Local xfr requests from Xfrin module would be rejected here.
+    #
+    When I send bind10 the following commands with cmdctl port 47804
+    """
+    config set Xfrout/zone_config[0]/transfer_acl [{"action":  "REJECT", "from": "::1"}]
+    config commit
+    """
+    last bindctl output should not contain Error
+
+    When I send bind10 with cmdctl port 47804 the command Xfrout notify example.org IN
+    Then wait for new master stderr message XFROUT_NOTIFY_COMMAND
+    Then wait for new bind10 stderr message AUTH_RECEIVED_NOTIFY
+    Then wait for new bind10 stderr message ZONEMGR_RECEIVE_NOTIFY
+    Then wait for new bind10 stderr message XFRIN_XFR_TRANSFER_STARTED
+    Then wait for new bind10 stderr message XFRIN_XFR_TRANSFER_PROTOCOL_ERROR not XFRIN_XFR_TRANSFER_STARTED
+    Then wait for new bind10 stderr message ZONEMGR_RECEIVE_XFRIN_FAILED not ZONEMGR_RECEIVE_XFRIN_SUCCESS
+    Then wait 5 times for new master stderr message NOTIFY_OUT_SENDING_NOTIFY
+    Then wait for new master stderr message NOTIFY_OUT_RETRY_EXCEEDED
+
+    A query for www.example.org to [::1]:47806 should have rcode NXDOMAIN
+
+    #
+    # Test2 for statistics
+    #
+    # check for statistics change
+    #
+    When I query statistics zones of bind10 module Xfrout with cmdctl port 47804
+    last bindctl output should not contain "error"
+    The counter notifyoutv4 for the zone _SERVER_ should be 0
+    The counter notifyoutv4 for the zone example.org. should be 0
+    The counter notifyoutv6 for the zone _SERVER_ should be 5
+    The counter notifyoutv6 for the zone example.org. should be 5
+    # The counts of rejection would be between 1 and 2. They are not
+    # fixed. It would depend on timing or the platform.
+    The counter xfrrej for the zone _SERVER_ should be greater than 0
+    The counter xfrrej for the zone _SERVER_ should be less than 3
+    The counter xfrrej for the zone example.org. should be greater than 0
+    The counter xfrrej for the zone example.org. should be less than 3
+    The counter xfrreqdone for the zone _SERVER_ should be 0
+    The counter xfrreqdone for the zone example.org. should be 0