~le-charmers/charms/trusty/rabbitmq-server/leadership-election

« back to all changes in this revision

Viewing changes to tests/charmhelpers/core/services/helpers.py

  • Committer: Liam Young
  • Date: 2015-05-11 08:03:57 UTC
  • mfrom: (83.1.14 rabbitmq-server)
  • Revision ID: liam.young@canonical.com-20150511080357-3ftop9kxb6o0e3mq
Merged trunk in + LE charmhelper sync

Show diffs side-by-side

added added

removed removed

Lines of Context:
 
1
# Copyright 2014-2015 Canonical Limited.
 
2
#
 
3
# This file is part of charm-helpers.
 
4
#
 
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.
 
8
#
 
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.
 
13
#
 
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/>.
 
16
 
 
17
import os
 
18
import yaml
 
19
from charmhelpers.core import hookenv
 
20
from charmhelpers.core import templating
 
21
 
 
22
from charmhelpers.core.services.base import ManagerCallback
 
23
 
 
24
 
 
25
__all__ = ['RelationContext', 'TemplateCallback',
 
26
           'render_template', 'template']
 
27
 
 
28
 
 
29
class RelationContext(dict):
 
30
    """
 
31
    Base class for a context generator that gets relation data from juju.
 
32
 
 
33
    Subclasses must provide the attributes `name`, which is the name of the
 
34
    interface of interest, `interface`, which is the type of the interface of
 
35
    interest, and `required_keys`, which is the set of keys required for the
 
36
    relation to be considered complete.  The data for all interfaces matching
 
37
    the `name` attribute that are complete will used to populate the dictionary
 
38
    values (see `get_data`, below).
 
39
 
 
40
    The generated context will be namespaced under the relation :attr:`name`,
 
41
    to prevent potential naming conflicts.
 
42
 
 
43
    :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
 
44
    :param list additional_required_keys: Extend the list of :attr:`required_keys`
 
45
    """
 
46
    name = None
 
47
    interface = None
 
48
 
 
49
    def __init__(self, name=None, additional_required_keys=None):
 
50
        if not hasattr(self, 'required_keys'):
 
51
            self.required_keys = []
 
52
 
 
53
        if name is not None:
 
54
            self.name = name
 
55
        if additional_required_keys:
 
56
            self.required_keys.extend(additional_required_keys)
 
57
        self.get_data()
 
58
 
 
59
    def __bool__(self):
 
60
        """
 
61
        Returns True if all of the required_keys are available.
 
62
        """
 
63
        return self.is_ready()
 
64
 
 
65
    __nonzero__ = __bool__
 
66
 
 
67
    def __repr__(self):
 
68
        return super(RelationContext, self).__repr__()
 
69
 
 
70
    def is_ready(self):
 
71
        """
 
72
        Returns True if all of the `required_keys` are available from any units.
 
73
        """
 
74
        ready = len(self.get(self.name, [])) > 0
 
75
        if not ready:
 
76
            hookenv.log('Incomplete relation: {}'.format(self.__class__.__name__), hookenv.DEBUG)
 
77
        return ready
 
78
 
 
79
    def _is_ready(self, unit_data):
 
80
        """
 
81
        Helper method that tests a set of relation data and returns True if
 
82
        all of the `required_keys` are present.
 
83
        """
 
84
        return set(unit_data.keys()).issuperset(set(self.required_keys))
 
85
 
 
86
    def get_data(self):
 
87
        """
 
88
        Retrieve the relation data for each unit involved in a relation and,
 
89
        if complete, store it in a list under `self[self.name]`.  This
 
90
        is automatically called when the RelationContext is instantiated.
 
91
 
 
92
        The units are sorted lexographically first by the service ID, then by
 
93
        the unit ID.  Thus, if an interface has two other services, 'db:1'
 
94
        and 'db:2', with 'db:1' having two units, 'wordpress/0' and 'wordpress/1',
 
95
        and 'db:2' having one unit, 'mediawiki/0', all of which have a complete
 
96
        set of data, the relation data for the units will be stored in the
 
97
        order: 'wordpress/0', 'wordpress/1', 'mediawiki/0'.
 
98
 
 
99
        If you only care about a single unit on the relation, you can just
 
100
        access it as `{{ interface[0]['key'] }}`.  However, if you can at all
 
101
        support multiple units on a relation, you should iterate over the list,
 
102
        like::
 
103
 
 
104
            {% for unit in interface -%}
 
105
                {{ unit['key'] }}{% if not loop.last %},{% endif %}
 
106
            {%- endfor %}
 
107
 
 
108
        Note that since all sets of relation data from all related services and
 
109
        units are in a single list, if you need to know which service or unit a
 
110
        set of data came from, you'll need to extend this class to preserve
 
111
        that information.
 
112
        """
 
113
        if not hookenv.relation_ids(self.name):
 
114
            return
 
115
 
 
116
        ns = self.setdefault(self.name, [])
 
117
        for rid in sorted(hookenv.relation_ids(self.name)):
 
118
            for unit in sorted(hookenv.related_units(rid)):
 
119
                reldata = hookenv.relation_get(rid=rid, unit=unit)
 
120
                if self._is_ready(reldata):
 
121
                    ns.append(reldata)
 
122
 
 
123
    def provide_data(self):
 
124
        """
 
125
        Return data to be relation_set for this interface.
 
126
        """
 
127
        return {}
 
