Add 'trace-key' command

Change-Id: I796e15544a9224ed53f435354a29f2616b541b97
diff --git a/reclass_tools/cli.py b/reclass_tools/cli.py
index 8673192..92e2bd2 100644
--- a/reclass_tools/cli.py
+++ b/reclass_tools/cli.py
@@ -90,6 +90,16 @@
         else:
             print('\n'.join(sorted(inventory.keys())))
 
+    def do_trace_key(self):
+        try:
+            from reclass_tools import reclass_models
+        except ImportError:
+            sys.exit("Please run this tool on the salt-master node "
+                     "with installed 'reclass'")
+        reclass_models.trace_key(key=self.params.key_name,
+                                 domain=self.params.domain,
+                                 node=self.params.node)
+
     def do_show_context(self):
         try:
             from reclass_tools import create_inventory
@@ -159,6 +169,12 @@
             help=('Show only the nodes which names are ended with the '
                   'specified domain, for example: example.local'))
 
+        node_parser = argparse.ArgumentParser(add_help=False)
+        node_parser.add_argument(
+            '--node', '-n', dest='node',
+            help=('Show only the specified node, for example: '
+                  'ctl01.example.local'))
+
         env_name_parser = argparse.ArgumentParser(add_help=False)
         env_name_parser.add_argument(
             '--env-name', '-e', dest='env_name',
@@ -238,6 +254,14 @@
                                     "for specified keys. Use on salt-master "
                                     "node for already generated inventory "
                                     "only!"))
+        subparsers.add_parser('trace-key',
+                              parents=[domain_parser, node_parser, key_parser],
+                              help=("Use 'reclass' to merge the model and "
+                                    "show all the classes where the "
+                                    "specified key is overwritten, and updated"
+                                    " values during merging and after "
+                                    "interpolation. "
+                                    "Use on salt-master node only!"))
         subparsers.add_parser('render',
                               parents=[render_parser, env_name_parser],
                               help=("Render cookiecutter template using "
diff --git a/reclass_tools/reclass_models.py b/reclass_tools/reclass_models.py
index 4f85e5e..265c37a 100644
--- a/reclass_tools/reclass_models.py
+++ b/reclass_tools/reclass_models.py
@@ -12,17 +12,94 @@
 #    License for the specific language governing permissions and limitations
 #    under the License.
 
+import copy
+
 import reclass
 # from reclass.adapters import salt as reclass_salt
 from reclass import config as reclass_config
 from reclass import core as reclass_core
+from reclass import defaults as reclass_defaults
+from reclass.utils.refvalue import RefValue
+import yaml
 
 from reclass_tools import helpers
 # import salt.cli.call
 # import salt.cli.caller
 
 
-def get_core():
+def refvalue_representer(dumper, data):
+    return dumper.represent_str(
+        data._assemble(
+            lambda s: s.join(reclass_defaults.PARAMETER_INTERPOLATION_SENTINELS)))
+yaml.add_representer(RefValue, refvalue_representer)
+
+class ReclassCore(reclass_core.Core):
+    """Track the specific key
+
+    :param key: string with dot-separated keys
+    """
+    track_key_path = None
+
+    def __init__(self, storage, class_mappings, input_data=None,
+                 key=None):
+        if key:
+            self.track_key_path = key.split('.')
+            if 'parameters' not in self.track_key_path:
+                raise Exception("Please use the key path starting from 'parameters'.")
+            # Remove the first 'parameters' element because the model entities
+            # keep parameters in different object format.
+            self.track_key_path = self.track_key_path[1:]
+
+        super(ReclassCore, self).__init__(storage, class_mappings, input_data)
+
+    def _recurse_entity(self, entity, merge_base=None, seen=None, nodename=None):
+        if seen is None:
+            seen = {}
+        if '__visited' not in seen:
+            seen['__visited'] = []
+
+        orig_visited = copy.deepcopy(seen['__visited'])
+        seen['__visited'].append(entity.name)
+
+        result =  super(ReclassCore, self)._recurse_entity(entity,
+                                                           merge_base,
+                                                           seen,
+                                                           nodename)
+        if self.track_key_path:
+            key = helpers.get_nested_key(entity.parameters.as_dict(),
+                                         path=self.track_key_path)
+            if key:
+                print("# " + ' < '.join(seen['__visited']))
+                out_dict = {}
+                helpers.create_nested_key(out_dict, ['parameters'] + self.track_key_path, key)
+                print(yaml.dump(out_dict,
+                                default_flow_style=False))
+
+        # Reset the data collected by child entries
+        seen['__visited'] = orig_visited
+
+        return result
+
+    def _nodeinfo(self, nodename):
+        if self.track_key_path:
+            print("\n" + nodename)
+            print("-" * len(nodename))
+
+        result =  super(ReclassCore, self)._nodeinfo(nodename)
+
+        if self.track_key_path:
+            key = helpers.get_nested_key(result.parameters.as_dict(),
+                                         path=self.track_key_path)
+            if key:
+                print("### Final result after interpolation: ###")
+                out_dict = {}
+                helpers.create_nested_key(out_dict, ['parameters'] + self.track_key_path, key)
+                print(yaml.dump(out_dict,
+                                default_flow_style=False))
+        return result
+
+
+def get_core(key=None):
     """Initializes reclass Core() using /etc/reclass settings"""
 
     defaults = reclass_config.find_and_read_configfile()
@@ -34,7 +111,8 @@
     storage = reclass.get_storage(storage_type, nodes_uri, classes_uri,
                                   default_environment='base')
 
-    return reclass_core.Core(storage, None, None)
+    #key = '_param.keepalived_vip_interface'
+    return ReclassCore(storage, None, None, key=key)
 
 
 # def get_minion_domain():
@@ -58,11 +136,31 @@
     return inventory
 
 
+def nodes_list(domain=None):
+    core = get_core()
+    nodes = core._storage.enumerate_nodes()
+    if domain is not None:
+        nodes = [node for node in nodes
+                 if node.endswith(domain)]
+    return nodes
+
+
 def get_nodeinfo(minion_id):
     core = get_core()
     return core.nodeinfo(minion_id)
 
 
+def trace_key(key, domain=None, node=None):
+    if node:
+        nodes = [node]
+    else:
+        nodes = nodes_list(domain=domain)
+
+    core = get_core(key=key)
+    for node in nodes:
+        core.nodeinfo(node)
+
+
 def vcp_list(domain=None, inventory=None):
     """List VCP node names