From 4696fd4f933b5aec1d7da39ec8fef336d6217734 Mon Sep 17 00:00:00 2001
From: Clint Byrum <clint@fewbar.com>
Date: Thu, 24 Jul 2014 14:37:26 -0700
Subject: [PATCH] Add a local data collector

This collector will collect data from the local system, allowing image
builds or simple processes to influence the metadata.

implements bp tripleo-juno-occ-localdatasource

Change-Id: I0e58e8c631ffe8b63e8b4117df2c9ce2f413044f
---
 os_collect_config/collect.py          |   9 +-
 os_collect_config/exc.py              |   4 +
 os_collect_config/local.py            |  96 +++++++++++++++++
 os_collect_config/tests/test_local.py | 142 ++++++++++++++++++++++++++
 4 files changed, 250 insertions(+), 1 deletion(-)
 create mode 100644 os_collect_config/local.py
 create mode 100644 os_collect_config/tests/test_local.py

diff --git a/os_collect_config/collect.py b/os_collect_config/collect.py
index d820f37..a6bec36 100644
--- a/os_collect_config/collect.py
+++ b/os_collect_config/collect.py
@@ -30,6 +30,7 @@ from os_collect_config import exc
 from os_collect_config import heat
 from os_collect_config import heat_local
 from os_collect_config import keystone
+from os_collect_config import local
 from os_collect_config import version
 from oslo.config import cfg
 
@@ -81,7 +82,8 @@ logger = log.getLogger('os-collect-config')
 COLLECTORS = {ec2.name: ec2,
               cfn.name: cfn,
               heat.name: heat,
-              heat_local.name: heat_local}
+              heat_local.name: heat_local,
+              local.name: local}
 
 
 def setup_conf():
@@ -94,6 +96,9 @@ def setup_conf():
     heat_local_group = cfg.OptGroup(name='heat_local',
                                     title='Heat Local Metadata options')
 
+    local_group = cfg.OptGroup(name='local',
+                               title='Local Metadata options')
+
     heat_group = cfg.OptGroup(name='heat',
                               title='Heat Metadata options')
 
@@ -103,11 +108,13 @@ def setup_conf():
     CONF.register_group(ec2_group)
     CONF.register_group(cfn_group)
     CONF.register_group(heat_local_group)
+    CONF.register_group(local_group)
     CONF.register_group(heat_group)
     CONF.register_group(keystone_group)
     CONF.register_cli_opts(ec2.opts, group='ec2')
     CONF.register_cli_opts(cfn.opts, group='cfn')
     CONF.register_cli_opts(heat_local.opts, group='heat_local')
+    CONF.register_cli_opts(local.opts, group='local')
     CONF.register_cli_opts(heat.opts, group='heat')
     CONF.register_cli_opts(keystone.opts, group='keystone')
 
diff --git a/os_collect_config/exc.py b/os_collect_config/exc.py
index 88a6485..9522650 100644
--- a/os_collect_config/exc.py
+++ b/os_collect_config/exc.py
@@ -42,5 +42,9 @@ class HeatLocalMetadataNotAvailable(SourceNotAvailable):
     """The local Heat metadata is not available."""
 
 
+class LocalMetadataNotAvailable(SourceNotAvailable):
+    """The local metadata is not available."""
+
+
 class InvalidArguments(ValueError):
     """Invalid arguments."""
