Merge lp:~johnsca/charm-helpers/services-callback-fu into lp:~cf-charmers/charm-helpers/cloud-foundry

Proposed by Cory Johns
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
Reviewer Review Type Date Requested Status
Cloud Foundry Charmers Pending
Review via email: mp+220733@code.launchpad.net

Description of the change

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://codereview.appspot.com/98490043/

To post a comment you must log in.
Revision history for this message
Cory Johns (johnsca) wrote :

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):
   A [revision details]
   M charmhelpers/core/host.py
   M charmhelpers/core/services.py
   M charmhelpers/core/templating.py
   M tests/core/test_host.py
   M tests/core/test_services.py
   M tox.ini

Revision history for this message
Benjamin Saller (bcsaller) wrote :

LGTM with some trivials

Thanks for this.

https://codereview.appspot.com/98490043/diff/1/charmhelpers/core/host.py
File charmhelpers/core/host.py (right):

https://codereview.appspot.com/98490043/diff/1/charmhelpers/core/host.py#newcode67
charmhelpers/core/host.py:67: """Determine whether a system service is
available"""
'an OS service' or 'an init service' or 'an upstart service'

or something similar, service is very overloaded in this context

https://codereview.appspot.com/98490043/diff/1/charmhelpers/core/services.py
File charmhelpers/core/services.py (right):

https://codereview.appspot.com/98490043/diff/1/charmhelpers/core/services.py#newcode32
charmhelpers/core/services.py:32: restarting the service via Upstart).
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://codereview.appspot.com/98490043/diff/1/charmhelpers/core/services.py#newcode35
charmhelpers/core/services.py:35: the service via Upstart).
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://codereview.appspot.com/98490043/diff/1/charmhelpers/core/services.py#newcode94
charmhelpers/core/services.py:94: for service_name in service_names or
SERVICES.keys():
Handle single argument string calls,
if service_names and isinstance(service_names, str):
    service_names = [service_names]

Makes sense with the 'incomplete' mentioned above

https://codereview.appspot.com/98490043/diff/1/charmhelpers/core/services.py#newcode104
charmhelpers/core/services.py:104: Given the name of a registered
service, return a ServiceManager
This isn't still a ServiceManager as per the comment if I'm reading this
properly.

https://codereview.appspot.com/98490043/

183. By Cory Johns

Updated services.register docstring to clarify when and how the callbacks are called

Revision history for this message
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://codereview.appspot.com/98490043/diff/1/tests/core/test_services.py#newcode94

`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.

https://codereview.appspot.com/98490043/

184. By Cory Johns

Refined services API to deemphasize template management and emphasize service management

Revision history for this message
Cory Johns (johnsca) wrote :
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

Revision history for this message
Cory Johns (johnsca) wrote :
187. By Cory Johns

Switch services API from module global to instance, per review

Revision history for this message
Cory Johns (johnsca) wrote :
Revision history for this message
Benjamin Saller (bcsaller) wrote :

Comments for this went into the charm, but this LGTM as well, thanks.

https://codereview.appspot.com/98490043/

188. By Cory Johns

Added managed list of ports to services API

Revision history for this message
Cory Johns (johnsca) wrote :
189. By Cory Johns

Removed default target for template helper, as it is too prone to errors

Revision history for this message
Cory Johns (johnsca) wrote :
Revision history for this message
Benjamin Saller (bcsaller) wrote :

Thanks for tracking down the err0r, and yes, explicit win (esp. since we
hit this in practice).

+1

https://codereview.appspot.com/98490043/

Revision history for this message
Cory Johns (johnsca) wrote :

*** Submitted:

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.

R=benjamin.saller
CC=
https://codereview.appspot.com/98490043

https://codereview.appspot.com/98490043/

Preview Diff

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

Subscribers

People subscribed via source and target branches