Browse code

Zuul: support plugin dependencies

Change-Id: I81302e8988fe6498fea9f08ed66f5d0cc1fce161

James E. Blair authored on 2017/11/22 10:05:43
Showing 5 changed files
... ...
@@ -3,6 +3,7 @@
3 3
 *.log
4 4
 *.log.[1-9]
5 5
 *.pem
6
+*.pyc
6 7
 .localrc.auto
7 8
 .localrc.password
8 9
 .prereqs
... ...
@@ -14,16 +14,69 @@
14 14
 # See the License for the specific language governing permissions and
15 15
 # limitations under the License.
16 16
 
17
+import os
17 18
 import re
18 19
 
19 20
 
20
-class VarGraph(object):
21
+class DependencyGraph(object):
21 22
     # This is based on the JobGraph from Zuul.
22 23
 
24
+    def __init__(self):
25
+        self._names = set()
26
+        self._dependencies = {}  # dependent_name -> set(parent_names)
27
+
28
+    def add(self, name, dependencies):
29
+        # Append the dependency information
30
+        self._dependencies.setdefault(name, set())
31
+        try:
32
+            for dependency in dependencies:
33
+                # Make sure a circular dependency is never created
34
+                ancestors = self._getParentNamesRecursively(
35
+                    dependency, soft=True)
36
+                ancestors.add(dependency)
37
+                if name in ancestors:
38
+                    raise Exception("Dependency cycle detected in {}".
39
+                                    format(name))
40
+                self._dependencies[name].add(dependency)
41
+        except Exception:
42
+            del self._dependencies[name]
43
+            raise
44
+
45
+    def getDependenciesRecursively(self, parent):
46
+        dependencies = []
47
+
48
+        current_dependencies = self._dependencies[parent]
49
+        for current in current_dependencies:
50
+            if current not in dependencies:
51
+                dependencies.append(current)
52
+            for dep in self.getDependenciesRecursively(current):
53
+                if dep not in dependencies:
54
+                    dependencies.append(dep)
55
+        return dependencies
56
+
57
+    def _getParentNamesRecursively(self, dependent, soft=False):
58
+        all_parent_items = set()
59
+        items_to_iterate = set([dependent])
60
+        while len(items_to_iterate) > 0:
61
+            current_item = items_to_iterate.pop()
62
+            current_parent_items = self._dependencies.get(current_item)
63
+            if current_parent_items is None:
64
+                if soft:
65
+                    current_parent_items = set()
66
+                else:
67
+                    raise Exception("Dependent item {} not found: ".format(
68
+                                    dependent))
69
+            new_parent_items = current_parent_items - all_parent_items
70
+            items_to_iterate |= new_parent_items
71
+            all_parent_items |= new_parent_items
72
+        return all_parent_items
73
+
74
+
75
+class VarGraph(DependencyGraph):
23 76
     def __init__(self, vars):
77
+        super(VarGraph, self).__init__()
24 78
         self.vars = {}
25 79
         self._varnames = set()
26
-        self._dependencies = {}  # dependent_var_name -> set(parent_var_names)
27 80
         for k, v in vars.items():
28 81
             self._varnames.add(k)
29 82
         for k, v in vars.items():
... ...
@@ -38,28 +91,21 @@ class VarGraph(object):
38 38
             raise Exception("Variable {} already added".format(key))
39 39
         self.vars[key] = value
40 40
         # Append the dependency information
41
-        self._dependencies.setdefault(key, set())
41
+        dependencies = set()
42
+        for dependency in self.getDependencies(value):
43
+            if dependency == key:
44
+                # A variable is allowed to reference itself; no
45
+                # dependency link needed in that case.
46
+                continue
47
+            if dependency not in self._varnames:
48
+                # It's not necessary to create a link for an
49
+                # external variable.
50
+                continue
51
+            dependencies.add(dependency)
42 52
         try:
