Repository: RedHatInsights/insights-core

Issue:
Parsers in ntp_sources return None when the output is empty
Parsers should be skipped when nothing needs to be parsed.

This issue cause some false positives


Discussion:

Resolving patch:
diff --git a/insights/parsers/ntp_sources.py b/insights/parsers/ntp_sources.py
--- a/insights/parsers/ntp_sources.py
+++ b/insights/parsers/ntp_sources.py
@@ -14,23 +14,20 @@
 ChronycSources - command ``/usr/bin/chronyc sources``
 -----------------------------------------------------
 
-NtpqLeap - command ``/usr/sbin/ntpq -c 'rv 0 leap'``
-----------------------------------------------------
-
 NtpqPn - command ``/usr/sbin/ntpq -pn``
 ---------------------------------------
 
-
+NtpqLeap - command ``/usr/sbin/ntpq -c 'rv 0 leap'``
+----------------------------------------------------
 """
 
-import re
-from .. import parser, CommandParser
+from insights import parser, CommandParser
 from insights.core.dr import SkipComponent
 from insights.specs import Specs
 
 
 @parser(Specs.chronyc_sources)
-class ChronycSources(CommandParser):
+class ChronycSources(CommandParser, list):
     """
     Chronyc Sources parser
 
@@ -49,14 +46,15 @@ class ChronycSources(CommandParser):
 
     Examples:
 
-        >>> sources = shared[ChronycSources].data
-        >>> len(sources)
+        >>> type(chrony_sources)
+        <class 'insights.parsers.ntp_sources.ChronycSources'>
+        >>> len(chrony_sources)
         4
-        >>> sources[0]['source']
+        >>> chrony_sources[0]['source']
         '10.20.30.40'
-        >>> sources[0]['mode']
+        >>> chrony_sources[0]['mode']
         '^'
-        >>> sources[0]['state']
+        >>> chrony_sources[0]['state']
         '-'
     """
 
@@ -64,15 +62,34 @@ def parse_content(self, content):
         """
         Get source, mode and state for chrony
         """
-        self.data = []
-        for row in content[3:]:
-            if row.strip():
-                values = row.split(" ", 2)
-                self.data.append({"source": values[1], "mode": values[0][0], "state": values[0][1]})
+        data = []
+        if len(content) > 3:
+            for row in content[3:]:
+                if row.strip():
+                    values = row.split(" ", 2)
+                    data.append(
+                        {
+                            "source": values[1],
+                            "mode": values[0][0],
+                            "state": values[0][1]
+                        }
+                    )
+
+        if not data:
+            raise SkipComponent()
+
+        self.extend(data)
+
+    @property
+    def data(self):
+        """
+        Set data as property to keep compatibility
+        """
+        return self
 
 
 @parser(Specs.ntpq_leap)
-class NtpqLeap(CommandParser):
+class NtpqLeap(CommandParser, dict):
     """
     Converts the output of ``ntpq -c 'rv 0 leap'`` into a dictionary in the
     ``data`` property, and sets the ``leap`` property to the value of the
@@ -84,26 +101,43 @@ class NtpqLeap(CommandParser):
 
     Examples:
 
-        >>> print shared[NtpqLeap].leap  # same data
+        >>> type(ntpq)
+        <class 'insights.parsers.ntp_sources.NtpqLeap'>
+        >>> ntpq.leap
         '00'
     """
 
     def parse_content(self, content):
-        if "Connection refused" in content[0]:
+        if content and "Connection refused" in content[0]:
             raise SkipComponent("NTP service is down and connection refused")
-        self.data = {}
+
+        leap = None
         for line in content:
-            m = re.search(r'leap=(\d*)', line)
-            if m:
-                self.data["leap"] = m.group(1)
+            if 'leap=' in line:
+                leap = line.split('leap=')[1].rstrip()
+
+        if leap is None:
+            raise SkipComponent()
+
+        self.update(leap=leap)
+
+    @property
+    def data(self):
+        """
+        Set data as property to keep compatibility
+        """
+        return self
 
     @property
     def leap(self):
-        return self.data.get('leap')
+        """
+        Return the value of the 'leap'
+        """
+        return self.get('leap')
 
 
 @parser(Specs.ntpq_pn)
-class NtpqPn(CommandParser):
+class NtpqPn(CommandParser, list):
     """
     Get source and flag for each NTP time source from the output of
     ``/usr/sbin/ntpq -pn``.
@@ -124,21 +158,36 @@ class NtpqPn(CommandParser):
 
     Examples:
 
-        >>> sources = shared[NtpqPn].data
-        >>> len(sources)
+        >>> type(ntp_sources)
+        <class 'insights.parsers.ntp_sources.NtpqPn'>
+        >>> len(ntp_sources)
         4
