Initial version of installed packages report
diff --git a/.gitignore b/.gitignore
index 23d7ec2..fda4fe0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,17 @@
 # C extensions
 *.so
 
+# mac os
+.DS_Store
+
+# project specific
+.vscode/*
+etc/nodes.list
+*.env
+# generated HTML files
+*.html
+
+
 # Distribution / packaging
 .Python
 build/
@@ -102,6 +113,3 @@
 
 # mypy
 .mypy_cache/
-
-# generated HTML files
-*.html
diff --git a/check_versions/__init__.py b/check_versions/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/check_versions/__init__.py
diff --git a/check_versions/common/__init__.py b/check_versions/common/__init__.py
new file mode 100644
index 0000000..e1c9f55
--- /dev/null
+++ b/check_versions/common/__init__.py
@@ -0,0 +1,15 @@
+import os
+import const
+import log
+
+from base_settings import PKG_DIR, base_config
+from other import Utils
+
+
+utils = Utils()
+const = const
+
+logger, logger_cli = log.setup_loggers(
+    'cee8_features',
+    log_fname=os.path.join(PKG_DIR, base_config.logfile_name)
+)
diff --git a/check_versions/common/base_settings.py b/check_versions/common/base_settings.py
new file mode 100644
index 0000000..937c22e
--- /dev/null
+++ b/check_versions/common/base_settings.py
@@ -0,0 +1,44 @@
+"""
+Base configuration module
+Gathers env values and supplies default ones
+
+Attributes:
+    base_config: class with all of the values prepared to work with env
+"""
+
+import os
+
+from check_versions.common.other import utils
+
+PKG_DIR = os.path.dirname(__file__)
+PKG_DIR = os.path.join(PKG_DIR, os.pardir, os.pardir)
+PKG_DIR = os.path.normpath(PKG_DIR)
+
+_default_work_folder = os.path.normpath(PKG_DIR)
+
+
+class TestsConfigurationBase(object):
+    """
+    Base configuration class. Only values that are common for all scripts
+    """
+
+    name = "CiTestsBaseConfig"
+    logfile_name = 'ci_packages.log'
+    working_folder = os.environ.get('CI_TESTS_WORK_DIR', _default_work_folder)
+    salt_host = os.environ.get('SALT_URL', None)
+    salt_port = os.environ.get('SALT_PORT', '6969')
+    salt_user = os.environ.get('SALT_USER', 'salt')
+    salt_pass = os.environ.get('SALT_PASSWORD', None)
+
+    salt_timeout = os.environ.get('SALT_TIMEOUT', 30)
+    salt_file_root = os.environ.get('SALT_FILE_ROOT', None)
+    salt_scripts_folder = os.environ.get('SALT_SCRIPTS_FOLDER', 'test_scripts')
+
+    all_nodes = utils.get_nodes_list(os.environ.get('CI_ALL_NODES', None))
+    skip_nodes = utils.node_string_to_list(os.environ.get(
+        'CI_SKIP_NODES',
+        None
+    ))
+
+
+base_config = TestsConfigurationBase()
diff --git a/check_versions/common/const.py b/check_versions/common/const.py
new file mode 100644
index 0000000..4142ea7
--- /dev/null
+++ b/check_versions/common/const.py
@@ -0,0 +1,32 @@
+"""Constants that is not to be changed and used in all other files
+"""
+
+from __future__ import print_function, absolute_import
+
+import itertools
+
+_cnt = itertools.count()
+NODE_DOWN = next(_cnt)
+NODE_UP = next(_cnt)
+
+del _cnt
+
+all_roles_map = {
+    "apt": "repository",
+    "bmk": "validation",
+    "cfg": "master",
+    "cid": "cicd",
+    "cmn": "storage_monitor",
+    "cmp": "compute",
+    "ctl": "openstack_controller",
+    "dbs": "database",
+    "gtw": "openstack_gateway",
+    "kvm": "foundation",
+    "log": "stacklight_logger",
+    "mon": "monitoring",
+    "msg": "messaging",
+    "mtr": "stacklight_metering",
+    "osd": "storage_node",
+    "prx": "proxy",
+    "rgw": "storage_rados"
+}
diff --git a/check_versions/common/exception.py b/check_versions/common/exception.py
new file mode 100644
index 0000000..93edfff
--- /dev/null
+++ b/check_versions/common/exception.py
@@ -0,0 +1,21 @@
+from exceptions import Exception
+
+
+class Cee8BaseExceptions(Exception):
+    pass
+
+
+class Cee8Exception(Cee8BaseExceptions):
+    def __init__(self, message, *args, **kwargs):
+        super(Cee8Exception, self).__init__(message, *args, **kwargs)
+        # get the trace
+        # TODO: get and log traceback
+
+        # prettify message
+        self.message = "CEE8Exception: {}".format(message)
+
+
+class ConfigException(Cee8Exception):
+    def __init__(self, message, *args, **kwargs):
+        super(ConfigException, self).__init__(message, *args, **kwargs)
+        self.message = "Configuration error: {}".format(message)
diff --git a/check_versions/common/log.py b/check_versions/common/log.py
new file mode 100644
index 0000000..78f5ab3
--- /dev/null
+++ b/check_versions/common/log.py
@@ -0,0 +1,80 @@
+import logging
+
+
+def color_me(color):
+    RESET_SEQ = "\033[0m"
+    COLOR_SEQ = "\033[1;%dm"
+
+    color_seq = COLOR_SEQ % (30 + color)
+
+    def closure(msg):
+        return color_seq + msg + RESET_SEQ
+    return closure
+
+
+class ColoredFormatter(logging.Formatter):
+    BLACK, RED, GREEN, YELLOW, BLUE, MAGENTA, CYAN, WHITE = range(8)
+
+    colors = {
+        'INFO': color_me(WHITE),
+        'WARNING': color_me(YELLOW),
+        'DEBUG': color_me(BLUE),
+        'CRITICAL': color_me(YELLOW),
+        'ERROR': color_me(RED)
+    }
+
+    def __init__(self, msg, use_color=True, datefmt=None):
+        logging.Formatter.__init__(self, msg, datefmt=datefmt)
+        self.use_color = use_color
+
+    def format(self, record):
+        orig = record.__dict__
+        record.__dict__ = record.__dict__.copy()
+        levelname = record.levelname
+
+        prn_name = levelname + ' ' * (8 - len(levelname))
+        if levelname in self.colors:
+            record.levelname = self.colors[levelname](prn_name)
+        else:
+            record.levelname = prn_name
+
+        # super doesn't work here in 2.6 O_o
+        res = logging.Formatter.format(self, record)
+
+        # res = super(ColoredFormatter, self).format(record)
+
+        # restore record, as it will be used by other formatters
+        record.__dict__ = orig
+        return res
+
+
+def setup_loggers(name, def_level=logging.DEBUG, log_fname=None):
+
+    # Stream Handler
+    sh = logging.StreamHandler()
+    sh.setLevel(def_level)
+    log_format = '%(message)s'
+    colored_formatter = ColoredFormatter(log_format, datefmt="%H:%M:%S")
+    sh.setFormatter(colored_formatter)
+
+    # File handler
+    if log_fname is not None:
+        fh = logging.FileHandler(log_fname)
+        log_format = '%(asctime)s - %(levelname)8s - %(name)-15s - %(message)s'
+        formatter = logging.Formatter(log_format, datefmt="%H:%M:%S")
+        fh.setFormatter(formatter)
+        fh.setLevel(logging.DEBUG)
+    else:
+        fh = None
+
+    logger = logging.getLogger(name)
+    logger.setLevel(logging.DEBUG)
+    if len(logger.handlers) == 0:
+        logger.addHandler(fh)
+
+    logger_cli = logging.getLogger(name + ".cli")
+    logger_cli.setLevel(logging.DEBUG)
+    if len(logger_cli.handlers) == 0:
+        logger_cli.addHandler(sh)
+
+    return logger, logger_cli
diff --git a/check_versions/common/other.py b/check_versions/common/other.py
new file mode 100644
index 0000000..809a9c0
--- /dev/null
+++ b/check_versions/common/other.py
@@ -0,0 +1,96 @@
+import os
+import re
+
+from check_versions.common.const import all_roles_map
+
+from check_versions.common.exception import ConfigException
+
+PKG_DIR = os.path.dirname(__file__)
+PKG_DIR = os.path.join(PKG_DIR, os.pardir, os.pardir)
+PKG_DIR = os.path.normpath(PKG_DIR)
+
+
+class Utils(object):
+    @staticmethod
+    def validate_name(fqdn, message=False):
+        """
+        Function that tries to validate node name.
+        Checks if code contains letters, has '.' in it,
+        roles map contains code's role
+
+        :param fqdn: node FQDN name to supply for the check
+        :param message: True if validate should return error check message
+        :return: False if checks failed, True if all checks passed
+        """
+        _message = "Validation passed"
+
+        def _result():
+            return (True, _message) if message else True
+
+        # node role code checks
+        _code = re.findall("[a-zA-Z]+", fqdn.split('.')[0])
+        if len(_code) > 0:
+            if _code[0] in all_roles_map:
+                return _result()
+            else:
+                # log warning here
+                _message = "Node code is unknown, '{}'. " \
+                           "Please, update map".format(_code)
+        else:
+            # log warning here
+            _message = "Node name is invalid, '{}'".format(fqdn)
+
+        # put other checks here
+
+        # output result
+        return _result()
+
+    @staticmethod
+    def node_string_to_list(node_string):
+        # supplied var should contain node list
+        # if there is no ',' -> use space as a delimiter
+        if node_string is not None:
+            if node_string.find(',') < 0:
+                return node_string.split(' ')
+            else:
+                return node_string.split(',')
+        else:
+            return []
+
+    def get_node_code(self, fqdn):
+        # validate
+        _isvalid, _message = self.validate_name(fqdn, message=True)
+        _code = re.findall("[a-zA-Z]+", fqdn.split('.')[0])
+        # check if it is valid and raise if not
+        if _isvalid:
+            return _code[0]
+        else:
+            raise ConfigException(_message)
+
+    def get_nodes_list(self, env):
+        _list = []
+        if env is None:
+            # nothing supplied, use the one in repo
+            try:
+                with open(os.path.join(PKG_DIR, 'etc', '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))
+
+        # validate names
+        _invalid = []
+        _valid = []
+        for idx in range(len(_list)):
+            _name = _list[idx]
+            if not self.validate_name(_name):
+                _invalid.append(_name)
+            else:
+                _valid.append(_name)
+
+        return _valid
+
+
+utils = Utils()
diff --git a/check_versions/common/salt_utils.py b/check_versions/common/salt_utils.py
new file mode 100644
index 0000000..363e621
--- /dev/null
+++ b/check_versions/common/salt_utils.py
@@ -0,0 +1,372 @@
+"""
+Module to handle interaction with salt
+"""
+import os
+import requests
+import time
+
+from check_versions.common.base_settings import base_config as config
+from check_versions.common import logger
+
+
+def list_to_target_string(node_list, separator):
+    result = ''
+    for node in node_list:
+        result += node + ' ' + separator + ' '
+    return result[:-(len(separator)+2)]
+
+
+class SaltRest(object):
+    _host = config.salt_host
+    _port = config.salt_port
+    uri = "http://" + config.salt_host + ":" + config.salt_port
+    _auth = {}
+
+    default_headers = {
+        'Accept': 'application/json',
+        'Content-Type': 'application/json',
+        'X-Auth-Token': None
+    }
+
+    def __init__(self):
+        self._token = self._login()
+        self.last_response = None
+
+    def get(self, path='', headers=default_headers, cookies=None):
+        _path = os.path.join(self.uri, path)
+        logger.debug("GET '{}'\nHeaders: '{}'\nCookies: {}".format(
+            _path,
+            headers,
+            cookies
+        ))
+        return requests.get(
+            _path,
+            headers=headers,
+            cookies=cookies
+        )
+
+    def post(self, data, path='', headers=default_headers, cookies=None):
+        if data is None:
+            data = {}
+        _path = os.path.join(self.uri, path)
+        if path == 'login':
+            _data = str(data).replace(config.salt_pass, "*****")
+        else:
+            _data = data
+        logger.debug("POST '{}'\nHeaders: '{}'\nCookies: {}\nBody: {}".format(
+            _path,
+            headers,
+            cookies,
+            _data
+        ))
+        return requests.post(
+            os.path.join(self.uri, path),
+            headers=headers,
+            json=data,
+            cookies=cookies
+        )
+
+    def _login(self):
+        login_payload = {
+            'username': config.salt_user,
+            'password': config.salt_pass,
+            'eauth': 'pam'
+        }
+
+        logger.debug("Logging in to salt master...")
+        _response = self.post(login_payload, path='login')
+
+        if _response.ok:
+            self._auth['response'] = _response.json()['return'][0]
+            self._auth['cookies'] = _response.cookies
+            self.default_headers['X-Auth-Token'] = \
+                self._auth['response']['token']
+            return self._auth['response']['token']
+        else:
+            raise EnvironmentError(
+                "HTTP:{}, Not authorized?".format(_response.status_code)
+            )
+
+    def salt_request(self, fn, *args, **kwargs):
+        # if token will expire in 5 min, re-login
+        if self._auth['response']['expire'] < time.time() + 300:
+            self._auth['response']['X-Auth-Token'] = self._login()
+
+        _method = getattr(self, fn)
+        _response = _method(*args, **kwargs)
+        self.last_response = _response
+        _content = "..."
+        _len = len(_response.content)
+        if _len < 1024:
+            _content = _response.content
+        logger.debug(
+            "Response (HTTP {}/{}), {}: {}".format(
+                _response.status_code,
+                _response.reason,
+                _len,
+                _content
+            )
+        )
+        if _response.ok:
+            return _response.json()['return']
+        else:
+            raise EnvironmentError(
+                "Salt Error: HTTP:{}, '{}'".format(
+                    _response.status_code,
+                    _response.reason
+                )
+            )
+
+
+class SaltRemote(SaltRest):
+    def __init__(self):
+        super(SaltRemote, self).__init__()
+
+    def cmd(
+            self,
+            tgt,
+            fun,
+            param=None,
+            client='local',
+            kwarg=None,
+            expr_form=None,
+            tgt_type=None,
+            timeout=None
+    ):
+        _timeout = timeout if timeout is not None else config.salt_timeout
+        _payload = {
+            'fun': fun,
+            'tgt': tgt,
+            'client': client,
+            'timeout': _timeout
+        }
+
+        if expr_form:
+            _payload['expr_form'] = expr_form
+        if tgt_type:
+            _payload['tgt_type'] = tgt_type
+        if param:
+            _payload['arg'] = param
+        if kwarg:
+            _payload['kwarg'] = kwarg
+
+        _response = self.salt_request('post', [_payload])
+        if isinstance(_response, list):
+            return _response[0]
+        else:
+            raise EnvironmentError(
+                "Unexpected response from from salt-api/LocalClient: "
+                "{}".format(_response)
+            )
+
+    def run(self, fun, kwarg=None):
+        _payload = {
+            'client': 'runner',
+            'fun': fun,
+            'timeout': config.salt_timeout
+        }
+
+        if kwarg:
+            _payload['kwarg'] = kwarg
+
+        _response = self.salt_request('post', [_payload])
+        if isinstance(_response, list):
+            return _response[0]
+        else:
+            raise EnvironmentError(
+                "Unexpected response from from salt-api/RunnerClient: "
+                "{}".format(_response)
+            )
+
+    def wheel(self, fun, arg=None, kwarg=None):
+        _payload = {
+            'client': 'wheel',
+            'fun': fun,
+            'timeout': config.salt_timeout
+        }
+
+        if arg:
+            _payload['arg'] = arg
+        if kwarg:
+            _payload['kwarg'] = kwarg
+
+        _response = self.salt_request('post', _payload)['data']
+        if _response['success']:
+            return _response
+        else:
+            raise EnvironmentError(
+                "Salt Error: '{}'".format(_response['return']))
+
+    def pillar_request(self, node_target, pillar_submodule, argument):
+        # example cli: 'salt "ctl01*" pillar.keys rsyslog'
+        _type = "compound"
+        if isinstance(node_target, list):
+            _type = "list"
+        return self.cmd(
+            node_target,
+            "pillar." + pillar_submodule,
+            argument,
+            expr_form=_type
+        )
+
+    def pillar_keys(self, node_target, argument):
+        return self.pillar_request(node_target, 'keys', argument)
+
+    def pillar_get(self, node_target, argument):
+        return self.pillar_request(node_target, 'get', argument)
+
+    def pillar_data(self, node_target, argument):
+        return self.pillar_request(node_target, 'data', argument)
+
+    def pillar_raw(self, node_target, argument):
+        return self.pillar_request(node_target, 'raw', argument)
+
+    def list_minions(self):
+        """
+            Fails in salt version 2016.3.8
+            api returns dict of minions with grains
+        """
+        return self.salt_request('get', 'minions')
+
+    def list_keys(self):
+        """
+            Fails in salt version 2016.3.8
+            api should return dict:
+            {
+                'local': [],
+                'minions': [],
+                'minions_denied': [],
+                'minions_pre': [],
+                'minions_rejected': [],
+            }
+        """
+        return self.salt_request('get', path='keys')
+
+    def get_status(self):
+        """
+            'runner' client is the equivalent of 'salt-run'
+            Returns the
+        """
+        return self.run(
+            'manage.status',
+            kwarg={'timeout': 10}
+        )
+
+    def get_active_nodes(self):
+        if config.skip_nodes:
+            logger.info("Nodes to be skipped: {0}".format(config.skip_nodes))
+            return self.cmd(
+                '* and not ' + list_to_target_string(
+                    config.skip_nodes,
+                    'and not'
+                ),
+                'test.ping',
+                expr_form='compound')
+        else:
+            return self.cmd('*', 'test.ping')
+
+    def get_monitoring_ip(self, param_name):
+        salt_output = self.cmd(
+            'docker:client:stack:monitoring',
+            'pillar.get',
+            param=param_name,
+            expr_form='pillar')
+        return salt_output[salt_output.keys()[0]]
+
+    def f_touch_master(self, path, makedirs=True):
+        _kwarg = {
+            "makedirs": makedirs
+        }
+        salt_output = self.cmd(
+            "cfg01*",
+            "file.touch",
+            param=path,
+            kwarg=_kwarg
+        )
+        return salt_output[salt_output.keys()[0]]
+
+    def f_append_master(self, path, strings_list, makedirs=True):
+        _kwarg = {
+            "makedirs": makedirs
+        }
+        _args = [path]
+        _args.extend(strings_list)
+        salt_output = self.cmd(
+            "cfg01*",
+            "file.write",
+            param=_args,
+            kwarg=_kwarg
+        )
+        return salt_output[salt_output.keys()[0]]
+
+    def mkdir(self, target, path, tgt_type=None):
+        salt_output = self.cmd(
+            target,
+            "file.mkdir",
+            param=path,
+            expr_form=tgt_type
+        )
+        return salt_output
+
+    def f_manage_file(self, target_path, source,
+                      sfn='', ret='{}',
+                      source_hash={},
+                      user='root', group='root', backup_mode='755',
+                      show_diff='base',
+                      contents='', makedirs=True):
+        """
+        REST variation of file.get_managed
+        CLI execution goes like this (10 agrs):
+        salt cfg01\* file.manage_file /root/test_scripts/pkg_versions.py
+        '' '{}' /root/diff_pkg_version.py
+        '{hash_type: 'md5', 'hsum': <md5sum>}' root root '755' base ''
+        makedirs=True
+            param: name - target file placement when managed
+            param: source - source for the file
+        """
+        _source_hash = {
+            "hash_type": "md5",
+            "hsum": 000
+        }
+        _arg = [
+            target_path,
+            sfn,
+            ret,
+            source,
+            _source_hash,
+            user,
+            group,
+            backup_mode,
+            show_diff,
+            contents
+        ]
+        _kwarg = {
+            "makedirs": makedirs
+        }
+        salt_output = self.cmd(
+            "cfg01*",
+            "file.manage_file",
+            param=_arg,
+            kwarg=_kwarg
+        )
+        return salt_output[salt_output.keys()[0]]
+
+    def cache_file(self, target, source_path):
+        salt_output = self.cmd(
+            target,
+            "cp.cache_file",
+            param=source_path
+        )
+        return salt_output[salt_output.keys()[0]]
+
+    def get_file(self, target, source_path, target_path, tgt_type=None):
+        return self.cmd(
+            target,
+            "cp.get_file",
+            param=[source_path, target_path],
+            expr_form=tgt_type
+        )
+
+    @staticmethod
+    def compound_string_from_list(nodes_list):
+        return " or ".join(nodes_list)
diff --git a/check_versions/pkg_check.py b/check_versions/pkg_check.py
new file mode 100644
index 0000000..4713543
--- /dev/null
+++ b/check_versions/pkg_check.py
@@ -0,0 +1,184 @@
+import json
+import os
+import sys
+
+from copy import deepcopy
+
+import common.const as const
+import pkg_reporter
+from check_versions.common import utils
+from check_versions.common import base_config, logger, PKG_DIR
+from check_versions.common import salt_utils
+
+node_tmpl = {
+    'role': '',
+    'node_group': '',
+    'status': const.NODE_DOWN,
+    'pillars': {},
+    'grains': {}
+}
+
+
+class CloudPackageChecker(object):
+    _config = base_config
+
+    def __init__(self):
+        logger.info("Collecting nodes for package check")
+        # simple salt rest client
+        self.salt = salt_utils.SaltRemote()
+
+        # Keys for all nodes
+        # this is not working in scope of 2016.8.3, will overide with list
+        # cls.node_keys = cls.salt.list_keys()
+
+        logger.debug("Collecting node names existing in the cloud")
+        self.node_keys = {
+            'minions': base_config.all_nodes
+        }
+
+        # all that answer ping
+        _active = self.salt.get_active_nodes()
+        logger.debug("Nodes responded: {}".format(_active))
+        # just inventory for faster interaction
+        # iterate through all accepted nodes and create a dict for it
+        self.nodes = {}
+        for _name in self.node_keys['minions']:
+            _nc = utils.get_node_code(_name)
+            _rmap = const.all_roles_map
+            _role = _rmap[_nc] if _nc in _rmap else 'unknown'
+            _status = const.NODE_UP if _name in _active else const.NODE_DOWN
+
+            self.nodes[_name] = deepcopy(node_tmpl)
+            self.nodes[_name]['node_group'] = _nc
+            self.nodes[_name]['role'] = _role
+            self.nodes[_name]['status'] = _status
+
+        logger.debug("{} nodes collected".format(len(self.nodes)))
+
+    def collect_installed_packages(self):
+        """
+        Collect installed packages on each node
+        sets 'installed' dict property in the class
+
+        :return: none
+        """
+        # form an all nodes compound string to use in salt
+        _active_nodes_string = self.salt.compound_string_from_list(
+            filter(
+                lambda nd: self.nodes[nd]['status'] == const.NODE_UP,
+                self.nodes
+            )
+        )
+        # Prepare script
+        _script_filename = "pkg_versions.py"
+        _p = os.path.join(PKG_DIR, 'scripts', _script_filename)
+        with open(_p, 'rt') as fd:
+            _script = fd.read().splitlines()
+
+        _storage_path = os.path.join(
+            base_config.salt_file_root, base_config.salt_scripts_folder
+        )
+        _result = self.salt.mkdir("cfg01*", _storage_path)
+        logger.debug(
+            "Tried to create folder on master. Salt returned: {}".format(
+                _result
+            )
+        )
+        # Form cache, source and target path
+        _cache_path = os.path.join(_storage_path, _script_filename)
+        _source_path = os.path.join(
+            'salt://',
+            base_config.salt_scripts_folder,
+            _script_filename
+        )
+        _target_path = os.path.join(
+            '/root',
+            base_config.salt_scripts_folder,
+            _script_filename
+        )
+
+        logger.debug("Creating file in cache '{}'".format(_cache_path))
+        _result = self.salt.f_touch_master(_cache_path)
+        _result = self.salt.f_append_master(_cache_path, _script)
+        # command salt to copy file to minions
+        logger.debug("Creating script target folder '{}'".format(_cache_path))
+        _result = self.salt.mkdir(
+            _active_nodes_string,
+            os.path.join(
+                '/root',
+                base_config.salt_scripts_folder
+            ),
+            tgt_type="compound"
+        )
+        logger.debug("Copying script to all nodes")
+        _result = self.salt.get_file(
+            _active_nodes_string,
+            _source_path,
+            _target_path,
+            tgt_type="compound"
+        )
+        # execute pkg collecting script
+        logger.debug("Running script to all nodes")
+        # handle results for each node
+        _result = self.salt.cmd(
+            _active_nodes_string,
+            'cmd.run',
+            param='python {}'.format(_target_path),
+            expr_form="compound"
+        )
+        for key in self.nodes.keys():
+            # due to much data to be passed from salt, it is happening in order
+            if key in _result:
+                _text = _result[key]
+                _dict = json.loads(_text[_text.find('{'):])
+                self.nodes[key]['packages'] = _dict
+            else:
+                self.nodes[key]['packages'] = {}
+            logger.info("{} has {} packages installed".format(
+                key,
+                len(self.nodes[key]['packages'].keys())
+            ))
+
+    def collect_packages(self):
+        """
+        Check package versions in repos vs installed
+
+        :return: no return values, all date put to dict in place
+        """
+        _all_packages = {}
+        for node_name, node_value in self.nodes.iteritems():
+            for package_name in node_value['packages']:
+                if package_name not in _all_packages:
+                    _all_packages[package_name] = {}
+                _all_packages[package_name][node_name] = node_value
+
+        # TODO: process data for per-package basis
+
+        self.all_packages = _all_packages
+
+    def create_html_report(self, filename):
+        """
+        Create static html showing packages diff per node
+
+        :return: buff with html
+        """
+        _report = pkg_reporter.ReportToFile(
+            pkg_reporter.HTMLPackageVersions(),
+            filename
+        )
+        _report(self.nodes)
+
+
+# init connection to salt and collect minion data
+cl = CloudPackageChecker()
+
+# collect data on installed packages
+cl.collect_installed_packages()
+
+# diff installed and candidates
+# cl.collect_packages()
+
+# report it
+cl.create_html_report("./pkg_versions.html")
+
+sys.exit(0)
diff --git a/check_versions/pkg_reporter.py b/check_versions/pkg_reporter.py
new file mode 100644
index 0000000..2ed9dcc
--- /dev/null
+++ b/check_versions/pkg_reporter.py
@@ -0,0 +1,165 @@
+import jinja2
+import six
+import abc
+import os
+
+from check_versions.common import const
+
+pkg_dir = os.path.dirname(__file__)
+pkg_dir = os.path.join(pkg_dir, os.pardir)
+pkg_dir = os.path.normpath(pkg_dir)
+
+
+def shortname(node_fqdn):
+    # form shortname out of node fqdn
+    return node_fqdn.split(".", 1)[0]
+
+
+def is_equal(pkg_dict):
+    # compare versions of given package
+    return pkg_dict['installed'] == pkg_dict['candidate']
+
+
+def is_active(node_dict):
+    # check node status in node dict
+    return node_dict['status'] == const.NODE_UP
+
+
+def line_breaks(text):
+    # replace python linebreaks with html breaks
+    return text.replace("\n", "<br />")
+
+
+@six.add_metaclass(abc.ABCMeta)
+class _Base(object):
+    def __init__(self):
+        self.jinja2_env = self.init_jinja2_env()
+
+    @abc.abstractmethod
+    def __call__(self, payload):
+        pass
+
+    @staticmethod
+    def init_jinja2_env():
+        return jinja2.Environment(
+            loader=jinja2.FileSystemLoader(os.path.join(pkg_dir, 'templates')),
+            trim_blocks=True,
+            lstrip_blocks=True)
+
+
+class _TMPLBase(_Base):
+    @abc.abstractproperty
+    def tmpl(self):
+        pass
+
+    @staticmethod
+    def _count_totals(data):
+        data['counters']['total_nodes'] = len(data['nodes'])
+
+    def __call__(self, nodes):
+        # init data structures
+        data = self.common_data()
+        data.update({
+            "nodes": nodes
+        })
+
+        # add template specific data
+        self._extend_data(data)
+
+        # do counts global
+        self._count_totals(data)
+
+        # specific filters
+        self.jinja2_env.filters['shortname'] = shortname
+        self.jinja2_env.filters['is_equal'] = is_equal
+        self.jinja2_env.filters['is_active'] = is_active
+        self.jinja2_env.filters['linebreaks'] = line_breaks
+
+        # render!
+        tmpl = self.jinja2_env.get_template(self.tmpl)
+        return tmpl.render(data)
+
+    def common_data(self):
+        return {
+            'counters': {},
+            'salt_info': {}
+        }
+
+    def _extend_data(self, data):
+        pass
+
+
+# Package versions report
+class HTMLPackageVersions(_TMPLBase):
+    tmpl = "pkg_versions_tmpl.j2"
+
+    @staticmethod
+    def is_fail_uniq(p_dict, p_name, nodes, node_name):
+        # look up package fail for nodes with similar role
+        _tgroup = nodes[node_name]['node_group']
+        # filter all nodes with the same role
+        _nodes_list = filter(
+            lambda nd: nodes[nd]['node_group'] == _tgroup and nd != node_name,
+            nodes
+        )
+        # lookup same package
+        _fail_uniq = False
+        for _node_name in _nodes_list:
+            # check if there is a package present on node
+            _nd = nodes[_node_name]['packages']
+            if p_name not in _nd:
+                continue
+            # if both backages has same version and differ from candidate
+            if p_dict['candidate'] == _nd[p_name]['candidate'] \
+                    and _nd[p_name]['candidate'] == _nd[p_name]['installed']:
+                # it is not uniq, mark and break
+                _fail_uniq = True
+        return _fail_uniq
+
+    def _extend_data(self, data):
+        _all_pkg = 0
+        for key, value in data['nodes'].iteritems():
+            # add count of packages for this node to total
+            _all_pkg += len(value.keys())
+
+            # count differences
+            data['counters'][key] = {}
+            data['counters'][key]['packages'] = len(value['packages'].keys())
+            data['counters'][key]['package_diff'] = 0
+            for pkg_name, pkg_value in value['packages'].iteritems():
+                if pkg_value['installed'] != pkg_value['candidate']:
+                    pkg_value['is_equal'] = False
+                    pkg_value['fail_uniq'] = self.is_fail_uniq(
+                        pkg_value,
+                        pkg_name,
+                        data['nodes'],
+                        key
+                    )
+                    data['counters'][key]['package_diff'] += 1
+                else:
+                    pkg_value['is_equal'] = True
+                    pkg_value['fail_uniq'] = False
+
+        data['counters']['total_packages'] = _all_pkg
+
+
+class ReportToFile(object):
+    def __init__(self, report, target):
+        self.report = report
+        self.target = target
+
+    def __call__(self, payload):
+        payload = self.report(payload)
+
+        if isinstance(self.target, six.string_types):
+            self._wrapped_dump(payload)
+        else:
+            self._dump(payload, self.target)
+
+    def _wrapped_dump(self, payload):
+        with open(self.target, 'wt') as target:
+            self._dump(payload, target)
+
+    @staticmethod
+    def _dump(payload, target):
+        target.write(payload)
diff --git a/scripts/pkg_versions.py b/scripts/pkg_versions.py
new file mode 100644
index 0000000..be02aa8
--- /dev/null
+++ b/scripts/pkg_versions.py
@@ -0,0 +1,54 @@
+import sys
+import subprocess
+import json
+
+from multiprocessing.dummy import Pool
+
+
+def shell(command):
+    _ps = subprocess.Popen(
+        command.split(),
+        stdout=subprocess.PIPE
+    ).communicate()[0].decode()
+
+    return _ps
+
+
+def get_versions(pkg):
+    # get the info for the package
+    _pkg_info = shell('apt-cache policy ' + pkg)
+
+    _installed = 'none'
+    _candidate = 'none'
+
+    # extract the installed and candidate
+    for line in _pkg_info.splitlines():
+        if line.find("Installed") > 0:
+            _installed = line.split(':', 1)[1].strip()
+        elif line.find("Candidate") > 0:
+            _candidate = line.split(':', 1)[1].strip()
+    return [pkg, _installed, _candidate, _pkg_info]
+
+
+# get list of packages
+_list = shell("apt list --installed")
+pkg_list = _list.splitlines()
+pkg_list = [_pkg.split('/')[0] for _pkg in pkg_list[1:]]
+
+# threading pool
+pool = Pool(10)
+
+result = pool.map(get_versions, pkg_list)
+
+# init pkg storage
+pkgs = {}
+for res in result:
+    _pkg = res[0]
+    if _pkg not in pkgs:
+        pkgs[_pkg] = {}
+    pkgs[_pkg]['installed'] = res[1]
+    pkgs[_pkg]['candidate'] = res[2]
+    pkgs[_pkg]['raw'] = res[3]
+
+buff = json.dumps(pkgs)
+sys.stdout.write(buff)
diff --git a/templates/pkg_versions_tmpl.j2 b/templates/pkg_versions_tmpl.j2
new file mode 100644
index 0000000..3c61745
--- /dev/null
+++ b/templates/pkg_versions_tmpl.j2
@@ -0,0 +1,269 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>Cloud Package versions check</title>
+    <style>
+        body {
+            font-family: Verdana, Geneva, Tahoma, sans-serif;
+            font-size: 90% !important;
+        }
+        .dot_green {
+            float: left;
+            color: green;
+            margin-right: 0.5em;
+            margin-top: 0.2em;
+        }
+        .dot_red {
+            float: left;
+            color: red;
+            margin-right: 0.5em;
+            margin-top: 0.2em;
+        }
+        .dot_empty {
+            float: left;
+            color: darkgray;
+            margin-right: 0.5em;
+            margin-top: 0.2em;
+        }
+        /* Style the tab */
+        .tab {
+            float: left;
+            width: 130px;
+            border: 1px solid #fff;
+            background-color: #efe;
+        }
+
+        /* Style the buttons that are used to open the tab content */
+        .tab button {
+            display: block;
+            background-color: inherit;
+            color: Black;
+            border: none;
+            outline: none;
+            font-family: "Lucida Console", Monaco, monospace;
+            text-align: left;
+            cursor: pointer;
+            transition: 0.3s;
+            font-size: 1.3em;
+            width: 100%;
+            padding: 1px;
+            margin: 1px;
+        }
+
+        button > div.node_name {
+            float: left;
+            font-size: 1.3em;
+        }
+
+        .smallgreytext {
+            float: right;
+            font-size: 0.7em;
+            color: gray;
+        }
+
+        /* Change background color of buttons on hover */
+        .tab button:hover {
+            background-color: #7b7;
+        }
+
+        /* Create an active/current "tab button" class */
+        .tab button.active {
+            background-color: #8c8;
+            color: white;
+        }
+
+        /* Style the tab content */
+        .tabcontent {
+            display: none;
+            position: absolute;
+            font-size: 1em;
+            padding: 0.5em;
+            right: -10%;
+            top: 0%;
+            transform: translateX(-12%);
+            width: calc(100% - 170px);
+            overflow-x: scroll;
+            overflow-wrap: break-word;
+        }
+
+        table {
+            border: 0 hidden;
+            width: 100%;
+        }
+        tr:nth-child(even) {
+            background-color: #fff;
+        }
+        tr:nth-child(odd) {
+            background-color: #ddd;
+        }
+        .Header {
+            background-color: #bbb;
+            color: Black;
+            width: 30%;
+            text-align: center;
+        }
+        .pkgName {
+            font-size: 1em;
+            padding-left: 10px;
+        }
+
+        .version {
+            font-size: 1.1em;
+            text-align: left;
+        }
+
+        .differ {
+            background-color: #eaa;
+        }
+        /* Tooltip container */
+        .tooltip {
+            position: relative;
+            display: inline-block;
+            border-bottom: 1px dotted black;
+        }
+
+        .tooltip .tooltiptext {
+            visibility: hidden;
+            background-color: black;
+            font-family: "Lucida Console", Monaco, monospace;
+            font-size: 0.5em;
+            width: auto;
+            color: #fff;
+            border-radius: 6px;
+            padding: 5px 5px;
+
+            /* Position the tooltip */
+            position: absolute;
+            z-index: 1;
+        }
+
+        .tooltip:hover .tooltiptext {
+            visibility: visible;
+        }
+
+    </style>
+    <script language="JavaScript">
+        function init() {
+            // Declare all variables
+            var i, tabcontent, tablinks;
+
+            // Get all elements with class="tabcontent" and hide them
+            tabcontent = document.getElementsByClassName("tabcontent");
+            for (i = 1; i < tabcontent.length; i++) {
+                tabcontent[i].style.display = "none";
+            }
+            tabcontent[0].style.display = "block";
+
+            // Get all elements with class="tablinks" and remove the class "active"
+            tablinks = document.getElementsByClassName("tablinks");
+            for (i = 1; i < tablinks.length; i++) {
+                tablinks[i].className = tablinks[i].className.replace(" active", "");
+            }
+            tablinks[0].className += " active";
+
+        }
+        function openTab(evt, tabName) {
+            // Declare all variables
+            var i, tabcontent, tablinks;
+
+            // Get all elements with class="tabcontent" and hide them
+            tabcontent = document.getElementsByClassName("tabcontent");
+            for (i = 0; i < tabcontent.length; i++) {
+                tabcontent[i].style.display = "none";
+            }
+
+            // Get all elements with class="tablinks" and remove the class "active"
+            tablinks = document.getElementsByClassName("tablinks");
+            for (i = 0; i < tablinks.length; i++) {
+                tablinks[i].className = tablinks[i].className.replace(" active", "");
+            }
+
+            // Show the current tab, and add an "active" class to the link that opened the tab
+            document.getElementById(tabName).style.display = "block";
+            evt.currentTarget.className += " active";
+        }
+    </script>
+</head>
+<body onload="init()">
+<div class="tab">
+{% for node_name in nodes.keys() | sort %}
+    {% if nodes[node_name] | is_active %}
+  <button class="tablinks" onclick="openTab(event, '{{ node_name | shortname }}')">
+    <div class="dot_green">&#9679;</div>
+    <div class="node_name">{{ node_name | shortname }}</div>
+    <div class="smallgreytext">({{ counters[node_name]['package_diff'] }} / {{ counters[node_name]['packages'] }})</div>
+  </button>
+    {% endif %}    
+{% endfor %}
+{% for node_name in nodes.keys() | sort %}
+    {% if not nodes[node_name] | is_active %}
+  <button class="tablinks" onclick="openTab(event, '{{ node_name | shortname }}')">
+    <div class="dot_empty">&#9675;</div>
+    <div class="node_name">{{ node_name | shortname }}</div>
+    <div class="smallgreytext">({{ counters[node_name]['package_diff'] }} / {{ counters[node_name]['packages'] }})</div>
+  </button>
+    {% endif %}    
+{% endfor %}
+</div>
+{% for node_name in nodes.keys() | sort %}
+<div id="{{ node_name | shortname }}" class="tabcontent">
+    <table class="pkgversions">
+        <tbody>
+        <tr>
+            <td class="Header">Package name</td>
+            <td class="Header">Installed</td>
+            <td class="Header">Candidate</td>
+        </tr>
+        <tr><td colspan=3>Package with different versions uniq for this node</td></tr>
+        {% for package_name in nodes[node_name]['packages'] | sort %}
+            {% if not nodes[node_name]['packages'][package_name]['is_equal'] %}
+            {% if nodes[node_name]['packages'][package_name]['fail_uniq'] %}
+        <tr>
+            <td class="pkgName">{{ package_name }}</td>
+            <td class="version differ">
+                <div class="tooltip">{{ nodes[node_name]['packages'][package_name]['installed'] }}
+                    <pre class="tooltiptext">{{ nodes[node_name]['packages'][package_name]['raw'] | linebreaks }}</pre>
+                </div>
+            </td>
+            <td class="version">{{ nodes[node_name]['packages'][package_name]['candidate'] }}</td>
+        </tr>
+            {% endif %}
+            {% endif %}
+        {% endfor %}
+        <tr><td colspan=3>Packages with different versions on nodes with similar role</td></tr>
+        {% for package_name in nodes[node_name]['packages'] | sort %}
+            {% if not nodes[node_name]['packages'][package_name]['is_equal'] %}
+            {% if not nodes[node_name]['packages'][package_name]['fail_uniq'] %}
+        <tr>
+            <td class="pkgName">{{ package_name }}</td>
+            <td class="version differ">
+                <div class="tooltip">{{ nodes[node_name]['packages'][package_name]['installed'] }}
+                    <pre class="tooltiptext">{{ nodes[node_name]['packages'][package_name]['raw'] | linebreaks }}</pre>
+                </div>
+            </td>
+            <td class="version">{{ nodes[node_name]['packages'][package_name]['candidate'] }}</td>
+        </tr>
+            {% endif %}
+            {% endif %}
+        {% endfor %}
+        <tr><td colspan=3>Packages with same versions: installed vs candidate</td></tr>
+        {% for package_name in nodes[node_name]['packages'] | sort %}
+            {% if nodes[node_name]['packages'][package_name] | is_equal %}
+        <tr>
+            <td class="pkgName">{{ package_name }}</td>
+            <td class="version">
+                <div class="tooltip">{{ nodes[node_name]['packages'][package_name]['installed'] }}
+                    <pre class="tooltiptext">{{ nodes[node_name]['packages'][package_name]['raw'] | linebreaks }}</pre>
+                </div>
+            </td>
+            <td class="version">{{ nodes[node_name]['packages'][package_name]['candidate'] }}</td>
+        </tr>
+            {% endif %}
+        {% endfor %}
+        </tbody>
+    </table>
+</div>
+{% endfor %}
+</body>
+</html>
\ No newline at end of file