From 64323d371ae5180c5b83482cc89d47659defe61e Mon Sep 17 00:00:00 2001
From: Daniel Miranda <danielkza2@gmail.com>
Date: Tue, 12 Mar 2019 17:01:42 -0300
Subject: [PATCH] commands: info: add configurable output formats

---
 stacker/actions/info.py            | 121 +++++++++++++++++++++++++++--
 stacker/commands/stacker/info.py   |   7 +-
 stacker/tests/actions/test_info.py |  91 ++++++++++++++++++++++
 3 files changed, 210 insertions(+), 9 deletions(-)
 create mode 100644 stacker/tests/actions/test_info.py

diff --git a/stacker/actions/info.py b/stacker/actions/info.py
index 1508de2f0..1060257c8 100644
--- a/stacker/actions/info.py
+++ b/stacker/actions/info.py
@@ -2,6 +2,8 @@
 from __future__ import division
 from __future__ import absolute_import
 import logging
+import json
+import sys
 
 from .base import BaseAction
 from .. import exceptions
@@ -9,6 +11,89 @@
 logger = logging.getLogger(__name__)
 
 
+class Exporter(object):
+    def __init__(self, context):
+        self.context = context
+
+    def start(self):
+        pass
+
+    def start_stack(self, stack):
+        pass
+
+    def end_stack(self, stack):
+        pass
+
+    def write_output(self, key, value):
+        pass
+
+    def finish(self):
+        pass
+
+
+class JsonExporter(Exporter):
+    def start(self):
+        self.current_outputs = {}
+        self.stacks = {}
+
+    def start_stack(self, stack):
+        self.current_outputs = {}
+
+    def end_stack(self, stack):
+        self.stacks[stack.name] = {
+            "outputs": self.current_outputs,
+            "fqn": stack.fqn
+        }
+        self.current_outputs = {}
+
+    def write_output(self, key, value):
+        self.current_outputs[key] = value
+
+    def finish(self):
+        json_data = json.dumps({'stacks': self.stacks}, indent=4)
+        sys.stdout.write(json_data)
+        sys.stdout.write('\n')
+        sys.stdout.flush()
+
+
+class PlainExporter(Exporter):
+    def start(self):
+        self.current_stack = None
+
+    def start_stack(self, stack):
+        self.current_stack = stack.name
+
+    def end_stack(self, stack):
+        self.current_stack = None
+
+    def write_output(self, key, value):
+        line = '{}.{}={}\n'.format(self.current_stack, key, value)
+        sys.stdout.write(line)
+
+    def finish(self):
+        sys.stdout.flush()
+
+
+class LogExporter(Exporter):
+    def start(self):
+        logger.info('Outputs for stacks: %s', self.context.get_fqn())
+
+    def start_stack(self, stack):
+        logger.info('%s:', stack.fqn)
+
+    def write_output(self, key, value):
+        logger.info('\t{}: {}'.format(key, value))
+
+
+EXPORTER_CLASSES = {
+    'json': JsonExporter,
+    'log': LogExporter,
+    'plain': PlainExporter
+}
+
+OUTPUT_FORMATS = list(EXPORTER_CLASSES.keys())
+
+
 class Action(BaseAction):
     """Get information on CloudFormation stacks.
 
@@ -16,10 +101,28 @@ class Action(BaseAction):
 
     """
 
-    def run(self, *args, **kwargs):
-        logger.info('Outputs for stacks: %s', self.context.get_fqn())
+    @classmethod
+    def build_exporter(cls, name):
+        try:
+            exporter_cls = EXPORTER_CLASSES[name]
+        except KeyError:
+            logger.error('Unknown output format "{}"'.format(name))
+            raise
+
+        try:
+            return exporter_cls()
+        except Exception:
+            logger.exception('Failed to create exporter instance')
+            raise
+
+    def run(self, output_format='log', *args, **kwargs):
         if not self.context.get_stacks():
             logger.warn('WARNING: No stacks detected (error in config?)')
+            return
+
+        exporter = self.build_exporter(output_format)
+        exporter.start(self.context)
+
         for stack in self.context.get_stacks():
             provider = self.build_provider(stack)
 
@@ -29,11 +132,13 @@ def run(self, *args, **kwargs):
                 logger.info('Stack "%s" does not exist.' % (stack.fqn,))
                 continue
 