-        >>> sources[0]
-        {'flag': '*', 'source', '10.20.30.40'}
+        >>> ntp_sources[0]['source']
+        '10.20.30.40'
     """
 
     def parse_content(self, content):
-        if "Connection refused" in content[0]:
+        if content and "Connection refused" in content[0]:
             raise SkipComponent("NTP service is down and connection refused")
-        self.data = []
-        for row in content[2:]:
-            if row.strip():
-                values = row.split(" ", 2)
-                if row.startswith(" "):
-                    self.data.append({"source": values[1], "flag": " "})
-                else:
-                    self.data.append({"source": values[0][1:], "flag": values[0][0]})
+
+        data = []
+        if len(content) > 2:
+            for row in content[2:]:
+                if row.strip():
+                    values = row.split(" ", 2)
+                    if row.startswith(" "):
+                        data.append({"source": values[1], "flag": " "})
+                    else:
+                        data.append({"source": values[0][1:], "flag": values[0][0]})
+
+        if not data:
+            raise SkipComponent()
+
+        self.extend(data)
+
+    @property
+    def data(self):
+        """
+        Set data as property to keep compatibility
+        """
+        return self


Test patch:
diff --git a/insights/parsers/tests/test_ntp_sources.py b/insights/parsers/tests/test_ntp_sources.py
--- a/insights/parsers/tests/test_ntp_sources.py
+++ b/insights/parsers/tests/test_ntp_sources.py
@@ -1,5 +1,7 @@
 import pytest
+import doctest
 from insights.core.dr import SkipComponent
+from insights.parsers import ntp_sources
 from insights.parsers.ntp_sources import ChronycSources, NtpqPn, NtpqLeap
 from insights.tests import context_wrap
 
@@ -12,6 +14,20 @@
 ^+ d.e.f 1 6 377 21 -2629us[-2619us] +/- 86ms
 """.strip()
 
+chrony_output_doc = """
+210 Number of sources = 6
+MS Name/IP address         Stratum Poll Reach LastRx Last sample
+===============================================================================
+^- 10.20.30.40                   2   9   377    95  -1345us[-1345us] +/-   87ms
+^- 10.56.72.8                    2  10   377   949  -3449us[-3483us] +/-  120ms
+^* 10.64.108.95                  2  10   377   371    -91us[ -128us] +/-   30ms
+^- 10.8.205.17                   2   8   377    27  +7161us[+7161us] +/-   52ms
+""".strip()
+
+empty_chrony_source = ""
+
+empty_ntpq_leap = ""
+
 ntpq_leap_output = """
 leap=00
 """.strip()
@@ -32,7 +48,18 @@
      remote           refid      st t when poll reach   delay   offset  jitter
 ==============================================================================
  202.118.1.81    .INIT.          16 u    - 1024    0    0.000    0.000   0.000
-"""
+""".strip()
+
+ntpd_qn_doc = """
+     remote           refid      st t when poll reach   delay   offset  jitter
+==============================================================================
++10.20.30.40     192.231.203.132  3 u  638 1024  377    0.242    2.461   1.886
+*2001:388:608c:8 .GPS.            1 u  371 1024  377   29.323    1.939   1.312
+-2001:44b8:1::1  216.218.254.202  2 u  396 1024  377   37.869   -3.340   6.458
++150.203.1.10    202.6.131.118    2 u  509 1024  377   20.135    0.800   3.260
+""".strip()
+
+empty_ntpq_pn = ""
 
 ntp_connection_issue = """
 /usr/sbin/ntpq: read: Connection refused
@@ -45,6 +72,9 @@ def test_get_chrony_sources():
     assert parser_result.data[2].get("state") == "+"
     assert parser_result.data[2].get("mode") == "^"
 
+    with pytest.raises(SkipComponent):
+        NtpqPn(context_wrap(empty_chrony_source))
+
 
 def test_get_ntpq_leap():
     parser_result = NtpqLeap(context_wrap(ntpq_leap_output))
@@ -57,6 +87,9 @@ def test_get_ntpq_leap():
         NtpqLeap(context_wrap(ntp_connection_issue))
     assert "NTP service is down" in str(e)
 
+    with pytest.raises(SkipComponent):
+        NtpqLeap(context_wrap(empty_ntpq_leap))
+
 
 def test_get_ntpd_sources():
     parser_result = NtpqPn(context_wrap(ntpd_output))
@@ -71,3 +104,16 @@ def test_get_ntpd_sources():
     with pytest.raises(SkipComponent) as e:
         NtpqPn(context_wrap(ntp_connection_issue))
     assert "NTP service is down" in str(e)
+
+    with pytest.raises(SkipComponent):
+        NtpqPn(context_wrap(empty_ntpq_pn))
+
+
+def test_ntp_sources_doc_examples():
+    env = {
+        'chrony_sources': ChronycSources(context_wrap(chrony_output_doc)),
+        'ntpq': NtpqLeap(context_wrap(ntpq_leap_output)),
+        'ntp_sources': NtpqPn(context_wrap(ntpd_qn_doc)),
+    }
+    failed, total = doctest.testmod(ntp_sources, globs=env)
+    assert failed == 0