43
-            for dependency in self.getDependencies(value):
44
-                if dependency == key:
45
-                    # A variable is allowed to reference itself; no
46
-                    # dependency link needed in that case.
47
-                    continue
48
-                if dependency not in self._varnames:
49
-                    # It's not necessary to create a link for an
50
-                    # external variable.
51
-                    continue
52
-                # Make sure a circular dependency is never created
53
-                ancestor_vars = self._getParentVarNamesRecursively(
54
-                    dependency, soft=True)
55
-                ancestor_vars.add(dependency)
56
-                if any((key == anc_var) for anc_var in ancestor_vars):
57
-                    raise Exception("Dependency cycle detected in var {}".
58
-                                    format(key))
59
-                self._dependencies[key].add(dependency)
53
+            self.add(key, dependencies)
60 54
         except Exception:
61 55
             del self.vars[key]
62
-            del self._dependencies[key]
63 56
             raise
64 57
 
65 58
     def getVars(self):
... ...
@@ -67,48 +113,105 @@ class VarGraph(object):
67 67
         keys = sorted(self.vars.keys())
68 68
         seen = set()
69 69
         for key in keys:
70
-            dependencies = self.getDependentVarsRecursively(key)
70
+            dependencies = self.getDependenciesRecursively(key)
71 71
             for var in dependencies + [key]:
72 72
                 if var not in seen:
73 73
                     ret.append((var, self.vars[var]))
74 74
                     seen.add(var)
75 75
         return ret
76 76
 
77
-    def getDependentVarsRecursively(self, parent_var):
78
-        dependent_vars = []
79
-
80
-        current_dependent_vars = self._dependencies[parent_var]
81
-        for current_var in current_dependent_vars:
82
-            if current_var not in dependent_vars:
83
-                dependent_vars.append(current_var)
84
-            for dep in self.getDependentVarsRecursively(current_var):
85
-                if dep not in dependent_vars:
86
-                    dependent_vars.append(dep)
87
-        return dependent_vars
88
-
89
-    def _getParentVarNamesRecursively(self, dependent_var, soft=False):
90
-        all_parent_vars = set()
91
-        vars_to_iterate = set([dependent_var])
92
-        while len(vars_to_iterate) > 0:
93
-            current_var = vars_to_iterate.pop()
94
-            current_parent_vars = self._dependencies.get(current_var)
95
-            if current_parent_vars is None:
96
-                if soft:
97
-                    current_parent_vars = set()
98
-                else:
99
-                    raise Exception("Dependent var {} not found: ".format(
100
-                                    dependent_var))
101
-            new_parent_vars = current_parent_vars - all_parent_vars
102
-            vars_to_iterate |= new_parent_vars
103
-            all_parent_vars |= new_parent_vars
104
-        return all_parent_vars
77
+
78
+class PluginGraph(DependencyGraph):
79
+    def __init__(self, base_dir, plugins):
80
+        super(PluginGraph, self).__init__()
81
+        # The dependency trees expressed by all the plugins we found
82
+        # (which may be more than those the job is using).
83
+        self._plugin_dependencies = {}
84
+        self.loadPluginNames(base_dir)
85
+
86
+        self.plugins = {}
87
+        self._pluginnames = set()
88
+        for k, v in plugins.items():
89
+            self._pluginnames.add(k)
90
+        for k, v in plugins.items():
91
+            self._addPlugin(k, str(v))
92
+
93
+    def loadPluginNames(self, base_dir):
94
+        if base_dir is None:
95
+            return
96
+        git_roots = []
97
+        for root, dirs, files in os.walk(base_dir):
98
+            if '.git' not in dirs:
99
+                continue
100
+            # Don't go deeper than git roots
101
+            dirs[:] = []
102
+            git_roots.append(root)
103
+        for root in git_roots:
104
+            devstack = os.path.join(root, 'devstack')
105
+            if not (os.path.exists(devstack) and os.path.isdir(devstack)):
106
+                continue
107
+            settings = os.path.join(devstack, 'settings')
108
+            if not (os.path.exists(settings) and os.path.isfile(settings)):
109
+                continue
110
+            self.loadDevstackPluginInfo(settings)
111
+
112
+    define_re = re.compile(r'^define_plugin\s+(\w+).*')
113
+    require_re = re.compile(r'^plugin_requires\s+(\w+)\s+(\w+).*')
114
+    def loadDevstackPluginInfo(self, fn):
115
+        name = None
116
+        reqs = set()
117
+        with open(fn) as f:
118
+            for line in f:
119
+                m = self.define_re.match(line)
120
+                if m:
121
+                    name = m.group(1)
122
+                m = self.require_re.match(line)
123
+                if m:
124
+                    if name == m.group(1):
125
+                        reqs.add(m.group(2))
126
+        if name and reqs:
127
+            self._plugin_dependencies[name] = reqs
128
+
129
+    def getDependencies(self, value):
130
+        return self._plugin_dependencies.get(value, [])
131
+
132
+    def _addPlugin(self, key, value):
133
+        if key in self.plugins:
134
+            raise Exception("Plugin {} already added".format(key))
135
+        self.plugins[key] = value
136
+        # Append the dependency information
137
+        dependencies = set()
138
+        for dependency in self.getDependencies(key):
139
+            if dependency == key:
140
+                continue
141
+            dependencies.add(dependency)
142
+        try:
143
+            self.add(key, dependencies)
144
+        except Exception:
145
+            del self.plugins[key]
146
+            raise
147
+
148
+    def getPlugins(self):
149
+        ret = []
150
+        keys = sorted(self.plugins.keys())
151
+        seen = set()
152
+        for key in keys:
153
+            dependencies = self.getDependenciesRecursively(key)
154
+            for plugin in dependencies + [key]:
155
+                if plugin not in seen:
156
+                    ret.append((plugin, self.plugins[plugin]))
157
+                    seen.add(plugin)
158
+        return ret
105 159
 
