Merge lp:~johnsca/charm-helpers/multi-unit into lp:~cf-charmers/charm-helpers/cloud-foundry
- multi-unit
- Merge into cloud-foundry
Status: | Merged |
---|---|
Merged at revision: | 183 |
Proposed branch: | lp:~johnsca/charm-helpers/multi-unit |
Merge into: | lp:~cf-charmers/charm-helpers/cloud-foundry |
Diff against target: |
312 lines (+104/-81) 4 files modified
charmhelpers/contrib/cloudfoundry/contexts.py (+17/-6) charmhelpers/core/services.py (+72/-53) tests/contrib/cloudfoundry/test_render_context.py (+8/-9) tests/core/test_services.py (+7/-13) |
To merge this branch: | bzr merge lp:~johnsca/charm-helpers/multi-unit |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Cloud Foundry Charmers | Pending | ||
Review via email: mp+221801@code.launchpad.net |
Commit message
Description of the change
Refactored RelationContext multi-unit support
Added a little magic to the RelationContext to make it better able to support
multiple units and still DWIM in both cases. This might be too magical,
though.
Cory Johns (johnsca) wrote : | # |
- 186. By Cory Johns
-
Fixed issue with default unit in services framework and improved docs
Cory Johns (johnsca) wrote : | # |
Please take a look.
Benjamin Saller (bcsaller) wrote : | # |
Thanks for this. I include some thoughts below, I hope they are
agreeable.
https:/
File charmhelpers/
https:/
charmhelpers/
I make suggestions below on altering this process. Let me know what you
think.
https:/
charmhelpers/
default unit.
There is a rule I try to use when generating/process data these days, no
data should live in the key side of the mapping. In the context of a
template for iteration is simpler to iterate
context[
This rule makes additional sense when thinking that knowing the unit
names to request them would imply a broken charm and thus shouldn't be
an index in this context. I'd suggest transitioning to that. We could
also make the rule (and document here) that only elements with complete
data appear in the list.
Cory Johns (johnsca) wrote : | # |
On 2014/06/04 20:57:39, benjamin.saller wrote:
https:/
> charmhelpers/
the default
> unit.
> There is a rule I try to use when generating/process data these days,
no data
> should live in the key side of the mapping. In the context of a
template for
> iteration is simpler to iterate
> context[
> {_unit: 'unit/1', 'foo': bar}]
> This rule makes additional sense when thinking that knowing the unit
names to
> request them would imply a broken charm and thus shouldn't be an index
in this
> context. I'd suggest transitioning to that. We could also make the
rule (and
> document here) that only elements with complete data appear in the
list.
Can you explain your reasoning behind making use of the key? I'm not
averse to changing it to being a list instead of a mapping, but using a
"special" field to hold the unit name ('_unit', in your example) seems
more dangerous, since it could potentially conflict with actual fields,
even if unlikely due to the underscore prefix.
With your comment that the charm shouldn't actually know / care about
the unit name, maybe we can just leave it out altogether, especially if
it only contains "complete" data sets.
Cory Johns (johnsca) wrote : | # |
That should be: "behind *not* making use of the key"
Benjamin Saller (bcsaller) wrote : | # |
On 2014/06/04 21:09:21, johnsca wrote:
> On 2014/06/04 20:57:39, benjamin.saller wrote:
> >
https:/
> > charmhelpers/
the
> default
> > unit.
> > There is a rule I try to use when generating/process data these
days, no data
> > should live in the key side of the mapping. In the context of a
template for
> > iteration is simpler to iterate
> >
> > context[
> > {_unit: 'unit/1', 'foo': bar}]
> >
> > This rule makes additional sense when thinking that knowing the unit
names to
> > request them would imply a broken charm and thus shouldn't be an
index in this
> > context. I'd suggest transitioning to that. We could also make the
rule (and
> > document here) that only elements with complete data appear in the
list.
> Can you explain your reasoning behind making use of the key? I'm not
averse to
> changing it to being a list instead of a mapping, but using a
"special" field to
> hold the unit name ('_unit', in your example) seems more dangerous,
since it
> could potentially conflict with actual fields, even if unlikely due to
the
> underscore prefix.
> With your comment that the charm shouldn't actually know / care about
the unit
> name, maybe we can just leave it out altogether, especially if it only
contains
> "complete" data sets.
I think for our use case we don't actually need the unitname at all and
could omit it, in the more general case I expect that there are cases
where people will want to delta the relation data vs previous hook calls
(cached data) before taking some actions.
The list vs dict thing is a pattern that I picked up working in the
machine learning space. It tends to be easier to consume and pivot flat
tuple style data. I was looking for a nice right up justifying this
pattern, its even known as "<someones> law" but I can't remember that
name :-/
However if the value in the key side of the mapping can't be known/used
its a good indicator to me that we can flatten the structure. Maybe not
very persuasive.
- 187. By Cory Johns
-
Refactored RelationContext to be a bit more intuitive
Cory Johns (johnsca) wrote : | # |
Please take a look.
- 188. By Cory Johns
-
Added mapping iteration methods to RelationContext helper
Cory Johns (johnsca) wrote : | # |
Please take a look.
- 189. By Cory Johns
-
Added ports for log relations and cleaned up other relation keys
Cory Johns (johnsca) wrote : | # |
Please take a look.
Benjamin Saller (bcsaller) wrote : | # |
Thanks for this, very close. Mostly this comes down to me wanting
another pass over the examples/docs around the multi-unit data access.
Hopefully I pointed to what I am after clearly, if not please let me
know.
https:/
File charmhelpers/
https:/
charmhelpers/
['address', 'port', 'user', 'password']
You have a follow on for the charms updating the templates as well?
https:/
charmhelpers/
['hostname', 'port']
I am a little cautious about this one as this isn't really a CF
component, it would be interesting if down the road we had something
like
EtcdInterface = charmhelpers.
To produce this class from a interface registry. We'll want that sort of
thing for the interface testing plans anyway.
just rambling, nothing to do now.
https:/
File charmhelpers/
https:/
charmhelpers/
This feels a bit magical. I'm fine with letting it in but the 'why'
you'd use it both ways should be explained a little more below. See my
following comment about the example being a bit unclear.
https:/
charmhelpers/
I worry that this example isn't clear. self.interface and the 'foo' key
are assumed.
I'd like two examples here in the style of
'if you need to iterate all the units of a relation, for example in a
template, you can ...'
'if you want the keys and values of any matching unit containing all the
expected keys of an interface, you can ...'
sorry to ask you to rephrase this, and thank you.
Cory Johns (johnsca) wrote : | # |
On 2014/06/06 16:43:12, benjamin.saller wrote:
https:/
> charmhelpers/
['address',
> 'port', 'user', 'password']
> You have a follow on for the charms updating the templates as well?
Yes, as part of the port conflict resolution. Just testing and hashing
out bugs currently.
https:/
> charmhelpers/
['hostname',
> 'port']
> I am a little cautious about this one as this isn't really a CF
component, it
> would be interesting if down the road we had something like
> EtcdInterface = charmhelpers.
> To produce this class from a interface registry. We'll want that sort
of thing
> for the interface testing plans anyway.
I had considered having RelationContext
name and required keys as parameters so that you don't have to create
stub subclasses, and only need to subclass if you want to add extra
behavior (e.g., the MysqlRelation). I think this is the right way to
go, and will do so.
https:/
> charmhelpers/
> This feels a bit magical. I'm fine with letting it in but the 'why'
you'd use it
> both ways should be explained a little more below. See my following
comment
> about the example being a bit unclear.
I agree. I am very on the fence as to whether to stick with the
"magical" implementation vs creating separate classes, or even just
forcing it to always be a list to force charm authors to always consider
the possibility that there may be more units.
The reason I didn't go with separate classes is because I didn't want to
have to duplicate all the stub RelationContext subclasses, but if we
make it so the interface and keys can be passed in, we can avoid
creating 95% of the stub classes, so that'd be ok.
But now I'm thinking that forcing it to be a list might be better, since
charm authors really should keep in mind that the admin could throw more
units at the relation.
- 190. By Cory Johns
-
Fixed Cloud Foundry MysqlRelation for new RelationContext data format
- 191. By Cory Johns
-
Removed magical default unit behavior from RelationContext
Cory Johns (johnsca) wrote : | # |
Please take a look.
Benjamin Saller (bcsaller) wrote : | # |
Thanks, notes follow.
https:/
File charmhelpers/
https:/
charmhelpers/
'mediawiki/0'.
I think you do a fine job of explaining this now, but the use-case for
it is still unclear to me.
If there is more than one relation with the same interface I don't think
we can silently combine those lists (the data is coming from different
relation ids). They represent different services and should expect
different orchestration.
I'd go so far as to say this is a whole in our model.
In CF mysql has relations to UAA and CC but doesn't use the services
framework to manage this. If it did and iterated those units uniformly
havoc could follow, no?
The default case isn't this and we built for it, but unless my thinking
here is muddy, we'd need to do something different here. Currently the
relation id information would be lost. We'd at minimum have to put the
rid in the data, or break the units into collections by relation.
Breaking them up by relation is to my mind similar to always processing
this as a list (rather than the default unit notion of before), its the
more complex case, but its one that some services need to be aware of.
I apologize for not picking up on this sooner. If you'd like to talk
about this in the hangout let me know.
https:/
charmhelpers/
This is good, thanks, it might have to turn into
interface[
for interface[first relation][first unit][key]
lets talk.
- 192. By Cory Johns
-
Added doc note about not (currently) preserving relation-id and unit-id info in RelationContext
Cory Johns (johnsca) wrote : | # |
Please take a look.
Benjamin Saller (bcsaller) wrote : | # |
+1 LTGM, thanks for talking through that issue.
https:/
File charmhelpers/
https:/
charmhelpers/
extend this class to preserve
Great, thanks
Preview Diff
1 | === modified file 'charmhelpers/contrib/cloudfoundry/contexts.py' |
2 | --- charmhelpers/contrib/cloudfoundry/contexts.py 2014-05-26 18:49:24 +0000 |
3 | +++ charmhelpers/contrib/cloudfoundry/contexts.py 2014-06-06 19:43:53 +0000 |
4 | @@ -33,7 +33,7 @@ |
5 | |
6 | class NatsRelation(RelationContext): |
7 | interface = 'nats' |
8 | - required_keys = ['nats_port', 'nats_address', 'nats_user', 'nats_password'] |
9 | + required_keys = ['address', 'port', 'user', 'password'] |
10 | |
11 | |
12 | class MysqlRelation(RelationContext): |
13 | @@ -44,9 +44,10 @@ |
14 | def get_data(self): |
15 | RelationContext.get_data(self) |
16 | if self.is_ready(): |
17 | - if 'port' not in self['db']: |
18 | - self['db']['port'] = '3306' |
19 | - self['db']['dsn'] = self.dsn_template.format(**self['db']) |
20 | + for unit in self['db']: |
21 | + if 'port' not in unit: |
22 | + unit['port'] = '3306' |
23 | + unit['dsn'] = self.dsn_template.format(**unit) |
24 | |
25 | |
26 | class RouterRelation(RelationContext): |
27 | @@ -56,9 +57,19 @@ |
28 | |
29 | class LogRouterRelation(RelationContext): |
30 | interface = 'logrouter' |
31 | - required_keys = ['shared-secret', 'logrouter-address'] |
32 | + required_keys = ['shared_secret', 'address', 'incoming_port', 'outgoing_port'] |
33 | |
34 | |
35 | class LoggregatorRelation(RelationContext): |
36 | interface = 'loggregator' |
37 | - required_keys = ['shared_secret', 'loggregator_address'] |
38 | + required_keys = ['shared_secret', 'address', 'incoming_port', 'outgoing_port'] |
39 | + |
40 | + |
41 | +class EtcdRelation(RelationContext): |
42 | + interface = 'etcd' |
43 | + required_keys = ['hostname', 'port'] |
44 | + |
45 | + |
46 | +class CloudControllerRelation(RelationContext): |
47 | + interface = 'cc' |
48 | + required_keys = ['hostname', 'port', 'user', 'password'] |
49 | |
50 | === modified file 'charmhelpers/core/services.py' |
51 | --- charmhelpers/core/services.py 2014-05-29 17:23:48 +0000 |
52 | +++ charmhelpers/core/services.py 2014-06-06 19:43:53 +0000 |
53 | @@ -11,6 +11,14 @@ |
54 | """ |
55 | Register a list of services, given their definitions. |
56 | |
57 | + Traditional charm authoring is focused on implementing hooks. That is, |
58 | + the charm author is thinking in terms of "What hook am I handling; what |
59 | + does this hook need to do?" However, in most cases, the real question |
60 | + should be "Do I have the information I need to configure and start this |
61 | + piece of software and, if so, what are the steps for doing so." The |
62 | + ServiceManager framework tries to bring the focus to the data and the |
63 | + setup tasks, in the most declarative way possible. |
64 | + |
65 | Service definitions are dicts in the following formats (all keys except |
66 | 'service' are optional): |
67 | |
68 | @@ -67,28 +75,28 @@ |
69 | a mongodb relation and which runs a custom `db_migrate` function prior to |
70 | restarting the service, and a Runit serivce called spadesd. |
71 | |
72 | - >>> manager = services.ServiceManager([ |
73 | - ... { |
74 | - ... 'service': 'bingod', |
75 | - ... 'ports': [80, 443], |
76 | - ... 'required_data': [MongoRelation(), config()], |
77 | - ... 'data_ready': [ |
78 | - ... services.template(source='bingod.conf'), |
79 | - ... services.template(source='bingod.ini', |
80 | - ... target='/etc/bingod.ini', |
81 | - ... owner='bingo', perms=0400), |
82 | - ... ], |
83 | - ... }, |
84 | - ... { |
85 | - ... 'service': 'spadesd', |
86 | - ... 'data_ready': services.template(source='spadesd_run.j2', |
87 | - ... target='/etc/sv/spadesd/run', |
88 | - ... perms=0555), |
89 | - ... 'start': runit_start, |
90 | - ... 'stop': runit_stop, |
91 | - ... }, |
92 | - ... ]) |
93 | - ... manager.manage() |
94 | + manager = services.ServiceManager([ |
95 | + { |
96 | + 'service': 'bingod', |
97 | + 'ports': [80, 443], |
98 | + 'required_data': [MongoRelation(), config(), {'my': 'data'}], |
99 | + 'data_ready': [ |
100 | + services.template(source='bingod.conf'), |
101 | + services.template(source='bingod.ini', |
102 | + target='/etc/bingod.ini', |
103 | + owner='bingo', perms=0400), |
104 | + ], |
105 | + }, |
106 | + { |
107 | + 'service': 'spadesd', |
108 | + 'data_ready': services.template(source='spadesd_run.j2', |
109 | + target='/etc/sv/spadesd/run', |
110 | + perms=0555), |
111 | + 'start': runit_start, |
112 | + 'stop': runit_stop, |
113 | + }, |
114 | + ]) |
115 | + manager.manage() |
116 | """ |
117 | self.services = {} |
118 | for service in services or []: |
119 | @@ -222,46 +230,57 @@ |
120 | |
121 | __nonzero__ = __bool__ |
122 | |
123 | + def __repr__(self): |
124 | + return super(RelationContext, self).__repr__() |
125 | + |
126 | def is_ready(self): |
127 | """ |
128 | - Returns True if all of the required_keys are available. |
129 | - """ |
130 | - return set(self.get(self.interface, {}).keys()).issuperset(set(self.required_keys)) |
131 | + Returns True if all of the `required_keys` are available from any units. |
132 | + """ |
133 | + return len(self.get(self.interface, [])) > 0 |
134 | + |
135 | + def _is_ready(self, unit_data): |
136 | + """ |
137 | + Helper method that tests a set of relation data and returns True if |
138 | + all of the `required_keys` are present. |
139 | + """ |
140 | + return set(unit_data.keys()).issuperset(set(self.required_keys)) |
141 | |
142 | def get_data(self): |
143 | """ |
144 | - Retrieve the relation data and store it under `self[self.interface]`. |
145 | - |
146 | - If there are more than one units related on the desired interface, |
147 | - then each unit will have its data stored under `self[self.interface][unit_id]` |
148 | - and one of the units with complete information will chosen at random |
149 | - to fill the values at `self[self.interface]`. |
150 | - |
151 | - |
152 | - For example: |
153 | - |
154 | - { |
155 | - 'foo': 'bar', |
156 | - 'unit/0': { |
157 | - 'foo': 'bar', |
158 | - }, |
159 | - 'unit/1': { |
160 | - 'foo': 'baz', |
161 | - }, |
162 | - } |
163 | + Retrieve the relation data for each unit involved in a realtion and, |
164 | + if complete, store it in a list under `self[self.interface]`. |
165 | + |
166 | + The units are sorted lexographically first by the service ID, then by |
167 | + the unit ID. Thus, if an interface has two other services, 'db:1' |
168 | + and 'db:2', with 'db:1' having two units, 'wordpress/0' and 'wordpress/1', |
169 | + and 'db:2' having one unit, 'mediawiki/0', all of which have a complete |
170 | + set of data, the relation data for the units will be stored in the |
171 | + order: 'wordpress/0', 'wordpress/1', 'mediawiki/0'. |
172 | + |
173 | + If you only care about a single unit on the relation, you can just |
174 | + access it as `{{ interface[0]['key'] }}`. However, if you can at all |
175 | + support multiple units on a relation, you should iterate over the list, |
176 | + like: |
177 | + |
178 | + {% for unit in interface -%} |
179 | + {{ unit['key'] }}{% if not loop.last %},{% endif %} |
180 | + {%- endfor %} |
181 | + |
182 | + Note that since all sets of relation data from all related services and |
183 | + units are in a single list, if you need to know which service or unit a |
184 | + set of data came from, you'll need to extend this class to preserve |
185 | + that information. |
186 | """ |
187 | if not hookenv.relation_ids(self.interface): |
188 | return |
189 | |
190 | - ns = self.setdefault(self.interface, {}) |
191 | - required = set(self.required_keys) |
192 | - for rid in hookenv.relation_ids(self.interface): |
193 | - for unit in hookenv.related_units(rid): |
194 | + ns = self.setdefault(self.interface, []) |
195 | + for rid in sorted(hookenv.relation_ids(self.interface)): |
196 | + for unit in sorted(hookenv.related_units(rid)): |
197 | reldata = hookenv.relation_get(rid=rid, unit=unit) |
198 | - unit_ns = ns.setdefault(unit, {}) |
199 | - unit_ns.update(reldata) |
200 | - if set(reldata.keys()).issuperset(required): |
201 | - ns.update(reldata) |
202 | + if self._is_ready(reldata): |
203 | + ns.append(reldata) |
204 | |
205 | |
206 | class ManagerCallback(object): |
207 | @@ -316,6 +335,6 @@ |
208 | |
209 | |
210 | # Convenience aliases |
211 | -template = TemplateCallback |
212 | +render_template = template = TemplateCallback |
213 | open_ports = PortManagerCallback() |
214 | close_ports = PortManagerCallback() |
215 | |
216 | === modified file 'tests/contrib/cloudfoundry/test_render_context.py' |
217 | --- tests/contrib/cloudfoundry/test_render_context.py 2014-05-26 18:49:24 +0000 |
218 | +++ tests/contrib/cloudfoundry/test_render_context.py 2014-06-06 19:43:53 +0000 |
219 | @@ -18,23 +18,22 @@ |
220 | @mock.patch('charmhelpers.core.hookenv.relation_get') |
221 | def test_nats_relation_populated(self, mrel, mid, mrelated): |
222 | mid.return_value = ['nats'] |
223 | - mrel.return_value = {'nats_port': 1234, 'nats_address': 'host', |
224 | - 'nats_user': 'user', 'nats_password': 'password'} |
225 | + mrel.return_value = {'port': 1234, 'address': 'host', |
226 | + 'user': 'user', 'password': 'password'} |
227 | mrelated.return_value = ['router/0'] |
228 | n = contexts.NatsRelation() |
229 | - expected = {'nats': {'nats_port': 1234, 'nats_address': 'host', |
230 | - 'nats_user': 'user', 'nats_password': 'password', |
231 | - 'router/0': {'nats_port': 1234, 'nats_address': 'host', |
232 | - 'nats_user': 'user', 'nats_password': 'password'}}} |
233 | + expected = {'nats': [{'port': 1234, 'address': 'host', |
234 | + 'user': 'user', 'password': 'password'}]} |
235 | self.assertTrue(bool(n)) |
236 | self.assertEqual(n, expected) |
237 | + self.assertEqual(n['nats'][0]['port'], 1234) |
238 | |
239 | @mock.patch('charmhelpers.core.hookenv.related_units') |
240 | @mock.patch('charmhelpers.core.hookenv.relation_ids') |
241 | @mock.patch('charmhelpers.core.hookenv.relation_get') |
242 | def test_nats_relation_partial(self, mrel, mid, mrelated): |
243 | mid.return_value = ['nats'] |
244 | - mrel.return_value = {'nats_address': 'host'} |
245 | + mrel.return_value = {'address': 'host'} |
246 | mrelated.return_value = ['router/0'] |
247 | n = contexts.NatsRelation() |
248 | self.assertEqual(n, {}) |
249 | @@ -56,10 +55,10 @@ |
250 | mrel.return_value = {'domain': 'example.com'} |
251 | mrelated.return_value = ['router/0'] |
252 | n = contexts.RouterRelation() |
253 | - expected = {'router': {'domain': 'example.com', |
254 | - 'router/0': {'domain': 'example.com'}}} |
255 | + expected = {'router': [{'domain': 'example.com'}]} |
256 | self.assertTrue(bool(n)) |
257 | self.assertEqual(n, expected) |
258 | + self.assertEqual(n['router'][0]['domain'], 'example.com') |
259 | |
260 | |
261 | class TestStoredContext(unittest.TestCase): |
262 | |
263 | === modified file 'tests/core/test_services.py' |
264 | --- tests/core/test_services.py 2014-05-29 17:23:48 +0000 |
265 | +++ tests/core/test_services.py 2014-06-06 19:43:53 +0000 |
266 | @@ -310,7 +310,7 @@ |
267 | mhookenv.related_units.return_value = [] |
268 | self.context.get_data() |
269 | self.assertFalse(self.context.is_ready()) |
270 | - self.assertEqual(self.context, {'http': {}}) |
271 | + self.assertEqual(self.context, {'http': []}) |
272 | |
273 | @mock.patch.object(services, 'hookenv') |
274 | def test_incomplete(self, mhookenv): |
275 | @@ -319,8 +319,8 @@ |
276 | mhookenv.relation_get.side_effect = [{}, {'foo': '1'}] |
277 | self.assertFalse(bool(self.context)) |
278 | self.assertEqual(mhookenv.relation_get.call_args_list, [ |
279 | + mock.call(rid='apache', unit='apache/0'), |
280 | mock.call(rid='nginx', unit='nginx/0'), |
281 | - mock.call(rid='apache', unit='apache/0'), |
282 | ]) |
283 | |
284 | @mock.patch.object(services, 'hookenv') |
285 | @@ -329,23 +329,17 @@ |
286 | mhookenv.related_units.side_effect = lambda i: [i+'/0'] |
287 | mhookenv.relation_get.side_effect = [{'foo': '1'}, {'foo': '2', 'bar': '3'}, {}] |
288 | self.context.get_data() |
289 | - self.assertEqual(self.context, {'http': { |
290 | - 'foo': '2', |
291 | - 'bar': '3', |
292 | - 'nginx/0': { |
293 | - 'foo': '1', |
294 | - }, |
295 | - 'apache/0': { |
296 | + self.assertTrue(self.context.is_ready()) |
297 | + self.assertEqual(self.context, {'http': [ |
298 | + { |
299 | 'foo': '2', |
300 | 'bar': '3', |
301 | }, |
302 | - 'tomcat/0': { |
303 | - }, |
304 | - }}) |
305 | + ]}) |
306 | mhookenv.relation_ids.assert_called_with('http') |
307 | self.assertEqual(mhookenv.relation_get.call_args_list, [ |
308 | + mock.call(rid='apache', unit='apache/0'), |
309 | mock.call(rid='nginx', unit='nginx/0'), |
310 | - mock.call(rid='apache', unit='apache/0'), |
311 | mock.call(rid='tomcat', unit='tomcat/0'), |
312 | ]) |
313 |
Reviewers: mp+221801_ code.launchpad. net,
Message:
Please take a look.
Description:
Refactored RelationContext multi-unit support
Added a little magic to the RelationContext to make it better able to
support
multiple units and still DWIM in both cases. This might be too magical,
though.
https:/ /code.launchpad .net/~johnsca/ charm-helpers/ multi-unit/ +merge/ 221801
(do not edit description out of merge proposal)
Please review this at https:/ /codereview. appspot. com/96680043/
Affected files (+105, -28 lines): contrib/ cloudfoundry/ contexts. py core/services. py cloudfoundry/ test_render_ context. py test_services. py
A [revision details]
M charmhelpers/
M charmhelpers/
M tests/contrib/
M tests/core/