1
# Copyright 2014-2015 Canonical Limited.
3
# This file is part of charm-helpers.
5
# charm-helpers is free software: you can redistribute it and/or modify
6
# it under the terms of the GNU Lesser General Public License version 3 as
7
# published by the Free Software Foundation.
9
# charm-helpers is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
# GNU Lesser General Public License for more details.
14
# You should have received a copy of the GNU Lesser General Public License
15
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
19
from inspect import getargspec
20
from collections import Iterable, OrderedDict
22
from charmhelpers.core import host
23
from charmhelpers.core import hookenv
26
__all__ = ['ServiceManager', 'ManagerCallback',
27
'PortManagerCallback', 'open_ports', 'close_ports', 'manage_ports',
28
'service_restart', 'service_stop']
31
class ServiceManager(object):
32
def __init__(self, services=None):
34
Register a list of services, given their definitions.
36
Service definitions are dicts in the following formats (all keys except
37
'service' are optional)::
40
"service": <service name>,
41
"required_data": <list of required data contexts>,
42
"provided_data": <list of provided data contexts>,
43
"data_ready": <one or more callbacks>,
44
"data_lost": <one or more callbacks>,
45
"start": <one or more callbacks>,
46
"stop": <one or more callbacks>,
47
"ports": <list of ports to manage>,
50
The 'required_data' list should contain dicts of required data (or
51
dependency managers that act like dicts and know how to collect the data).
52
Only when all items in the 'required_data' list are populated are the list
53
of 'data_ready' and 'start' callbacks executed. See `is_ready()` for more
56
The 'provided_data' list should contain relation data providers, most likely
57
a subclass of :class:`charmhelpers.core.services.helpers.RelationContext`,
58
that will indicate a set of data to set on a given relation.
60
The 'data_ready' value should be either a single callback, or a list of
61
callbacks, to be called when all items in 'required_data' pass `is_ready()`.
62
Each callback will be called with the service name as the only parameter.
63
After all of the 'data_ready' callbacks are called, the 'start' callbacks
66
The 'data_lost' value should be either a single callback, or a list of
67
callbacks, to be called when a 'required_data' item no longer passes
68
`is_ready()`. Each callback will be called with the service name as the
69
only parameter. After all of the 'data_lost' callbacks are called,
70
the 'stop' callbacks are fired.
72
The 'start' value should be either a single callback, or a list of
73
callbacks, to be called when starting the service, after the 'data_ready'
74
callbacks are complete. Each callback will be called with the service
75
name as the only parameter. This defaults to
76
`[host.service_start, services.open_ports]`.
78
The 'stop' value should be either a single callback, or a list of
79
callbacks, to be called when stopping the service. If the service is
80
being stopped because it no longer has all of its 'required_data', this
81
will be called after all of the 'data_lost' callbacks are complete.
82
Each callback will be called with the service name as the only parameter.
83
This defaults to `[services.close_ports, host.service_stop]`.
85
The 'ports' value should be a list of ports to manage. The default
86
'start' handler will open the ports after the service is started,
87
and the default 'stop' handler will close the ports prior to stopping
93
The following registers an Upstart service called bingod that depends on
94
a mongodb relation and which runs a custom `db_migrate` function prior to
95
restarting the service, and a Runit service called spadesd::
97
manager = services.ServiceManager([
101
'required_data': [MongoRelation(), config(), {'my': 'data'}],
103
services.template(source='bingod.conf'),
104
services.template(source='bingod.ini',
105
target='/etc/bingod.ini',
106
owner='bingo', perms=0400),
110
'service': 'spadesd',
111
'data_ready': services.template(source='spadesd_run.j2',
112
target='/etc/sv/spadesd/run',
114
'start': runit_start,
120
self._ready_file = os.path.join(hookenv.charm_dir(), 'READY-SERVICES.json')
122
self.services = OrderedDict()
123
for service in services or []:
124
service_name = service['service']
125
self.services[service_name] = service
129
Handle the current hook by doing The Right Thing with the registered services.
131
hookenv._run_atstart()
133
hook_name = hookenv.hook_name()
134
if hook_name == 'stop':
137
self.reconfigure_services()
139
except SystemExit as x:
140
if x.code is None or x.code == 0:
141
hookenv._run_atexit()
142
hookenv._run_atexit()
144
def provide_data(self):
146
Set the relation data for each provider in the ``provided_data`` list.
148
A provider must have a `name` attribute, which indicates which relation
149
to set data on, and a `provide_data()` method, which returns a dict of
152
The `provide_data()` method can optionally accept two parameters:
154
* ``remote_service`` The name of the remote service that the data will
155
be provided to. The `provide_data()` method will be called once
156
for each connected service (not unit). This allows the method to
157
tailor its data to the given service.
158
* ``service_ready`` Whether or not the service definition had all of
159
its requirements met, and thus the ``data_ready`` callbacks run.
161
Note that the ``provided_data`` methods are now called **after** the
162
``data_ready`` callbacks are run. This gives the ``data_ready`` callbacks
163
a chance to generate any data necessary for the providing to the remote
166
for service_name, service in self.services.items():
167
service_ready = self.is_ready(service_name)
168
for provider in service.get('provided_data', []):
169
for relid in hookenv.relation_ids(provider.name):
170
units = hookenv.related_units(relid)
173
remote_service = units[0].split('/')[0]
174
argspec = getargspec(provider.provide_data)
175
if len(argspec.args) > 1:
176
data = provider.provide_data(remote_service, service_ready)
178
data = provider.provide_data()
180
hookenv.relation_set(relid, data)
182
def reconfigure_services(self, *service_names):
184
Update all files for one or more registered services, and,
185
if ready, optionally restart them.
187
If no service names are given, reconfigures all registered services.
189
for service_name in service_names or self.services.keys():
190
if self.is_ready(service_name):
191
self.fire_event('data_ready', service_name)
192
self.fire_event('start', service_name, default=[
195
self.save_ready(service_name)
197
if self.was_ready(service_name):
198
self.fire_event('data_lost', service_name)
199
self.fire_event('stop', service_name, default=[
202
self.save_lost(service_name)
204
def stop_services(self, *service_names):
206
Stop one or more registered services, by name.
208
If no service names are given, stops all registered services.
210
for service_name in service_names or self.services.keys():
211
self.fire_event('stop', service_name, default=[
215
def get_service(self, service_name):
217
Given the name of a registered service, return its service definition.
219
service = self.services.get(service_name)
221
raise KeyError('Service not registered: %s' % service_name)
224
def fire_event(self, event_name, service_name, default=None):
226
Fire a data_ready, data_lost, start, or stop event on a given service.
228
service = self.get_service(service_name)
229
callbacks = service.get(event_name, default)
232
if not isinstance(callbacks, Iterable):
233
callbacks = [callbacks]
234
for callback in callbacks:
235
if isinstance(callback, ManagerCallback):
236
callback(self, service_name, event_name)
238
callback(service_name)
240
def is_ready(self, service_name):
242
Determine if a registered service is ready, by checking its 'required_data'.
244
A 'required_data' item can be any mapping type, and is considered ready
245
if `bool(item)` evaluates as True.
247
service = self.get_service(service_name)
248
reqs = service.get('required_data', [])
249
return all(bool(req) for req in reqs)
251
def _load_ready_file(self):
252
if self._ready is not None:
254
if os.path.exists(self._ready_file):
255
with open(self._ready_file) as fp:
256
self._ready = set(json.load(fp))
260
def _save_ready_file(self):
261
if self._ready is None:
263
with open(self._ready_file, 'w') as fp:
264
json.dump(list(self._ready), fp)
266
def save_ready(self, service_name):
268
Save an indicator that the given service is now data_ready.
270
self._load_ready_file()
271
self._ready.add(service_name)
272
self._save_ready_file()
274
def save_lost(self, service_name):
276
Save an indicator that the given service is no longer data_ready.
278
self._load_ready_file()
279
self._ready.discard(service_name)
280
self._save_ready_file()
282
def was_ready(self, service_name):
284
Determine if the given service was previously data_ready.
286
self._load_ready_file()
287
return service_name in self._ready
290
class ManagerCallback(object):
292
Special case of a callback that takes the `ServiceManager` instance
293
in addition to the service name.
295
Subclasses should implement `__call__` which should accept three parameters:
297
* `manager` The `ServiceManager` instance
298
* `service_name` The name of the service it's being triggered for
299
* `event_name` The name of the event that this callback is handling
301
def __call__(self, manager, service_name, event_name):
302
raise NotImplementedError()
305
class PortManagerCallback(ManagerCallback):
307
Callback class that will open or close ports, for use as either
308
a start or stop action.
310
def __call__(self, manager, service_name, event_name):
311
service = manager.get_service(service_name)
312
new_ports = service.get('ports', [])
313
port_file = os.path.join(hookenv.charm_dir(), '.{}.ports'.format(service_name))
314
if os.path.exists(port_file):
315
with open(port_file) as fp:
316
old_ports = fp.read().split(',')
317
for old_port in old_ports:
319
old_port = int(old_port)
320
if old_port not in new_ports:
321
hookenv.close_port(old_port)
322
with open(port_file, 'w') as fp:
323
fp.write(','.join(str(port) for port in new_ports))
324
for port in new_ports:
325
if event_name == 'start':
326
hookenv.open_port(port)
327
elif event_name == 'stop':
328
hookenv.close_port(port)
331
def service_stop(service_name):
333
Wrapper around host.service_stop to prevent spurious "unknown service"
334
messages in the logs.
336
if host.service_running(service_name):
337
host.service_stop(service_name)
340
def service_restart(service_name):
342
Wrapper around host.service_restart to prevent spurious "unknown service"
343
messages in the logs.
345
if host.service_available(service_name):
346
if host.service_running(service_name):
347
host.service_restart(service_name)
349
host.service_start(service_name)
352
# Convenience aliases
353
open_ports = close_ports = manage_ports = PortManagerCallback()