-            logger.info('%s:', stack.fqn)
+            exporter.start_stack(stack)
+
             if 'Outputs' in provider_stack:
                 for output in provider_stack['Outputs']:
-                    logger.info(
-                        '\t%s: %s',
-                        output['OutputKey'],
-                        output['OutputValue']
-                    )
+                    exporter.write_output(output['OutputKey'],
+                                          output['OutputValue'])
+
+            exporter.end_stack(stack)
+
+        exporter.finish()
diff --git a/stacker/commands/stacker/info.py b/stacker/commands/stacker/info.py
index ac847bbec..cf9663615 100644
--- a/stacker/commands/stacker/info.py
+++ b/stacker/commands/stacker/info.py
@@ -20,13 +20,18 @@ def add_arguments(self, parser):
                                  "specified more than once. If not specified "
                                  "then stacker will work on all stacks in the "
                                  "config file.")
+        parser.add_argument("--output-format", action="store", type=str,
+                            choices=info.OUTPUT_FORMATS,
+                            help="Write out stack information in the given "
+                                 "export format. Use it if you intend to "
+                                 "parse the result programatically.")
 
     def run(self, options, **kwargs):
         super(Info, self).run(options, **kwargs)
         action = info.Action(options.context,
                              provider_builder=options.provider_builder)
 
-        action.execute()
+        action.execute(output_format=options.output_format)
 
     def get_context_kwargs(self, options, **kwargs):
         return {"stack_names": options.stacks}
diff --git a/stacker/tests/actions/test_info.py b/stacker/tests/actions/test_info.py
new file mode 100644
index 000000000..7b77c5022
--- /dev/null
+++ b/stacker/tests/actions/test_info.py
@@ -0,0 +1,91 @@
+from __future__ import print_function
+from __future__ import division
+from __future__ import absolute_import
+import json
+import unittest
+
+from mock import Mock, patch
+from six import StringIO
+
+from stacker.context import Context, Config
+from stacker.actions.info import (
+    JsonExporter,
+    PlainExporter
+)
+
+
+def stack_mock(name, **kwargs):
+    m = Mock(**kwargs)
+    m.name = name
+    return m
+
+
+class TestExporters(unittest.TestCase):
+    def setUp(self):
+        self.context = Context(config=Config({"namespace": "namespace"}))
+        self.stacks = [
+            stack_mock(name='vpc', fqn='namespace-test-1'),
+            stack_mock(name='bucket', fqn='namespace-test-2'),
+            stack_mock(name='role', fqn='namespace-test-3')
+        ]
+        self.outputs = {
+            'vpc': {
+                'VpcId': 'vpc-123456',
+                'VpcName': 'dev'
+            },
+            'bucket': {
+                'BucketName': 'my-bucket'
+            },
+            'role': {
+                'RoleName': 'my-role',
+                'RoleArn': 'arn:::'
+            }
+        }
+
+    def run_export(self, exporter):
+        exporter.start()
+
+        for stack in self.stacks:
+            exporter.start_stack(stack)
+            for key, value in self.outputs[stack.name].items():
+                exporter.write_output(key, value)
+            exporter.end_stack(stack)
+
+        exporter.finish()
+
+    def test_json(self):
+        exporter = JsonExporter(self.context)
+        with patch('sys.stdout', new=StringIO()) as fake_out:
+            self.run_export(exporter)
+
+        json_data = json.loads(fake_out.getvalue().strip())
+        self.assertEqual(
+            json_data,
+            {
+                u'stacks': {
+                    u'vpc': {
+                        u'fqn': u'namespace-vpc',
+                        u'outputs': self.outputs['vpc']
+                    },
+                    'bucket': {
+                        u'fqn': u'namespace-bucket',
+                        u'outputs': self.outputs['bucket']
+                    },
+                    u'role': {
+                        u'fqn': u'namespace-role',
+                        u'outputs': self.outputs['role']
+                    }
+                }
+            })
+
+    def test_plain(self):
+        exporter = PlainExporter(self.context)
+        with patch('sys.stdout', new=StringIO()) as fake_out:
+            self.run_export(exporter)
+
+        lines = fake_out.getvalue().strip().splitlines()
+
+        for stack_name, outputs in self.outputs.items():
+            for key, value in outputs.items():
+                line = '{}.{}={}'.format(stack_name, key, value)
+                self.assertIn(line, lines)