Skip nodes functionality for Package and Network modules

Added to main entrypoint
- Skip nodes using simple argument with '*' as a trailing wildcard
- Skip nodes using file list

Usability improovement
- Node list preview in status line
- Node stats alignment in net report

Minor fixes:
- Python version detection (3.5+)
- Node counter for each status
- Proper node skip handling

Change-Id: I086ef501bc06f0e739df25349257f1c63a2e2fcf
Related-PROD: PROD-35009
diff --git a/cfg_checker/cfg_check.py b/cfg_checker/cfg_check.py
index 5c986a2..2ce37eb 100644
--- a/cfg_checker/cfg_check.py
+++ b/cfg_checker/cfg_check.py
@@ -32,6 +32,20 @@
         action='store_true', default=True,
         help="Use sudo for getting salt creds"
     )
+
+    parser.add_argument(
+        '--skip-nodes',
+        metavar='skip_string', default=None,
+        help="String with nodes to skip. Only trailing '*' supported!"
+             " Example: 'cmp*,ctl01'"
+    )
+
+    parser.add_argument(
+        '--skip-nodes-file',
+        metavar='skip_nodes_file', default=None,
+        help="Filename with nodes to skip. Note: use fqdn node names."
+    )
+
     subparsers = parser.add_subparsers(dest='command')
 
     # create parsers
diff --git a/cfg_checker/common/const.py b/cfg_checker/common/const.py
index 685c79a..3b17099 100644
--- a/cfg_checker/common/const.py
+++ b/cfg_checker/common/const.py
@@ -8,6 +8,7 @@
 _cnt = itertools.count()
 NODE_DOWN = next(_cnt)
 NODE_UP = next(_cnt)
+NODE_SKIP = next(_cnt)
 
 # version const order is important!
 # biggest get shown in report top row
@@ -42,12 +43,13 @@
     VERSION_DOWN: "downgraded",
     VERSION_WARN: "warning",
     VERSION_ERR: "error",
-    VERSION_NA: "no status"
+    VERSION_NA: "nostatus"
 }
 
 node_status = {
     NODE_UP: "up",
-    NODE_DOWN: "down"
+    NODE_DOWN: "down",
+    NODE_SKIP: "skip"
 }
 
 uknown_code = "unk"
diff --git a/cfg_checker/common/other.py b/cfg_checker/common/other.py
index a385b90..5a4c552 100644
--- a/cfg_checker/common/other.py
+++ b/cfg_checker/common/other.py
@@ -126,20 +126,20 @@
         else:
             raise ConfigException(_message)
 
-    def get_nodes_list(self, env, nodes_list):
+    def get_nodes_list(self, nodes_list, env_sting=None):
         _list = []
-        if env is None:
+        if env_sting is None:
             # nothing supplied, use the one in repo
             try:
                 if not nodes_list:
                     return []
-                with open(os.path.join(pkg_dir, nodes_list)) as _f:
+                with open(nodes_list) as _f:
                     _list.extend(_f.read().splitlines())
             except IOError as e:
                 raise ConfigException("# Error while loading file, '{}': "
                                       "{}".format(e.filename, e.strerror))
         else:
-            _list.extend(self.node_string_to_list(env))
+            _list.extend(self.node_string_to_list(env_sting))
 
         # validate names
         _invalid = []
@@ -151,7 +151,7 @@
             else:
                 _valid.append(_name)
 
-        return _valid
+        return _valid, _invalid
 
 
 utils = Utils()
diff --git a/cfg_checker/common/settings.py b/cfg_checker/common/settings.py
index 92c17a5..cca5142 100644
--- a/cfg_checker/common/settings.py
+++ b/cfg_checker/common/settings.py
@@ -1,4 +1,5 @@
 import os
+import sys
 
 from cfg_checker.common.exception import ConfigException
 
@@ -15,11 +16,17 @@
 
 
 class CheckerConfiguration(object):