106 160
 
107 161
 class LocalConf(object):
108 162
 
109
-    def __init__(self, localrc, localconf, base_services, services, plugins):
163
+    def __init__(self, localrc, localconf, base_services, services, plugins,
164
+                 base_dir):
110 165
         self.localrc = []
111 166
         self.meta_sections = {}
167
+        self.plugin_deps = {}
168
+        self.base_dir = base_dir
112 169
         if plugins:
113 170
             self.handle_plugins(plugins)
114 171
         if services or base_services:
... ...
@@ -119,7 +222,8 @@ class LocalConf(object):
119 119
             self.handle_localconf(localconf)
120 120
 
121 121
     def handle_plugins(self, plugins):
122
-        for k, v in plugins.items():
122
+        pg = PluginGraph(self.base_dir, plugins)
123
+        for k, v in pg.getPlugins():
123 124
             if v:
124 125
                 self.localrc.append('enable_plugin {} {}'.format(k, v))
125 126
 
... ...
@@ -171,6 +275,7 @@ def main():
171 171
             services=dict(type='dict'),
172 172
             localrc=dict(type='dict'),
173 173
             local_conf=dict(type='dict'),
174
+            base_dir=dict(type='path'),
174 175
             path=dict(type='str'),
175 176
         )
176 177
     )
... ...
@@ -180,14 +285,18 @@ def main():
180 180
                    p.get('local_conf'),
181 181
                    p.get('base_services'),
182 182
                    p.get('services'),
183
-                   p.get('plugins'))
183
+                   p.get('plugins'),
184
+                   p.get('base_dir'))
184 185
     lc.write(p['path'])
185 186
 
186 187
     module.exit_json()
187 188
 
188 189
 
189
-from ansible.module_utils.basic import *  # noqa
190
-from ansible.module_utils.basic import AnsibleModule
190
+try:
191
+    from ansible.module_utils.basic import *  # noqa
192
+    from ansible.module_utils.basic import AnsibleModule
193
+except ImportError:
194
+    pass
191 195
 
192 196
 if __name__ == '__main__':
193 197
     main()
