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/>.
20
from collections import Iterable
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')
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
hook_name = hookenv.hook_name()
132
if hook_name == 'stop':
136
self.reconfigure_services()
137
cfg = hookenv.config()
138
if cfg.implicit_save:
141
def provide_data(self):
143
Set the relation data for each provider in the ``provided_data`` list.
145
A provider must have a `name` attribute, which indicates which relation
146
to set data on, and a `provide_data()` method, which returns a dict of
149
hook_name = hookenv.hook_name()
150
for service in self.services.values():
151
for provider in service.get('provided_data', []):
152
if re.match(r'{}-relation-(joined|changed)'.format(provider.name), hook_name):
153
data = provider.provide_data()
154
_ready = provider._is_ready(data) if hasattr(provider, '_is_ready') else data
156
hookenv.relation_set(None, data)
158
def reconfigure_services(self, *service_names):
160
Update all files for one or more registered services, and,
161
if ready, optionally restart them.
163
If no service names are given, reconfigures all registered services.
165
for service_name in service_names or self.services.keys():
166
if self.is_ready(service_name):
167
self.fire_event('data_ready', service_name)
168
self.fire_event('start', service_name, default=[
171
self.save_ready(service_name)
173
if self.was_ready(service_name):
174
self.fire_event('data_lost', service_name)
175
self.fire_event('stop', service_name, default=[
178
self.save_lost(service_name)
180
def stop_services(self, *service_names):
182
Stop one or more registered services, by name.
184
If no service names are given, stops all registered services.
186
for service_name in service_names or self.services.keys():
187
self.fire_event('stop', service_name, default=[
191
def get_service(self, service_name):
193
Given the name of a registered service, return its service definition.
195
service = self.services.get(service_name)
197
raise KeyError('Service not registered: %s' % service_name)
200
def fire_event(self, event_name, service_name, default=None):
202
Fire a data_ready, data_lost, start, or stop event on a given service.
204
service = self.get_service(service_name)
205
callbacks = service.get(event_name, default)
208
if not isinstance(callbacks, Iterable):
209
callbacks = [callbacks]
210
for callback in callbacks:
211
if isinstance(callback, ManagerCallback):
212
callback(self, service_name, event_name)
214
callback(service_name)
216
def is_ready(self, service_name):
218
Determine if a registered service is ready, by checking its 'required_data'.
220
A 'required_data' item can be any mapping type, and is considered ready
221
if `bool(item)` evaluates as True.
223
service = self.get_service(service_name)
224
reqs = service.get('required_data', [])
225
return all(bool(req) for req in reqs)
227
def _load_ready_file(self):
228
if self._ready is not None:
230
if os.path.exists(self._ready_file):
231
with open(self._ready_file) as fp:
232
self._ready = set(json.load(fp))
236
def _save_ready_file(self):
237
if self._ready is None:
239
with open(self._ready_file, 'w') as fp:
240
json.dump(list(self._ready), fp)
242
def save_ready(self, service_name):
244
Save an indicator that the given service is now data_ready.
246
self._load_ready_file()
247
self._ready.add(service_name)
248
self._save_ready_file()
250
def save_lost(self, service_name):
252
Save an indicator that the given service is no longer data_ready.
254
self._load_ready_file()
255
self._ready.discard(service_name)
256
self._save_ready_file()
258
def was_ready(self, service_name):
260
Determine if the given service was previously data_ready.
262
self._load_ready_file()
263
return service_name in self._ready
266
class ManagerCallback(object):
268
Special case of a callback that takes the `ServiceManager` instance
269
in addition to the service name.
271
Subclasses should implement `__call__` which should accept three parameters:
273
* `manager` The `ServiceManager` instance
274
* `service_name` The name of the service it's being triggered for
275
* `event_name` The name of the event that this callback is handling
277
def __call__(self, manager, service_name, event_name):
278
raise NotImplementedError()
281
class PortManagerCallback(ManagerCallback):
283
Callback class that will open or close ports, for use as either
284
a start or stop action.
286
def __call__(self, manager, service_name, event_name):
287
service = manager.get_service(service_name)
288
new_ports = service.get('ports', [])
289
port_file = os.path.join(hookenv.charm_dir(), '.{}.ports'.format(service_name))
290
if os.path.exists(port_file):
291
with open(port_file) as fp:
292
old_ports = fp.read().split(',')
293
for old_port in old_ports:
295
old_port = int(old_port)
296
if old_port not in new_ports:
297
hookenv.close_port(old_port)
298
with open(port_file, 'w') as fp:
299
fp.write(','.join(str(port) for port in new_ports))
300
for port in new_ports:
301
if event_name == 'start':
302
hookenv.open_port(port)
303
elif event_name == 'stop':
304
hookenv.close_port(port)
307
def service_stop(service_name):
309
Wrapper around host.service_stop to prevent spurious "unknown service"
310
messages in the logs.
312
if host.service_running(service_name):
313
host.service_stop(service_name)
316
def service_restart(service_name):
318
Wrapper around host.service_restart to prevent spurious "unknown service"
319
messages in the logs.
321
if host.service_available(service_name):
322
if host.service_running(service_name):
323
host.service_restart(service_name)
325
host.service_start(service_name)
328
# Convenience aliases
329
open_ports = close_ports = manage_ports = PortManagerCallback()