Merge lp:~johnsca/charms/trusty/cf-go-router/services-callback-fu into lp:~cf-charmers/charms/trusty/cf-go-router/trunk
- Trusty Tahr (14.04)
- services-callback-fu
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 28 |
Proposed branch: | lp:~johnsca/charms/trusty/cf-go-router/services-callback-fu |
Merge into: | lp:~cf-charmers/charms/trusty/cf-go-router/trunk |
Diff against target: |
946 lines (+474/-351) 11 files modified
config.yaml (+5/-2) hooks/charmhelpers/contrib/cloudfoundry/contexts.py (+41/-32) hooks/charmhelpers/core/host.py (+5/-0) hooks/charmhelpers/core/services.py (+316/-79) hooks/charmhelpers/core/templating.py (+38/-145) hooks/config-changed (+11/-0) hooks/config.py (+41/-0) hooks/hooks.py (+0/-93) hooks/nats-relation-changed (+6/-0) hooks/router-relation-joined (+5/-0) hooks/stop (+6/-0) |
To merge this branch: | bzr merge lp:~johnsca/charms/trusty/cf-go-router/services-callback-fu |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Cloud Foundry Charmers | Pending | ||
Review via email: mp+221443@code.launchpad.net |
Commit message
Description of the change
Refactor to callback services API
Cory Johns (johnsca) wrote : | # |
Benjamin Saller (bcsaller) wrote : | # |
LGTM, thanks
https:/
File hooks/config.py (right):
https:/
hooks/config.py:23: result = subprocess.
'@8.8.8.8', address])
thank you google, there should be a better way, but I've done this too
https:/
hooks/config.py:38: 'ports': [80, 443],
we still expose 443 but its aspirational iirc
Cory Johns (johnsca) wrote : | # |
On 2014/05/29 18:51:20, benjamin.saller wrote:
> hooks/config.py:23: result = subprocess.
> mailto:, address])
> thank you google, there should be a better way, but I've done this too
> hooks/config.py:38: 'ports': [80, 443],
> we still expose 443 but its aspirational iirc
Yeah, I just moved the existing logic, but it did seem like a strange
approach to me. I expected juju to provide a way to fetch the IP
address instead of the resolved name.
Cory Johns (johnsca) wrote : | # |
*** Submitted:
Refactor to callback services API
R=benjamin.saller
CC=
https:/
Preview Diff
1 | === modified file 'config.yaml' |
2 | --- config.yaml 2014-05-12 07:19:21 +0000 |
3 | +++ config.yaml 2014-05-29 18:28:48 +0000 |
4 | @@ -1,8 +1,11 @@ |
5 | options: |
6 | domain: |
7 | type: string |
8 | - description: "Router domain name. Currently we use xip.io service to resolve local IP's." |
9 | - default: '' |
10 | + description: | |
11 | + Router domain name. If the special (default) value of 'xip.io' is used, |
12 | + the public IP address will be prepended to it (so that it becomes, for |
13 | + example, '127.0.0.1.xip.io'). Any other value will be used as-is. |
14 | + default: 'xip.io' |
15 | source: |
16 | type: string |
17 | default: 'ppa:cf-charm/ppa' |
18 | |
19 | === modified file 'hooks/charmhelpers/contrib/cloudfoundry/contexts.py' |
20 | --- hooks/charmhelpers/contrib/cloudfoundry/contexts.py 2014-05-20 20:06:35 +0000 |
21 | +++ hooks/charmhelpers/contrib/cloudfoundry/contexts.py 2014-05-29 18:28:48 +0000 |
22 | @@ -1,55 +1,64 @@ |
23 | import os |
24 | - |
25 | -from charmhelpers.core.templating import ( |
26 | - ContextGenerator, |
27 | - RelationContext, |
28 | - StorableContext, |
29 | -) |
30 | - |
31 | - |
32 | -# Stores `config_data` hash into yaml file with `file_name` as a name |
33 | -# if `file_name` already exists, then it loads data from `file_name`. |
34 | -class StoredContext(ContextGenerator, StorableContext): |
35 | +import yaml |
36 | + |
37 | +from charmhelpers.core.services import RelationContext |
38 | + |
39 | + |
40 | +class StoredContext(dict): |
41 | + """ |
42 | + A data context that always returns the data that it was first created with. |
43 | + """ |
44 | def __init__(self, file_name, config_data): |
45 | + """ |
46 | + If the file exists, populate `self` with the data from the file. |
47 | + Otherwise, populate with the given data and persist it to the file. |
48 | + """ |
49 | if os.path.exists(file_name): |
50 | - self.data = self.read_context(file_name) |
51 | + self.update(self.read_context(file_name)) |
52 | else: |
53 | self.store_context(file_name, config_data) |
54 | - self.data = config_data |
55 | - |
56 | - def __call__(self): |
57 | - return self.data |
58 | - |
59 | - |
60 | -class NatsContext(RelationContext): |
61 | + self.update(config_data) |
62 | + |
63 | + def store_context(self, file_name, config_data): |
64 | + with open(file_name, 'w') as file_stream: |
65 | + yaml.dump(config_data, file_stream) |
66 | + |
67 | + def read_context(self, file_name): |
68 | + with open(file_name, 'r') as file_stream: |
69 | + data = yaml.load(file_stream) |
70 | + if not data: |
71 | + raise OSError("%s is empty" % file_name) |
72 | + return data |
73 | + |
74 | + |
75 | +class NatsRelation(RelationContext): |
76 | interface = 'nats' |
77 | required_keys = ['nats_port', 'nats_address', 'nats_user', 'nats_password'] |
78 | |
79 | |
80 | -class MysqlDSNContext(RelationContext): |
81 | +class MysqlRelation(RelationContext): |
82 | interface = 'db' |
83 | required_keys = ['user', 'password', 'host', 'database'] |
84 | dsn_template = "mysql2://{user}:{password}@{host}:{port}/{database}" |
85 | |
86 | - def __call__(self): |
87 | - ctx = RelationContext.__call__(self) |
88 | - if ctx: |
89 | - if 'port' not in ctx: |
90 | - ctx['db']['port'] = '3306' |
91 | - ctx['db']['dsn'] = self.dsn_template.format(**ctx['db']) |
92 | - return ctx |
93 | - |
94 | - |
95 | -class RouterContext(RelationContext): |
96 | + def get_data(self): |
97 | + RelationContext.get_data(self) |
98 | + if self.is_ready(): |
99 | + if 'port' not in self['db']: |
100 | + self['db']['port'] = '3306' |
101 | + self['db']['dsn'] = self.dsn_template.format(**self['db']) |
102 | + |
103 | + |
104 | +class RouterRelation(RelationContext): |
105 | interface = 'router' |
106 | required_keys = ['domain'] |
107 | |
108 | |
109 | -class LogRouterContext(RelationContext): |
110 | +class LogRouterRelation(RelationContext): |
111 | interface = 'logrouter' |
112 | required_keys = ['shared-secret', 'logrouter-address'] |
113 | |
114 | |
115 | -class LoggregatorContext(RelationContext): |
116 | +class LoggregatorRelation(RelationContext): |
117 | interface = 'loggregator' |
118 | required_keys = ['shared_secret', 'loggregator_address'] |
119 | |
120 | === modified file 'hooks/charmhelpers/core/host.py' |
121 | --- hooks/charmhelpers/core/host.py 2014-05-20 20:06:35 +0000 |
122 | +++ hooks/charmhelpers/core/host.py 2014-05-29 18:28:48 +0000 |
123 | @@ -63,6 +63,11 @@ |
124 | return False |
125 | |
126 | |
127 | +def service_available(service_name): |
128 | + """Determine whether a system service is available""" |
129 | + return service('status', service_name) |
130 | + |
131 | + |
132 | def adduser(username, password=None, shell='/bin/bash', system_user=False): |
133 | """Add a user to the system""" |
134 | try: |
135 | |
136 | === modified file 'hooks/charmhelpers/core/services.py' |
137 | --- hooks/charmhelpers/core/services.py 2014-05-16 22:36:50 +0000 |
138 | +++ hooks/charmhelpers/core/services.py 2014-05-29 18:28:48 +0000 |
139 | @@ -1,84 +1,321 @@ |
140 | +import os |
141 | +import sys |
142 | +from collections import Iterable |
143 | from charmhelpers.core import templating |
144 | from charmhelpers.core import host |
145 | - |
146 | - |
147 | -SERVICES = {} |
148 | - |
149 | - |
150 | -def register(services, templates_dir=None): |
151 | - """ |
152 | - Register a list of service configs. |
153 | - |
154 | - Service Configs are dicts in the following formats: |
155 | - |
156 | - { |
157 | - "service": <service name>, |
158 | - "templates": [ { |
159 | - 'target': <render target of template>, |
160 | - 'source': <optional name of template in passed in templates_dir> |
161 | - 'file_properties': <optional dict taking owner and octal mode> |
162 | - 'contexts': [ context generators, see contexts.py ] |
163 | - } |
164 | - ] } |
165 | - |
166 | - Either `source` or `target` must be provided. |
167 | - |
168 | - If 'source' is not provided for a template the templates_dir will |
169 | - be consulted for ``basename(target).j2``. |
170 | - |
171 | - If `target` is not provided, it will be assumed to be |
172 | - ``/etc/init/<service name>.conf``. |
173 | - """ |
174 | - for service in services: |
175 | - service.setdefault('templates_dir', templates_dir) |
176 | - SERVICES[service['service']] = service |
177 | - |
178 | - |
179 | -def reconfigure_services(restart=True): |
180 | - """ |
181 | - Update all files for all services and optionally restart them, if ready. |
182 | - """ |
183 | - for service_name in SERVICES.keys(): |
184 | - reconfigure_service(service_name, restart=restart) |
185 | - |
186 | - |
187 | -def reconfigure_service(service_name, restart=True): |
188 | - """ |
189 | - Update all files for a single service and optionally restart it, if ready. |
190 | - """ |
191 | - service = SERVICES.get(service_name) |
192 | - if not service or service['service'] != service_name: |
193 | - raise KeyError('Service not registered: %s' % service_name) |
194 | - |
195 | - manager_type = service.get('type', UpstartService) |
196 | - manager_type(service).reconfigure(restart) |
197 | - |
198 | - |
199 | -def stop_services(): |
200 | - for service_name in SERVICES.keys(): |
201 | - if host.service_running(service_name): |
202 | - host.service_stop(service_name) |
203 | - |
204 | - |
205 | -class ServiceTypeManager(object): |
206 | - def __init__(self, service_definition): |
207 | - self.service_name = service_definition['service'] |
208 | - self.templates = service_definition['templates'] |
209 | - self.templates_dir = service_definition['templates_dir'] |
210 | - |
211 | - def reconfigure(self, restart=True): |
212 | +from charmhelpers.core import hookenv |
213 | + |
214 | + |
215 | +class ServiceManager(object): |
216 | + def __init__(self, services=None): |
217 | + """ |
218 | + Register a list of services, given their definitions. |
219 | + |
220 | + Service definitions are dicts in the following formats (all keys except |
221 | + 'service' are optional): |
222 | + |
223 | + { |
224 | + "service": <service name>, |
225 | + "required_data": <list of required data contexts>, |
226 | + "data_ready": <one or more callbacks>, |
227 | + "data_lost": <one or more callbacks>, |
228 | + "start": <one or more callbacks>, |
229 | + "stop": <one or more callbacks>, |
230 | + "ports": <list of ports to manage>, |
231 | + } |
232 | + |
233 | + The 'required_data' list should contain dicts of required data (or |
234 | + dependency managers that act like dicts and know how to collect the data). |
235 | + Only when all items in the 'required_data' list are populated are the list |
236 | + of 'data_ready' and 'start' callbacks executed. See `is_ready()` for more |
237 | + information. |
238 | + |
239 | + The 'data_ready' value should be either a single callback, or a list of |
240 | + callbacks, to be called when all items in 'required_data' pass `is_ready()`. |
241 | + Each callback will be called with the service name as the only parameter. |
242 | + After these all of the 'data_ready' callbacks are called, the 'start' |
243 | + callbacks are fired. |
244 | + |
245 | + The 'data_lost' value should be either a single callback, or a list of |
246 | + callbacks, to be called when a 'required_data' item no longer passes |
247 | + `is_ready()`. Each callback will be called with the service name as the |
248 | + only parameter. After these all of the 'data_ready' callbacks are called, |
249 | + the 'stop' callbacks are fired. |
250 | + |
251 | + The 'start' value should be either a single callback, or a list of |
252 | + callbacks, to be called when starting the service, after the 'data_ready' |
253 | + callbacks are complete. Each callback will be called with the service |
254 | + name as the only parameter. This defaults to |
255 | + `[host.service_start, services.open_ports]`. |
256 | + |
257 | + The 'stop' value should be either a single callback, or a list of |
258 | + callbacks, to be called when stopping the service. If the service is |
259 | + being stopped because it no longer has all of its 'required_data', this |
260 | + will be called after all of the 'data_lost' callbacks are complete. |
261 | + Each callback will be called with the service name as the only parameter. |
262 | + This defaults to `[services.close_ports, host.service_stop]`. |
263 | + |
264 | + The 'ports' value should be a list of ports to manage. The default |
265 | + 'start' handler will open the ports after the service is started, |
266 | + and the default 'stop' handler will close the ports prior to stopping |
267 | + the service. |
268 | + |
269 | + |
270 | + Examples: |
271 | + |
272 | + The following registers an Upstart service called bingod that depends on |
273 | + a mongodb relation and which runs a custom `db_migrate` function prior to |
274 | + restarting the service, and a Runit serivce called spadesd. |
275 | + |
276 | + >>> manager = services.ServiceManager([ |
277 | + ... { |
278 | + ... 'service': 'bingod', |
279 | + ... 'ports': [80, 443], |
280 | + ... 'required_data': [MongoRelation(), config()], |
281 | + ... 'data_ready': [ |
282 | + ... services.template(source='bingod.conf'), |
283 | + ... services.template(source='bingod.ini', |
284 | + ... target='/etc/bingod.ini', |
285 | + ... owner='bingo', perms=0400), |
286 | + ... ], |
287 | + ... }, |
288 | + ... { |
289 | + ... 'service': 'spadesd', |
290 | + ... 'data_ready': services.template(source='spadesd_run.j2', |
291 | + ... target='/etc/sv/spadesd/run', |
292 | + ... perms=0555), |
293 | + ... 'start': runit_start, |
294 | + ... 'stop': runit_stop, |
295 | + ... }, |
296 | + ... ]) |
297 | + ... manager.manage() |
298 | + """ |
299 | + self.services = {} |
300 | + for service in services or []: |
301 | + service_name = service['service'] |
302 | + self.services[service_name] = service |
303 | + |
304 | + def manage(self): |
305 | + """ |
306 | + Handle the current hook by doing The Right Thing with the registered services. |
307 | + """ |
308 | + hook_name = os.path.basename(sys.argv[0]) |
309 | + if hook_name == 'stop': |
310 | + self.stop_services() |
311 | + else: |
312 | + self.reconfigure_services() |
313 | + |
314 | + def reconfigure_services(self, *service_names): |
315 | + """ |
316 | + Update all files for one or more registered services, and, |
317 | + if ready, optionally restart them. |
318 | + |
319 | + If no service names are given, reconfigures all registered services. |
320 | + """ |
321 | + for service_name in service_names or self.services.keys(): |
322 | + if self.is_ready(service_name): |
323 | + self.fire_event('data_ready', service_name) |
324 | + self.fire_event('start', service_name, default=[ |
325 | + host.service_restart, |
326 | + open_ports]) |
327 | + self.save_ready(service_name) |
328 | + else: |
329 | + if self.was_ready(service_name): |
330 | + self.fire_event('data_lost', service_name) |
331 | + self.fire_event('stop', service_name, default=[ |
332 | + close_ports, |
333 | + host.service_stop]) |
334 | + self.save_lost(service_name) |
335 | + |
336 | + def stop_services(self, *service_names): |
337 | + """ |
338 | + Stop one or more registered services, by name. |
339 | + |
340 | + If no service names are given, stops all registered services. |
341 | + """ |
342 | + for service_name in service_names or self.services.keys(): |
343 | + self.fire_event('stop', service_name, default=[ |
344 | + close_ports, |
345 | + host.service_stop]) |
346 | + |
347 | + def get_service(self, service_name): |
348 | + """ |
349 | + Given the name of a registered service, return its service definition. |
350 | + """ |
351 | + service = self.services.get(service_name) |
352 | + if not service: |
353 | + raise KeyError('Service not registered: %s' % service_name) |
354 | + return service |
355 | + |
356 | + def fire_event(self, event_name, service_name, default=None): |
357 | + """ |
358 | + Fire a data_ready, data_lost, start, or stop event on a given service. |
359 | + """ |
360 | + service = self.get_service(service_name) |
361 | + callbacks = service.get(event_name, default) |
362 | + if not callbacks: |
363 | + return |
364 | + if not isinstance(callbacks, Iterable): |
365 | + callbacks = [callbacks] |
366 | + for callback in callbacks: |
367 | + if isinstance(callback, ManagerCallback): |
368 | + callback(self, service_name, event_name) |
369 | + else: |
370 | + callback(service_name) |
371 | + |
372 | + def is_ready(self, service_name): |
373 | + """ |
374 | + Determine if a registered service is ready, by checking its 'required_data'. |
375 | + |
376 | + A 'required_data' item can be any mapping type, and is considered ready |
377 | + if `bool(item)` evaluates as True. |
378 | + """ |
379 | + service = self.get_service(service_name) |
380 | + reqs = service.get('required_data', []) |
381 | + return all(bool(req) for req in reqs) |
382 | + |
383 | + def save_ready(self, service_name): |
384 | + """ |
385 | + Save an indicator that the given service is now data_ready. |
386 | + """ |
387 | + ready_file = '{}/.ready.{}'.format(hookenv.charm_dir(), service_name) |
388 | + with open(ready_file, 'a'): |
389 | + pass |
390 | + |
391 | + def save_lost(self, service_name): |
392 | + """ |
393 | + Save an indicator that the given service is no longer data_ready. |
394 | + """ |
395 | + ready_file = '{}/.ready.{}'.format(hookenv.charm_dir(), service_name) |
396 | + if os.path.exists(ready_file): |
397 | + os.remove(ready_file) |
398 | + |
399 | + def was_ready(self, service_name): |
400 | + """ |
401 | + Determine if the given service was previously data_ready. |
402 | + """ |
403 | + ready_file = '{}/.ready.{}'.format(hookenv.charm_dir(), service_name) |
404 | + return os.path.exists(ready_file) |
405 | + |
406 | + |
407 | +class RelationContext(dict): |
408 | + """ |
409 | + Base class for a context generator that gets relation data from juju. |
410 | + |
411 | + Subclasses must provide `interface`, which is the interface type of interest, |
412 | + and `required_keys`, which is the set of keys required for the relation to |
413 | + be considered complete. The first relation for the interface that is complete |
414 | + will be used to populate the data for template. |
415 | + |
416 | + The generated context will be namespaced under the interface type, to prevent |
417 | + potential naming conflicts. |
418 | + """ |
419 | + interface = None |
420 | + required_keys = [] |
421 | + |
422 | + def __bool__(self): |
423 | + """ |
424 | + Updates the data and returns True if all of the required_keys are available. |
425 | + """ |
426 | + self.get_data() |
427 | + return self.is_ready() |
428 | + |
429 | + __nonzero__ = __bool__ |
430 | + |
431 | + def is_ready(self): |
432 | + """ |
433 | + Returns True if all of the required_keys are available. |
434 | + """ |
435 | + return set(self.get(self.interface, {}).keys()).issuperset(set(self.required_keys)) |
436 | + |
437 | + def get_data(self): |
438 | + """ |
439 | + Retrieve the relation data and store it under `self[self.interface]`. |
440 | + |
441 | + If there are more than one units related on the desired interface, |
442 | + then each unit will have its data stored under `self[self.interface][unit_id]` |
443 | + and one of the units with complete information will chosen at random |
444 | + to fill the values at `self[self.interface]`. |
445 | + |
446 | + |
447 | + For example: |
448 | + |
449 | + { |
450 | + 'foo': 'bar', |
451 | + 'unit/0': { |
452 | + 'foo': 'bar', |
453 | + }, |
454 | + 'unit/1': { |
455 | + 'foo': 'baz', |
456 | + }, |
457 | + } |
458 | + """ |
459 | + if not hookenv.relation_ids(self.interface): |
460 | + return |
461 | + |
462 | + ns = self.setdefault(self.interface, {}) |
463 | + required = set(self.required_keys) |
464 | + for rid in hookenv.relation_ids(self.interface): |
465 | + for unit in hookenv.related_units(rid): |
466 | + reldata = hookenv.relation_get(rid=rid, unit=unit) |
467 | + unit_ns = ns.setdefault(unit, {}) |
468 | + unit_ns.update(reldata) |
469 | + if set(reldata.keys()).issuperset(required): |
470 | + ns.update(reldata) |
471 | + |
472 | + |
473 | +class ManagerCallback(object): |
474 | + """ |
475 | + Special case of a callback that takes the `ServiceManager` instance |
476 | + in addition to the service name. |
477 | + |
478 | + Subclasses should implement `__call__` which should accept two parameters: |
479 | + |
480 | + * `manager` The `ServiceManager` instance |
481 | + * `service_name` The name of the service it's being triggered for |
482 | + * `event_name` The name of the event that this callback is handling |
483 | + """ |
484 | + def __call__(self, manager, service_name, event_name): |
485 | raise NotImplementedError() |
486 | |
487 | |
488 | -class UpstartService(ServiceTypeManager): |
489 | - def __init__(self, service_definition): |
490 | - super(UpstartService, self).__init__(service_definition) |
491 | - for tmpl in self.templates: |
492 | - if 'target' not in tmpl: |
493 | - tmpl['target'] = '/etc/init/%s.conf' % self.service_name |
494 | - |
495 | - def reconfigure(self, restart): |
496 | - complete = templating.render(self.templates, self.templates_dir) |
497 | - |
498 | - if restart and complete: |
499 | - host.service_restart(self.service_name) |
500 | +class TemplateCallback(ManagerCallback): |
501 | + """ |
502 | + Callback class that will render a template, for use as a ready action. |
503 | + |
504 | + The `target` param, if omitted, will default to `/etc/init/<service name>`. |
505 | + """ |
506 | + def __init__(self, source, target, owner='root', group='root', perms=0444): |
507 | + self.source = source |
508 | + self.target = target |
509 | + self.owner = owner |
510 | + self.group = group |
511 | + self.perms = perms |
512 | + |
513 | + def __call__(self, manager, service_name, event_name): |
514 | + service = manager.get_service(service_name) |
515 | + context = {} |
516 | + for ctx in service.get('required_data', []): |
517 | + context.update(ctx) |
518 | + templating.render(self.source, self.target, context, |
519 | + self.owner, self.group, self.perms) |
520 | + |
521 | + |
522 | +class PortManagerCallback(ManagerCallback): |
523 | + """ |
524 | + Callback class that will open or close ports, for use as either |
525 | + a start or stop action. |
526 | + """ |
527 | + def __call__(self, manager, service_name, event_name): |
528 | + service = manager.get_service(service_name) |
529 | + for port in service.get('ports', []): |
530 | + if event_name == 'start': |
531 | + hookenv.open_port(port) |
532 | + elif event_name == 'stop': |
533 | + hookenv.close_port(port) |
534 | + |
535 | + |
536 | +# Convenience aliases |
537 | +template = TemplateCallback |
538 | +open_ports = PortManagerCallback() |
539 | +close_ports = PortManagerCallback() |
540 | |
541 | === modified file 'hooks/charmhelpers/core/templating.py' |
542 | --- hooks/charmhelpers/core/templating.py 2014-05-20 20:06:35 +0000 |
543 | +++ hooks/charmhelpers/core/templating.py 2014-05-29 18:28:48 +0000 |
544 | @@ -1,158 +1,51 @@ |
545 | import os |
546 | -import yaml |
547 | |
548 | from charmhelpers.core import host |
549 | from charmhelpers.core import hookenv |
550 | |
551 | |
552 | -class ContextGenerator(object): |
553 | - """ |
554 | - Base interface for template context container generators. |
555 | - |
556 | - A template context is a dictionary that contains data needed to populate |
557 | - the template. The generator instance should produce the context when |
558 | - called (without arguments) by collecting information from juju (config-get, |
559 | - relation-get, etc), the system, or whatever other sources are appropriate. |
560 | - |
561 | - A context generator should only return any values if it has enough information |
562 | - to provide all of its values. Any context that is missing data is considered |
563 | - incomplete and will cause that template to not render until it has all of its |
564 | - necessary data. |
565 | - |
566 | - The template may receive several contexts, which will be merged together, |
567 | - so care should be taken in the key names. |
568 | - """ |
569 | - def __call__(self): |
570 | - raise NotImplementedError |
571 | - |
572 | - |
573 | -class StorableContext(object): |
574 | - """ |
575 | - A mixin for persisting a context to disk. |
576 | - """ |
577 | - def store_context(self, file_name, config_data): |
578 | - with open(file_name, 'w') as file_stream: |
579 | - yaml.dump(config_data, file_stream) |
580 | - |
581 | - def read_context(self, file_name): |
582 | - with open(file_name, 'r') as file_stream: |
583 | - data = yaml.load(file_stream) |
584 | - if not data: |
585 | - raise OSError("%s is empty" % file_name) |
586 | - return data |
587 | - |
588 | - |
589 | -class ConfigContext(ContextGenerator): |
590 | - """ |
591 | - A context generator that generates a context containing all of the |
592 | - juju config values. |
593 | - """ |
594 | - def __call__(self): |
595 | - return hookenv.config() |
596 | - |
597 | - |
598 | -class RelationContext(ContextGenerator): |
599 | - """ |
600 | - Base class for a context generator that gets relation data from juju. |
601 | - |
602 | - Subclasses must provide `interface`, which is the interface type of interest, |
603 | - and `required_keys`, which is the set of keys required for the relation to |
604 | - be considered complete. The first relation for the interface that is complete |
605 | - will be used to populate the data for template. |
606 | - |
607 | - The generated context will be namespaced under the interface type, to prevent |
608 | - potential naming conflicts. |
609 | - """ |
610 | - interface = None |
611 | - required_keys = [] |
612 | - |
613 | - def __call__(self): |
614 | - if not hookenv.relation_ids(self.interface): |
615 | - return {} |
616 | - |
617 | - ctx = {} |
618 | - for rid in hookenv.relation_ids(self.interface): |
619 | - for unit in hookenv.related_units(rid): |
620 | - reldata = hookenv.relation_get(rid=rid, unit=unit) |
621 | - required = set(self.required_keys) |
622 | - if set(reldata.keys()).issuperset(required): |
623 | - ns = ctx.setdefault(self.interface, {}) |
624 | - for k, v in reldata.items(): |
625 | - ns[k] = v |
626 | - return ctx |
627 | - |
628 | - return {} |
629 | - |
630 | - |
631 | -class StaticContext(ContextGenerator): |
632 | - def __init__(self, data): |
633 | - self.data = data |
634 | - |
635 | - def __call__(self): |
636 | - return self.data |
637 | - |
638 | - |
639 | -def _collect_contexts(context_providers): |
640 | - """ |
641 | - Helper function to collect and merge contexts from a list of providers. |
642 | - |
643 | - If any of the contexts are incomplete (i.e., they return an empty dict), |
644 | - the template is considered incomplete and will not render. |
645 | - """ |
646 | - ctx = {} |
647 | - for provider in context_providers: |
648 | - c = provider() |
649 | - if not c: |
650 | - return False |
651 | - ctx.update(c) |
652 | - return ctx |
653 | - |
654 | - |
655 | -def render(template_definitions, templates_dir=None): |
656 | - """ |
657 | - Render one or more templates, given a list of template definitions. |
658 | - |
659 | - The template definitions should be dicts with the keys: `source`, `target`, |
660 | - `file_properties`, and `contexts`. |
661 | - |
662 | - The `source` path, if not absolute, is relative to the `templates_dir` |
663 | - given when the rendered was created. If `source` is not provided |
664 | - for a template the `template_dir` will be consulted for |
665 | - ``basename(target).j2``. |
666 | +def render(source, target, context, owner='root', group='root', perms=0444, templates_dir=None): |
667 | + """ |
668 | + Render a template. |
669 | + |
670 | + The `source` path, if not absolute, is relative to the `templates_dir`. |
671 | |
672 | The `target` path should be absolute. |
673 | |
674 | - The `file_properties` should be a dict optionally containing |
675 | - `owner`, `group`, or `perms` options, to be passed to `write_file`. |
676 | - |
677 | - The `contexts` should be a list containing zero or more ContextGenerators. |
678 | - |
679 | - The `template_dir` defaults to `$CHARM_DIR/templates` |
680 | - |
681 | - Returns True if all of the templates were "complete" (i.e., the context |
682 | - generators were able to collect the information needed to render the |
683 | - template) and were rendered. |
684 | + The context should be a dict containing the values to be replaced in the |
685 | + template. |
686 | + |
687 | + The `owner`, `group`, and `perms` options will be passed to `write_file`. |
688 | + |
689 | + If omitted, `templates_dir` defaults to the `templates` folder in the charm. |
690 | + |
691 | + Note: Using this requires python-jinja2; if it is not installed, calling |
692 | + this will attempt to use charmhelpers.fetch.apt_install to install it. |
693 | """ |
694 | - # lazy import jinja2 in case templating is needed in install hook |
695 | - from jinja2 import FileSystemLoader, Environment, exceptions |
696 | - all_complete = True |
697 | + try: |
698 | + from jinja2 import FileSystemLoader, Environment, exceptions |
699 | + except ImportError: |
700 | + try: |
701 | + from charmhelpers.fetch import apt_install |
702 | + except ImportError: |
703 | + hookenv.log('Could not import jinja2, and could not import ' |
704 | + 'charmhelpers.fetch to install it', |
705 | + level=hookenv.ERROR) |
706 | + raise |
707 | + apt_install('python-jinja2', fatal=True) |
708 | + from jinja2 import FileSystemLoader, Environment, exceptions |
709 | + |
710 | if templates_dir is None: |
711 | templates_dir = os.path.join(hookenv.charm_dir(), 'templates') |
712 | loader = Environment(loader=FileSystemLoader(templates_dir)) |
713 | - for tmpl in template_definitions: |
714 | - ctx = _collect_contexts(tmpl.get('contexts', [])) |
715 | - if ctx is False: |
716 | - all_complete = False |
717 | - continue |
718 | - try: |
719 | - source = tmpl.get('source', os.path.basename(tmpl['target'])+'.j2') |
720 | - template = loader.get_template(source) |
721 | - except exceptions.TemplateNotFound as e: |
722 | - hookenv.log('Could not load template %s from %s.' % |
723 | - (tmpl['source'], templates_dir), |
724 | - level=hookenv.ERROR) |
725 | - raise e |
726 | - content = template.render(ctx) |
727 | - host.mkdir(os.path.dirname(tmpl['target'])) |
728 | - host.write_file(tmpl['target'], content, **tmpl.get('file_properties', {})) |
729 | - return all_complete |
730 | + try: |
731 | + source = source |
732 | + template = loader.get_template(source) |
733 | + except exceptions.TemplateNotFound as e: |
734 | + hookenv.log('Could not load template %s from %s.' % |
735 | + (source, templates_dir), |
736 | + level=hookenv.ERROR) |
737 | + raise e |
738 | + content = template.render(context) |
739 | + host.mkdir(os.path.dirname(target)) |
740 | + host.write_file(target, content, owner, group, perms) |
741 | |
742 | === modified symlink 'hooks/config-changed' (properties changed: -x to +x) |
743 | === target was u'hooks.py' |
744 | --- hooks/config-changed 1970-01-01 00:00:00 +0000 |
745 | +++ hooks/config-changed 2014-05-29 18:28:48 +0000 |
746 | @@ -0,0 +1,11 @@ |
747 | +#!/usr/bin/env python |
748 | +from charmhelpers.core import hookenv |
749 | +from charmhelpers.core import services |
750 | +import config |
751 | + |
752 | +domain = config.domain() |
753 | +for rid in hookenv.relation_ids('router'): |
754 | + hookenv.relation_set(rid, {'domain': domain}) |
755 | + |
756 | +manager = services.ServiceManager(config.SERVICES) |
757 | +manager.manage() |
758 | |
759 | === modified file 'hooks/config.py' |
760 | --- hooks/config.py 2014-04-02 01:33:00 +0000 |
761 | +++ hooks/config.py 2014-05-29 18:28:48 +0000 |
762 | @@ -1,4 +1,10 @@ |
763 | import os |
764 | +import re |
765 | +import subprocess |
766 | +from charmhelpers.core import hookenv |
767 | +from charmhelpers.core import services |
768 | +from charmhelpers.contrib.cloudfoundry import contexts |
769 | + |
770 | |
771 | __all__ = ['CF_DIR', 'ROUTER_DIR', 'ROUTER_CONFIG_DIR', 'ROUTER_CONFIG_FILE', |
772 | 'ROUTER_PACKAGES'] |
773 | @@ -8,3 +14,38 @@ |
774 | ROUTER_CONFIG_DIR = os.path.join(ROUTER_DIR, 'config') |
775 | ROUTER_CONFIG_FILE = os.path.join(ROUTER_CONFIG_DIR, 'gorouter.yml') |
776 | ROUTER_PACKAGES = ['cfgorouter', 'python-jinja2'] |
777 | + |
778 | + |
779 | +def to_ip(address): |
780 | + if re.match('^(\d{1,3}\.){3}\d{1,3}$', address): |
781 | + return address # already an IP |
782 | + else: |
783 | + result = subprocess.check_output(['dig', '+short', '@8.8.8.8', address]) |
784 | + return result.strip() |
785 | + |
786 | + |
787 | +def domain(): |
788 | + domain = hookenv.config()['domain'] |
789 | + if domain == 'xip.io': |
790 | + public_address = hookenv.unit_get('public-address') |
791 | + domain = "%s.xip.io" % to_ip(public_address) |
792 | + return domain |
793 | + |
794 | + |
795 | +SERVICES = [ |
796 | + { |
797 | + 'service': 'gorouter', |
798 | + 'ports': [80, 443], |
799 | + 'required_data': [ |
800 | + {'domain': domain()}, |
801 | + contexts.NatsRelation(), |
802 | + ], |
803 | + 'data_ready': [ |
804 | + services.template(source='gorouter.conf', |
805 | + target='/etc/init/gorouter.conf'), |
806 | + services.template(source='gorouter.yml', |
807 | + target=ROUTER_CONFIG_FILE, |
808 | + owner='vcap', perms=0644), |
809 | + ], |
810 | + } |
811 | +] |
812 | |
813 | === removed file 'hooks/hooks.py' |
814 | --- hooks/hooks.py 2014-05-20 21:54:24 +0000 |
815 | +++ hooks/hooks.py 1970-01-01 00:00:00 +0000 |
816 | @@ -1,93 +0,0 @@ |
817 | -#!/usr/bin/env python |
818 | -# vim: et sta sts ai ts=4 sw=4: |
819 | -import os |
820 | -import subprocess |
821 | -import sys |
822 | -import re |
823 | - |
824 | -from charmhelpers.core import hookenv |
825 | -from charmhelpers.core.hookenv import log |
826 | -from charmhelpers.core import services |
827 | -from charmhelpers.core import templating |
828 | -from charmhelpers.contrib.cloudfoundry import contexts |
829 | -import config |
830 | - |
831 | - |
832 | -def default_domain(): |
833 | - public_address = hookenv.unit_get('public-address') |
834 | - if not re.match('^(\d{1,3}\.){3}\d{1,3}$', public_address): |
835 | - result = subprocess.check_output([ |
836 | - 'dig', '+short', '@8.8.8.8', public_address]) |
837 | - public_address = result.strip() |
838 | - domain = "%s.xip.io" % public_address |
839 | - return domain |
840 | - |
841 | - |
842 | -def get_domain(): |
843 | - config = hookenv.config() |
844 | - domain = config.get('domain') |
845 | - if not domain: |
846 | - domain = default_domain() |
847 | - return domain |
848 | - |
849 | - |
850 | -hooks = hookenv.Hooks() |
851 | -fileproperties = {'owner': 'vcap', 'perms': 0644} |
852 | - |
853 | -services.register([ |
854 | - { |
855 | - 'service': 'gorouter', |
856 | - 'templates': [ |
857 | - {'source': 'gorouter.conf'}, |
858 | - {'source': 'gorouter.yml', |
859 | - 'target': config.ROUTER_CONFIG_FILE, |
860 | - 'file_properties': fileproperties, |
861 | - 'contexts': [ |
862 | - templating.StaticContext({'domain': default_domain()}), |
863 | - templating.ConfigContext(), |
864 | - contexts.NatsContext() |
865 | - ]}, |
866 | - ] |
867 | - } |
868 | -]) |
869 | - |
870 | - |
871 | -@hooks.hook() |
872 | -def start(): |
873 | - hookenv.open_port(80) |
874 | - hookenv.open_port(443) |
875 | - |
876 | - |
877 | -@hooks.hook("config-changed") |
878 | -def config_changed(): |
879 | - services.reconfigure_services() |
880 | - domain = get_domain() |
881 | - for rid in hookenv.relation_ids('router'): |
882 | - hookenv.relation_set(rid, {'domain': domain}) |
883 | - |
884 | - |
885 | -@hooks.hook() |
886 | -def stop(): |
887 | - services.stop_services() |
888 | - hookenv.close_port(80) |
889 | - hookenv.close_port(443) |
890 | - |
891 | - |
892 | -@hooks.hook('nats-relation-changed') |
893 | -def nats_relation_changed(): |
894 | - services.reconfigure_services() |
895 | - |
896 | - |
897 | -@hooks.hook('router-relation-joined') |
898 | -def router_relation_joined(): |
899 | - domain = get_domain() |
900 | - hookenv.relation_set(None, {'domain': domain}) |
901 | - |
902 | - |
903 | -if __name__ == '__main__': |
904 | - hook_name = os.path.basename(sys.argv[0]) |
905 | - log("Running {} hook".format(hook_name)) |
906 | - if hookenv.relation_id(): |
907 | - log("Relation {} with {}".format( |
908 | - hookenv.relation_id(), hookenv.remote_unit())) |
909 | - hooks.execute(sys.argv) |
910 | |
911 | === modified symlink 'hooks/nats-relation-changed' (properties changed: -x to +x) |
912 | === target was u'hooks.py' |
913 | --- hooks/nats-relation-changed 1970-01-01 00:00:00 +0000 |
914 | +++ hooks/nats-relation-changed 2014-05-29 18:28:48 +0000 |
915 | @@ -0,0 +1,6 @@ |
916 | +#!/usr/bin/env python |
917 | +from charmhelpers.core import services |
918 | +import config |
919 | + |
920 | +manager = services.ServiceManager(config.SERVICES) |
921 | +manager.manage() |
922 | |
923 | === modified symlink 'hooks/router-relation-joined' (properties changed: -x to +x) |
924 | === target was u'hooks.py' |
925 | --- hooks/router-relation-joined 1970-01-01 00:00:00 +0000 |
926 | +++ hooks/router-relation-joined 2014-05-29 18:28:48 +0000 |
927 | @@ -0,0 +1,5 @@ |
928 | +#!/usr/bin/env python |
929 | +from charmhelpers.core import hookenv |
930 | +import config |
931 | + |
932 | +hookenv.relation_set(None, {'domain': config.domain()}) |
933 | |
934 | === removed symlink 'hooks/start' |
935 | === target was u'hooks.py' |
936 | === modified symlink 'hooks/stop' (properties changed: -x to +x) |
937 | === target was u'hooks.py' |
938 | --- hooks/stop 1970-01-01 00:00:00 +0000 |
939 | +++ hooks/stop 2014-05-29 18:28:48 +0000 |
940 | @@ -0,0 +1,6 @@ |
941 | +#!/usr/bin/env python |
942 | +from charmhelpers.core import services |
943 | +import config |
944 | + |
945 | +manager = services.ServiceManager(config.SERVICES) |
946 | +manager.manage() |
Reviewers: mp+221443_ code.launchpad. net,
Message:
Please take a look.
Description:
Refactor to callback services API
https:/ /code.launchpad .net/~johnsca/ charms/ trusty/ cf-go-router/ services- callback- fu/+merge/ 221443
(do not edit description out of merge proposal)
Please review this at https:/ /codereview. appspot. com/104720044/
Affected files (+480, -355 lines): ers/contrib/ cloudfoundry/ contexts. py ers/core/ host.py ers/core/ services. py ers/core/ templating. py
A [revision details]
M config.yaml
M hooks/charmhelp
M hooks/charmhelp
M hooks/charmhelp
M hooks/charmhelp
M hooks/config.py
D hooks/hooks.py