OLD | NEW |
(Empty) | |
| 1 import os |
| 2 import sys |
| 3 from collections import Iterable |
| 4 from charmhelpers.core import templating |
| 5 from charmhelpers.core import host |
| 6 from charmhelpers.core import hookenv |
| 7 |
| 8 |
| 9 class ServiceManager(object): |
| 10 def __init__(self, services=None): |
| 11 """ |
| 12 Register a list of services, given their definitions. |
| 13 |
| 14 Traditional charm authoring is focused on implementing hooks. That is, |
| 15 the charm author is thinking in terms of "What hook am I handling; what |
| 16 does this hook need to do?" However, in most cases, the real question |
| 17 should be "Do I have the information I need to configure and start this |
| 18 piece of software and, if so, what are the steps for doing so." The |
| 19 ServiceManager framework tries to bring the focus to the data and the |
| 20 setup tasks, in the most declarative way possible. |
| 21 |
| 22 Service definitions are dicts in the following formats (all keys except |
| 23 'service' are optional): |
| 24 |
| 25 { |
| 26 "service": <service name>, |
| 27 "required_data": <list of required data contexts>, |
| 28 "data_ready": <one or more callbacks>, |
| 29 "data_lost": <one or more callbacks>, |
| 30 "start": <one or more callbacks>, |
| 31 "stop": <one or more callbacks>, |
| 32 "ports": <list of ports to manage>, |
| 33 } |
| 34 |
| 35 The 'required_data' list should contain dicts of required data (or |
| 36 dependency managers that act like dicts and know how to collect the data
). |
| 37 Only when all items in the 'required_data' list are populated are the li
st |
| 38 of 'data_ready' and 'start' callbacks executed. See `is_ready()` for mo
re |
| 39 information. |
| 40 |
| 41 The 'data_ready' value should be either a single callback, or a list of |
| 42 callbacks, to be called when all items in 'required_data' pass `is_ready
()`. |
| 43 Each callback will be called with the service name as the only parameter
. |
| 44 After these all of the 'data_ready' callbacks are called, the 'start' |
| 45 callbacks are fired. |
| 46 |
| 47 The 'data_lost' value should be either a single callback, or a list of |
| 48 callbacks, to be called when a 'required_data' item no longer passes |
| 49 `is_ready()`. Each callback will be called with the service name as the |
| 50 only parameter. After these all of the 'data_ready' callbacks are calle
d, |
| 51 the 'stop' callbacks are fired. |
| 52 |
| 53 The 'start' value should be either a single callback, or a list of |
| 54 callbacks, to be called when starting the service, after the 'data_ready
' |
| 55 callbacks are complete. Each callback will be called with the service |
| 56 name as the only parameter. This defaults to |
| 57 `[host.service_start, services.open_ports]`. |
| 58 |
| 59 The 'stop' value should be either a single callback, or a list of |
| 60 callbacks, to be called when stopping the service. If the service is |
| 61 being stopped because it no longer has all of its 'required_data', this |
| 62 will be called after all of the 'data_lost' callbacks are complete. |
| 63 Each callback will be called with the service name as the only parameter
. |
| 64 This defaults to `[services.close_ports, host.service_stop]`. |
| 65 |
| 66 The 'ports' value should be a list of ports to manage. The default |
| 67 'start' handler will open the ports after the service is started, |
| 68 and the default 'stop' handler will close the ports prior to stopping |
| 69 the service. |
| 70 |
| 71 |
| 72 Examples: |
| 73 |
| 74 The following registers an Upstart service called bingod that depends on |
| 75 a mongodb relation and which runs a custom `db_migrate` function prior t
o |
| 76 restarting the service, and a Runit serivce called spadesd. |
| 77 |
| 78 manager = services.ServiceManager([ |
| 79 { |
| 80 'service': 'bingod', |
| 81 'ports': [80, 443], |
| 82 'required_data': [MongoRelation(), config(), {'my': 'data'}]
, |
| 83 'data_ready': [ |
| 84 services.template(source='bingod.conf'), |
| 85 services.template(source='bingod.ini', |
| 86 target='/etc/bingod.ini', |
| 87 owner='bingo', perms=0400), |
| 88 ], |
| 89 }, |
| 90 { |
| 91 'service': 'spadesd', |
| 92 'data_ready': services.template(source='spadesd_run.j2', |
| 93 target='/etc/sv/spadesd/run'
, |
| 94 perms=0555), |
| 95 'start': runit_start, |
| 96 'stop': runit_stop, |
| 97 }, |
| 98 ]) |
| 99 manager.manage() |
| 100 """ |
| 101 self.services = {} |
| 102 for service in services or []: |
| 103 service_name = service['service'] |
| 104 self.services[service_name] = service |
| 105 |
| 106 def manage(self): |
| 107 """ |
| 108 Handle the current hook by doing The Right Thing with the registered ser
vices. |
| 109 """ |
| 110 hook_name = os.path.basename(sys.argv[0]) |
| 111 if hook_name == 'stop': |
| 112 self.stop_services() |
| 113 else: |
| 114 self.reconfigure_services() |
| 115 |
| 116 def reconfigure_services(self, *service_names): |
| 117 """ |
| 118 Update all files for one or more registered services, and, |
| 119 if ready, optionally restart them. |
| 120 |
| 121 If no service names are given, reconfigures all registered services. |
| 122 """ |
| 123 for service_name in service_names or self.services.keys(): |
| 124 if self.is_ready(service_name): |
| 125 self.fire_event('data_ready', service_name) |
| 126 self.fire_event('start', service_name, default=[ |
| 127 host.service_restart, |
| 128 open_ports]) |
| 129 self.save_ready(service_name) |
| 130 else: |
| 131 if self.was_ready(service_name): |
| 132 self.fire_event('data_lost', service_name) |
| 133 self.fire_event('stop', service_name, default=[ |
| 134 close_ports, |
| 135 host.service_stop]) |
| 136 self.save_lost(service_name) |
| 137 |
| 138 def stop_services(self, *service_names): |
| 139 """ |
| 140 Stop one or more registered services, by name. |
| 141 |
| 142 If no service names are given, stops all registered services. |
| 143 """ |
| 144 for service_name in service_names or self.services.keys(): |
| 145 self.fire_event('stop', service_name, default=[ |
| 146 close_ports, |
| 147 host.service_stop]) |
| 148 |
| 149 def get_service(self, service_name): |
| 150 """ |
| 151 Given the name of a registered service, return its service definition. |
| 152 """ |
| 153 service = self.services.get(service_name) |
| 154 if not service: |
| 155 raise KeyError('Service not registered: %s' % service_name) |
| 156 return service |
| 157 |
| 158 def fire_event(self, event_name, service_name, default=None): |
| 159 """ |
| 160 Fire a data_ready, data_lost, start, or stop event on a given service. |
| 161 """ |
| 162 service = self.get_service(service_name) |
| 163 callbacks = service.get(event_name, default) |
| 164 if not callbacks: |
| 165 return |
| 166 if not isinstance(callbacks, Iterable): |
| 167 callbacks = [callbacks] |
| 168 for callback in callbacks: |
| 169 if isinstance(callback, ManagerCallback): |
| 170 callback(self, service_name, event_name) |
| 171 else: |
| 172 callback(service_name) |
| 173 |
| 174 def is_ready(self, service_name): |
| 175 """ |
| 176 Determine if a registered service is ready, by checking its 'required_da
ta'. |
| 177 |
| 178 A 'required_data' item can be any mapping type, and is considered ready |
| 179 if `bool(item)` evaluates as True. |
| 180 """ |
| 181 service = self.get_service(service_name) |
| 182 reqs = service.get('required_data', []) |
| 183 return all(bool(req) for req in reqs) |
| 184 |
| 185 def save_ready(self, service_name): |
| 186 """ |
| 187 Save an indicator that the given service is now data_ready. |
| 188 """ |
| 189 ready_file = '{}/.ready.{}'.format(hookenv.charm_dir(), service_name) |
| 190 with open(ready_file, 'a'): |
| 191 pass |
| 192 |
| 193 def save_lost(self, service_name): |
| 194 """ |
| 195 Save an indicator that the given service is no longer data_ready. |
| 196 """ |
| 197 ready_file = '{}/.ready.{}'.format(hookenv.charm_dir(), service_name) |
| 198 if os.path.exists(ready_file): |
| 199 os.remove(ready_file) |
| 200 |
| 201 def was_ready(self, service_name): |
| 202 """ |
| 203 Determine if the given service was previously data_ready. |
| 204 """ |
| 205 ready_file = '{}/.ready.{}'.format(hookenv.charm_dir(), service_name) |
| 206 return os.path.exists(ready_file) |
| 207 |
| 208 |
| 209 class RelationContext(dict): |
| 210 """ |
| 211 Base class for a context generator that gets relation data from juju. |
| 212 |
| 213 Subclasses must provide `interface`, which is the interface type of interest
, |
| 214 and `required_keys`, which is the set of keys required for the relation to |
| 215 be considered complete. The first relation for the interface that is comple
te |
| 216 will be used to populate the data for template. |
| 217 |
| 218 The generated context will be namespaced under the interface type, to preven
t |
| 219 potential naming conflicts. |
| 220 """ |
| 221 interface = None |
| 222 required_keys = [] |
| 223 |
| 224 def __init__(self, *args, **kwargs): |
| 225 super(RelationContext, self).__init__(*args, **kwargs) |
| 226 self.get_data() |
| 227 |
| 228 def __bool__(self): |
| 229 """ |
| 230 Returns True if all of the required_keys are available. |
| 231 """ |
| 232 return self.is_ready() |
| 233 |
| 234 __nonzero__ = __bool__ |
| 235 |
| 236 def __repr__(self): |
| 237 return super(RelationContext, self).__repr__() |
| 238 |
| 239 def is_ready(self): |
| 240 """ |
| 241 Returns True if all of the `required_keys` are available from any units. |
| 242 """ |
| 243 ready = len(self.get(self.interface, [])) > 0 |
| 244 if not ready: |
| 245 hookenv.log('Incomplete relation: {}'.format(self.__class__.__name__
), hookenv.DEBUG) |
| 246 return ready |
| 247 |
| 248 def _is_ready(self, unit_data): |
| 249 """ |
| 250 Helper method that tests a set of relation data and returns True if |
| 251 all of the `required_keys` are present. |
| 252 """ |
| 253 return set(unit_data.keys()).issuperset(set(self.required_keys)) |
| 254 |
| 255 def get_data(self): |
| 256 """ |
| 257 Retrieve the relation data for each unit involved in a realtion and, |
| 258 if complete, store it in a list under `self[self.interface]`. This |
| 259 is automatically called when the RelationContext is instantiated. |
| 260 |
| 261 The units are sorted lexographically first by the service ID, then by |
| 262 the unit ID. Thus, if an interface has two other services, 'db:1' |
| 263 and 'db:2', with 'db:1' having two units, 'wordpress/0' and 'wordpress/1
', |
| 264 and 'db:2' having one unit, 'mediawiki/0', all of which have a complete |
| 265 set of data, the relation data for the units will be stored in the |
| 266 order: 'wordpress/0', 'wordpress/1', 'mediawiki/0'. |
| 267 |
| 268 If you only care about a single unit on the relation, you can just |
| 269 access it as `{{ interface[0]['key'] }}`. However, if you can at all |
| 270 support multiple units on a relation, you should iterate over the list, |
| 271 like: |
| 272 |
| 273 {% for unit in interface -%} |
| 274 {{ unit['key'] }}{% if not loop.last %},{% endif %} |
| 275 {%- endfor %} |
| 276 |
| 277 Note that since all sets of relation data from all related services and |
| 278 units are in a single list, if you need to know which service or unit a |
| 279 set of data came from, you'll need to extend this class to preserve |
| 280 that information. |
| 281 """ |
| 282 if not hookenv.relation_ids(self.interface): |
| 283 return |
| 284 |
| 285 ns = self.setdefault(self.interface, []) |
| 286 for rid in sorted(hookenv.relation_ids(self.interface)): |
| 287 for unit in sorted(hookenv.related_units(rid)): |
| 288 reldata = hookenv.relation_get(rid=rid, unit=unit) |
| 289 if self._is_ready(reldata): |
| 290 ns.append(reldata) |
| 291 |
| 292 |
| 293 class ManagerCallback(object): |
| 294 """ |
| 295 Special case of a callback that takes the `ServiceManager` instance |
| 296 in addition to the service name. |
| 297 |
| 298 Subclasses should implement `__call__` which should accept two parameters: |
| 299 |
| 300 * `manager` The `ServiceManager` instance |
| 301 * `service_name` The name of the service it's being triggered for |
| 302 * `event_name` The name of the event that this callback is handling |
| 303 """ |
| 304 def __call__(self, manager, service_name, event_name): |
| 305 raise NotImplementedError() |
| 306 |
| 307 |
| 308 class TemplateCallback(ManagerCallback): |
| 309 """ |
| 310 Callback class that will render a template, for use as a ready action. |
| 311 |
| 312 The `target` param, if omitted, will default to `/etc/init/<service name>`. |
| 313 """ |
| 314 def __init__(self, source, target, owner='root', group='root', perms=0444): |
| 315 self.source = source |
| 316 self.target = target |
| 317 self.owner = owner |
| 318 self.group = group |
| 319 self.perms = perms |
| 320 |
| 321 def __call__(self, manager, service_name, event_name): |
| 322 service = manager.get_service(service_name) |
| 323 context = {} |
| 324 for ctx in service.get('required_data', []): |
| 325 context.update(ctx) |
| 326 templating.render(self.source, self.target, context, |
| 327 self.owner, self.group, self.perms) |
| 328 |
| 329 |
| 330 class PortManagerCallback(ManagerCallback): |
| 331 """ |
| 332 Callback class that will open or close ports, for use as either |
| 333 a start or stop action. |
| 334 """ |
| 335 def __call__(self, manager, service_name, event_name): |
| 336 service = manager.get_service(service_name) |
| 337 for port in service.get('ports', []): |
| 338 if event_name == 'start': |
| 339 hookenv.open_port(port) |
| 340 elif event_name == 'stop': |
| 341 hookenv.close_port(port) |
| 342 |
| 343 |
| 344 # Convenience aliases |
| 345 render_template = template = TemplateCallback |
| 346 open_ports = PortManagerCallback() |
| 347 close_ports = PortManagerCallback() |
OLD | NEW |