Merge lp:~johnsca/charm-helpers/services-callback-fu into lp:~cf-charmers/charm-helpers/cloud-foundry
- services-callback-fu
- Merge into cloud-foundry
Status: | Merged |
---|---|
Merged at revision: | 182 |
Proposed branch: | lp:~johnsca/charm-helpers/services-callback-fu |
Merge into: | lp:~cf-charmers/charm-helpers/cloud-foundry |
Diff against target: |
1640 lines (+866/-582) 10 files modified
charmhelpers/contrib/cloudfoundry/contexts.py (+41/-32) charmhelpers/core/host.py (+5/-0) charmhelpers/core/services.py (+316/-79) charmhelpers/core/templating.py (+38/-145) test-requirements-tox.txt (+1/-1) tests/contrib/cloudfoundry/test_render_context.py (+28/-23) tests/core/test_host.py (+10/-0) tests/core/test_services.py (+398/-118) tests/core/test_templating.py (+22/-177) tox.ini (+7/-7) |
To merge this branch: | bzr merge lp:~johnsca/charm-helpers/services-callback-fu |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Cloud Foundry Charmers | Pending | ||
Review via email:
|
Commit message
Description of the change
Callbacks instead of ServiceManagers
Refactored charmhelpers.
service types / managers, to make it easier for charms to specify
custom behavior.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Cory Johns (johnsca) wrote : | # |
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Benjamin Saller (bcsaller) wrote : | # |
LGTM with some trivials
Thanks for this.
https:/
File charmhelpers/
https:/
charmhelpers/
available"""
'an OS service' or 'an init service' or 'an upstart service'
or something similar, service is very overloaded in this context
https:/
File charmhelpers/
https:/
charmhelpers/
Indicate that callback is called with service name,
We should also consider an 'incomplete' event, if a relation is broken
we might stop the service for example.
https:/
charmhelpers/
stop is fine, for a stop hook, but incomplete would be for
relation-broken hooks. We can defer that till we actually intend to
support it though.
https:/
charmhelpers/
SERVICES.keys():
Handle single argument string calls,
if service_names and isinstance(
service_names = [service_names]
Makes sense with the 'incomplete' mentioned above
https:/
charmhelpers/
service, return a ServiceManager
This isn't still a ServiceManager as per the comment if I'm reading this
properly.
- 183. By Cory Johns
-
Updated services.register docstring to clarify when and how the callbacks are called
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Cory Johns (johnsca) wrote : | # |
On 2014/05/22 23:47:43, benjamin.saller wrote:
> We should also consider an 'incomplete' event, if a relation is broken
we might
> stop the service for example.
> stop is fine, for a stop hook, but incomplete would be for
relation-broken
> hooks. We can defer that till we actually intend to support it though.
I wonder if we should change "complete" to "start," since what we're
really saying is "this code should run when the service is ready to
start." Then, "stop" can be called if stop_services() is called or if a
relationship gets broken such that the service no longer has all of the
valid information ("this code should run when the service has to stop,
for whatever reason").
> Handle single argument string calls,
https:/
`foo(*args)` is a "varargs" definition. It will accept `foo()`,
`foo('bar')`, `foo('bar', 'baz')`, etc. (but not `params = ['bar',
'baz']; foo(params)`; that would have to be written `params = ['bar',
'baz']; foo(*params)`), and `args` will automatically be a list.
- 184. By Cory Johns
-
Refined services API to deemphasize template management and emphasize service management
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Cory Johns (johnsca) wrote : | # |
Please take a look.
- 185. By Cory Johns
-
Fixed missed key change in services API
- 186. By Cory Johns
-
Fixed key error checking services.is_ready() before any relations are present
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Cory Johns (johnsca) wrote : | # |
Please take a look.
- 187. By Cory Johns
-
Switch services API from module global to instance, per review
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Cory Johns (johnsca) wrote : | # |
Please take a look.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Benjamin Saller (bcsaller) wrote : | # |
Comments for this went into the charm, but this LGTM as well, thanks.
- 188. By Cory Johns
-
Added managed list of ports to services API
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Cory Johns (johnsca) wrote : | # |
Please take a look.
- 189. By Cory Johns
-
Removed default target for template helper, as it is too prone to errors
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Cory Johns (johnsca) wrote : | # |
Please take a look.
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Benjamin Saller (bcsaller) wrote : | # |
Thanks for tracking down the err0r, and yes, explicit win (esp. since we
hit this in practice).
+1
![](/+icing/build/overlay/assets/skins/sam/images/close.gif)
Cory Johns (johnsca) wrote : | # |
*** Submitted:
Callbacks instead of ServiceManagers
Refactored charmhelpers.
service types / managers, to make it easier for charms to specify
custom behavior.
R=benjamin.saller
CC=
https:/
Preview Diff
1 | === modified file 'charmhelpers/contrib/cloudfoundry/contexts.py' | |||
2 | --- charmhelpers/contrib/cloudfoundry/contexts.py 2014-05-16 23:28:28 +0000 | |||
3 | +++ charmhelpers/contrib/cloudfoundry/contexts.py 2014-05-29 17:24:14 +0000 | |||
4 | @@ -1,55 +1,64 @@ | |||
5 | 1 | import os | 1 | import os |
17 | 2 | 2 | import yaml | |
18 | 3 | from charmhelpers.core.templating import ( | 3 | |
19 | 4 | ContextGenerator, | 4 | from charmhelpers.core.services import RelationContext |
20 | 5 | RelationContext, | 5 | |
21 | 6 | StorableContext, | 6 | |
22 | 7 | ) | 7 | class StoredContext(dict): |
23 | 8 | 8 | """ | |
24 | 9 | 9 | A data context that always returns the data that it was first created with. | |
25 | 10 | # Stores `config_data` hash into yaml file with `file_name` as a name | 10 | """ |
15 | 11 | # if `file_name` already exists, then it loads data from `file_name`. | ||
16 | 12 | class StoredContext(ContextGenerator, StorableContext): | ||
26 | 13 | def __init__(self, file_name, config_data): | 11 | def __init__(self, file_name, config_data): |
27 | 12 | """ | ||
28 | 13 | If the file exists, populate `self` with the data from the file. | ||
29 | 14 | Otherwise, populate with the given data and persist it to the file. | ||
30 | 15 | """ | ||
31 | 14 | if os.path.exists(file_name): | 16 | if os.path.exists(file_name): |
33 | 15 | self.data = self.read_context(file_name) | 17 | self.update(self.read_context(file_name)) |
34 | 16 | else: | 18 | else: |
35 | 17 | self.store_context(file_name, config_data) | 19 | self.store_context(file_name, config_data) |
43 | 18 | self.data = config_data | 20 | self.update(config_data) |
44 | 19 | 21 | ||
45 | 20 | def __call__(self): | 22 | def store_context(self, file_name, config_data): |
46 | 21 | return self.data | 23 | with open(file_name, 'w') as file_stream: |
47 | 22 | 24 | yaml.dump(config_data, file_stream) | |
48 | 23 | 25 | ||
49 | 24 | class NatsContext(RelationContext): | 26 | def read_context(self, file_name): |
50 | 27 | with open(file_name, 'r') as file_stream: | ||
51 | 28 | data = yaml.load(file_stream) | ||
52 | 29 | if not data: | ||
53 | 30 | raise OSError("%s is empty" % file_name) | ||
54 | 31 | return data | ||
55 | 32 | |||
56 | 33 | |||
57 | 34 | class NatsRelation(RelationContext): | ||
58 | 25 | interface = 'nats' | 35 | interface = 'nats' |
59 | 26 | required_keys = ['nats_port', 'nats_address', 'nats_user', 'nats_password'] | 36 | required_keys = ['nats_port', 'nats_address', 'nats_user', 'nats_password'] |
60 | 27 | 37 | ||
61 | 28 | 38 | ||
63 | 29 | class MysqlDSNContext(RelationContext): | 39 | class MysqlRelation(RelationContext): |
64 | 30 | interface = 'db' | 40 | interface = 'db' |
65 | 31 | required_keys = ['user', 'password', 'host', 'database'] | 41 | required_keys = ['user', 'password', 'host', 'database'] |
66 | 32 | dsn_template = "mysql2://{user}:{password}@{host}:{port}/{database}" | 42 | dsn_template = "mysql2://{user}:{password}@{host}:{port}/{database}" |
67 | 33 | 43 | ||
78 | 34 | def __call__(self): | 44 | def get_data(self): |
79 | 35 | ctx = RelationContext.__call__(self) | 45 | RelationContext.get_data(self) |
80 | 36 | if ctx: | 46 | if self.is_ready(): |
81 | 37 | if 'port' not in ctx: | 47 | if 'port' not in self['db']: |
82 | 38 | ctx['db']['port'] = '3306' | 48 | self['db']['port'] = '3306' |
83 | 39 | ctx['db']['dsn'] = self.dsn_template.format(**ctx['db']) | 49 | self['db']['dsn'] = self.dsn_template.format(**self['db']) |
84 | 40 | return ctx | 50 | |
85 | 41 | 51 | ||
86 | 42 | 52 | class RouterRelation(RelationContext): | |
77 | 43 | class RouterContext(RelationContext): | ||
87 | 44 | interface = 'router' | 53 | interface = 'router' |
88 | 45 | required_keys = ['domain'] | 54 | required_keys = ['domain'] |
89 | 46 | 55 | ||
90 | 47 | 56 | ||
92 | 48 | class LogRouterContext(RelationContext): | 57 | class LogRouterRelation(RelationContext): |
93 | 49 | interface = 'logrouter' | 58 | interface = 'logrouter' |
94 | 50 | required_keys = ['shared-secret', 'logrouter-address'] | 59 | required_keys = ['shared-secret', 'logrouter-address'] |
95 | 51 | 60 | ||
96 | 52 | 61 | ||
98 | 53 | class LoggregatorContext(RelationContext): | 62 | class LoggregatorRelation(RelationContext): |
99 | 54 | interface = 'loggregator' | 63 | interface = 'loggregator' |
100 | 55 | required_keys = ['shared_secret', 'loggregator_address'] | 64 | required_keys = ['shared_secret', 'loggregator_address'] |
101 | 56 | 65 | ||
102 | === modified file 'charmhelpers/core/host.py' | |||
103 | --- charmhelpers/core/host.py 2014-05-20 15:40:48 +0000 | |||
104 | +++ charmhelpers/core/host.py 2014-05-29 17:24:14 +0000 | |||
105 | @@ -63,6 +63,11 @@ | |||
106 | 63 | return False | 63 | return False |
107 | 64 | 64 | ||
108 | 65 | 65 | ||
109 | 66 | def service_available(service_name): | ||
110 | 67 | """Determine whether a system service is available""" | ||
111 | 68 | return service('status', service_name) | ||
112 | 69 | |||
113 | 70 | |||
114 | 66 | def adduser(username, password=None, shell='/bin/bash', system_user=False): | 71 | def adduser(username, password=None, shell='/bin/bash', system_user=False): |
115 | 67 | """Add a user to the system""" | 72 | """Add a user to the system""" |
116 | 68 | try: | 73 | try: |
117 | 69 | 74 | ||
118 | === modified file 'charmhelpers/core/services.py' | |||
119 | --- charmhelpers/core/services.py 2014-05-16 21:48:06 +0000 | |||
120 | +++ charmhelpers/core/services.py 2014-05-29 17:24:14 +0000 | |||
121 | @@ -1,84 +1,321 @@ | |||
122 | 1 | import os | ||
123 | 2 | import sys | ||
124 | 3 | from collections import Iterable | ||
125 | 1 | from charmhelpers.core import templating | 4 | from charmhelpers.core import templating |
126 | 2 | from charmhelpers.core import host | 5 | from charmhelpers.core import host |
194 | 3 | 6 | from charmhelpers.core import hookenv | |
195 | 4 | 7 | ||
196 | 5 | SERVICES = {} | 8 | |
197 | 6 | 9 | class ServiceManager(object): | |
198 | 7 | 10 | def __init__(self, services=None): | |
199 | 8 | def register(services, templates_dir=None): | 11 | """ |
200 | 9 | """ | 12 | Register a list of services, given their definitions. |
201 | 10 | Register a list of service configs. | 13 | |
202 | 11 | 14 | Service definitions are dicts in the following formats (all keys except | |
203 | 12 | Service Configs are dicts in the following formats: | 15 | 'service' are optional): |
204 | 13 | 16 | ||
205 | 14 | { | 17 | { |
206 | 15 | "service": <service name>, | 18 | "service": <service name>, |
207 | 16 | "templates": [ { | 19 | "required_data": <list of required data contexts>, |
208 | 17 | 'target': <render target of template>, | 20 | "data_ready": <one or more callbacks>, |
209 | 18 | 'source': <optional name of template in passed in templates_dir> | 21 | "data_lost": <one or more callbacks>, |
210 | 19 | 'file_properties': <optional dict taking owner and octal mode> | 22 | "start": <one or more callbacks>, |
211 | 20 | 'contexts': [ context generators, see contexts.py ] | 23 | "stop": <one or more callbacks>, |
212 | 21 | } | 24 | "ports": <list of ports to manage>, |
213 | 22 | ] } | 25 | } |
214 | 23 | 26 | ||
215 | 24 | Either `source` or `target` must be provided. | 27 | The 'required_data' list should contain dicts of required data (or |
216 | 25 | 28 | dependency managers that act like dicts and know how to collect the data). | |
217 | 26 | If 'source' is not provided for a template the templates_dir will | 29 | Only when all items in the 'required_data' list are populated are the list |
218 | 27 | be consulted for ``basename(target).j2``. | 30 | of 'data_ready' and 'start' callbacks executed. See `is_ready()` for more |
219 | 28 | 31 | information. | |
220 | 29 | If `target` is not provided, it will be assumed to be | 32 | |
221 | 30 | ``/etc/init/<service name>.conf``. | 33 | The 'data_ready' value should be either a single callback, or a list of |
222 | 31 | """ | 34 | callbacks, to be called when all items in 'required_data' pass `is_ready()`. |
223 | 32 | for service in services: | 35 | Each callback will be called with the service name as the only parameter. |
224 | 33 | service.setdefault('templates_dir', templates_dir) | 36 | After these all of the 'data_ready' callbacks are called, the 'start' |
225 | 34 | SERVICES[service['service']] = service | 37 | callbacks are fired. |
226 | 35 | 38 | ||
227 | 36 | 39 | The 'data_lost' value should be either a single callback, or a list of | |
228 | 37 | def reconfigure_services(restart=True): | 40 | callbacks, to be called when a 'required_data' item no longer passes |
229 | 38 | """ | 41 | `is_ready()`. Each callback will be called with the service name as the |
230 | 39 | Update all files for all services and optionally restart them, if ready. | 42 | only parameter. After these all of the 'data_ready' callbacks are called, |
231 | 40 | """ | 43 | the 'stop' callbacks are fired. |
232 | 41 | for service_name in SERVICES.keys(): | 44 | |
233 | 42 | reconfigure_service(service_name, restart=restart) | 45 | The 'start' value should be either a single callback, or a list of |
234 | 43 | 46 | callbacks, to be called when starting the service, after the 'data_ready' | |
235 | 44 | 47 | callbacks are complete. Each callback will be called with the service | |
236 | 45 | def reconfigure_service(service_name, restart=True): | 48 | name as the only parameter. This defaults to |
237 | 46 | """ | 49 | `[host.service_start, services.open_ports]`. |
238 | 47 | Update all files for a single service and optionally restart it, if ready. | 50 | |
239 | 48 | """ | 51 | The 'stop' value should be either a single callback, or a list of |
240 | 49 | service = SERVICES.get(service_name) | 52 | callbacks, to be called when stopping the service. If the service is |
241 | 50 | if not service or service['service'] != service_name: | 53 | being stopped because it no longer has all of its 'required_data', this |
242 | 51 | raise KeyError('Service not registered: %s' % service_name) | 54 | will be called after all of the 'data_lost' callbacks are complete. |
243 | 52 | 55 | Each callback will be called with the service name as the only parameter. | |
244 | 53 | manager_type = service.get('type', UpstartService) | 56 | This defaults to `[services.close_ports, host.service_stop]`. |
245 | 54 | manager_type(service).reconfigure(restart) | 57 | |
246 | 55 | 58 | The 'ports' value should be a list of ports to manage. The default | |
247 | 56 | 59 | 'start' handler will open the ports after the service is started, | |
248 | 57 | def stop_services(): | 60 | and the default 'stop' handler will close the ports prior to stopping |
249 | 58 | for service_name in SERVICES.keys(): | 61 | the service. |
250 | 59 | if host.service_running(service_name): | 62 | |
251 | 60 | host.service_stop(service_name) | 63 | |
252 | 61 | 64 | Examples: | |
253 | 62 | 65 | ||
254 | 63 | class ServiceTypeManager(object): | 66 | The following registers an Upstart service called bingod that depends on |
255 | 64 | def __init__(self, service_definition): | 67 | a mongodb relation and which runs a custom `db_migrate` function prior to |
256 | 65 | self.service_name = service_definition['service'] | 68 | restarting the service, and a Runit serivce called spadesd. |
257 | 66 | self.templates = service_definition['templates'] | 69 | |
258 | 67 | self.templates_dir = service_definition['templates_dir'] | 70 | >>> manager = services.ServiceManager([ |
259 | 68 | 71 | ... { | |
260 | 69 | def reconfigure(self, restart=True): | 72 | ... 'service': 'bingod', |
261 | 73 | ... 'ports': [80, 443], | ||
262 | 74 | ... 'required_data': [MongoRelation(), config()], | ||
263 | 75 | ... 'data_ready': [ | ||
264 | 76 | ... services.template(source='bingod.conf'), | ||
265 | 77 | ... services.template(source='bingod.ini', | ||
266 | 78 | ... target='/etc/bingod.ini', | ||
267 | 79 | ... owner='bingo', perms=0400), | ||
268 | 80 | ... ], | ||
269 | 81 | ... }, | ||
270 | 82 | ... { | ||
271 | 83 | ... 'service': 'spadesd', | ||
272 | 84 | ... 'data_ready': services.template(source='spadesd_run.j2', | ||
273 | 85 | ... target='/etc/sv/spadesd/run', | ||
274 | 86 | ... perms=0555), | ||
275 | 87 | ... 'start': runit_start, | ||
276 | 88 | ... 'stop': runit_stop, | ||
277 | 89 | ... }, | ||
278 | 90 | ... ]) | ||
279 | 91 | ... manager.manage() | ||
280 | 92 | """ | ||
281 | 93 | self.services = {} | ||
282 | 94 | for service in services or []: | ||
283 | 95 | service_name = service['service'] | ||
284 | 96 | self.services[service_name] = service | ||
285 | 97 | |||
286 | 98 | def manage(self): | ||
287 | 99 | """ | ||
288 | 100 | Handle the current hook by doing The Right Thing with the registered services. | ||
289 | 101 | """ | ||
290 | 102 | hook_name = os.path.basename(sys.argv[0]) | ||
291 | 103 | if hook_name == 'stop': | ||
292 | 104 | self.stop_services() | ||
293 | 105 | else: | ||
294 | 106 | self.reconfigure_services() | ||
295 | 107 | |||
296 | 108 | def reconfigure_services(self, *service_names): | ||
297 | 109 | """ | ||
298 | 110 | Update all files for one or more registered services, and, | ||
299 | 111 | if ready, optionally restart them. | ||
300 | 112 | |||
301 | 113 | If no service names are given, reconfigures all registered services. | ||
302 | 114 | """ | ||
303 | 115 | for service_name in service_names or self.services.keys(): | ||
304 | 116 | if self.is_ready(service_name): | ||
305 | 117 | self.fire_event('data_ready', service_name) | ||
306 | 118 | self.fire_event('start', service_name, default=[ | ||
307 | 119 | host.service_restart, | ||
308 | 120 | open_ports]) | ||
309 | 121 | self.save_ready(service_name) | ||
310 | 122 | else: | ||
311 | 123 | if self.was_ready(service_name): | ||
312 | 124 | self.fire_event('data_lost', service_name) | ||
313 | 125 | self.fire_event('stop', service_name, default=[ | ||
314 | 126 | close_ports, | ||
315 | 127 | host.service_stop]) | ||
316 | 128 | self.save_lost(service_name) | ||
317 | 129 | |||
318 | 130 | def stop_services(self, *service_names): | ||
319 | 131 | """ | ||
320 | 132 | Stop one or more registered services, by name. | ||
321 | 133 | |||
322 | 134 | If no service names are given, stops all registered services. | ||
323 | 135 | """ | ||
324 | 136 | for service_name in service_names or self.services.keys(): | ||
325 | 137 | self.fire_event('stop', service_name, default=[ | ||
326 | 138 | close_ports, | ||
327 | 139 | host.service_stop]) | ||
328 | 140 | |||
329 | 141 | def get_service(self, service_name): | ||
330 | 142 | """ | ||
331 | 143 | Given the name of a registered service, return its service definition. | ||
332 | 144 | """ | ||
333 | 145 | service = self.services.get(service_name) | ||
334 | 146 | if not service: | ||
335 | 147 | raise KeyError('Service not registered: %s' % service_name) | ||
336 | 148 | return service | ||
337 | 149 | |||
338 | 150 | def fire_event(self, event_name, service_name, default=None): | ||
339 | 151 | """ | ||
340 | 152 | Fire a data_ready, data_lost, start, or stop event on a given service. | ||
341 | 153 | """ | ||
342 | 154 | service = self.get_service(service_name) | ||
343 | 155 | callbacks = service.get(event_name, default) | ||
344 | 156 | if not callbacks: | ||
345 | 157 | return | ||
346 | 158 | if not isinstance(callbacks, Iterable): | ||
347 | 159 | callbacks = [callbacks] | ||
348 | 160 | for callback in callbacks: | ||
349 | 161 | if isinstance(callback, ManagerCallback): | ||
350 | 162 | callback(self, service_name, event_name) | ||
351 | 163 | else: | ||
352 | 164 | callback(service_name) | ||
353 | 165 | |||
354 | 166 | def is_ready(self, service_name): | ||
355 | 167 | """ | ||
356 | 168 | Determine if a registered service is ready, by checking its 'required_data'. | ||
357 | 169 | |||
358 | 170 | A 'required_data' item can be any mapping type, and is considered ready | ||
359 | 171 | if `bool(item)` evaluates as True. | ||
360 | 172 | """ | ||
361 | 173 | service = self.get_service(service_name) | ||
362 | 174 | reqs = service.get('required_data', []) | ||
363 | 175 | return all(bool(req) for req in reqs) | ||
364 | 176 | |||
365 | 177 | def save_ready(self, service_name): | ||
366 | 178 | """ | ||
367 | 179 | Save an indicator that the given service is now data_ready. | ||
368 | 180 | """ | ||
369 | 181 | ready_file = '{}/.ready.{}'.format(hookenv.charm_dir(), service_name) | ||
370 | 182 | with open(ready_file, 'a'): | ||
371 | 183 | pass | ||
372 | 184 | |||
373 | 185 | def save_lost(self, service_name): | ||
374 | 186 | """ | ||
375 | 187 | Save an indicator that the given service is no longer data_ready. | ||
376 | 188 | """ | ||
377 | 189 | ready_file = '{}/.ready.{}'.format(hookenv.charm_dir(), service_name) | ||
378 | 190 | if os.path.exists(ready_file): | ||
379 | 191 | os.remove(ready_file) | ||
380 | 192 | |||
381 | 193 | def was_ready(self, service_name): | ||
382 | 194 | """ | ||
383 | 195 | Determine if the given service was previously data_ready. | ||
384 | 196 | """ | ||
385 | 197 | ready_file = '{}/.ready.{}'.format(hookenv.charm_dir(), service_name) | ||
386 | 198 | return os.path.exists(ready_file) | ||
387 | 199 | |||
388 | 200 | |||
389 | 201 | class RelationContext(dict): | ||
390 | 202 | """ | ||
391 | 203 | Base class for a context generator that gets relation data from juju. | ||
392 | 204 | |||
393 | 205 | Subclasses must provide `interface`, which is the interface type of interest, | ||
394 | 206 | and `required_keys`, which is the set of keys required for the relation to | ||
395 | 207 | be considered complete. The first relation for the interface that is complete | ||
396 | 208 | will be used to populate the data for template. | ||
397 | 209 | |||
398 | 210 | The generated context will be namespaced under the interface type, to prevent | ||
399 | 211 | potential naming conflicts. | ||
400 | 212 | """ | ||
401 | 213 | interface = None | ||
402 | 214 | required_keys = [] | ||
403 | 215 | |||
404 | 216 | def __bool__(self): | ||
405 | 217 | """ | ||
406 | 218 | Updates the data and returns True if all of the required_keys are available. | ||
407 | 219 | """ | ||
408 | 220 | self.get_data() | ||
409 | 221 | return self.is_ready() | ||
410 | 222 | |||
411 | 223 | __nonzero__ = __bool__ | ||
412 | 224 | |||
413 | 225 | def is_ready(self): | ||
414 | 226 | """ | ||
415 | 227 | Returns True if all of the required_keys are available. | ||
416 | 228 | """ | ||
417 | 229 | return set(self.get(self.interface, {}).keys()).issuperset(set(self.required_keys)) | ||
418 | 230 | |||
419 | 231 | def get_data(self): | ||
420 | 232 | """ | ||
421 | 233 | Retrieve the relation data and store it under `self[self.interface]`. | ||
422 | 234 | |||
423 | 235 | If there are more than one units related on the desired interface, | ||
424 | 236 | then each unit will have its data stored under `self[self.interface][unit_id]` | ||
425 | 237 | and one of the units with complete information will chosen at random | ||
426 | 238 | to fill the values at `self[self.interface]`. | ||
427 | 239 | |||
428 | 240 | |||
429 | 241 | For example: | ||
430 | 242 | |||
431 | 243 | { | ||
432 | 244 | 'foo': 'bar', | ||
433 | 245 | 'unit/0': { | ||
434 | 246 | 'foo': 'bar', | ||
435 | 247 | }, | ||
436 | 248 | 'unit/1': { | ||
437 | 249 | 'foo': 'baz', | ||
438 | 250 | }, | ||
439 | 251 | } | ||
440 | 252 | """ | ||
441 | 253 | if not hookenv.relation_ids(self.interface): | ||
442 | 254 | return | ||
443 | 255 | |||
444 | 256 | ns = self.setdefault(self.interface, {}) | ||
445 | 257 | required = set(self.required_keys) | ||
446 | 258 | for rid in hookenv.relation_ids(self.interface): | ||
447 | 259 | for unit in hookenv.related_units(rid): | ||
448 | 260 | reldata = hookenv.relation_get(rid=rid, unit=unit) | ||
449 | 261 | unit_ns = ns.setdefault(unit, {}) | ||
450 | 262 | unit_ns.update(reldata) | ||
451 | 263 | if set(reldata.keys()).issuperset(required): | ||
452 | 264 | ns.update(reldata) | ||
453 | 265 | |||
454 | 266 | |||
455 | 267 | class ManagerCallback(object): | ||
456 | 268 | """ | ||
457 | 269 | Special case of a callback that takes the `ServiceManager` instance | ||
458 | 270 | in addition to the service name. | ||
459 | 271 | |||
460 | 272 | Subclasses should implement `__call__` which should accept two parameters: | ||
461 | 273 | |||
462 | 274 | * `manager` The `ServiceManager` instance | ||
463 | 275 | * `service_name` The name of the service it's being triggered for | ||
464 | 276 | * `event_name` The name of the event that this callback is handling | ||
465 | 277 | """ | ||
466 | 278 | def __call__(self, manager, service_name, event_name): | ||
467 | 70 | raise NotImplementedError() | 279 | raise NotImplementedError() |
468 | 71 | 280 | ||
469 | 72 | 281 | ||
482 | 73 | class UpstartService(ServiceTypeManager): | 282 | class TemplateCallback(ManagerCallback): |
483 | 74 | def __init__(self, service_definition): | 283 | """ |
484 | 75 | super(UpstartService, self).__init__(service_definition) | 284 | Callback class that will render a template, for use as a ready action. |
485 | 76 | for tmpl in self.templates: | 285 | |
486 | 77 | if 'target' not in tmpl: | 286 | The `target` param, if omitted, will default to `/etc/init/<service name>`. |
487 | 78 | tmpl['target'] = '/etc/init/%s.conf' % self.service_name | 287 | """ |
488 | 79 | 288 | def __init__(self, source, target, owner='root', group='root', perms=0444): | |
489 | 80 | def reconfigure(self, restart): | 289 | self.source = source |
490 | 81 | complete = templating.render(self.templates, self.templates_dir) | 290 | self.target = target |
491 | 82 | 291 | self.owner = owner | |
492 | 83 | if restart and complete: | 292 | self.group = group |
493 | 84 | host.service_restart(self.service_name) | 293 | self.perms = perms |
494 | 294 | |||
495 | 295 | def __call__(self, manager, service_name, event_name): | ||
496 | 296 | service = manager.get_service(service_name) | ||
497 | 297 | context = {} | ||
498 | 298 | for ctx in service.get('required_data', []): | ||
499 | 299 | context.update(ctx) | ||
500 | 300 | templating.render(self.source, self.target, context, | ||
501 | 301 | self.owner, self.group, self.perms) | ||
502 | 302 | |||
503 | 303 | |||
504 | 304 | class PortManagerCallback(ManagerCallback): | ||
505 | 305 | """ | ||
506 | 306 | Callback class that will open or close ports, for use as either | ||
507 | 307 | a start or stop action. | ||
508 | 308 | """ | ||
509 | 309 | def __call__(self, manager, service_name, event_name): | ||
510 | 310 | service = manager.get_service(service_name) | ||
511 | 311 | for port in service.get('ports', []): | ||
512 | 312 | if event_name == 'start': | ||
513 | 313 | hookenv.open_port(port) | ||
514 | 314 | elif event_name == 'stop': | ||
515 | 315 | hookenv.close_port(port) | ||
516 | 316 | |||
517 | 317 | |||
518 | 318 | # Convenience aliases | ||
519 | 319 | template = TemplateCallback | ||
520 | 320 | open_ports = PortManagerCallback() | ||
521 | 321 | close_ports = PortManagerCallback() | ||
522 | 85 | 322 | ||
523 | === modified file 'charmhelpers/core/templating.py' | |||
524 | --- charmhelpers/core/templating.py 2014-05-20 15:40:48 +0000 | |||
525 | +++ charmhelpers/core/templating.py 2014-05-29 17:24:14 +0000 | |||
526 | @@ -1,158 +1,51 @@ | |||
527 | 1 | import os | 1 | import os |
528 | 2 | import yaml | ||
529 | 3 | 2 | ||
530 | 4 | from charmhelpers.core import host | 3 | from charmhelpers.core import host |
531 | 5 | from charmhelpers.core import hookenv | 4 | from charmhelpers.core import hookenv |
532 | 6 | 5 | ||
533 | 7 | 6 | ||
648 | 8 | class ContextGenerator(object): | 7 | def render(source, target, context, owner='root', group='root', perms=0444, templates_dir=None): |
649 | 9 | """ | 8 | """ |
650 | 10 | Base interface for template context container generators. | 9 | Render a template. |
651 | 11 | 10 | ||
652 | 12 | A template context is a dictionary that contains data needed to populate | 11 | The `source` path, if not absolute, is relative to the `templates_dir`. |
539 | 13 | the template. The generator instance should produce the context when | ||
540 | 14 | called (without arguments) by collecting information from juju (config-get, | ||
541 | 15 | relation-get, etc), the system, or whatever other sources are appropriate. | ||
542 | 16 | |||
543 | 17 | A context generator should only return any values if it has enough information | ||
544 | 18 | to provide all of its values. Any context that is missing data is considered | ||
545 | 19 | incomplete and will cause that template to not render until it has all of its | ||
546 | 20 | necessary data. | ||
547 | 21 | |||
548 | 22 | The template may receive several contexts, which will be merged together, | ||
549 | 23 | so care should be taken in the key names. | ||
550 | 24 | """ | ||
551 | 25 | def __call__(self): | ||
552 | 26 | raise NotImplementedError | ||
553 | 27 | |||
554 | 28 | |||
555 | 29 | class StorableContext(object): | ||
556 | 30 | """ | ||
557 | 31 | A mixin for persisting a context to disk. | ||
558 | 32 | """ | ||
559 | 33 | def store_context(self, file_name, config_data): | ||
560 | 34 | with open(file_name, 'w') as file_stream: | ||
561 | 35 | yaml.dump(config_data, file_stream) | ||
562 | 36 | |||
563 | 37 | def read_context(self, file_name): | ||
564 | 38 | with open(file_name, 'r') as file_stream: | ||
565 | 39 | data = yaml.load(file_stream) | ||
566 | 40 | if not data: | ||
567 | 41 | raise OSError("%s is empty" % file_name) | ||
568 | 42 | return data | ||
569 | 43 | |||
570 | 44 | |||
571 | 45 | class ConfigContext(ContextGenerator): | ||
572 | 46 | """ | ||
573 | 47 | A context generator that generates a context containing all of the | ||
574 | 48 | juju config values. | ||
575 | 49 | """ | ||
576 | 50 | def __call__(self): | ||
577 | 51 | return hookenv.config() | ||
578 | 52 | |||
579 | 53 | |||
580 | 54 | class RelationContext(ContextGenerator): | ||
581 | 55 | """ | ||
582 | 56 | Base class for a context generator that gets relation data from juju. | ||
583 | 57 | |||
584 | 58 | Subclasses must provide `interface`, which is the interface type of interest, | ||
585 | 59 | and `required_keys`, which is the set of keys required for the relation to | ||
586 | 60 | be considered complete. The first relation for the interface that is complete | ||
587 | 61 | will be used to populate the data for template. | ||
588 | 62 | |||
589 | 63 | The generated context will be namespaced under the interface type, to prevent | ||
590 | 64 | potential naming conflicts. | ||
591 | 65 | """ | ||
592 | 66 | interface = None | ||
593 | 67 | required_keys = [] | ||
594 | 68 | |||
595 | 69 | def __call__(self): | ||
596 | 70 | if not hookenv.relation_ids(self.interface): | ||
597 | 71 | return {} | ||
598 | 72 | |||
599 | 73 | ctx = {} | ||
600 | 74 | for rid in hookenv.relation_ids(self.interface): | ||
601 | 75 | for unit in hookenv.related_units(rid): | ||
602 | 76 | reldata = hookenv.relation_get(rid=rid, unit=unit) | ||
603 | 77 | required = set(self.required_keys) | ||
604 | 78 | if set(reldata.keys()).issuperset(required): | ||
605 | 79 | ns = ctx.setdefault(self.interface, {}) | ||
606 | 80 | for k, v in reldata.items(): | ||
607 | 81 | ns[k] = v | ||
608 | 82 | return ctx | ||
609 | 83 | |||
610 | 84 | return {} | ||
611 | 85 | |||
612 | 86 | |||
613 | 87 | class StaticContext(ContextGenerator): | ||
614 | 88 | def __init__(self, data): | ||
615 | 89 | self.data = data | ||
616 | 90 | |||
617 | 91 | def __call__(self): | ||
618 | 92 | return self.data | ||
619 | 93 | |||
620 | 94 | |||
621 | 95 | def _collect_contexts(context_providers): | ||
622 | 96 | """ | ||
623 | 97 | Helper function to collect and merge contexts from a list of providers. | ||
624 | 98 | |||
625 | 99 | If any of the contexts are incomplete (i.e., they return an empty dict), | ||
626 | 100 | the template is considered incomplete and will not render. | ||
627 | 101 | """ | ||
628 | 102 | ctx = {} | ||
629 | 103 | for provider in context_providers: | ||
630 | 104 | c = provider() | ||
631 | 105 | if not c: | ||
632 | 106 | return False | ||
633 | 107 | ctx.update(c) | ||
634 | 108 | return ctx | ||
635 | 109 | |||
636 | 110 | |||
637 | 111 | def render(template_definitions, templates_dir=None): | ||
638 | 112 | """ | ||
639 | 113 | Render one or more templates, given a list of template definitions. | ||
640 | 114 | |||
641 | 115 | The template definitions should be dicts with the keys: `source`, `target`, | ||
642 | 116 | `file_properties`, and `contexts`. | ||
643 | 117 | |||
644 | 118 | The `source` path, if not absolute, is relative to the `templates_dir` | ||
645 | 119 | given when the rendered was created. If `source` is not provided | ||
646 | 120 | for a template the `template_dir` will be consulted for | ||
647 | 121 | ``basename(target).j2``. | ||
653 | 122 | 12 | ||
654 | 123 | The `target` path should be absolute. | 13 | The `target` path should be absolute. |
655 | 124 | 14 | ||
666 | 125 | The `file_properties` should be a dict optionally containing | 15 | The context should be a dict containing the values to be replaced in the |
667 | 126 | `owner`, `group`, or `perms` options, to be passed to `write_file`. | 16 | template. |
668 | 127 | 17 | ||
669 | 128 | The `contexts` should be a list containing zero or more ContextGenerators. | 18 | The `owner`, `group`, and `perms` options will be passed to `write_file`. |
670 | 129 | 19 | ||
671 | 130 | The `template_dir` defaults to `$CHARM_DIR/templates` | 20 | If omitted, `templates_dir` defaults to the `templates` folder in the charm. |
672 | 131 | 21 | ||
673 | 132 | Returns True if all of the templates were "complete" (i.e., the context | 22 | Note: Using this requires python-jinja2; if it is not installed, calling |
674 | 133 | generators were able to collect the information needed to render the | 23 | this will attempt to use charmhelpers.fetch.apt_install to install it. |
665 | 134 | template) and were rendered. | ||
675 | 135 | """ | 24 | """ |
679 | 136 | # lazy import jinja2 in case templating is needed in install hook | 25 | try: |
680 | 137 | from jinja2 import FileSystemLoader, Environment, exceptions | 26 | from jinja2 import FileSystemLoader, Environment, exceptions |
681 | 138 | all_complete = True | 27 | except ImportError: |
682 | 28 | try: | ||
683 | 29 | from charmhelpers.fetch import apt_install | ||
684 | 30 | except ImportError: | ||
685 | 31 | hookenv.log('Could not import jinja2, and could not import ' | ||
686 | 32 | 'charmhelpers.fetch to install it', | ||
687 | 33 | level=hookenv.ERROR) | ||
688 | 34 | raise | ||
689 | 35 | apt_install('python-jinja2', fatal=True) | ||
690 | 36 | from jinja2 import FileSystemLoader, Environment, exceptions | ||
691 | 37 | |||
692 | 139 | if templates_dir is None: | 38 | if templates_dir is None: |
693 | 140 | templates_dir = os.path.join(hookenv.charm_dir(), 'templates') | 39 | templates_dir = os.path.join(hookenv.charm_dir(), 'templates') |
694 | 141 | loader = Environment(loader=FileSystemLoader(templates_dir)) | 40 | loader = Environment(loader=FileSystemLoader(templates_dir)) |
712 | 142 | for tmpl in template_definitions: | 41 | try: |
713 | 143 | ctx = _collect_contexts(tmpl.get('contexts', [])) | 42 | source = source |
714 | 144 | if ctx is False: | 43 | template = loader.get_template(source) |
715 | 145 | all_complete = False | 44 | except exceptions.TemplateNotFound as e: |
716 | 146 | continue | 45 | hookenv.log('Could not load template %s from %s.' % |
717 | 147 | try: | 46 | (source, templates_dir), |
718 | 148 | source = tmpl.get('source', os.path.basename(tmpl['target'])+'.j2') | 47 | level=hookenv.ERROR) |
719 | 149 | template = loader.get_template(source) | 48 | raise e |
720 | 150 | except exceptions.TemplateNotFound as e: | 49 | content = template.render(context) |
721 | 151 | hookenv.log('Could not load template %s from %s.' % | 50 | host.mkdir(os.path.dirname(target)) |
722 | 152 | (tmpl['source'], templates_dir), | 51 | host.write_file(target, content, owner, group, perms) |
706 | 153 | level=hookenv.ERROR) | ||
707 | 154 | raise e | ||
708 | 155 | content = template.render(ctx) | ||
709 | 156 | host.mkdir(os.path.dirname(tmpl['target'])) | ||
710 | 157 | host.write_file(tmpl['target'], content, **tmpl.get('file_properties', {})) | ||
711 | 158 | return all_complete | ||
723 | 159 | 52 | ||
724 | === modified file 'test-requirements-tox.txt' | |||
725 | --- test-requirements-tox.txt 2014-05-09 16:10:54 +0000 | |||
726 | +++ test-requirements-tox.txt 2014-05-29 17:24:14 +0000 | |||
727 | @@ -15,4 +15,4 @@ | |||
728 | 15 | wsgiref==0.1.2 | 15 | wsgiref==0.1.2 |
729 | 16 | launchpadlib==1.10.2 | 16 | launchpadlib==1.10.2 |
730 | 17 | bzr==2.6.0 | 17 | bzr==2.6.0 |
732 | 18 | 18 | ipdb | |
733 | 19 | 19 | ||
734 | === modified file 'tests/contrib/cloudfoundry/test_render_context.py' | |||
735 | --- tests/contrib/cloudfoundry/test_render_context.py 2014-05-13 20:14:01 +0000 | |||
736 | +++ tests/contrib/cloudfoundry/test_render_context.py 2014-05-29 17:24:14 +0000 | |||
737 | @@ -5,56 +5,61 @@ | |||
738 | 5 | from charmhelpers.contrib.cloudfoundry import contexts | 5 | from charmhelpers.contrib.cloudfoundry import contexts |
739 | 6 | 6 | ||
740 | 7 | 7 | ||
742 | 8 | class TestNatsContext(unittest.TestCase): | 8 | class TestNatsRelation(unittest.TestCase): |
743 | 9 | 9 | ||
744 | 10 | @mock.patch('charmhelpers.core.hookenv.relation_ids') | 10 | @mock.patch('charmhelpers.core.hookenv.relation_ids') |
746 | 11 | def test_nats_context_empty(self, mid): | 11 | def test_nats_relation_empty(self, mid): |
747 | 12 | mid.return_value = None | 12 | mid.return_value = None |
750 | 13 | n = contexts.NatsContext() | 13 | n = contexts.NatsRelation() |
751 | 14 | self.assertEqual(n(), {}) | 14 | self.assertEqual(n, {}) |
752 | 15 | 15 | ||
753 | 16 | @mock.patch('charmhelpers.core.hookenv.related_units') | 16 | @mock.patch('charmhelpers.core.hookenv.related_units') |
754 | 17 | @mock.patch('charmhelpers.core.hookenv.relation_ids') | 17 | @mock.patch('charmhelpers.core.hookenv.relation_ids') |
755 | 18 | @mock.patch('charmhelpers.core.hookenv.relation_get') | 18 | @mock.patch('charmhelpers.core.hookenv.relation_get') |
757 | 19 | def test_nats_context_populated(self, mrel, mid, mrelated): | 19 | def test_nats_relation_populated(self, mrel, mid, mrelated): |
758 | 20 | mid.return_value = ['nats'] | 20 | mid.return_value = ['nats'] |
759 | 21 | mrel.return_value = {'nats_port': 1234, 'nats_address': 'host', | 21 | mrel.return_value = {'nats_port': 1234, 'nats_address': 'host', |
760 | 22 | 'nats_user': 'user', 'nats_password': 'password'} | 22 | 'nats_user': 'user', 'nats_password': 'password'} |
761 | 23 | mrelated.return_value = ['router/0'] | 23 | mrelated.return_value = ['router/0'] |
763 | 24 | n = contexts.NatsContext() | 24 | n = contexts.NatsRelation() |
764 | 25 | expected = {'nats': {'nats_port': 1234, 'nats_address': 'host', | 25 | expected = {'nats': {'nats_port': 1234, 'nats_address': 'host', |
767 | 26 | 'nats_user': 'user', 'nats_password': 'password'}} | 26 | 'nats_user': 'user', 'nats_password': 'password', |
768 | 27 | self.assertEqual(n(), expected) | 27 | 'router/0': {'nats_port': 1234, 'nats_address': 'host', |
769 | 28 | 'nats_user': 'user', 'nats_password': 'password'}}} | ||
770 | 29 | self.assertTrue(bool(n)) | ||
771 | 30 | self.assertEqual(n, expected) | ||
772 | 28 | 31 | ||
773 | 29 | @mock.patch('charmhelpers.core.hookenv.related_units') | 32 | @mock.patch('charmhelpers.core.hookenv.related_units') |
774 | 30 | @mock.patch('charmhelpers.core.hookenv.relation_ids') | 33 | @mock.patch('charmhelpers.core.hookenv.relation_ids') |
775 | 31 | @mock.patch('charmhelpers.core.hookenv.relation_get') | 34 | @mock.patch('charmhelpers.core.hookenv.relation_get') |
777 | 32 | def test_nats_context_partial(self, mrel, mid, mrelated): | 35 | def test_nats_relation_partial(self, mrel, mid, mrelated): |
778 | 33 | mid.return_value = ['nats'] | 36 | mid.return_value = ['nats'] |
779 | 34 | mrel.return_value = {'nats_address': 'host'} | 37 | mrel.return_value = {'nats_address': 'host'} |
780 | 35 | mrelated.return_value = ['router/0'] | 38 | mrelated.return_value = ['router/0'] |
786 | 36 | n = contexts.NatsContext() | 39 | n = contexts.NatsRelation() |
787 | 37 | self.assertEqual(n(), {}) | 40 | self.assertEqual(n, {}) |
788 | 38 | 41 | ||
789 | 39 | 42 | ||
790 | 40 | class TestRouterContext(unittest.TestCase): | 43 | class TestRouterRelation(unittest.TestCase): |
791 | 41 | 44 | ||
792 | 42 | @mock.patch('charmhelpers.core.hookenv.relation_ids') | 45 | @mock.patch('charmhelpers.core.hookenv.relation_ids') |
794 | 43 | def test_router_context_empty(self, mid): | 46 | def test_router_relation_empty(self, mid): |
795 | 44 | mid.return_value = None | 47 | mid.return_value = None |
798 | 45 | n = contexts.RouterContext() | 48 | n = contexts.RouterRelation() |
799 | 46 | self.assertEqual(n(), {}) | 49 | self.assertEqual(n, {}) |
800 | 47 | 50 | ||
801 | 48 | @mock.patch('charmhelpers.core.hookenv.related_units') | 51 | @mock.patch('charmhelpers.core.hookenv.related_units') |
802 | 49 | @mock.patch('charmhelpers.core.hookenv.relation_ids') | 52 | @mock.patch('charmhelpers.core.hookenv.relation_ids') |
803 | 50 | @mock.patch('charmhelpers.core.hookenv.relation_get') | 53 | @mock.patch('charmhelpers.core.hookenv.relation_get') |
805 | 51 | def test_router_context_populated(self, mrel, mid, mrelated): | 54 | def test_router_relation_populated(self, mrel, mid, mrelated): |
806 | 52 | mid.return_value = ['router'] | 55 | mid.return_value = ['router'] |
807 | 53 | mrel.return_value = {'domain': 'example.com'} | 56 | mrel.return_value = {'domain': 'example.com'} |
808 | 54 | mrelated.return_value = ['router/0'] | 57 | mrelated.return_value = ['router/0'] |
812 | 55 | n = contexts.RouterContext() | 58 | n = contexts.RouterRelation() |
813 | 56 | expected = {'router': {'domain': 'example.com'}} | 59 | expected = {'router': {'domain': 'example.com', |
814 | 57 | self.assertEqual(n(), expected) | 60 | 'router/0': {'domain': 'example.com'}}} |
815 | 61 | self.assertTrue(bool(n)) | ||
816 | 62 | self.assertEqual(n, expected) | ||
817 | 58 | 63 | ||
818 | 59 | 64 | ||
819 | 60 | class TestStoredContext(unittest.TestCase): | 65 | class TestStoredContext(unittest.TestCase): |
820 | @@ -71,13 +76,13 @@ | |||
821 | 71 | os.unlink(file_name) | 76 | os.unlink(file_name) |
822 | 72 | contexts.StoredContext(file_name, {'key': 'initial_value'}) | 77 | contexts.StoredContext(file_name, {'key': 'initial_value'}) |
823 | 73 | self.assertTrue(os.path.isfile(file_name)) | 78 | self.assertTrue(os.path.isfile(file_name)) |
825 | 74 | context = contexts.StoredContext(file_name, {'key': 'random_value'})() | 79 | context = contexts.StoredContext(file_name, {'key': 'random_value'}) |
826 | 75 | self.assertIn('key', context) | 80 | self.assertIn('key', context) |
827 | 76 | self.assertEqual(context['key'], 'initial_value') | 81 | self.assertEqual(context['key'], 'initial_value') |
828 | 77 | 82 | ||
829 | 78 | def test_stored_context_raise(self): | 83 | def test_stored_context_raise(self): |
830 | 79 | _, file_name = tempfile.mkstemp() | 84 | _, file_name = tempfile.mkstemp() |
832 | 80 | with self.assertRaises(OSError) as cm: | 85 | with self.assertRaises(OSError): |
833 | 81 | contexts.StoredContext(file_name, {'key': 'initial_value'}) | 86 | contexts.StoredContext(file_name, {'key': 'initial_value'}) |
834 | 82 | os.unlink(file_name) | 87 | os.unlink(file_name) |
835 | 83 | 88 | ||
836 | 84 | 89 | ||
837 | === modified file 'tests/core/test_host.py' | |||
838 | --- tests/core/test_host.py 2014-05-20 15:40:48 +0000 | |||
839 | +++ tests/core/test_host.py 2014-05-29 17:24:14 +0000 | |||
840 | @@ -96,6 +96,16 @@ | |||
841 | 96 | service.assert_called_with('reload', service_name) | 96 | service.assert_called_with('reload', service_name) |
842 | 97 | 97 | ||
843 | 98 | @patch.object(host, 'service') | 98 | @patch.object(host, 'service') |
844 | 99 | def test_service_available(self, service): | ||
845 | 100 | service_name = 'foo-service' | ||
846 | 101 | service.side_effect = [True] | ||
847 | 102 | self.assertTrue(host.service_available(service_name)) | ||
848 | 103 | service.side_effect = [False] | ||
849 | 104 | self.assertFalse(host.service_available(service_name)) | ||
850 | 105 | |||
851 | 106 | service.assert_called_with('status', service_name) | ||
852 | 107 | |||
853 | 108 | @patch.object(host, 'service') | ||
854 | 99 | def test_failed_reload_restarts_a_service(self, service): | 109 | def test_failed_reload_restarts_a_service(self, service): |
855 | 100 | service_name = 'foo-service' | 110 | service_name = 'foo-service' |
856 | 101 | service.side_effect = [False, True] | 111 | service.side_effect = [False, True] |
857 | 102 | 112 | ||
858 | === modified file 'tests/core/test_services.py' | |||
859 | --- tests/core/test_services.py 2014-05-16 21:48:06 +0000 | |||
860 | +++ tests/core/test_services.py 2014-05-29 17:24:14 +0000 | |||
861 | @@ -1,126 +1,406 @@ | |||
862 | 1 | import pkg_resources | ||
863 | 2 | import mock | 1 | import mock |
864 | 3 | import unittest | 2 | import unittest |
865 | 4 | from charmhelpers.core import services | 3 | from charmhelpers.core import services |
866 | 5 | 4 | ||
903 | 6 | TEMPLATES_DIR = pkg_resources.resource_filename(__name__, 'templates') | 5 | |
904 | 7 | 6 | class TestServiceManager(unittest.TestCase): | |
905 | 8 | 7 | def test_register(self): | |
906 | 9 | def static_content(data): | 8 | manager = services.ServiceManager([ |
907 | 10 | def context(): | 9 | {'service': 'service1', |
908 | 11 | return data | 10 | 'foo': 'bar'}, |
909 | 12 | return context | 11 | {'service': 'service2', |
910 | 13 | 12 | 'qux': 'baz'}, | |
911 | 14 | 13 | ]) | |
912 | 15 | # Sample render context used to test rendering paths | 14 | self.assertEqual(manager.services, { |
913 | 16 | default_context = { | 15 | 'service1': {'service': 'service1', |
914 | 17 | 'nats': { | 16 | 'foo': 'bar'}, |
915 | 18 | 'nats_port': '1234', | 17 | 'service2': {'service': 'service2', |
916 | 19 | 'nats_host': 'example.com', | 18 | 'qux': 'baz'}, |
917 | 20 | }, | 19 | }) |
918 | 21 | 'router': { | 20 | |
919 | 22 | 'domain': 'api.foo.com' | 21 | @mock.patch.object(services.ServiceManager, 'reconfigure_services') |
920 | 23 | } | 22 | @mock.patch.object(services.ServiceManager, 'stop_services') |
921 | 24 | } | 23 | @mock.patch.object(services, 'sys') |
922 | 25 | 24 | def test_manage_stop(self, msys, stop_services, reconfigure_services): | |
923 | 26 | 25 | manager = services.ServiceManager() | |
924 | 27 | # Method returning a mutable copy of a default config | 26 | msys.argv = ['charm_dir/hooks/stop'] |
925 | 28 | # for services.register() | 27 | manager.manage() |
926 | 29 | def default_service_config(): | 28 | stop_services.assert_called_once_with() |
927 | 30 | return [{ | 29 | assert not reconfigure_services.called |
928 | 31 | 'service': 'cf-cloudcontroller', | 30 | |
929 | 32 | 'templates': [ | 31 | @mock.patch.object(services.ServiceManager, 'reconfigure_services') |
930 | 33 | {'target': 'cc.yml', | 32 | @mock.patch.object(services.ServiceManager, 'stop_services') |
931 | 34 | 'source': 'fake_cc.yml', | 33 | @mock.patch.object(services, 'sys') |
932 | 35 | 'file_properties': {}, | 34 | def test_manage_other(self, msys, stop_services, reconfigure_services): |
933 | 36 | 'contexts': [static_content(default_context)] | 35 | manager = services.ServiceManager() |
934 | 37 | }] | 36 | msys.argv = ['charm_dir/hooks/config-changed'] |
935 | 38 | }] | 37 | manager.manage() |
936 | 39 | 38 | assert not stop_services.called | |
937 | 40 | 39 | reconfigure_services.assert_called_once_with() | |
938 | 41 | class TestService(unittest.TestCase): | 40 | |
939 | 41 | @mock.patch.object(services.ServiceManager, 'save_ready') | ||
940 | 42 | @mock.patch.object(services.ServiceManager, 'fire_event') | ||
941 | 43 | @mock.patch.object(services.ServiceManager, 'is_ready') | ||
942 | 44 | def test_reconfigure_ready(self, is_ready, fire_event, save_ready): | ||
943 | 45 | manager = services.ServiceManager([ | ||
944 | 46 | {'service': 'service1'}, {'service': 'service2'}]) | ||
945 | 47 | is_ready.return_value = True | ||
946 | 48 | manager.reconfigure_services() | ||
947 | 49 | is_ready.assert_has_calls([ | ||
948 | 50 | mock.call('service1'), | ||
949 | 51 | mock.call('service2'), | ||
950 | 52 | ], any_order=True) | ||
951 | 53 | fire_event.assert_has_calls([ | ||
952 | 54 | mock.call('data_ready', 'service1'), | ||
953 | 55 | mock.call('start', 'service1', default=[ | ||
954 | 56 | services.host.service_restart, | ||
955 | 57 | services.open_ports]), | ||
956 | 58 | ], any_order=False) | ||
957 | 59 | fire_event.assert_has_calls([ | ||
958 | 60 | mock.call('data_ready', 'service2'), | ||
959 | 61 | mock.call('start', 'service2', default=[ | ||
960 | 62 | services.host.service_restart, | ||
961 | 63 | services.open_ports]), | ||
962 | 64 | ], any_order=False) | ||
963 | 65 | save_ready.assert_has_calls([ | ||
964 | 66 | mock.call('service1'), | ||
965 | 67 | mock.call('service2'), | ||
966 | 68 | ], any_order=True) | ||
967 | 69 | |||
968 | 70 | @mock.patch.object(services.ServiceManager, 'save_ready') | ||
969 | 71 | @mock.patch.object(services.ServiceManager, 'fire_event') | ||
970 | 72 | @mock.patch.object(services.ServiceManager, 'is_ready') | ||
971 | 73 | def test_reconfigure_ready_list(self, is_ready, fire_event, save_ready): | ||
972 | 74 | manager = services.ServiceManager([ | ||
973 | 75 | {'service': 'service1'}, {'service': 'service2'}]) | ||
974 | 76 | is_ready.return_value = True | ||
975 | 77 | manager.reconfigure_services('service3', 'service4') | ||
976 | 78 | self.assertEqual(is_ready.call_args_list, [ | ||
977 | 79 | mock.call('service3'), | ||
978 | 80 | mock.call('service4'), | ||
979 | 81 | ]) | ||
980 | 82 | self.assertEqual(fire_event.call_args_list, [ | ||
981 | 83 | mock.call('data_ready', 'service3'), | ||
982 | 84 | mock.call('start', 'service3', default=[ | ||
983 | 85 | services.host.service_restart, | ||
984 | 86 | services.open_ports]), | ||
985 | 87 | mock.call('data_ready', 'service4'), | ||
986 | 88 | mock.call('start', 'service4', default=[ | ||
987 | 89 | services.host.service_restart, | ||
988 | 90 | services.open_ports]), | ||
989 | 91 | ]) | ||
990 | 92 | self.assertEqual(save_ready.call_args_list, [ | ||
991 | 93 | mock.call('service3'), | ||
992 | 94 | mock.call('service4'), | ||
993 | 95 | ]) | ||
994 | 96 | |||
995 | 97 | @mock.patch.object(services.ServiceManager, 'save_lost') | ||
996 | 98 | @mock.patch.object(services.ServiceManager, 'fire_event') | ||
997 | 99 | @mock.patch.object(services.ServiceManager, 'was_ready') | ||
998 | 100 | @mock.patch.object(services.ServiceManager, 'is_ready') | ||
999 | 101 | def test_reconfigure_not_ready(self, is_ready, was_ready, fire_event, save_lost): | ||
1000 | 102 | manager = services.ServiceManager([ | ||
1001 | 103 | {'service': 'service1'}, {'service': 'service2'}]) | ||
1002 | 104 | is_ready.return_value = False | ||
1003 | 105 | was_ready.return_value = False | ||
1004 | 106 | manager.reconfigure_services() | ||
1005 | 107 | is_ready.assert_has_calls([ | ||
1006 | 108 | mock.call('service1'), | ||
1007 | 109 | mock.call('service2'), | ||
1008 | 110 | ], any_order=True) | ||
1009 | 111 | fire_event.assert_has_calls([ | ||
1010 | 112 | mock.call('stop', 'service1', default=[ | ||
1011 | 113 | services.close_ports, | ||
1012 | 114 | services.host.service_stop]), | ||
1013 | 115 | mock.call('stop', 'service2', default=[ | ||
1014 | 116 | services.close_ports, | ||
1015 | 117 | services.host.service_stop]), | ||
1016 | 118 | ], any_order=True) | ||
1017 | 119 | save_lost.assert_has_calls([ | ||
1018 | 120 | mock.call('service1'), | ||
1019 | 121 | mock.call('service2'), | ||
1020 | 122 | ], any_order=True) | ||
1021 | 123 | |||
1022 | 124 | @mock.patch.object(services.ServiceManager, 'save_lost') | ||
1023 | 125 | @mock.patch.object(services.ServiceManager, 'fire_event') | ||
1024 | 126 | @mock.patch.object(services.ServiceManager, 'was_ready') | ||
1025 | 127 | @mock.patch.object(services.ServiceManager, 'is_ready') | ||
1026 | 128 | def test_reconfigure_no_longer_ready(self, is_ready, was_ready, fire_event, save_lost): | ||
1027 | 129 | manager = services.ServiceManager([ | ||
1028 | 130 | {'service': 'service1'}, {'service': 'service2'}]) | ||
1029 | 131 | is_ready.return_value = False | ||
1030 | 132 | was_ready.return_value = True | ||
1031 | 133 | manager.reconfigure_services() | ||
1032 | 134 | is_ready.assert_has_calls([ | ||
1033 | 135 | mock.call('service1'), | ||
1034 | 136 | mock.call('service2'), | ||
1035 | 137 | ], any_order=True) | ||
1036 | 138 | fire_event.assert_has_calls([ | ||
1037 | 139 | mock.call('data_lost', 'service1'), | ||
1038 | 140 | mock.call('stop', 'service1', default=[ | ||
1039 | 141 | services.close_ports, | ||
1040 | 142 | services.host.service_stop]), | ||
1041 | 143 | ], any_order=False) | ||
1042 | 144 | fire_event.assert_has_calls([ | ||
1043 | 145 | mock.call('data_lost', 'service2'), | ||
1044 | 146 | mock.call('stop', 'service2', default=[ | ||
1045 | 147 | services.close_ports, | ||
1046 | 148 | services.host.service_stop]), | ||
1047 | 149 | ], any_order=False) | ||
1048 | 150 | save_lost.assert_has_calls([ | ||
1049 | 151 | mock.call('service1'), | ||
1050 | 152 | mock.call('service2'), | ||
1051 | 153 | ], any_order=True) | ||
1052 | 154 | |||
1053 | 155 | @mock.patch.object(services.ServiceManager, 'fire_event') | ||
1054 | 156 | def test_stop_services(self, fire_event): | ||
1055 | 157 | manager = services.ServiceManager([ | ||
1056 | 158 | {'service': 'service1'}, {'service': 'service2'}]) | ||
1057 | 159 | manager.stop_services() | ||
1058 | 160 | fire_event.assert_has_calls([ | ||
1059 | 161 | mock.call('stop', 'service1', default=[ | ||
1060 | 162 | services.close_ports, | ||
1061 | 163 | services.host.service_stop]), | ||
1062 | 164 | mock.call('stop', 'service2', default=[ | ||
1063 | 165 | services.close_ports, | ||
1064 | 166 | services.host.service_stop]), | ||
1065 | 167 | ], any_order=True) | ||
1066 | 168 | |||
1067 | 169 | @mock.patch.object(services.ServiceManager, 'fire_event') | ||
1068 | 170 | def test_stop_services_list(self, fire_event): | ||
1069 | 171 | manager = services.ServiceManager([ | ||
1070 | 172 | {'service': 'service1'}, {'service': 'service2'}]) | ||
1071 | 173 | manager.stop_services('service3', 'service4') | ||
1072 | 174 | self.assertEqual(fire_event.call_args_list, [ | ||
1073 | 175 | mock.call('stop', 'service3', default=[ | ||
1074 | 176 | services.close_ports, | ||
1075 | 177 | services.host.service_stop]), | ||
1076 | 178 | mock.call('stop', 'service4', default=[ | ||
1077 | 179 | services.close_ports, | ||
1078 | 180 | services.host.service_stop]), | ||
1079 | 181 | ]) | ||
1080 | 182 | |||
1081 | 183 | def test_get_service(self): | ||
1082 | 184 | service = {'service': 'test', 'test': 'test_service'} | ||
1083 | 185 | manager = services.ServiceManager([service]) | ||
1084 | 186 | self.assertEqual(manager.get_service('test'), service) | ||
1085 | 187 | |||
1086 | 188 | def test_get_service_not_registered(self): | ||
1087 | 189 | service = {'service': 'test', 'test': 'test_service'} | ||
1088 | 190 | manager = services.ServiceManager([service]) | ||
1089 | 191 | self.assertRaises(KeyError, manager.get_service, 'foo') | ||
1090 | 192 | |||
1091 | 193 | @mock.patch.object(services.ServiceManager, 'get_service') | ||
1092 | 194 | def test_fire_event_default(self, get_service): | ||
1093 | 195 | get_service.return_value = {} | ||
1094 | 196 | cb = mock.Mock() | ||
1095 | 197 | manager = services.ServiceManager() | ||
1096 | 198 | manager.fire_event('event', 'service', cb) | ||
1097 | 199 | cb.assert_called_once_with('service') | ||
1098 | 200 | |||
1099 | 201 | @mock.patch.object(services.ServiceManager, 'get_service') | ||
1100 | 202 | def test_fire_event_default_list(self, get_service): | ||
1101 | 203 | get_service.return_value = {} | ||
1102 | 204 | cb = mock.Mock() | ||
1103 | 205 | manager = services.ServiceManager() | ||
1104 | 206 | manager.fire_event('event', 'service', [cb]) | ||
1105 | 207 | cb.assert_called_once_with('service') | ||
1106 | 208 | |||
1107 | 209 | @mock.patch.object(services.ServiceManager, 'get_service') | ||
1108 | 210 | def test_fire_event_simple_callback(self, get_service): | ||
1109 | 211 | cb = mock.Mock() | ||
1110 | 212 | dcb = mock.Mock() | ||
1111 | 213 | get_service.return_value = {'event': cb} | ||
1112 | 214 | manager = services.ServiceManager() | ||
1113 | 215 | manager.fire_event('event', 'service', dcb) | ||
1114 | 216 | assert not dcb.called | ||
1115 | 217 | cb.assert_called_once_with('service') | ||
1116 | 218 | |||
1117 | 219 | @mock.patch.object(services.ServiceManager, 'get_service') | ||
1118 | 220 | def test_fire_event_simple_callback_list(self, get_service): | ||
1119 | 221 | cb = mock.Mock() | ||
1120 | 222 | dcb = mock.Mock() | ||
1121 | 223 | get_service.return_value = {'event': [cb]} | ||
1122 | 224 | manager = services.ServiceManager() | ||
1123 | 225 | manager.fire_event('event', 'service', dcb) | ||
1124 | 226 | assert not dcb.called | ||
1125 | 227 | cb.assert_called_once_with('service') | ||
1126 | 228 | |||
1127 | 229 | @mock.patch.object(services.ManagerCallback, '__call__') | ||
1128 | 230 | @mock.patch.object(services.ServiceManager, 'get_service') | ||
1129 | 231 | def test_fire_event_manager_callback(self, get_service, mcall): | ||
1130 | 232 | cb = services.ManagerCallback() | ||
1131 | 233 | dcb = mock.Mock() | ||
1132 | 234 | get_service.return_value = {'event': cb} | ||
1133 | 235 | manager = services.ServiceManager() | ||
1134 | 236 | manager.fire_event('event', 'service', dcb) | ||
1135 | 237 | assert not dcb.called | ||
1136 | 238 | mcall.assert_called_once_with(manager, 'service', 'event') | ||
1137 | 239 | |||
1138 | 240 | @mock.patch.object(services.ManagerCallback, '__call__') | ||
1139 | 241 | @mock.patch.object(services.ServiceManager, 'get_service') | ||
1140 | 242 | def test_fire_event_manager_callback_list(self, get_service, mcall): | ||
1141 | 243 | cb = services.ManagerCallback() | ||
1142 | 244 | dcb = mock.Mock() | ||
1143 | 245 | get_service.return_value = {'event': [cb]} | ||
1144 | 246 | manager = services.ServiceManager() | ||
1145 | 247 | manager.fire_event('event', 'service', dcb) | ||
1146 | 248 | assert not dcb.called | ||
1147 | 249 | mcall.assert_called_once_with(manager, 'service', 'event') | ||
1148 | 250 | |||
1149 | 251 | @mock.patch.object(services.ServiceManager, 'get_service') | ||
1150 | 252 | def test_is_ready(self, get_service): | ||
1151 | 253 | get_service.side_effect = [ | ||
1152 | 254 | {}, | ||
1153 | 255 | {'required_data': [True]}, | ||
1154 | 256 | {'required_data': [False]}, | ||
1155 | 257 | {'required_data': [True, False]}, | ||
1156 | 258 | ] | ||
1157 | 259 | manager = services.ServiceManager() | ||
1158 | 260 | assert manager.is_ready('foo') | ||
1159 | 261 | assert manager.is_ready('bar') | ||
1160 | 262 | assert not manager.is_ready('foo') | ||
1161 | 263 | assert not manager.is_ready('foo') | ||
1162 | 264 | get_service.assert_has_calls([mock.call('foo'), mock.call('bar')]) | ||
1163 | 265 | |||
1164 | 266 | @mock.patch.object(services.hookenv, 'charm_dir') | ||
1165 | 267 | @mock.patch.object(services, 'open', create=True) | ||
1166 | 268 | def test_save_ready(self, mopen, charm_dir): | ||
1167 | 269 | charm_dir.return_value = 'charm_dir' | ||
1168 | 270 | manager = services.ServiceManager() | ||
1169 | 271 | manager.save_ready('foo') | ||
1170 | 272 | mopen.assert_called_once_with('charm_dir/.ready.foo', 'a') | ||
1171 | 273 | |||
1172 | 274 | @mock.patch.object(services.hookenv, 'charm_dir') | ||
1173 | 275 | @mock.patch('os.remove') | ||
1174 | 276 | @mock.patch('os.path.exists') | ||
1175 | 277 | def test_save_lost(self, exists, remove, charm_dir): | ||
1176 | 278 | charm_dir.return_value = 'charm_dir' | ||
1177 | 279 | manager = services.ServiceManager() | ||
1178 | 280 | manager.save_lost('foo') | ||
1179 | 281 | exists.assert_called_once_with('charm_dir/.ready.foo') | ||
1180 | 282 | remove.assert_called_once_with('charm_dir/.ready.foo') | ||
1181 | 283 | |||
1182 | 284 | @mock.patch.object(services.hookenv, 'charm_dir') | ||
1183 | 285 | @mock.patch('os.path.exists') | ||
1184 | 286 | def test_was_ready(self, exists, charm_dir): | ||
1185 | 287 | charm_dir.return_value = 'charm_dir' | ||
1186 | 288 | manager = services.ServiceManager() | ||
1187 | 289 | manager.was_ready('foo') | ||
1188 | 290 | exists.assert_called_once_with('charm_dir/.ready.foo') | ||
1189 | 291 | |||
1190 | 292 | |||
1191 | 293 | class TestRelationContext(unittest.TestCase): | ||
1192 | 42 | def setUp(self): | 294 | def setUp(self): |
1274 | 43 | services.SERVICES = {} | 295 | self.context = services.RelationContext() |
1275 | 44 | 296 | self.context.interface = 'http' | |
1276 | 45 | @mock.patch('charmhelpers.core.host.service_restart') | 297 | self.context.required_keys = ['foo', 'bar'] |
1277 | 46 | @mock.patch('charmhelpers.core.services.templating') | 298 | |
1278 | 47 | def test_register_no_target(self, mtemplating, mservice_restart): | 299 | @mock.patch.object(services, 'hookenv') |
1279 | 48 | config = default_service_config() | 300 | def test_no_relations(self, mhookenv): |
1280 | 49 | del config[0]['templates'][0]['target'] | 301 | mhookenv.relation_ids.return_value = [] |
1281 | 50 | services.register(config, TEMPLATES_DIR) | 302 | self.context.get_data() |
1282 | 51 | services.reconfigure_services() | 303 | self.assertFalse(self.context.is_ready()) |
1283 | 52 | self.assertEqual(mtemplating.render.call_args[0][0][0]['target'], | 304 | self.assertEqual(self.context, {}) |
1284 | 53 | '/etc/init/cf-cloudcontroller.conf') | 305 | mhookenv.relation_ids.assert_called_once_with('http') |
1285 | 54 | self.assertEqual(mtemplating.render.call_args[0][1], TEMPLATES_DIR) | 306 | |
1286 | 55 | 307 | @mock.patch.object(services, 'hookenv') | |
1287 | 56 | @mock.patch('charmhelpers.core.host.service_restart') | 308 | def test_no_units(self, mhookenv): |
1288 | 57 | @mock.patch('charmhelpers.core.services.templating') | 309 | mhookenv.relation_ids.return_value = ['nginx'] |
1289 | 58 | def test_register_default_tmpl_dir(self, mtemplating, mservice_restart): | 310 | mhookenv.related_units.return_value = [] |
1290 | 59 | config = default_service_config() | 311 | self.context.get_data() |
1291 | 60 | services.register(config) | 312 | self.assertFalse(self.context.is_ready()) |
1292 | 61 | services.reconfigure_services() | 313 | self.assertEqual(self.context, {'http': {}}) |
1293 | 62 | self.assertEqual(mtemplating.render.call_args[0][1], None) | 314 | |
1294 | 63 | 315 | @mock.patch.object(services, 'hookenv') | |
1295 | 64 | @mock.patch('charmhelpers.core.services.templating') | 316 | def test_incomplete(self, mhookenv): |
1296 | 65 | @mock.patch('charmhelpers.core.services.reconfigure_service') | 317 | mhookenv.relation_ids.return_value = ['nginx', 'apache'] |
1297 | 66 | def test_reconfigure_services(self, mreconfig, mtemplating): | 318 | mhookenv.related_units.side_effect = lambda i: [i+'/0'] |
1298 | 67 | services.register(default_service_config(), TEMPLATES_DIR) | 319 | mhookenv.relation_get.side_effect = [{}, {'foo': '1'}] |
1299 | 68 | services.reconfigure_services() | 320 | self.assertFalse(bool(self.context)) |
1300 | 69 | mreconfig.assert_called_once_with('cf-cloudcontroller', restart=True) | 321 | self.assertEqual(mhookenv.relation_get.call_args_list, [ |
1301 | 70 | 322 | mock.call(rid='nginx', unit='nginx/0'), | |
1302 | 71 | @mock.patch('charmhelpers.core.services.templating') | 323 | mock.call(rid='apache', unit='apache/0'), |
1303 | 72 | @mock.patch('charmhelpers.core.host.service_restart') | 324 | ]) |
1304 | 73 | def test_reconfigure_service_restart(self, mrestart, mtemplating): | 325 | |
1305 | 74 | services.register(default_service_config(), TEMPLATES_DIR) | 326 | @mock.patch.object(services, 'hookenv') |
1306 | 75 | services.reconfigure_service('cf-cloudcontroller', restart=True) | 327 | def test_complete(self, mhookenv): |
1307 | 76 | mrestart.assert_called_once_with('cf-cloudcontroller') | 328 | mhookenv.relation_ids.return_value = ['nginx', 'apache', 'tomcat'] |
1308 | 77 | 329 | mhookenv.related_units.side_effect = lambda i: [i+'/0'] | |
1309 | 78 | @mock.patch('charmhelpers.core.services.templating') | 330 | mhookenv.relation_get.side_effect = [{'foo': '1'}, {'foo': '2', 'bar': '3'}, {}] |
1310 | 79 | @mock.patch('charmhelpers.core.host.service_restart') | 331 | self.context.get_data() |
1311 | 80 | def test_reconfigure_service_no_restart(self, mrestart, mtemplating): | 332 | self.assertEqual(self.context, {'http': { |
1312 | 81 | services.register(default_service_config(), TEMPLATES_DIR) | 333 | 'foo': '2', |
1313 | 82 | services.reconfigure_service('cf-cloudcontroller', restart=False) | 334 | 'bar': '3', |
1314 | 83 | self.assertFalse(mrestart.called) | 335 | 'nginx/0': { |
1315 | 84 | 336 | 'foo': '1', | |
1316 | 85 | @mock.patch('charmhelpers.core.services.templating') | 337 | }, |
1317 | 86 | @mock.patch('charmhelpers.core.host.service_restart') | 338 | 'apache/0': { |
1318 | 87 | def test_reconfigure_service_incomplete(self, mrestart, mtemplating): | 339 | 'foo': '2', |
1319 | 88 | config = default_service_config() | 340 | 'bar': '3', |
1320 | 89 | mtemplating.render.return_value = False # render fails when incomplete | 341 | }, |
1321 | 90 | services.register(config, TEMPLATES_DIR) | 342 | 'tomcat/0': { |
1322 | 91 | services.reconfigure_service('cf-cloudcontroller', restart=True) | 343 | }, |
1323 | 92 | # verify that we did not restart the service | 344 | }}) |
1324 | 93 | self.assertFalse(mrestart.called) | 345 | mhookenv.relation_ids.assert_called_with('http') |
1325 | 94 | 346 | self.assertEqual(mhookenv.relation_get.call_args_list, [ | |
1326 | 95 | @mock.patch('charmhelpers.core.services.templating') | 347 | mock.call(rid='nginx', unit='nginx/0'), |
1327 | 96 | @mock.patch('charmhelpers.core.host.service_restart') | 348 | mock.call(rid='apache', unit='apache/0'), |
1328 | 97 | def test_reconfigure_service_no_context(self, mrestart, mtemplating): | 349 | mock.call(rid='tomcat', unit='tomcat/0'), |
1329 | 98 | config = default_service_config() | 350 | ]) |
1330 | 99 | services.register(config, 'foo') | 351 | |
1331 | 100 | services.reconfigure_service('cf-cloudcontroller', restart=False) | 352 | |
1332 | 101 | # verify that we called render template with the expected name | 353 | class TestTemplateCallback(unittest.TestCase): |
1333 | 102 | mtemplating.render.assert_called_once_with(config[0]['templates'], 'foo') | 354 | @mock.patch.object(services, 'templating') |
1334 | 103 | self.assertFalse(mrestart.called) | 355 | def test_template_defaults(self, mtemplating): |
1335 | 104 | self.assertRaises(KeyError, services.reconfigure_service, 'unknownservice', restart=False) | 356 | manager = mock.Mock(**{'get_service.return_value': { |
1336 | 105 | 357 | 'required_data': [{'foo': 'bar'}]}}) | |
1337 | 106 | @mock.patch('charmhelpers.core.services.templating') | 358 | self.assertRaises(TypeError, services.template, source='foo.yml') |
1338 | 107 | @mock.patch('charmhelpers.core.host.service_restart') | 359 | callback = services.template(source='foo.yml', target='bar.yml') |
1339 | 108 | def test_custom_service_type(self, mrestart, mtemplating): | 360 | assert isinstance(callback, services.ManagerCallback) |
1340 | 109 | config = default_service_config() | 361 | assert not mtemplating.render.called |
1341 | 110 | config[0]['type'] = mock.Mock(name='CustomService') | 362 | callback(manager, 'test', 'event') |
1342 | 111 | services.register(config, 'foo') | 363 | mtemplating.render.assert_called_once_with( |
1343 | 112 | services.reconfigure_service('cf-cloudcontroller', restart=False) | 364 | 'foo.yml', 'bar.yml', {'foo': 'bar'}, |
1344 | 113 | config[0]['type'].assert_called_once_with(config[0]) | 365 | 'root', 'root', 0444) |
1345 | 114 | config[0]['type'].return_value.reconfigure.assert_called_once_with(False) | 366 | |
1346 | 115 | 367 | @mock.patch.object(services, 'templating') | |
1347 | 116 | @mock.patch('charmhelpers.core.services.templating') | 368 | def test_template_explicit(self, mtemplating): |
1348 | 117 | @mock.patch('charmhelpers.core.host.service_stop') | 369 | manager = mock.Mock(**{'get_service.return_value': { |
1349 | 118 | @mock.patch('charmhelpers.core.host.service_running') | 370 | 'required_data': [{'foo': 'bar'}]}}) |
1350 | 119 | def test_stop_services(self, mrunning, mstop, mtemplating): | 371 | callback = services.template( |
1351 | 120 | services.register(default_service_config(), TEMPLATES_DIR) | 372 | source='foo.yml', target='bar.yml', |
1352 | 121 | services.stop_services() | 373 | owner='user', group='group', perms=0555 |
1353 | 122 | mrunning.assert_called_once_with('cf-cloudcontroller') | 374 | ) |
1354 | 123 | mstop.assert_called_once_with('cf-cloudcontroller') | 375 | assert isinstance(callback, services.ManagerCallback) |
1355 | 376 | assert not mtemplating.render.called | ||
1356 | 377 | callback(manager, 'test', 'event') | ||
1357 | 378 | mtemplating.render.assert_called_once_with( | ||
1358 | 379 | 'foo.yml', 'bar.yml', {'foo': 'bar'}, | ||
1359 | 380 | 'user', 'group', 0555) | ||
1360 | 381 | |||
1361 | 382 | |||
1362 | 383 | class TestPortsCallback(unittest.TestCase): | ||
1363 | 384 | @mock.patch.object(services, 'hookenv') | ||
1364 | 385 | def test_no_ports(self, hookenv): | ||
1365 | 386 | manager = mock.Mock(**{'get_service.return_value': {}}) | ||
1366 | 387 | services.PortManagerCallback()(manager, 'service', 'event') | ||
1367 | 388 | assert not hookenv.open_port.called | ||
1368 | 389 | assert not hookenv.close_port.called | ||
1369 | 390 | |||
1370 | 391 | @mock.patch.object(services, 'hookenv') | ||
1371 | 392 | def test_open_ports(self, hookenv): | ||
1372 | 393 | manager = mock.Mock(**{'get_service.return_value': {'ports': [1, 2]}}) | ||
1373 | 394 | services.open_ports(manager, 'service', 'start') | ||
1374 | 395 | hookenv.open_port.has_calls([mock.call(1), mock.call(2)]) | ||
1375 | 396 | assert not hookenv.close_port.called | ||
1376 | 397 | |||
1377 | 398 | @mock.patch.object(services, 'hookenv') | ||
1378 | 399 | def test_close_ports(self, hookenv): | ||
1379 | 400 | manager = mock.Mock(**{'get_service.return_value': {'ports': [1, 2]}}) | ||
1380 | 401 | services.close_ports(manager, 'service', 'stop') | ||
1381 | 402 | assert not hookenv.open_port.called | ||
1382 | 403 | hookenv.close_port.has_calls([mock.call(1), mock.call(2)]) | ||
1383 | 124 | 404 | ||
1384 | 125 | if __name__ == '__main__': | 405 | if __name__ == '__main__': |
1385 | 126 | unittest.main() | 406 | unittest.main() |
1386 | 127 | 407 | ||
1387 | === modified file 'tests/core/test_templating.py' | |||
1388 | --- tests/core/test_templating.py 2014-05-16 21:48:06 +0000 | |||
1389 | +++ tests/core/test_templating.py 2014-05-29 17:24:14 +0000 | |||
1390 | @@ -1,7 +1,7 @@ | |||
1391 | 1 | import os | ||
1392 | 2 | import pkg_resources | 1 | import pkg_resources |
1393 | 3 | import tempfile | 2 | import tempfile |
1394 | 4 | import unittest | 3 | import unittest |
1395 | 4 | import jinja2 | ||
1396 | 5 | 5 | ||
1397 | 6 | import mock | 6 | import mock |
1398 | 7 | from charmhelpers.core import templating | 7 | from charmhelpers.core import templating |
1399 | @@ -10,30 +10,6 @@ | |||
1400 | 10 | TEMPLATES_DIR = pkg_resources.resource_filename(__name__, 'templates') | 10 | TEMPLATES_DIR = pkg_resources.resource_filename(__name__, 'templates') |
1401 | 11 | 11 | ||
1402 | 12 | 12 | ||
1403 | 13 | def noop(): | ||
1404 | 14 | return {} | ||
1405 | 15 | |||
1406 | 16 | |||
1407 | 17 | def found(): | ||
1408 | 18 | return {'foo': 'bar'} | ||
1409 | 19 | |||
1410 | 20 | |||
1411 | 21 | DEFAULT_CONTEXT = { | ||
1412 | 22 | 'nats': { | ||
1413 | 23 | 'nats_port': '1234', | ||
1414 | 24 | 'nats_host': 'example.com', | ||
1415 | 25 | }, | ||
1416 | 26 | 'router': { | ||
1417 | 27 | 'domain': 'api.foo.com' | ||
1418 | 28 | }, | ||
1419 | 29 | 'nginx_port': 80, | ||
1420 | 30 | } | ||
1421 | 31 | |||
1422 | 32 | |||
1423 | 33 | def default_context_provider(): | ||
1424 | 34 | return DEFAULT_CONTEXT | ||
1425 | 35 | |||
1426 | 36 | |||
1427 | 37 | class TestTemplating(unittest.TestCase): | 13 | class TestTemplating(unittest.TestCase): |
1428 | 38 | def setUp(self): | 14 | def setUp(self): |
1429 | 39 | self.charm_dir = pkg_resources.resource_filename(__name__, '') | 15 | self.charm_dir = pkg_resources.resource_filename(__name__, '') |
1430 | @@ -50,164 +26,33 @@ | |||
1431 | 50 | def test_render(self, log, mkdir, fchown): | 26 | def test_render(self, log, mkdir, fchown): |
1432 | 51 | _, fn1 = tempfile.mkstemp() | 27 | _, fn1 = tempfile.mkstemp() |
1433 | 52 | _, fn2 = tempfile.mkstemp() | 28 | _, fn2 = tempfile.mkstemp() |
1443 | 53 | template_definitions = [ | 29 | context = { |
1444 | 54 | {'source': 'fake_cc.yml', | 30 | 'nats': { |
1445 | 55 | 'target': fn1, | 31 | 'nats_port': '1234', |
1446 | 56 | 'contexts': [default_context_provider]}, | 32 | 'nats_host': 'example.com', |
1447 | 57 | {'source': 'test.conf', | 33 | }, |
1448 | 58 | 'target': fn2, | 34 | 'router': { |
1449 | 59 | 'contexts': [default_context_provider]}, | 35 | 'domain': 'api.foo.com' |
1450 | 60 | ] | 36 | }, |
1451 | 61 | self.assertTrue(templating.render(template_definitions)) | 37 | 'nginx_port': 80, |
1452 | 38 | } | ||
1453 | 39 | templating.render('fake_cc.yml', fn1, context, templates_dir=TEMPLATES_DIR) | ||
1454 | 62 | contents = open(fn1).read() | 40 | contents = open(fn1).read() |
1455 | 63 | self.assertRegexpMatches(contents, 'port: 1234') | 41 | self.assertRegexpMatches(contents, 'port: 1234') |
1456 | 64 | self.assertRegexpMatches(contents, 'host: example.com') | 42 | self.assertRegexpMatches(contents, 'host: example.com') |
1457 | 65 | self.assertRegexpMatches(contents, 'domain: api.foo.com') | 43 | self.assertRegexpMatches(contents, 'domain: api.foo.com') |
1458 | 44 | |||
1459 | 45 | templating.render('test.conf', fn2, context, templates_dir=TEMPLATES_DIR) | ||
1460 | 66 | contents = open(fn2).read() | 46 | contents = open(fn2).read() |
1461 | 67 | self.assertRegexpMatches(contents, 'listen 80') | 47 | self.assertRegexpMatches(contents, 'listen 80') |
1462 | 68 | self.assertEqual(fchown.call_count, 2) | 48 | self.assertEqual(fchown.call_count, 2) |
1463 | 69 | self.assertEqual(mkdir.call_count, 2) | 49 | self.assertEqual(mkdir.call_count, 2) |
1464 | 70 | 50 | ||
1608 | 71 | @mock.patch.object(templating.host.os, 'fchown') | 51 | @mock.patch.object(templating, 'hookenv') |
1609 | 72 | @mock.patch.object(templating.host, 'mkdir') | 52 | @mock.patch('jinja2.Environment') |
1610 | 73 | @mock.patch.object(templating.host, 'log') | 53 | def test_load_error(self, Env, hookenv): |
1611 | 74 | def test_render_incomplete(self, log, mkdir, fchown): | 54 | Env().get_template.side_effect = jinja2.exceptions.TemplateNotFound('fake_cc.yml') |
1612 | 75 | _, fn1 = tempfile.mkstemp() | 55 | self.assertRaises( |
1613 | 76 | _, fn2 = tempfile.mkstemp() | 56 | jinja2.exceptions.TemplateNotFound, templating.render, |
1614 | 77 | os.remove(fn1) | 57 | 'fake.src', 'fake.tgt', {}, templates_dir='tmpl') |
1615 | 78 | os.remove(fn2) | 58 | hookenv.log.assert_called_once_with('Could not load template fake.src from tmpl.', level=hookenv.ERROR) |
1473 | 79 | template_definitions = [ | ||
1474 | 80 | {'source': 'fake_cc.yml', | ||
1475 | 81 | 'target': fn1, | ||
1476 | 82 | 'contexts': [lambda: {}]}, | ||
1477 | 83 | {'source': 'test.conf', | ||
1478 | 84 | 'target': fn2, | ||
1479 | 85 | 'contexts': [default_context_provider]}, | ||
1480 | 86 | ] | ||
1481 | 87 | self.assertFalse(templating.render(template_definitions)) | ||
1482 | 88 | self.assertFalse(os.path.exists(fn1)) | ||
1483 | 89 | contents = open(fn2).read() | ||
1484 | 90 | self.assertRegexpMatches(contents, 'listen 80') | ||
1485 | 91 | self.assertEqual(fchown.call_count, 1) | ||
1486 | 92 | self.assertEqual(mkdir.call_count, 1) | ||
1487 | 93 | |||
1488 | 94 | @mock.patch('jinja2.Environment') | ||
1489 | 95 | @mock.patch.object(templating.host, 'mkdir') | ||
1490 | 96 | @mock.patch.object(templating.host, 'write_file') | ||
1491 | 97 | def test_render_no_source(self, write_file, mkdir, Env): | ||
1492 | 98 | template_definitions = [{ | ||
1493 | 99 | 'target': 'fake_cc.yml', | ||
1494 | 100 | 'contexts': [default_context_provider], | ||
1495 | 101 | }] | ||
1496 | 102 | self.assertTrue(templating.render(template_definitions)) | ||
1497 | 103 | Env().get_template.assert_called_once_with('fake_cc.yml.j2') | ||
1498 | 104 | Env().get_template.return_value.render.assert_called_once() | ||
1499 | 105 | write_file.assert_called_once() | ||
1500 | 106 | |||
1501 | 107 | @mock.patch('jinja2.Environment') | ||
1502 | 108 | @mock.patch.object(templating.host, 'mkdir') | ||
1503 | 109 | @mock.patch.object(templating.host, 'write_file') | ||
1504 | 110 | def test_render_no_contexts(self, write_file, mkdir, Env): | ||
1505 | 111 | template_definitions = [{ | ||
1506 | 112 | 'target': 'fake_cc.yml', | ||
1507 | 113 | 'contexts': [], | ||
1508 | 114 | }] | ||
1509 | 115 | self.assertTrue(templating.render(template_definitions)) | ||
1510 | 116 | Env().get_template.assert_called_once_with('fake_cc.yml.j2') | ||
1511 | 117 | Env().get_template.return_value.render.assert_called_once_with({}) | ||
1512 | 118 | write_file.assert_called_once() | ||
1513 | 119 | |||
1514 | 120 | @mock.patch('jinja2.FileSystemLoader') | ||
1515 | 121 | @mock.patch('jinja2.Environment') | ||
1516 | 122 | @mock.patch.object(templating.host, 'mkdir') | ||
1517 | 123 | @mock.patch.object(templating.host, 'write_file') | ||
1518 | 124 | def test_render_implicit_dir(self, write_file, mkdir, Env, FSL): | ||
1519 | 125 | self.charm_dir = 'foo' | ||
1520 | 126 | template_definitions = [{ | ||
1521 | 127 | 'target': 'fake_cc.yml', | ||
1522 | 128 | 'contexts': [default_context_provider], | ||
1523 | 129 | }] | ||
1524 | 130 | self.assertTrue(templating.render(template_definitions)) | ||
1525 | 131 | FSL.assert_called_once_with('foo/templates') | ||
1526 | 132 | |||
1527 | 133 | @mock.patch('jinja2.FileSystemLoader') | ||
1528 | 134 | @mock.patch('jinja2.Environment') | ||
1529 | 135 | @mock.patch.object(templating.host, 'mkdir') | ||
1530 | 136 | @mock.patch.object(templating.host, 'write_file') | ||
1531 | 137 | def test_render_explicit_dir(self, write_file, mkdir, Env, FSL): | ||
1532 | 138 | self.charm_dir = 'foo' | ||
1533 | 139 | template_definitions = [{ | ||
1534 | 140 | 'target': 'fake_cc.yml', | ||
1535 | 141 | 'contexts': [default_context_provider], | ||
1536 | 142 | }] | ||
1537 | 143 | self.assertTrue(templating.render(template_definitions, 'bar')) | ||
1538 | 144 | FSL.assert_called_once_with('bar') | ||
1539 | 145 | |||
1540 | 146 | def test_collect_contexts_fail(self): | ||
1541 | 147 | cc = templating._collect_contexts | ||
1542 | 148 | self.assertEqual(cc([]), {}) | ||
1543 | 149 | self.assertEqual(cc([noop]), False) | ||
1544 | 150 | self.assertEqual(cc([noop, noop]), False) | ||
1545 | 151 | self.assertEqual(cc([noop, found]), False) | ||
1546 | 152 | |||
1547 | 153 | def test_collect_contexts_found(self): | ||
1548 | 154 | cc = templating._collect_contexts | ||
1549 | 155 | expected = {'foo': 'bar'} | ||
1550 | 156 | self.assertEqual(cc([found]), expected) | ||
1551 | 157 | self.assertEqual(cc([found, found]), expected) | ||
1552 | 158 | |||
1553 | 159 | |||
1554 | 160 | class TestConfigContext(unittest.TestCase): | ||
1555 | 161 | @mock.patch('charmhelpers.core.hookenv.config') | ||
1556 | 162 | def test_config_context(self, mconfig): | ||
1557 | 163 | templating.ConfigContext()() | ||
1558 | 164 | self.assertTrue(mconfig.called) | ||
1559 | 165 | |||
1560 | 166 | |||
1561 | 167 | class TestStaticContext(unittest.TestCase): | ||
1562 | 168 | def test_static_context(self): | ||
1563 | 169 | a = templating.StaticContext('a') | ||
1564 | 170 | self.assertEqual(a.data, 'a') | ||
1565 | 171 | self.assertEqual(a(), 'a') | ||
1566 | 172 | |||
1567 | 173 | |||
1568 | 174 | class TestRelationContext(unittest.TestCase): | ||
1569 | 175 | def setUp(self): | ||
1570 | 176 | self.context_provider = templating.RelationContext() | ||
1571 | 177 | self.context_provider.interface = 'http' | ||
1572 | 178 | self.context_provider.required_keys = ['foo', 'bar'] | ||
1573 | 179 | |||
1574 | 180 | @mock.patch.object(templating, 'hookenv') | ||
1575 | 181 | def test_no_relations(self, mhookenv): | ||
1576 | 182 | mhookenv.relation_ids.return_value = [] | ||
1577 | 183 | self.assertEqual(self.context_provider(), {}) | ||
1578 | 184 | mhookenv.relation_ids.assert_called_once_with('http') | ||
1579 | 185 | |||
1580 | 186 | @mock.patch.object(templating, 'hookenv') | ||
1581 | 187 | def test_no_units(self, mhookenv): | ||
1582 | 188 | mhookenv.relation_ids.return_value = ['nginx'] | ||
1583 | 189 | mhookenv.related_units.return_value = [] | ||
1584 | 190 | self.assertEqual(self.context_provider(), {}) | ||
1585 | 191 | |||
1586 | 192 | @mock.patch.object(templating, 'hookenv') | ||
1587 | 193 | def test_incomplete(self, mhookenv): | ||
1588 | 194 | mhookenv.relation_ids.return_value = ['nginx', 'apache'] | ||
1589 | 195 | mhookenv.related_units.side_effect = lambda i: [i+'/0'] | ||
1590 | 196 | mhookenv.relation_get.side_effect = [{}, {'foo': '1'}] | ||
1591 | 197 | self.assertEqual(self.context_provider(), {}) | ||
1592 | 198 | self.assertEqual(mhookenv.relation_get.call_args_list, [ | ||
1593 | 199 | mock.call(rid='nginx', unit='nginx/0'), | ||
1594 | 200 | mock.call(rid='apache', unit='apache/0'), | ||
1595 | 201 | ]) | ||
1596 | 202 | |||
1597 | 203 | @mock.patch.object(templating, 'hookenv') | ||
1598 | 204 | def test_complete(self, mhookenv): | ||
1599 | 205 | mhookenv.relation_ids.return_value = ['nginx', 'apache', 'tomcat'] | ||
1600 | 206 | mhookenv.related_units.side_effect = lambda i: [i+'/0'] | ||
1601 | 207 | mhookenv.relation_get.side_effect = [{'foo': '1'}, {'foo': '2', 'bar': '3'}, {}] | ||
1602 | 208 | self.assertEqual(self.context_provider(), {'http': {'foo': '2', 'bar': '3'}}) | ||
1603 | 209 | mhookenv.relation_ids.assert_called_with('http') | ||
1604 | 210 | self.assertEqual(mhookenv.relation_get.call_args_list, [ | ||
1605 | 211 | mock.call(rid='nginx', unit='nginx/0'), | ||
1606 | 212 | mock.call(rid='apache', unit='apache/0'), | ||
1607 | 213 | ]) | ||
1616 | 214 | 59 | ||
1617 | === modified file 'tox.ini' | |||
1618 | --- tox.ini 2014-05-09 16:10:54 +0000 | |||
1619 | +++ tox.ini 2014-05-29 17:24:14 +0000 | |||
1620 | @@ -2,13 +2,13 @@ | |||
1621 | 2 | envlist = py27 | 2 | envlist = py27 |
1622 | 3 | 3 | ||
1623 | 4 | [testenv] | 4 | [testenv] |
1629 | 5 | install_command=pip install --pre {opts} | 5 | install_command=pip install --pre {opts} |
1630 | 6 | --allow-all-external | 6 | --allow-all-external |
1631 | 7 | --allow-unverified launchpadlib | 7 | --allow-unverified launchpadlib |
1632 | 8 | --allow-unverified python-apt | 8 | --allow-unverified python-apt |
1633 | 9 | --allow-unverified bzr | 9 | --allow-unverified bzr |
1634 | 10 | --allow-unverified lazr.authentication | 10 | --allow-unverified lazr.authentication |
1636 | 11 | {packages} | 11 | {packages} |
1637 | 12 | deps=-r{toxinidir}/test-requirements-tox.txt | 12 | deps=-r{toxinidir}/test-requirements-tox.txt |
1638 | 13 | commands = | 13 | commands = |
1640 | 14 | nosetests --nologcapture | 14 | nosetests --nologcapture {posargs} |
Reviewers: mp+220733_ code.launchpad. net,
Message:
Please take a look.
Description:
Callbacks instead of ServiceManagers
Refactored charmhelpers. core.services to use callbacks instead of
service types / managers, to make it easier for charms to specify
custom behavior.
https:/ /code.launchpad .net/~johnsca/ charm-helpers/ services- callback- fu/+merge/ 220733
(do not edit description out of merge proposal)
Please review this at https:/ /codereview. appspot. com/98490043/
Affected files (+146, -92 lines): core/host. py core/services. py core/templating .py test_host. py test_services. py
A [revision details]
M charmhelpers/
M charmhelpers/
M charmhelpers/
M tests/core/
M tests/core/
M tox.ini