128
 
 
129
 
 
130
class MysqlRelation(RelationContext):
 
131
    """
 
132
    Relation context for the `mysql` interface.
 
133
 
 
134
    :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
 
135
    :param list additional_required_keys: Extend the list of :attr:`required_keys`
 
136
    """
 
137
    name = 'db'
 
138
    interface = 'mysql'
 
139
 
 
140
    def __init__(self, *args, **kwargs):
 
141
        self.required_keys = ['host', 'user', 'password', 'database']
 
142
        RelationContext.__init__(self, *args, **kwargs)
 
143
 
 
144
 
 
145
class HttpRelation(RelationContext):
 
146
    """
 
147
    Relation context for the `http` interface.
 
148
 
 
149
    :param str name: Override the relation :attr:`name`, since it can vary from charm to charm
 
150
    :param list additional_required_keys: Extend the list of :attr:`required_keys`
 
151
    """
 
152
    name = 'website'
 
153
    interface = 'http'
 
154
 
 
155
    def __init__(self, *args, **kwargs):
 
156
        self.required_keys = ['host', 'port']
 
157
        RelationContext.__init__(self, *args, **kwargs)
 
158
 
 
159
    def provide_data(self):
 
160
        return {
 
161
            'host': hookenv.unit_get('private-address'),
 
162
            'port': 80,
 
163
        }
 
164
 
 
165
 
 
166
class RequiredConfig(dict):
 
167
    """
 
168
    Data context that loads config options with one or more mandatory options.
 
169
 
 
170
    Once the required options have been changed from their default values, all
 
171
    config options will be available, namespaced under `config` to prevent
 
172
    potential naming conflicts (for example, between a config option and a
 
173
    relation property).
 
174
 
 
175
    :param list *args: List of options that must be changed from their default values.
 
176
    """
 
177
 
 
178
    def __init__(self, *args):
 
179
        self.required_options = args
 
180
        self['config'] = hookenv.config()
 
181
        with open(os.path.join(hookenv.charm_dir(), 'config.yaml')) as fp:
 
182
            self.config = yaml.load(fp).get('options', {})
 
183
 
 
184
    def __bool__(self):
 
185
        for option in self.required_options:
 
186
            if option not in self['config']:
 
187
                return False
 
188
            current_value = self['config'][option]
 
189
            default_value = self.config[option].get('default')
 
190
            if current_value == default_value:
 
191
                return False
 
192
            if current_value in (None, '') and default_value in (None, ''):
 
193
                return False
 
194
        return True
 
195
 
 
196
    def __nonzero__(self):
 
197
        return self.__bool__()
 
198
 
 
199
 
 
200
class StoredContext(dict):
 
201
    """
 
202
    A data context that always returns the data that it was first created with.
 
203
 
 
204
    This is useful to do a one-time generation of things like passwords, that
 
205
    will thereafter use the same value that was originally generated, instead
 
206
    of generating a new value each time it is run.
 
207
    """
 
208
    def __init__(self, file_name, config_data):
 
209
        """
 
210
        If the file exists, populate `self` with the data from the file.
 
211
        Otherwise, populate with the given data and persist it to the file.
 
212
        """
 
213
        if os.path.exists(file_name):
 
214
            self.update(self.read_context(file_name))
 
215
        else:
 
216
            self.store_context(file_name, config_data)
 
217
            self.update(config_data)
 
218
 
 
219
    def store_context(self, file_name, config_data):
 
220
        if not os.path.isabs(file_name):
 
221
            file_name = os.path.join(hookenv.charm_dir(), file_name)
 
222
        with open(file_name, 'w') as file_stream:
 
223
            os.fchmod(file_stream.fileno(), 0o600)
 
224
            yaml.dump(config_data, file_stream)
 
225
 
 
226
    def read_context(self, file_name):
 
227
        if not os.path.isabs(file_name):
 
228
            file_name = os.path.join(hookenv.charm_dir(), file_name)
 
229
        with open(file_name, 'r') as file_stream:
 
230
            data = yaml.load(file_stream)
 
231
            if not data:
 
232
                raise OSError("%s is empty" % file_name)
 
233
            return data
 
234
 
 
235
 
 
236
class TemplateCallback(ManagerCallback):
 
237
    """
 
238
    Callback class that will render a Jinja2 template, for use as a ready
 
239
    action.
 
240
 
 
241
    :param str source: The template source file, relative to
 
242
    `$CHARM_DIR/templates`
 
243
 
 
244
    :param str target: The target to write the rendered template to
 
245
    :param str owner: The owner of the rendered file
 
246
    :param str group: The group of the rendered file
 
247
    :param int perms: The permissions of the rendered file
 
248
    """
 
249
    def __init__(self, source, target,
 
250
                 owner='root', group='root', perms=0o444):
 
251
        self.source = source
 
252
        self.target = target
 
253
        self.owner = owner
 
254
        self.group = group
 
255
        self.perms = perms
 
256
 
 
257
    def __call__(self, manager, service_name, event_name):
 
258
        service = manager.get_service(service_name)
 
259
        context = {}
 
260
        for ctx in service.get('required_data', []):
 
261
            context.update(ctx)
 
262
        templating.render(self.source, self.target, context,
 
263
                          self.owner, self.group, self.perms)
 
264
 
 
265
 
 
266
# Convenience aliases for templates
 
267
render_template = template = TemplateCallback