Updates on error logging and handling

- iterative error log storage
- config like value storage
- updates logging format for improved readablility

Change-Id: I171a1b44452c1225340a7d7b1f7593ab9b8ce7c2
Related-PROD: PROD-28199
diff --git a/.gitignore b/.gitignore
index a951307..54591df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,6 +10,7 @@
 .DS_Store
 
 # project specific
+.cfgerrors
 .vscode/*
 etc/*.list
 *.env
diff --git a/cfg_checker/common/config_file.py b/cfg_checker/common/config_file.py
new file mode 100644
index 0000000..87f4759
--- /dev/null
+++ b/cfg_checker/common/config_file.py
@@ -0,0 +1,72 @@
+import configparser
+import os
+
+from cfg_checker.common import logger_cli
+
+
+class ConfigFile(object):
+    _truth = ['true', '1', 't', 'y', 'yes', 'yeah', 'yup',
+              'certainly', 'uh-huh']
+    _config = None
+    _section_name = None
+    _config_filepath = None
+
+    def __init__(self, section_name, filepath=None):
+        self._section_name = section_name
+        self._config = configparser.ConfigParser()
+        if filepath is not None:
+            self._config_filepath = self._ensure_abs_path(filepath)
+            self._config.read(self._config_filepath)
+        else:
+            logger_cli.debug("... previous iteration conf not found")
+
+    def force_reload_config(self, path):
+        _path = self._ensure_abs_path(path)
+        self._config.read(_path)
+
+    def save_config(self, filepath=None):
+        if filepath:
+            self._config_filepath = filepath
+        with open(self._config_filepath, "w") as configfile:
+            self._config.write(configfile)
+
+    @staticmethod
+    def _ensure_abs_path(path):
+        if path.startswith('~'):
+            path = os.path.expanduser(path)
+        else:
+            # keep it safe, create var :)
+            path = path
+
+        # make sure it is absolute
+        if not os.path.isabs(path):
+            return os.path.abspath(path)
+        else:
+            return path
+
+    def _ensure_boolean(self, _value):
+        if _value.lower() in self._truth:
+            return True
+        else:
+            return False
+
+    def get_value(self, key, value_type=None):
+        if not value_type:
+            # return str by default
+            return self._config.get(self._section_name, key)
+        elif value_type == int:
+            return self._config.getint(self._section_name, key)
+        elif value_type == bool:
+            return self._config.getboolean(self._section_name, key)
+
+    def set_value(self, key, value):
+        _v = None
+        if not isinstance(value, str):
+            _v = str(value)
+        else:
+            _v = value
+
+        if self._section_name not in self._config.sections():
+            self._config.add_section(self._section_name)
+
+        self._config[self._section_name][key] = _v
diff --git a/cfg_checker/common/file_utils.py b/cfg_checker/common/file_utils.py
new file mode 100644
index 0000000..d508121
--- /dev/null
+++ b/cfg_checker/common/file_utils.py
@@ -0,0 +1,84 @@
+import grp
+import os
+import pwd
+import time
+
+from cfg_checker.common import config
+
+_default_time_format = config.date_format
+
+
+def remove_file(filename):
+    os.remove(filename)
+    # open('filename', 'w').close()
+
+
+def write_str_to_file(filename, _str):
+    with open(filename, 'w') as fo:
+        fo.write(_str)
+
+
+def append_str_to_file(filename, _str):
+    with open(filename, 'a') as fa:
+        fa.write(_str)
+
+
+def write_lines_to_file(filename, source_list):
+    with open(filename, 'w') as fw:
+        fw.write("\n".join(source_list) + "\n")
+
+
+def append_lines_to_file(filename, source_list):
+    _buf = "\n".join(source_list)
+    with open(filename, 'a') as fw:
+        fw.write(_buf + "\n")
+
+
+def append_line_to_file(filename, _str):
+    with open(filename, 'a') as fa:
+        fa.write(_str+'\n')
+
+
+def read_file(filename):
+    _buf = None
+    with open(filename, 'rb') as fr:
+        _buf = fr.read()
+    return _buf
+
+
+def read_file_as_lines(filename):
+    _list = []
+    with open(filename, 'r') as fr:
+        for line in fr:
+            _list.append(line)
+    return _list
+
+
+def get_file_info_fd(fd, time_format=_default_time_format):
+
+    def format_time(unixtime):
+        return time.strftime(
+            time_format,
+            time.gmtime(unixtime)
+        )
+
+    (mode, ino, dev, nlink, uid, gid, size, atime, mtime, ctime) = \
+        os.fstat(fd.fileno())
+
+    _dict = {
+        'fd': fd.fileno(),
+        'mode': oct(mode & 0777),
+        'device': hex(dev),
+        'inode': ino,
+        'hard_links': nlink,
+        'owner_id': uid,
+        'owner_name': pwd.getpwuid(uid).pw_name,
+        'owner_group_name': grp.getgrgid(gid).gr_name,
+        'owner_group_id': gid,
+        'size': size,
+        'access_time': format_time(atime),
+        'modification_time': format_time(mtime),
+        'creation_time': format_time(ctime)
+    }
+
+    return _dict
diff --git a/cfg_checker/common/salt_utils.py b/cfg_checker/common/salt_utils.py
index 8b1b47f..2927e28 100644
--- a/cfg_checker/common/salt_utils.py
+++ b/cfg_checker/common/salt_utils.py
@@ -45,7 +45,7 @@
 
     _ssh_cmd.append(_salt_cmd)
     _ssh_cmd = " ".join(_ssh_cmd)
-    logger_cli.debug("...calling salt: '{}'".format(_ssh_cmd))
+    logger_cli.debug("... calling salt: '{}'".format(_ssh_cmd))
     _result = shell(_ssh_cmd)
     if len(_result) < 1:
         raise InvalidReturnException("# Empty value returned for '{}".format(
diff --git a/cfg_checker/helpers/errors.py b/cfg_checker/helpers/errors.py
index b124315..ca8a8da 100644
--- a/cfg_checker/helpers/errors.py
+++ b/cfg_checker/helpers/errors.py
@@ -1,8 +1,21 @@
-from cfg_checker.common import logger
+import os
+
+from cfg_checker.common import file_utils as fu
+from cfg_checker.common import logger, logger_cli
+from cfg_checker.common.config_file import ConfigFile
 from cfg_checker.common.exception import ErrorMappingException
+from cfg_checker.common.settings import pkg_dir
 
 
 class ErrorIndex(object):
+    # logs folder filenames
+    _error_logs_folder_name = ".cfgerrors"
+    _conf_filename = "conf"
+    # config file object
+    conf = None
+    # iteration counter
+    _iteration = 0
+    # local vars for codes
     _area_code = ""
     _delimiter = ""
     _index = 0
@@ -16,6 +29,62 @@
         self._delimiter = delimiter
         self._index += 1
 
+        # init the error log storage folder
+        _folder = os.path.join(pkg_dir, self._error_logs_folder_name)
+        self._conf_filename = os.path.join(
+            _folder,
+            self._conf_filename
+        )
+
+        if not os.path.exists(_folder):
+            # it is not exists, create it
+            os.mkdir(_folder)
+            logger_cli.debug(
+                "... error logs folder '{}' created".format(_folder)
+            )
+        else:
+            logger_cli.debug(
+                "... error logs folder is at '{}'".format(_folder)
+            )
+        if not os.path.exists(self._conf_filename):
+            # put file with init values
+            self.conf = ConfigFile(self._area_code.lower())
+            self.conf.set_value('iteration', self._iteration)
+            self.conf.save_config(filepath=self._conf_filename)
+            logger_cli.debug(
+                "... create new config file '{}'".format(
+                    self._conf_filename
+                )
+            )
+        else:
+            # it exists, try to load latest run
+            self.conf = ConfigFile(
+                self._area_code.lower(),
+                filepath=self._conf_filename
+            )
+            # it is loaded, update iteration from file
+            self._iteration = self.conf.get_value('iteration', value_type=int)
+            self._iteration += 1
+        logger_cli.debug(" ... starting iteration {}".format(self._iteration))
+
+    def save_iteration_data(self):
+        # save error log
+        _filename = "-".join([self._area_code.lower(), "errors"])
+        _filename += "." + str(self._iteration)
+        _log_filename = os.path.join(
+            pkg_dir,
+            self._error_logs_folder_name,
+            _filename
+        )
+        fu.write_lines_to_file(_log_filename, self.get_errors(as_list=True))
+        fu.append_line_to_file(_log_filename, "")
+        fu.append_lines_to_file(_log_filename, self.get_summary(as_list=True))
+        logger_cli.debug("... saved errors to '{}'".format(_log_filename))
+
+        # save last iteration number
+        self.conf.set_value('iteration', self._iteration)
+        self.conf.save_config()
+
     def _format_error_code(self, index):
         _t = "{:02d}".format(self._errors[index]['type'])
         _i = "{:04d}".format(index)
@@ -27,9 +96,9 @@
         _code = self._format_error_code(index)
         # prepare data as string list
         _d = self._errors[index]['data']
-        _data = ["{}: {}".format(_k, _v) for _k, _v in _d.iteritems()]
+        _data = ["    {}: {}".format(_k, _v) for _k, _v in _d.iteritems()]
         # format message
-        _msg = "### {}: {}\n{}".format(
+        _msg = "### {}:\n    Description: {}\n{}".format(
             _code,
             self._get_error_type_text(self._errors[index]['type']),
             "\n".join(_data)
@@ -89,9 +158,14 @@
         else:
             return "Unknown error index of {}".format(index)
 
-    def get_summary(self, print_zeros=True):
+    def get_summary(self, print_zeros=True, as_list=False):
         # create summary with counts per error type
-        _list = []
+        _list = "\n{:=^8s}\n{:^8s}\n{:=^8s}".format(
+            "=",
+            "Totals",
+            "="
+        ).splitlines()
+
         for _type in self._types.keys():
             _len = len(
                 filter(
@@ -112,12 +186,27 @@
                 )
             )
 
-        return "\n".join(_list)
+        _total_errors = self.get_errors_total()
 
-    def get_errors_as_list(self):
-        # create list of strings with error messages
-        _list = []
-        for _idx in range(0, self._index - 1):
-            _list.append("{}".format(self.get_error(_idx)))
+        _list.append('-'*20)
+        _list.append("{:5d} total errors found\n".format(_total_errors))
+        if as_list:
+            return _list
+        else:
+            return "\n".join(_list)
 
-        return _list
+    def get_errors(self, as_list=False):
+        _list = ["# Errors"]
+        # Detailed errors
+        if self.get_errors_total() > 0:
+            # create list of strings with error messages
+            for _idx in range(1, self._index - 1):
+                _list.append(self._format_error(_idx))
+                _list.append("\n")
+        else:
+            _list.append("-> No errors")
+
+        if as_list:
+            return _list
+        else:
+            return "\n".join(_list)
diff --git a/cfg_checker/modules/network/__init__.py b/cfg_checker/modules/network/__init__.py
index 8f4a037..78df6c6 100644
--- a/cfg_checker/modules/network/__init__.py
+++ b/cfg_checker/modules/network/__init__.py
@@ -16,8 +16,13 @@
     netChecker = _prepare_check()
     netChecker.print_network_report()
 
+    # save what was collected
+    netChecker.errors.save_iteration_data()
+
+    # print a report
     netChecker.print_summary()
 
+    # if set, print details
     if args.detailed:
         netChecker.print_error_details()
 
diff --git a/cfg_checker/modules/network/checker.py b/cfg_checker/modules/network/checker.py
index aeb1e61..48e7acb 100644
--- a/cfg_checker/modules/network/checker.py
+++ b/cfg_checker/modules/network/checker.py
@@ -10,7 +10,9 @@
 
 class NetworkChecker(SaltNodes):
     def __init__(self):
+        logger_cli.info("# Gathering environment information")
         super(NetworkChecker, self).__init__()
+        logger_cli.info("# Initializing error logs folder")
         self.errors = NetworkErrors()
 
     # adding net data to tree
@@ -375,27 +377,15 @@
                     )
 
     def print_summary(self):
-        _total_errors = self.errors.get_errors_total()
-        # Summary
-        logger_cli.info(
-            "\n{:=^8s}\n{:^8s}\n{:=^8s}".format(
-                "=",
-                "Totals",
-                "="
-            )
-        )
         logger_cli.info(self.errors.get_summary(print_zeros=False))
-        logger_cli.info('-'*20)
-        logger_cli.info("{:5d} total errors found\n".format(_total_errors))
 
     def print_error_details(self):
         # Detailed errors
-        if self.errors.get_errors_total() > 0:
-            logger_cli.info("\n# Errors")
-            for _msg in self.errors.get_errors_as_list():
-                logger_cli.info("{}\n".format(_msg))
-        else:
-            logger_cli.info("-> No errors\n")
+        logger_cli.info(
+            "\n{}\n".format(
+                self.errors.get_errors()
+            )
+        )
 
     def create_html_report(self, filename):
         """
diff --git a/cfg_checker/nodes.py b/cfg_checker/nodes.py
index 3990cec..5e47447 100644
--- a/cfg_checker/nodes.py
+++ b/cfg_checker/nodes.py
@@ -23,7 +23,7 @@
 
         # 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")
+        logger_cli.debug("... collecting node names existing in the cloud")
         try:
             _keys = self.salt.list_keys()
             _str = []
@@ -121,7 +121,7 @@
         :return: no return value, data pulished internally
         """
         logger_cli.debug(
-            "...collecting node pillars for '{}'".format(pillar_path)
+            "... collecting node pillars for '{}'".format(pillar_path)
         )
         _result = self.salt.pillar_get(self.active_nodes_compound, pillar_path)
         self.not_responded = []
@@ -165,7 +165,7 @@
             config.salt_file_root, config.salt_scripts_folder
         )
         logger_cli.debug(
-            "...Uploading script {} "
+            "... uploading script {} "
             "to master's file cache folder: '{}'".format(
                 script_filename,
                 _storage_path
@@ -185,12 +185,12 @@
             script_filename
         )
 
-        logger_cli.debug("...creating file in cache '{}'".format(_cache_path))
+        logger_cli.debug("... creating file in cache '{}'".format(_cache_path))
         self.salt.f_touch_master(_cache_path)
         self.salt.f_append_master(_cache_path, _script)
         # command salt to copy file to minions
         logger_cli.debug(
-            "...creating script target folder '{}'".format(
+            "... creating script target folder '{}'".format(
                 _cache_path
             )
         )
diff --git a/requirements.txt b/requirements.txt
index 7f6827f..fe8d8d4 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -2,4 +2,5 @@
 pyyaml
 jinja2
 requests
-ipaddress
\ No newline at end of file
+ipaddress
+configparser
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 91b1e80..42ca20f 100644
--- a/setup.py
+++ b/setup.py
@@ -17,7 +17,8 @@
     'pyyaml',
     'jinja2',
     'requests',
-    'ipaddress'
+    'ipaddress',
+    'configparser'
 ]
 
 entry_points = {