194 198
new file mode 100644
... ...
@@ -0,0 +1,166 @@
0
+# Copyright (C) 2017 Red Hat, Inc.
1
+#
2
+# Licensed under the Apache License, Version 2.0 (the "License");
3
+# you may not use this file except in compliance with the License.
4
+# You may obtain a copy of the License at
5
+#
6
+#    http://www.apache.org/licenses/LICENSE-2.0
7
+#
8
+# Unless required by applicable law or agreed to in writing, software
9
+# distributed under the License is distributed on an "AS IS" BASIS,
10
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
11
+# implied.
12
+#
13
+# See the License for the specific language governing permissions and
14
+# limitations under the License.
15
+
16
+import os
17
+import shutil
18
+import tempfile
19
+import unittest
20
+
21
+from devstack_local_conf import LocalConf
22
+from collections import OrderedDict
23
+
24
+class TestDevstackLocalConf(unittest.TestCase):
25
+    def setUp(self):
26
+        self.tmpdir = tempfile.mkdtemp()
27
+
28
+    def tearDown(self):
29
+        shutil.rmtree(self.tmpdir)
30
+
31
+    def test_plugins(self):
32
+        "Test that plugins without dependencies work"
33
+        localrc = {'test_localrc': '1'}
34
+        local_conf = {'install':
35
+                      {'nova.conf':
36
+                       {'main':
37
+                        {'test_conf': '2'}}}}
38
+        services = {'cinder': True}
39
+        # We use ordereddict here to make sure the plugins are in the
40
+        # *wrong* order for testing.
41
+        plugins = OrderedDict([
42
+            ('bar', 'git://git.openstack.org/openstack/bar-plugin'),
43
+            ('foo', 'git://git.openstack.org/openstack/foo-plugin'),
44
+            ('baz', 'git://git.openstack.org/openstack/baz-plugin'),
45
+            ])
46
+        p = dict(localrc=localrc,
47
+                 local_conf=local_conf,
48
+                 base_services=[],
49
+                 services=services,
50
+                 plugins=plugins,
51
+                 base_dir='./test',
52
+                 path=os.path.join(self.tmpdir, 'test.local.conf'))
53
+        lc = LocalConf(p.get('localrc'),
54
+                       p.get('local_conf'),
55
+                       p.get('base_services'),
56
+                       p.get('services'),
57
+                       p.get('plugins'),
58
+                       p.get('base_dir'))
59
+        lc.write(p['path'])
60
+
61
+        plugins = []
62
+        with open(p['path']) as f:
63
+            for line in f:
64
+                if line.startswith('enable_plugin'):
65
+                    plugins.append(line.split()[1])
66
+        self.assertEqual(['bar', 'baz', 'foo'], plugins)
67
+
68
+    def test_plugin_deps(self):
69
+        "Test that plugins with dependencies work"
70
+        os.makedirs(os.path.join(self.tmpdir, 'foo-plugin', 'devstack'))
71
+        os.makedirs(os.path.join(self.tmpdir, 'foo-plugin', '.git'))
72
+        os.makedirs(os.path.join(self.tmpdir, 'bar-plugin', 'devstack'))
73
+        os.makedirs(os.path.join(self.tmpdir, 'bar-plugin', '.git'))
74
+        with open(os.path.join(
75
+                self.tmpdir,
76
+                'foo-plugin', 'devstack', 'settings'), 'w') as f:
77
+            f.write('define_plugin foo\n')
78
+        with open(os.path.join(
79
+                self.tmpdir,
80
+                'bar-plugin', 'devstack', 'settings'), 'w') as f:
81
+            f.write('define_plugin bar\n')
82
+            f.write('plugin_requires bar foo\n')
83
+
84
+        localrc = {'test_localrc': '1'}
85
+        local_conf = {'install':
86
+                      {'nova.conf':
87
+                       {'main':
88
+                        {'test_conf': '2'}}}}
89
+        services = {'cinder': True}
90
+        # We use ordereddict here to make sure the plugins are in the
91
+        # *wrong* order for testing.
92
+        plugins = OrderedDict([
93
+            ('bar', 'git://git.openstack.org/openstack/bar-plugin'),
94
+            ('foo', 'git://git.openstack.org/openstack/foo-plugin'),
95
+            ])
96
+        p = dict(localrc=localrc,
97
+                 local_conf=local_conf,
98
+                 base_services=[],
99
+                 services=services,
100
+                 plugins=plugins,
101
+                 base_dir=self.tmpdir,
102
+                 path=os.path.join(self.tmpdir, 'test.local.conf'))
103
+        lc = LocalConf(p.get('localrc'),
104
+                       p.get('local_conf'),
105
+                       p.get('base_services'),
106
+                       p.get('services'),
107
+                       p.get('plugins'),
108
+                       p.get('base_dir'))
109
+        lc.write(p['path'])
110
+
111
+        plugins = []
112
+        with open(p['path']) as f:
113
+            for line in f:
114
+                if line.startswith('enable_plugin'):
115
+                    plugins.append(line.split()[1])
116
+        self.assertEqual(['foo', 'bar'], plugins)
117
+
118
+    def test_plugin_circular_deps(self):
119
+        "Test that plugins with circular dependencies fail"
120
+        os.makedirs(os.path.join(self.tmpdir, 'foo-plugin', 'devstack'))
121
+        os.makedirs(os.path.join(self.tmpdir, 'foo-plugin', '.git'))
122
+        os.makedirs(os.path.join(self.tmpdir, 'bar-plugin', 'devstack'))
123
+        os.makedirs(os.path.join(self.tmpdir, 'bar-plugin', '.git'))
124
+        with open(os.path.join(
125
+                self.tmpdir,
126
+                'foo-plugin', 'devstack', 'settings'), 'w') as f:
127
+            f.write('define_plugin foo\n')
128
+            f.write('plugin_requires foo bar\n')
129
+        with open(os.path.join(
130
+                self.tmpdir,
131
+                'bar-plugin', 'devstack', 'settings'), 'w') as f:
132
+            f.write('define_plugin bar\n')
133
+            f.write('plugin_requires bar foo\n')
134
+
135
+        localrc = {'test_localrc': '1'}
136
+        local_conf = {'install':
137
+                      {'nova.conf':
138
+                       {'main':
139
+                        {'test_conf': '2'}}}}
140
+        services = {'cinder': True}
141
+        # We use ordereddict here to make sure the plugins are in the
142
+        # *wrong* order for testing.
143
+        plugins = OrderedDict([
144
+            ('bar', 'git://git.openstack.org/openstack/bar-plugin'),
145
+            ('foo', 'git://git.openstack.org/openstack/foo-plugin'),
146
+            ])
147
+        p = dict(localrc=localrc,
148
+                 local_conf=local_conf,
149
+                 base_services=[],
150
+                 services=services,
151
+                 plugins=plugins,
152
+                 base_dir=self.tmpdir,
153
+                 path=os.path.join(self.tmpdir, 'test.local.conf'))
154
+        with self.assertRaises(Exception):
155
+            lc = LocalConf(p.get('localrc'),
156
+                           p.get('local_conf'),
157
+                           p.get('base_services'),
158
+                           p.get('services'),
159
+                           p.get('plugins'),
160
+                           p.get('base_dir'))
161
+            lc.write(p['path'])
162
+
163
+
164
+if __name__ == '__main__':
165
+    unittest.main()
... ...
@@ -8,3 +8,4 @@
8 8
     services: "{{ devstack_services|default(omit) }}"
9 9
     localrc: "{{ devstack_localrc|default(omit) }}"
10 10
     local_conf: "{{ devstack_local_conf|default(omit) }}"
11
+    base_dir: "{{ devstack_base_dir|default(omit) }}"
11 12
new file mode 100755
... ...
@@ -0,0 +1,9 @@
0
+#!/usr/bin/env bash
1
+
2
+TOP=$(cd $(dirname "$0")/.. && pwd)
3
+
4
+# Import common functions
5
+source $TOP/functions
6
+source $TOP/tests/unittest.sh
7
+
8
+python ./roles/write-devstack-local-conf/library/test.py