diff --git a/os_collect_config/local.py b/os_collect_config/local.py
new file mode 100644
index 0000000..1fc8d50
--- /dev/null
+++ b/os_collect_config/local.py
@@ -0,0 +1,96 @@
+# Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import json
+import locale
+import os
+from oslo.config import cfg
+import stat
+
+from openstack.common import log
+from os_collect_config import exc
+
+LOCAL_DEFAULT_PATHS = ['/var/lib/os-collect-config/local-data']
+CONF = cfg.CONF
+
+opts = [
+    cfg.MultiStrOpt('path',
+                    default=LOCAL_DEFAULT_PATHS,
+                    help='Local directory to scan for Metadata files.')
+]
+name = 'local'
+logger = log.getLogger(__name__)
+
+
+def _dest_looks_insecure(local_path):
+    '''We allow group writable so owner can let others write.'''
+    looks_insecure = False
+    uid = os.getuid()
+    st = os.stat(local_path)
+    if uid != st[stat.ST_UID]:
+        logger.error('%s is owned by another user. This is a'
+                     ' security risk.' % local_path)
+        looks_insecure = True
+    if st.st_mode & stat.S_IWOTH:
+        logger.error('%s is world writable. This is a security risk.'
+                     % local_path)
+        looks_insecure = True
+    return looks_insecure
+
+
+class Collector(object):
+    def __init__(self, requests_impl=None):
+        pass
+
+    def collect(self):
+        if len(cfg.CONF.local.path) == 0:
+            raise exc.LocalMetadataNotAvailable
+        final_content = []
+        for local_path in cfg.CONF.local.path:
+            if _dest_looks_insecure(local_path):
+                raise exc.LocalMetadataNotAvailable
+            for data_file in os.listdir(local_path):
+                if data_file.startswith('.'):
+                    continue
+                data_file = os.path.join(local_path, data_file)
+                if os.path.isdir(data_file):
+                    continue
+                st = os.stat(data_file)
+                if st.st_mode & stat.S_IWOTH:
+                    logger.error(
+                        '%s is world writable. This is a security risk.' %
+                        data_file)
+                    raise exc.LocalMetadataNotAvailable
+                with open(data_file) as metadata:
+                    try:
+                        value = json.loads(metadata.read())
+                    except ValueError as e:
+                        logger.error(
+                            '%s is not valid JSON (%s)' % (data_file, e))
+                        raise exc.LocalMetadataNotAvailable
+                    basename = os.path.basename(data_file)
+                    final_content.append((basename, value))
+        if not final_content:
+            logger.warn('No local metadata found (%s)' %
+                        cfg.CONF.local.path)
+
+        # Now sort specifically by C locale
+        def locale_aware_by_first_item(data):
+            return locale.strxfrm(data[0])
+        save_locale = locale.getlocale(locale.LC_ALL)
+        locale.setlocale(locale.LC_ALL, 'C')
+        sorted_content = sorted(final_content, key=locale_aware_by_first_item)
+        locale.setlocale(locale.LC_ALL, save_locale)
+        return sorted_content
diff --git a/os_collect_config/tests/test_local.py b/os_collect_config/tests/test_local.py
new file mode 100644
index 0000000..22f5d16
--- /dev/null
+++ b/os_collect_config/tests/test_local.py
@@ -0,0 +1,142 @@
+# Copyright (c) 2014 Hewlett-Packard Development Company, L.P.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#    http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+# implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import json
+import locale
+import os
+import tempfile
+
+import fixtures
+from oslo.config import cfg
+import testtools
+from testtools import matchers
+
+from os_collect_config import collect
+from os_collect_config import exc
+from os_collect_config import local
+
+
+META_DATA = {u'localstrA': u'A',
+             u'localint9': 9,
+             u'localmap_xy': {
+                 u'x': 42,
+                 u'y': 'foo',
+             }}
+META_DATA2 = {u'localstrA': u'Z',
+              u'localint9': 9}
+
+
+class TestLocal(testtools.TestCase):
+    def setUp(self):
+        super(TestLocal, self).setUp()
+        self.log = self.useFixture(fixtures.FakeLogger())
+        self.useFixture(fixtures.NestedTempfile())
+        self.tdir = tempfile.mkdtemp()
+        collect.setup_conf()
+        self.addCleanup(cfg.CONF.reset)
+        cfg.CONF.register_cli_opts(local.opts, group='local')
+        cfg.CONF.set_override(name='path',
+                              override=[self.tdir],
+                              group='local')
+
+    def _call_collect(self):
+        md = local.Collector().collect()
+        return md
+
+    def _setup_test_json(self, data, md_base='test.json'):
+        md_name = os.path.join(self.tdir, md_base)
+        with open(md_name, 'w') as md:
+            md.write(json.dumps(data))
+        return md_name
+
+    def test_collect_local(self):
+        self._setup_test_json(META_DATA)
+        local_md = self._call_collect()
+
+        self.assertThat(local_md, matchers.IsInstance(list))
+        self.assertEqual(1, len(local_md))
+        self.assertThat(local_md[0], matchers.IsInstance(tuple))
+        self.assertEqual(2, len(local_md[0]))
+        self.assertEqual('test.json', local_md[0][0])
+
+        only_md = local_md[0][1]
+        self.assertThat(only_md, matchers.IsInstance(dict))
+
+        for k in ('localstrA', 'localint9', 'localmap_xy'):
+            self.assertIn(k, only_md)
+            self.assertEqual(only_md[k], META_DATA[k])
+
+        self.assertEqual('', self.log.output)
+
+    def test_collect_local_world_writable(self):
+        md_name = self._setup_test_json(META_DATA)
+        os.chmod(md_name, 0o666)
+        self.assertRaises(exc.LocalMetadataNotAvailable, self._call_collect)
+        self.assertIn('%s is world writable. This is a security risk.' %
+                      md_name, self.log.output)
+
+    def test_collect_local_world_writable_dir(self):
+        self._setup_test_json(META_DATA)
+        os.chmod(self.tdir, 0o666)
+        self.assertRaises(exc.LocalMetadataNotAvailable, self._call_collect)
+        self.assertIn('%s is world writable. This is a security risk.' %
+                      self.tdir, self.log.output)
+
+    def test_collect_local_owner_not_uid(self):
+        self._setup_test_json(META_DATA)
+        real_getuid = os.getuid
+
+        def fake_getuid():
+            return real_getuid() + 1
+        self.useFixture(fixtures.MonkeyPatch('os.getuid', fake_getuid))
+        self.assertRaises(exc.LocalMetadataNotAvailable, self._call_collect)
+        self.assertIn('%s is owned by another user. This is a security risk.' %
+                      self.tdir, self.log.output)
+
+    def test_collect_local_orders_multiple(self):
+        self._setup_test_json(META_DATA, '00test.json')
+        self._setup_test_json(META_DATA2, '99test.json')
+
+        # Monkey Patch os.listdir so it _always_ returns the wrong sort
+        unpatched_listdir = os.listdir
+
+        def wrong_sort_listdir(path):
+            ret = unpatched_listdir(path)
+            save_locale = locale.getlocale(locale.LC_ALL)
+            locale.setlocale(locale.LC_ALL, 'C')
+            bad_sort = sorted(ret, reverse=True)
+            locale.setlocale(locale.LC_ALL, save_locale)
+            return bad_sort
+        self.useFixture(fixtures.MonkeyPatch('os.listdir', wrong_sort_listdir))
+        local_md = self._call_collect()
+
+        self.assertThat(local_md, matchers.IsInstance(list))
+        self.assertEqual(2, len(local_md))
+        self.assertThat(local_md[0], matchers.IsInstance(tuple))
+
+        self.assertEqual('00test.json', local_md[0][0])
+        md1 = local_md[0][1]
+        self.assertEqual(META_DATA, md1)
+
+        self.assertEqual('99test.json', local_md[1][0])
+        md2 = local_md[1][1]
+        self.assertEqual(META_DATA2, md2)
+
+    def test_collect_invalid_json_fail(self):
+        self._setup_test_json(META_DATA)
+        with open(os.path.join(self.tdir, 'bad.json'), 'w') as badjson:
+            badjson.write('{')
+        self.assertRaises(exc.LocalMetadataNotAvailable, self._call_collect)
+        self.assertIn('is not valid JSON', self.log.output)