Merge lp:~saviq/charm-helpers/confg-patterns into lp:charm-helpers

Proposed by Michał Sawicz
Status: Rejected
Rejected by: Stuart Bishop
Proposed branch: lp:~saviq/charm-helpers/confg-patterns
Merge into: lp:charm-helpers
Diff against target: 391 lines (+217/-15)
3 files modified
charmhelpers/contrib/openstack/context.py (+9/-0)
charmhelpers/contrib/openstack/templating.py (+96/-11)
tests/contrib/openstack/test_os_templating.py (+112/-4)
To merge this branch: bzr merge lp:~saviq/charm-helpers/confg-patterns
Reviewer Review Type Date Requested Status
Stuart Bishop (community) Disapprove
Review via email: mp+260750@code.launchpad.net

Commit message

[saviq] Add support for config file patterns

To post a comment you must log in.
384. By Michał Sawicz

Fix lint

385. By Michał Sawicz

Fix for python3

Revision history for this message
Corey Bryant (corey.bryant) wrote :

Hi Michal,

I know the initial target is for the horizon subordinate plugin charms to use this, but would you mind giving an example of how horizon would use it?

I have a couple of comments inline below.

Corey

Revision history for this message
Michał Sawicz (saviq) wrote :

> I know the initial target is for the horizon subordinate plugin charms
> to use this, but would you mind giving an example of how horizon would use it?

Sure. The charm would register a pattern config:

> register_config('/path/to/dashboard/enabled/_{}_juju_{}.py')

Then,

> class RouterSettingContext(OSPatternContextGenerator):
> def __call__(self):
> ctxt = {
> (40, 'router'): {'DISABLED': False if config('profile') in ['cisco'] else True}
> }
> return ctxt