+    @staticmethod
     def load_nodes_list():
-        return utils.get_nodes_list(
-            os.environ.get('CFG_ALL_NODES', None),
-            os.environ.get('SALT_NODE_LIST_FILE', None)
-        )
+        _file = os.environ.get('SALT_NODE_LIST_FILE', None)
+        if _file:
+            _v, _ = utils.get_nodes_list(
+                os.path.join(pkg_dir, _file),
+                env_sting=os.environ.get('CFG_ALL_NODES', None)
+            )
+            return _v
+        else:
+            return None
 
     def _init_values(self):
         """Load values from environment variables or put default ones
@@ -119,6 +126,16 @@
     def __init__(self):
         """Base configuration class. Only values that are common for all scripts
         """
+        # Make sure we running on Python 3
+        if sys.version_info[0] < 3 and sys.version_info[1] < 5:
+            logger_cli.error("# ERROR: Python 3.5+ is required")
+            sys.exit(1)
+        else:
+            logger_cli.debug("### Python version is {}.{}".format(
+                sys.version_info[0],
+                sys.version_info[1]
+            ))
+
         _env = os.getenv('SALT_ENV', None)
         self._init_env(_env)
         self._init_values()
diff --git a/cfg_checker/helpers/args_utils.py b/cfg_checker/helpers/args_utils.py
index 726c20e..498ed30 100644
--- a/cfg_checker/helpers/args_utils.py
+++ b/cfg_checker/helpers/args_utils.py
@@ -11,6 +11,20 @@
         self.print_help()
 
 
+def get_skip_args(args):
+    if hasattr(args, "skip_nodes"):
+        _skip = getattr(args, "skip_nodes")
+        if _skip:
+            _skip = _skip.split(',')
+    else:
+        _skip = None
+    if hasattr(args, "skip_nodes_file"):
+        _skip_file = getattr(args, "skip_nodes_file")
+    else:
+        _skip_file = None
+    return _skip, _skip_file
+
+
 def get_arg(args, str_arg):
     _attr = getattr(args, str_arg)
     if _attr:
diff --git a/cfg_checker/modules/network/__init__.py b/cfg_checker/modules/network/__init__.py
index 71df82d..28d08c4 100644
--- a/cfg_checker/modules/network/__init__.py
+++ b/cfg_checker/modules/network/__init__.py
@@ -71,7 +71,11 @@
     # should not print map, etc...
     # Just bare summary and errors
     logger_cli.info("# Network check to console")
-    netChecker = checker.NetworkChecker()
+    _skip, _skip_file = args_utils.get_skip_args(args)
+    netChecker = checker.NetworkChecker(
+        skip_list=_skip,
+        skip_list_file=_skip_file
+    )
     netChecker.check_networks()
 
     # save what was collected
@@ -93,8 +97,11 @@
     logger_cli.info("# Network report (check, node map")
 
     _filename = args_utils.get_arg(args, 'html')
-
-    netChecker = checker.NetworkChecker()
+    _skip, _skip_file = args_utils.get_skip_args(args)
+    netChecker = checker.NetworkChecker(
+        skip_list=_skip,
+        skip_list_file=_skip_file
+    )
     netChecker.check_networks(map=False)
 
     # save what was collected
@@ -108,8 +115,11 @@
     # Network Map
     # Should generate network map to console or HTML
     logger_cli.info("# Network report")
-
-    networkMap = mapper.NetworkMapper()
+    _skip, _skip_file = args_utils.get_skip_args(args)
+    networkMap = mapper.NetworkMapper(
+        skip_list=_skip,
+        skip_list_file=_skip_file
+    )
     networkMap.prepare_all_maps()
     networkMap.create_map()
     networkMap.print_map()
@@ -120,7 +130,11 @@
 def do_list(args):
     # Network List
     # Should generate network map to console or HTML
-    _map = mapper.NetworkMapper()
+    _skip, _skip_file = args_utils.get_skip_args(args)
+    _map = mapper.NetworkMapper(
+        skip_list=_skip,
+        skip_list_file=_skip_file
+    )
     reclass = _map.map_network(_map.RECLASS)
     runtime = _map.map_network(_map.RUNTIME)
 
@@ -141,7 +155,13 @@
     if not args.cidr:
         logger_cli.error("\n# Use mcp-check network list to get list of CIDRs")
     _cidr = args_utils.get_arg(args, "cidr")
-    _pinger = pinger.NetworkPinger(mtu=args.mtu, detailed=args.detailed)
+    _skip, _skip_file = args_utils.get_skip_args(args)
+    _pinger = pinger.NetworkPinger(
+        mtu=args.mtu,
+        detailed=args.detailed,
+        skip_list=_skip,
+        skip_list_file=_skip_file
+    )
 
     _ret = _pinger.ping_nodes(_cidr)
 
diff --git a/cfg_checker/modules/network/checker.py b/cfg_checker/modules/network/checker.py
index acd3bb1..c590d13 100644
--- a/cfg_checker/modules/network/checker.py
+++ b/cfg_checker/modules/network/checker.py
@@ -5,10 +5,18 @@
 
 
 class NetworkChecker(object):
-    def __init__(self):
+    def __init__(
+        self,
+        skip_list=None,
+        skip_list_file=None
+    ):
         logger_cli.debug("... init error logs folder")
         self.errors = NetworkErrors()
-        self.mapper = NetworkMapper(self.errors)
+        self.mapper = NetworkMapper(
+            self.errors,
+            skip_list=skip_list,
+            skip_list_file=skip_list_file
+        )
 
     def check_networks(self, map=True):
         self.mapper.map_network(self.mapper.RECLASS)
@@ -41,6 +49,7 @@
             filename
         )
         _report({
+            "domain": self.mapper.domain,
             "nodes": self.mapper.nodes,
             "map": self.mapper.map,
             "mcp_release": self.mapper.cluster['mcp_release'],
diff --git a/cfg_checker/modules/network/mapper.py b/cfg_checker/modules/network/mapper.py
index 483c11f..51f52bb 100644
--- a/cfg_checker/modules/network/mapper.py
+++ b/cfg_checker/modules/network/mapper.py
@@ -31,12 +31,21 @@
     CONFIG = "config"
     RUNTIME = "runtime"
 
-    def __init__(self, errors_class=None):
+    def __init__(
+        self,
+        errors_class=None,
+        skip_list=None,
+        skip_list_file=None
+    ):
         logger_cli.info("# Initializing mapper")
         # init networks and nodes
         self.networks = {}
-        self.nodes = salt_master.get_nodes()
+        self.nodes = salt_master.get_nodes(
+            skip_list=skip_list,
+            skip_list_file=skip_list_file
+        )
         self.cluster = salt_master.get_info()
+        self.domain = salt_master.domain
         # init and pre-populate interfaces
         self.interfaces = {k: {} for k in self.nodes}
         # Init errors class
diff --git a/cfg_checker/modules/network/pinger.py b/cfg_checker/modules/network/pinger.py
index 0500284..5b12a94 100644
--- a/cfg_checker/modules/network/pinger.py
+++ b/cfg_checker/modules/network/pinger.py
@@ -10,10 +10,21 @@
 
 # This is independent class with a salt.nodes input
 class NetworkPinger(object):
-    def __init__(self, mtu=None, detailed=False, errors_class=None):
+    def __init__(
+        self,
+        mtu=None,
+        detailed=False,
+        errors_class=None,
+        skip_list=None,
+        skip_list_file=None
+    ):
         logger_cli.info("# Initializing")
         # all active nodes in the cloud
-        self.target_nodes = salt_master.get_nodes()
+        self.target_nodes = salt_master.get_nodes(
+            skip_list=skip_list,
+            skip_list_file=skip_list_file
+        )
+
         # default MTU value
         self.target_mtu = mtu if mtu else 64
         # only data
diff --git a/cfg_checker/modules/packages/__init__.py b/cfg_checker/modules/packages/__init__.py
index 41dfca1..2d0cc79 100644
--- a/cfg_checker/modules/packages/__init__.py
+++ b/cfg_checker/modules/packages/__init__.py
@@ -112,9 +112,12 @@
         _kw = [args.exclude_keywords]
 
     # init connection to salt and collect minion data
+    _skip, _skip_file = args_utils.get_skip_args(args)
     pChecker = checker.CloudPackageChecker(
         force_tag=args.force_tag,
-        exclude_keywords=_kw
+        exclude_keywords=_kw,
+        skip_list=_skip,
+        skip_list_file=_skip_file
     )
     # collect data on installed packages
     pChecker.collect_installed_packages()
diff --git a/cfg_checker/modules/packages/checker.py b/cfg_checker/modules/packages/checker.py
index 8f30f3c..fb02db2 100644
--- a/cfg_checker/modules/packages/checker.py
+++ b/cfg_checker/modules/packages/checker.py
@@ -12,10 +12,19 @@
 
 
 class CloudPackageChecker(object):
-    def __init__(self, force_tag=None, exclude_keywords=[]):
+    def __init__(
+        self,
+        force_tag=None,
+        exclude_keywords=[],
+        skip_list=None,
+        skip_list_file=None
+    ):
         # Init salt master info
         if not salt_master.nodes:
-            salt_master.nodes = salt_master.get_nodes()
+            salt_master.nodes = salt_master.get_nodes(
+                skip_list=skip_list,
+                skip_list_file=skip_list_file
+            )
 
         # check that this env tag is present in Manager
         self.rm = RepoManager()
@@ -57,6 +66,7 @@
         _data['status_err'] = const.VERSION_ERR
         _data['status_warn'] = const.VERSION_WARN
         _data['status_down'] = const.VERSION_DOWN
+        _data['status_skip'] = const.VERSION_NA
 
         # Presort packages
         _data['critical'] = {}
diff --git a/cfg_checker/modules/reclass/comparer.py b/cfg_checker/modules/reclass/comparer.py
index 47e4baf..3c308e0 100644
--- a/cfg_checker/modules/reclass/comparer.py
+++ b/cfg_checker/modules/reclass/comparer.py
@@ -151,11 +151,15 @@
             # ignore _source key
             if k == "_source":
                 continue
-            # ignore secrets
+            # ignore secrets and other secure stuff
             if isinstance(k, str) and k == "secrets.yml":
                 continue
             if isinstance(k, str) and k.find("_password") > 0:
                 continue
+            if isinstance(k, str) and k.find("_key") > 0:
+                continue
+            if isinstance(k, str) and k.find("_token") > 0:
+                continue
             # check if this is an env name cluster entry
             if dict2 is not None and \
                     k == self.model_name_1 and \
diff --git a/cfg_checker/nodes.py b/cfg_checker/nodes.py
index c261752..13ab3e7 100644
--- a/cfg_checker/nodes.py
+++ b/cfg_checker/nodes.py
@@ -3,7 +3,9 @@
 from copy import deepcopy
 
 from cfg_checker.clients import get_salt_remote, salt
-from cfg_checker.common import config, const
+from cfg_checker.common import config
+from cfg_checker.common.const import all_roles_map
+from cfg_checker.common.const import NODE_UP, NODE_DOWN, NODE_SKIP
 from cfg_checker.common import logger, logger_cli
 from cfg_checker.common import utils
 from cfg_checker.common.exception import SaltException
@@ -12,7 +14,7 @@
 node_tmpl = {
     'role': '',
     'node_group': '',
-    'status': const.NODE_DOWN,
+    'status': NODE_DOWN,
     'pillars': {},
     'grains': {}
 }
@@ -25,7 +27,7 @@
         self.salt = salt
         self.nodes = None
 
-    def gather_node_info(self):
+    def gather_node_info(self, skip_list, skip_list_file):
         # Keys for all nodes
         # this is not working in scope of 2016.8.3, will overide with list
         logger_cli.debug("... collecting node names existing in the cloud")
@@ -61,33 +63,86 @@
         else:
             _minions = self.node_keys['minions']
 
+        # Skip nodes if needed
+        _skipped_minions = []
+        # skip list file
+        if skip_list_file:
+            _valid, _invalid = utils.get_nodes_list(skip_list_file)
+            logger_cli.info(
+                "\n# WARNING: Detected invalid entries "
+                "in nodes skip list:\n".format(
+                    "\n".join(_invalid)
+                )
+            )
+            _skipped_minions.extend(_valid)
+        # process wildcard, create node list out of mask
+        if skip_list:
+            _list = []
+            _invalid = []
+            for _item in skip_list:
+                if '*' in _item:
+                    _str = _item[:_item.index('*')]
+                    _nodes = [_m for _m in _minions if _m.startswith(_str)]
+                    if not _nodes:
+                        logger_cli.warn(
+                            "# WARNING: No nodes found for {}".format(_item)
+                        )
+                    _list.extend(_nodes)
+                else:
+                    if _item in _minions:
+                        _list += _item
+                    else:
+                        logger_cli.warn(
+                            "# WARNING: No node found for {}".format(_item)
+                        )
+            # removing duplicates
+            _list = list(set(_list))
+            _skipped_minions.extend(_list)
+
         # in case API not listed minions, we need all that answer ping
         _active = self.salt.get_active_nodes()
         logger_cli.info("-> nodes responded: {}".format(len(_active)))
         # iterate through all accepted nodes and create a dict for it
         self.nodes = {}
         self.skip_list = []
+        _domains = set()
         for _name in _minions:
             _nc = utils.get_node_code(_name)
-            _rmap = const.all_roles_map
+            _rmap = all_roles_map
             _role = _rmap[_nc] if _nc in _rmap else 'unknown'
-            _status = const.NODE_UP if _name in _active else const.NODE_DOWN
-            if _status == const.NODE_DOWN:
+            if _name in _skipped_minions:
+                _status = NODE_SKIP
                 self.skip_list.append(_name)
-                logger_cli.info("-> '{}' is down, marked to skip".format(
-                    _name
-                ))
+            else:
+                _status = NODE_UP if _name in _active else NODE_DOWN
+                if _status == NODE_DOWN:
+                    self.skip_list.append(_name)
+                    logger_cli.info(
+                        "-> '{}' is down, "
+                        "added to skip list".format(
+                            _name
+                        )
+                    )
             self.nodes[_name] = deepcopy(node_tmpl)
+            self.nodes[_name]['shortname'] = _name.split(".", 1)[0]
+            _domains.add(_name.split(".", 1)[1])
             self.nodes[_name]['node_group'] = _nc
             self.nodes[_name]['role'] = _role
             self.nodes[_name]['status'] = _status
+        _domains = list(_domains)
+        if len(_domains) > 1:
+            logger_cli.warning(
+                "Multiple domains detected: {}".format(",".join(_domains))
+            )
+        else:
+            self.domain = _domains[0]
         logger_cli.info("-> {} nodes inactive".format(len(self.skip_list)))
         logger_cli.info("-> {} nodes collected".format(len(self.nodes)))
 
         # form an all nodes compound string to use in salt
         self.active_nodes_compound = self.salt.compound_string_from_list(
             filter(
-                lambda nd: self.nodes[nd]['status'] == const.NODE_UP,
+                lambda nd: self.nodes[nd]['status'] == NODE_UP,
                 self.nodes
             )
         )
@@ -96,7 +151,7 @@
         #     lambda nd: self.nodes[nd]['role'] == const.all_roles_map['cfg'],
         #     self.nodes
         # )
-        _role = const.all_roles_map['cfg']
+        _role = all_roles_map['cfg']
         _filtered = [n for n, v in self.nodes.items() if v['role'] == _role]
         if len(_filtered) < 1:
             raise SaltException(
@@ -137,9 +192,12 @@
         else:
             return False
 
-    def get_nodes(self):
+    def get_nodes(self, skip_list=None, skip_list_file=None):
         if not self.nodes:
-            self.gather_node_info()
+            if not skip_list and config.skip_nodes:
+                self.gather_node_info(config.skip_nodes, skip_list_file)
+            else:
+                self.gather_node_info(skip_list, skip_list_file)
         return self.nodes
 
     def get_info(self):
@@ -177,7 +235,7 @@
             if target_key not in data:
                 data[target_key] = None
             # Save data
-            if data['status'] == const.NODE_DOWN:
+            if data['status'] in [NODE_DOWN, NODE_SKIP]:
                 data[target_key] = None
             elif node not in _result:
                 continue
@@ -219,7 +277,7 @@
                 if _key not in _data:
                     _data[_key] = {}
                 _data = _data[_key]
-            if data['status'] == const.NODE_DOWN:
+            if data['status'] in [NODE_DOWN, NODE_SKIP]:
                 _data[_pillar_keys[-1]] = None
             elif not _result[node]:
                 logger_cli.debug(
diff --git a/cfg_checker/reports/reporter.py b/cfg_checker/reports/reporter.py
index 3d3ede3..e0a746e 100644
--- a/cfg_checker/reports/reporter.py
+++ b/cfg_checker/reports/reporter.py
@@ -25,6 +25,7 @@
 
 UP = const.NODE_UP
 DOWN = const.NODE_DOWN
+SKIP = const.NODE_SKIP
 
 
 def line_breaks(text):
@@ -265,7 +266,7 @@
             # parse them and put into dict
             for node, dt in _dict.items():
                 dt[_key] = {}
-                if dt['status'] == DOWN:
+                if dt['status'] == DOWN or dt['status'] == SKIP:
                     continue
                 if not dt[_key_r]:
                     # no stats collected, put negatives
@@ -295,7 +296,7 @@
             # parse them and put into dict
             for node, dt in _dict.items():
                 dt[_key] = {}
-                if dt['status'] == DOWN:
+                if dt['status'] == DOWN or dt['status'] == SKIP:
                     continue
                 if not dt[_key_r]:
                     # no stats collected, put negatives
@@ -327,7 +328,7 @@
             _f_cmd(_cmd, _key_r, target_dict=_dict)
             for node, dt in _dict.items():
                 dt[_key] = {}
-                if dt['status'] == DOWN:
+                if dt['status'] == DOWN or dt['status'] == SKIP:
                     continue
                 if not dt[_key_r]:
                     # no stats collected, put negatives
@@ -359,7 +360,7 @@
             for node in _kvm:
                 dt = _dict[node]
                 dt[_key] = {}
-                if dt['status'] == DOWN:
+                if dt['status'] == DOWN or dt['status'] == SKIP:
                     continue
                 if not dt[_key_r]:
                     # no stats collected, put negatives
@@ -394,7 +395,7 @@
                 # totals for start mark
                 _ts = [0, 0, 0, 0]
                 # skip if node is down
-                if dt['status'] == DOWN:
+                if dt['status'] == DOWN or dt['status'] == SKIP:
                     dt[_key] = {
                         "total": [-1, -1, -1, -1]
                     }
@@ -492,6 +493,10 @@
                 dt["disk"]["unknown"] = {}
                 dt["disk_max_dev"] = "unknown"
                 continue
+            if dt['status'] == SKIP:
+                dt["disk"]["skipped"] = {}
+                dt["disk_max_dev"] = "skipped"
+                continue
             if not dt[_key_r]:
                 # no stats collected, put negatives
                 dt.pop(_key_r)