and, for example
> class PluginContext(OSPatternContextGenerator):
> def __call__(self):
> # ...
> ctxt = {}
> for rid in relation_ids('plugin'):
> # ...
> rdata = relation_get(rid=rid, unit=unit)
> ctxt[(rdata['priority'], unit): {'DASHBOARD': rdata['DASHBOARD'],
> 'DISABLED': rdata['DISABLED'],
> # ...
> return ctxt

This would result in generating files, say:

> /path/to/config/enabled/_40_juju_router.py
> /path/to/config/enabled/_99_juju_horizon-contrail.py

In PluginContext I can then warn about conflicts etc. Multiple subordinates could even override one another.

HTH, let me know if I should explain more of this in the docstring.

386. By Michał Sawicz

Fix a bug in pattern context generation and add some more comments

Revision history for this message
Corey Bryant (corey.bryant) wrote :

> > I know the initial target is for the horizon subordinate plugin charms
> > to use this, but would you mind giving an example of how horizon would use
> it?
>
> Sure. The charm would register a pattern config:
>
> > register_config('/path/to/dashboard/enabled/_{}_juju_{}.py')
>
> Then,
>
> > class RouterSettingContext(OSPatternContextGenerator):
> > def __call__(self):
> > ctxt = {
> > (40, 'router'): {'DISABLED': False if config('profile') in
> ['cisco'] else True}
> > }
> > return ctxt
>
> and, for example
> > class PluginContext(OSPatternContextGenerator):
> > def __call__(self):
> > # ...
> > ctxt = {}
> > for rid in relation_ids('plugin'):
> > # ...
> > rdata = relation_get(rid=rid, unit=unit)
> > ctxt[(rdata['priority'], unit): {'DASHBOARD':
> rdata['DASHBOARD'],
> > 'DISABLED': rdata['DISABLED'],
> > # ...
> > return ctxt
>
> This would result in generating files, say:
>
> > /path/to/config/enabled/_40_juju_router.py
> > /path/to/config/enabled/_99_juju_horizon-contrail.py
>
> In PluginContext I can then warn about conflicts etc. Multiple subordinates
> could even override one another.
>
> HTH, let me know if I should explain more of this in the docstring.

Thanks for the explanation. I'm wondering if this can be solved without adding the pattern context code. I was just looking at neutron-openvswitch, which is a subordinate charm. For example it has:

BASE_RESOURCE_MAP = OrderedDict([
     (NEUTRON_CONF, {
         'services': ['neutron-plugin-openvswitch-agent'],
         'contexts': [neutron_ovs_context.OVSPluginContext(),
                      context.AMQPContext(ssl_dir=NEUTRON_CONF_DIR),
                      context.ZeroMQContext(),
                      context.NotificationDriverContext()],
     }),
     ...

Can you do something similar for subordinate horizon charms? In other words, the subordinate charms would write the plugin file themselves.

For example. horizon subordinate 1 could have:

BASE_RESOURCE_MAP = OrderedDict([
     (/path/to/config/enabled/_40_juju_router.py, {
         'contexts': [RouterContext()],
     }),
     ...

and horizon subordinate 2 could have:

BASE_RESOURCE_MAP = OrderedDict([
     (/path/to/config/enabled//_99_juju_horizon-contrail.py, {
         'contexts': [ContrailContext()],
     }),
     ...

You could make the precedence (ie. 40 and 99) more flexibly in the subordinate charm and allow them to be changed via a config option.

Revision history for this message
Michał Sawicz (saviq) wrote :

@Corey,

Sure, the subordinate could write that file itself, if that's preferable. Unfortunately the remaining problem for the contrail use case is that before Juno there's no way to update HORIZON_CONFIG from the split files, so we need to amend the main settings.py file. But this isn't really related to this MP...

Revision history for this message
Michał Sawicz (saviq) wrote :

I will update the horizon-contrail charm to do this, so this MP can be dropped, really.

Revision history for this message
Michał Sawicz (saviq) wrote :

Hah, I can't change the status of this MP to Rejected, can you?

Revision history for this message
Stuart Bishop (stub) :
review: Disapprove

Unmerged revisions

386. By Michał Sawicz

Fix a bug in pattern context generation and add some more comments

385. By Michał Sawicz

Fix for python3

384. By Michał Sawicz

Fix lint

383. By Michał Sawicz

Refactor test to disallow additional calls

382. By Michał Sawicz

Implement support for generating multiple config files based on a pattern

381. By Michał Sawicz

Add context and templating classes to support generating multiple files based on a pattern

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'charmhelpers/contrib/openstack/context.py'
--- charmhelpers/contrib/openstack/context.py 2015-04-16 19:19:18 +0000
+++ charmhelpers/contrib/openstack/context.py 2015-06-01 22:06:07 +0000
@@ -194,6 +194,15 @@
194 raise NotImplementedError194 raise NotImplementedError
195195
196196
197class OSPatternContextGenerator(OSContextGenerator):
198 """Base class for pattern context generators.
199
200 __call__ should return a dictionary of { tuple: dict }, where the tuple
201 will be used as input for format() for the filename pattern as registered
202 by OSConfigRenderer.register_pattern().
203 """
204
205
197class SharedDBContext(OSContextGenerator):206class SharedDBContext(OSContextGenerator):
198 interfaces = ['shared-db']207 interfaces = ['shared-db']
199208
200209
=== modified file 'charmhelpers/contrib/openstack/templating.py'
--- charmhelpers/contrib/openstack/templating.py 2015-01-22 06:06:03 +0000
+++ charmhelpers/contrib/openstack/templating.py 2015-06-01 22:06:07 +0000
@@ -14,7 +14,9 @@
14# You should have received a copy of the GNU Lesser General Public License14# You should have received a copy of the GNU Lesser General Public License
15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.15# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
1616
17import glob
17import os18import os
19import string
1820
19import six21import six
2022
@@ -25,6 +27,7 @@
25 INFO27 INFO
26)28)
27from charmhelpers.contrib.openstack.utils import OPENSTACK_CODENAMES29from charmhelpers.contrib.openstack.utils import OPENSTACK_CODENAMES
30from charmhelpers.contrib.openstack.context import OSPatternContextGenerator
2831
29try:32try:
30 from jinja2 import FileSystemLoader, ChoiceLoader, Environment, exceptions33 from jinja2 import FileSystemLoader, ChoiceLoader, Environment, exceptions
@@ -96,7 +99,7 @@
96 else:99 else:
97 self.contexts = contexts100 self.contexts = contexts
98101
99 self._complete_contexts = []102 self._complete_contexts = set()
100103
101 def context(self):104 def context(self):
102 ctxt = {}105 ctxt = {}
@@ -105,9 +108,7 @@
105 if _ctxt:108 if _ctxt:
106 ctxt.update(_ctxt)109 ctxt.update(_ctxt)
107 # track interfaces for every complete context.110 # track interfaces for every complete context.
108 [self._complete_contexts.append(interface)111 self._complete_contexts.update(context.interfaces)
109 for interface in context.interfaces
110 if interface not in self._complete_contexts]
111 return ctxt112 return ctxt
112113
113 def complete_contexts(self):114 def complete_contexts(self):
@@ -120,6 +121,41 @@
120 return self._complete_contexts121 return self._complete_contexts
121122
122123
124class OSPatternConfigTemplate(OSConfigTemplate):
125 """
126 Associates a config pattern template with a list of context generators.
127 Responsible for constructing a template context based on those generators.
128 """
129 def __init__(self, pattern, contexts):
130 self.pattern = pattern
131 super(OSPatternConfigTemplate, self).__init__(config_file=None,
132 contexts=contexts)
133
134 def context(self):
135 base_ctxt = {}
136 ctxt = {}
137 for context in self.contexts:
138 _ctxt = context()
139 if not _ctxt:
140 continue
141 elif isinstance(context, OSPatternContextGenerator):
142 # for each returned key initialize its context with base_ctxt
143 # if not defined before and update with new data
144 for k, v in _ctxt.items():
145 if k not in ctxt:
146 ctxt[k] = base_ctxt.copy()
147 ctxt[k].update(v)
148 else:
149 # update the base context and all pre-existing file-specific
150 # contexts
151 base_ctxt.update(_ctxt)
152 for key in ctxt:
153 ctxt[key].update(_ctxt)
154 # track interfaces for every complete context.
155 self._complete_contexts.update(context.interfaces)
156 return ctxt
157
158
123class OSConfigRenderer(object):159class OSConfigRenderer(object):
124 """160 """
125 This class provides a common templating system to be used by OpenStack161 This class provides a common templating system to be used by OpenStack
@@ -132,6 +168,13 @@
132 # import some common context generates from charmhelpers168 # import some common context generates from charmhelpers
133 from charmhelpers.contrib.openstack import context169 from charmhelpers.contrib.openstack import context
134170
171 # or create your own
172 class SimpleContextGenerator(OSContextGenerator):
173 def __call__():
174 return {
175 'key': 'value'
176 }
177
135 # Create a renderer object for a specific OS release.178 # Create a renderer object for a specific OS release.
136 configs = OSConfigRenderer(templates_dir='/tmp/templates',179 configs = OSConfigRenderer(templates_dir='/tmp/templates',
137 openstack_release='folsom')180 openstack_release='folsom')
@@ -142,12 +185,31 @@
142 configs.register(config_file='/etc/nova/api-paste.ini',185 configs.register(config_file='/etc/nova/api-paste.ini',
143 contexts=[context.IdentityServiceContext()])186 contexts=[context.IdentityServiceContext()])
144 configs.register(config_file='/etc/haproxy/haproxy.conf',187 configs.register(config_file='/etc/haproxy/haproxy.conf',
145 contexts=[context.HAProxyContext()])188 contexts=[context.HAProxyContext(),
189 SimpleContextGenerator()])
146 # write out a single config190 # write out a single config
147 configs.write('/etc/nova/nova.conf')191 configs.write('/etc/nova/nova.conf')
148 # write out all registered configs192 # write out all registered configs
149 configs.write_all()193 configs.write_all()
150194
195 Using patterns::
196 class DashboardContextGenerator(OSPatternContextGenerator):
197 def __call__():
198 return {
199 (40, 'router'): { 'DISABLED': True }
200 }
201
202 configs.register_pattern(
203 pattern='/usr/share/openstack-dashboard/openstack_dashboard'
204 '/enabled/_{}_juju_{}.py',
205 contexts=[
206 DashboardContextGenerator()
207 ])
208 # delete all files matching the pattern and
209 # write _40_juju_router.py anew
210 configs.write('/usr/share/openstack-dashboard/openstack_dashboard'
211 '/enabled/_{}_juju_{}.py')
212
151 **OpenStack Releases and template loading**213 **OpenStack Releases and template loading**
152214
153 When the object is instantiated, it is associated with a specific OS215 When the object is instantiated, it is associated with a specific OS
@@ -219,6 +281,15 @@
219 contexts=contexts)281 contexts=contexts)
220 log('Registered config file: %s' % config_file, level=INFO)282 log('Registered config file: %s' % config_file, level=INFO)
221283
284 def register_pattern(self, pattern, contexts):
285 """
286 Register a config file name pattern with a list of context generators
287 to be called during rendering. Use standard format() specification.
288 """
289 self.templates[pattern] = OSPatternConfigTemplate(pattern=pattern,
290 contexts=contexts)
291 log('Registered config pattern: %s' % pattern, level=INFO)
292
222 def _get_tmpl_env(self):293 def _get_tmpl_env(self):
223 if not self._tmpl_env:294 if not self._tmpl_env:
224 loader = get_loader(self.templates_dir, self.openstack_release)295 loader = get_loader(self.templates_dir, self.openstack_release)
@@ -234,7 +305,8 @@
234 if config_file not in self.templates:305 if config_file not in self.templates:
235 log('Config not registered: %s' % config_file, level=ERROR)306 log('Config not registered: %s' % config_file, level=ERROR)
236 raise OSConfigException307 raise OSConfigException
237 ctxt = self.templates[config_file].context()308 ostemplate = self.templates[config_file]
309 ctxt = ostemplate.context()
238310
239 _tmpl = os.path.basename(config_file)311 _tmpl = os.path.basename(config_file)
240 try:312 try:
@@ -253,7 +325,14 @@
253 raise e325 raise e
254326
255 log('Rendering from template: %s' % _tmpl, level=INFO)327 log('Rendering from template: %s' % _tmpl, level=INFO)
256 return template.render(ctxt)328
329 if not isinstance(ostemplate, OSPatternConfigTemplate):
330 ctxt = {(): ctxt}
331
332 renders = {}
333 for args, file_ctxt in ctxt.items():
334 renders[args] = template.render(file_ctxt)
335 return renders
257336
258 def write(self, config_file):337 def write(self, config_file):
259 """338 """
@@ -263,10 +342,16 @@
263 log('Config not registered: %s' % config_file, level=ERROR)342 log('Config not registered: %s' % config_file, level=ERROR)
264 raise OSConfigException343 raise OSConfigException
265344
266 _out = self.render(config_file)345 renders = self.render(config_file)
267346
268 with open(config_file, 'wb') as out:347 files = glob.glob(''.join([t[0] + ('' if t[1] is None else '*')
269 out.write(_out)348 for t in string.Formatter().parse(config_file)]))
349 for name in files:
350 os.unlink(name)
351
352 for args, render in renders.items():
353 with open(config_file.format(*args), 'wb') as out:
354 out.write(render)
270355
271 log('Wrote template %s.' % config_file, level=INFO)356 log('Wrote template %s.' % config_file, level=INFO)
272357
273358
=== modified file 'tests/contrib/openstack/test_os_templating.py'
--- tests/contrib/openstack/test_os_templating.py 2015-02-11 21:41:57 +0000
+++ tests/contrib/openstack/test_os_templating.py 2015-06-01 22:06:07 +0000
@@ -2,9 +2,11 @@
2import os2import os
3import unittest3import unittest
44
5from mock import patch, call, MagicMock5from mock import patch, call, Mock, MagicMock
66
7import charmhelpers.contrib.openstack.templating as templating7import charmhelpers.contrib.openstack.templating as templating
8from charmhelpers.contrib.openstack.context import OSPatternContextGenerator,\
9 OSContextGenerator
810
9from jinja2.exceptions import TemplateNotFound11from jinja2.exceptions import TemplateNotFound
1012
@@ -15,7 +17,7 @@
15 builtin_open = 'builtins.open'17 builtin_open = 'builtins.open'
1618
1719
18class FakeContextGenerator(object):20class FakeContextGenerator(OSContextGenerator):
19 interfaces = None21 interfaces = None
2022
21 def set(self, interfaces, context):23 def set(self, interfaces, context):
@@ -26,6 +28,10 @@
26 return self.context28 return self.context
2729
2830
31class FakePatternContextGenerator(FakeContextGenerator, OSPatternContextGenerator):
32 pass
33
34
29class FakeLoader(object):35class FakeLoader(object):
30 def set(self, template):36 def set(self, template):
31 self.template = template37 self.template = template
@@ -55,6 +61,7 @@
55 path = os.path.dirname(__file__)61 path = os.path.dirname(__file__)
56 self.loader = FakeLoader()62 self.loader = FakeLoader()
57 self.context = FakeContextGenerator()63 self.context = FakeContextGenerator()
64 self.pattern_context = FakePatternContextGenerator()
5865
59 self.addCleanup(patch.object(templating, 'apt_install').start().stop())66 self.addCleanup(patch.object(templating, 'apt_install').start().stop())
60 self.addCleanup(patch.object(templating, 'log').start().stop())67 self.addCleanup(patch.object(templating, 'log').start().stop())
@@ -167,9 +174,11 @@
167 '''It renders template if it finds it by config file basename'''174 '''It renders template if it finds it by config file basename'''
168175
169 @patch(builtin_open)176 @patch(builtin_open)
177 @patch('glob.glob')
170 @patch.object(templating, 'get_loader')178 @patch.object(templating, 'get_loader')
171 def test_write_out_config(self, loader, _open):179 def test_write_out_config(self, loader, _glob, _open):
172 '''It writes a templated config when provided a complete context'''180 '''It writes a templated config when provided a complete context'''
181 _glob.return_value = []
173 self.context.set(interfaces=['fooservice'], context={'foo': 'bar'})182 self.context.set(interfaces=['fooservice'], context={'foo': 'bar'})
174 self.renderer.register('/tmp/foo', [self.context])183 self.renderer.register('/tmp/foo', [self.context])
175 with patch.object(self.renderer, '_get_template') as _get_t:184 with patch.object(self.renderer, '_get_template') as _get_t:
@@ -178,8 +187,10 @@
178 self.renderer.write('/tmp/foo')187 self.renderer.write('/tmp/foo')
179 _open.assert_called_with('/tmp/foo', 'wb')188 _open.assert_called_with('/tmp/foo', 'wb')
180189
181 def test_write_all(self):190 @patch('glob.glob')
191 def test_write_all(self, _glob):
182 '''It writes out all configuration files at once'''192 '''It writes out all configuration files at once'''
193 _glob.return_value = []
183 self.context.set(interfaces=['fooservice'], context={'foo': 'bar'})194 self.context.set(interfaces=['fooservice'], context={'foo': 'bar'})
184 self.renderer.register('/tmp/foo', [self.context])195 self.renderer.register('/tmp/foo', [self.context])
185 self.renderer.register('/tmp/bar', [self.context])196 self.renderer.register('/tmp/bar', [self.context])
@@ -192,6 +203,53 @@
192 self.assertEquals(sorted(ex_calls), sorted(_write.call_args_list))203 self.assertEquals(sorted(ex_calls), sorted(_write.call_args_list))
193 pass204 pass
194205
206 @patch(builtin_open)
207 @patch('glob.glob')
208 @patch.object(templating, 'get_loader')
209 def test_write_out_config_pattern(self, loader, _glob, _open):
210 '''It writes a templated pattern config when provided a complete context'''
211 self.pattern_context.set(interfaces=[], context={(1,): {'foo': 'bar'},
212 (2,): {'foo': 'baz'}})
213 self.renderer.register_pattern('/tmp/foo_{}', [self.pattern_context])
214 _glob.return_value = []
215
216 ex_calls = [
217 call('/tmp/foo_1', 'wb'),
218 call('/tmp/foo_2', 'wb')
219 ]
220
221 with patch.object(self.renderer, '_get_template') as _get_t:
222 fake_tmpl = MockTemplate()
223 _get_t.return_value = fake_tmpl
224 self.renderer.write('/tmp/foo_{}')
225 self.assertEquals(sorted(ex_calls), sorted(_open.call_args_list))
226
227 @patch(builtin_open)
228 @patch('os.unlink')
229 @patch('glob.glob')
230 @patch.object(templating, 'get_loader')
231 def test_write_out_config_pattern_clean(self, loader, _glob, _unlink, _open):
232 '''It deletes all matching files prior to writing new ones'''
233 self.pattern_context.set(interfaces=[], context={(1,): {'foo': 'bar'}})
234 self.renderer.register_pattern('/tmp/foo_{}', [self.pattern_context])
235 _glob.return_value = ['/tmp/foo_1', '/tmp/foo_2']
236
237 m = Mock()
238 m.attach_mock(_open, 'open')
239 m.attach_mock(_unlink, 'unlink')
240 m.attach_mock(_glob, 'glob')
241
242 with patch.object(self.renderer, '_get_template') as _get_t:
243 fake_tmpl = MockTemplate()
244 _get_t.return_value = fake_tmpl
245 self.renderer.write('/tmp/foo_{}')
246 m.assert_has_calls([
247 call.glob('/tmp/foo_*'),
248 call.unlink('/tmp/foo_1'),
249 call.unlink('/tmp/foo_2'),
250 call.open('/tmp/foo_1', 'wb')
251 ])
252
195 @patch.object(templating, 'get_loader')253 @patch.object(templating, 'get_loader')
196 def test_reset_template_loader_for_new_os_release(self, loader):254 def test_reset_template_loader_for_new_os_release(self, loader):
197 self.loader.set('')255 self.loader.set('')
@@ -282,3 +340,53 @@
282 tmpl = templating.OSConfigTemplate(config_file='/tmp/foo',340 tmpl = templating.OSConfigTemplate(config_file='/tmp/foo',
283 contexts=_c1)341 contexts=_c1)
284 self.assertEquals(tmpl.contexts, [_c1])342 self.assertEquals(tmpl.contexts, [_c1])
343
344 def test_generate_context(self):
345 '''Ensure context is generated correctly'''
346 def _c1():
347 return {'a': 1, 'b': 2}
348 _c1.interfaces = []
349
350 def _c2():
351 return {'a': 3}
352 _c2.interfaces = []
353 tmpl = templating.OSConfigTemplate(config_file='/tmp/foo',
354 contexts=[_c1, _c2])
355 self.assertEqual(tmpl.context(), {'a': 3, 'b': 2})
356
357 def test_generate_pattern_context(self):
358 class _c1(OSContextGenerator):
359 def __call__(self):
360 return {'a': 1, 'b': 2, 'c': 3, 'd': 4}
361
362 class _c2(OSPatternContextGenerator):
363 def __call__(self):
364 return {
365 ('foo',): {'a': 4},
366 ('bar',): {'b': 5}
367 }
368
369 class _c3(OSContextGenerator):
370 def __call__(self):
371 return {'d': 6}
372
373 class _c4(OSPatternContextGenerator):
374 def __call__(self):
375 return {
376 ('bar',): {'a': 7},
377 ('baz',): {}
378 }
379
380 class _c5(OSContextGenerator):
381 def __call__(self):
382 return {'e': 0}
383
384 tmpl = templating.OSPatternConfigTemplate(pattern='/tmp/foo_{}',
385 contexts=[_c1(), _c2(),
386 _c3(), _c4(),
387 _c5()])
388 self.assertEqual(tmpl.context(), {
389 ('foo',): {'a': 4, 'b': 2, 'c': 3, 'd': 6, 'e': 0},
390 ('bar',): {'a': 7, 'b': 5, 'c': 3, 'd': 6, 'e': 0},
391 ('baz',): {'a': 1, 'b': 2, 'c': 3, 'd': 6, 'e': 0},
392 })

Subscribers

People subscribed via source and target branches