~openstack-charmers-archive/charms/trusty/swift-proxy/trunk

« back to all changes in this revision

Viewing changes to hooks/charmhelpers/core/unitdata.py

  • Committer: James Page
  • Date: 2015-10-22 13:24:57 UTC
  • Revision ID: james.page@ubuntu.com-20151022132457-4p14oifelnzjz5n3
Tags: 15.10
15.10 Charm release

Show diffs side-by-side

added added

removed removed

Lines of Context:
1
 
#!/usr/bin/env python
2
 
# -*- coding: utf-8 -*-
3
 
#
4
 
# Copyright 2014-2015 Canonical Limited.
5
 
#
6
 
# This file is part of charm-helpers.
7
 
#
8
 
# charm-helpers is free software: you can redistribute it and/or modify
9
 
# it under the terms of the GNU Lesser General Public License version 3 as
10
 
# published by the Free Software Foundation.
11
 
#
12
 
# charm-helpers is distributed in the hope that it will be useful,
13
 
# but WITHOUT ANY WARRANTY; without even the implied warranty of
14
 
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15
 
# GNU Lesser General Public License for more details.
16
 
#
17
 
# You should have received a copy of the GNU Lesser General Public License
18
 
# along with charm-helpers.  If not, see <http://www.gnu.org/licenses/>.
19
 
#
20
 
#
21
 
# Authors:
22
 
#  Kapil Thangavelu <kapil.foss@gmail.com>
23
 
#
24
 
"""
25
 
Intro
26
 
-----
27
 
 
28
 
A simple way to store state in units. This provides a key value
29
 
storage with support for versioned, transactional operation,
30
 
and can calculate deltas from previous values to simplify unit logic
31
 
when processing changes.
32
 
 
33
 
 
34
 
Hook Integration
35
 
----------------
36
 
 
37
 
There are several extant frameworks for hook execution, including
38
 
 
39
 
 - charmhelpers.core.hookenv.Hooks
40
 
 - charmhelpers.core.services.ServiceManager
41
 
 
42
 
The storage classes are framework agnostic, one simple integration is
43
 
via the HookData contextmanager. It will record the current hook
44
 
execution environment (including relation data, config data, etc.),
45
 
setup a transaction and allow easy access to the changes from
46
 
previously seen values. One consequence of the integration is the
47
 
reservation of particular keys ('rels', 'unit', 'env', 'config',
48
 
'charm_revisions') for their respective values.
49
 
 
50
 
Here's a fully worked integration example using hookenv.Hooks::
51
 
 
52
 
       from charmhelper.core import hookenv, unitdata
53
 
 
54
 
       hook_data = unitdata.HookData()
55
 
       db = unitdata.kv()
56
 
       hooks = hookenv.Hooks()
57
 
 
58
 
       @hooks.hook
59
 
       def config_changed():
60
 
           # Print all changes to configuration from previously seen
61
 
           # values.
62
 
           for changed, (prev, cur) in hook_data.conf.items():
63
 
               print('config changed', changed,
64
 
                     'previous value', prev,
65
 
                     'current value',  cur)
66
 
 
67
 
           # Get some unit specific bookeeping
68
 
           if not db.get('pkg_key'):
69
 
               key = urllib.urlopen('https://example.com/pkg_key').read()
70
 
               db.set('pkg_key', key)
71
 
 
72
 
           # Directly access all charm config as a mapping.
73
 
           conf = db.getrange('config', True)
74
 
 
75
 
           # Directly access all relation data as a mapping
76
 
           rels = db.getrange('rels', True)
77
 
 
78
 
       if __name__ == '__main__':
79
 
           with hook_data():
80
 
               hook.execute()
81
 
 
82
 
 
83
 
A more basic integration is via the hook_scope context manager which simply
84
 
manages transaction scope (and records hook name, and timestamp)::
85
 
 
86
 
  >>> from unitdata import kv
87
 
  >>> db = kv()
88
 
  >>> with db.hook_scope('install'):
89
 
  ...    # do work, in transactional scope.
90
 
  ...    db.set('x', 1)
91
 
  >>> db.get('x')
92
 
  1
93
 
 
94
 
 
95
 
Usage
96
 
-----
97
 
 
98
 
Values are automatically json de/serialized to preserve basic typing
99
 
and complex data struct capabilities (dicts, lists, ints, booleans, etc).
100
 
 
101
 
Individual values can be manipulated via get/set::
102
 
 
103
 
   >>> kv.set('y', True)
104
 
   >>> kv.get('y')
105
 
   True
106
 
 
107
 
   # We can set complex values (dicts, lists) as a single key.
108
 
   >>> kv.set('config', {'a': 1, 'b': True'})
109
 
 
110
 
   # Also supports returning dictionaries as a record which
111
 
   # provides attribute access.
112
 
   >>> config = kv.get('config', record=True)
113
 
   >>> config.b
114
 
   True
115
 
 
116
 
 
117
 
Groups of keys can be manipulated with update/getrange::
118
 
 
119
 
   >>> kv.update({'z': 1, 'y': 2}, prefix="gui.")
120
 
   >>> kv.getrange('gui.', strip=True)
121
 
   {'z': 1, 'y': 2}
122
 
 
123
 
When updating values, its very helpful to understand which values
124
 
have actually changed and how have they changed. The storage
125
 
provides a delta method to provide for this::
126
 
 
127
 
   >>> data = {'debug': True, 'option': 2}
128
 
   >>> delta = kv.delta(data, 'config.')
129
 
   >>> delta.debug.previous
130
 
   None
131
 
   >>> delta.debug.current
132
 
   True
133
 
   >>> delta
134
 
   {'debug': (None, True), 'option': (None, 2)}
135
 
 
136
 
Note the delta method does not persist the actual change, it needs to
137
 
be explicitly saved via 'update' method::
138
 
 
139
 
   >>> kv.update(data, 'config.')
140
 
 
141
 
Values modified in the context of a hook scope retain historical values
142
 
associated to the hookname.
143
 
 
144
 
   >>> with db.hook_scope('config-changed'):
145
 
   ...      db.set('x', 42)
146
 
   >>> db.gethistory('x')
147
 
   [(1, u'x', 1, u'install', u'2015-01-21T16:49:30.038372'),
148
 
    (2, u'x', 42, u'config-changed', u'2015-01-21T16:49:30.038786')]
149
 
 
150
 
"""
151
 
 
152
 
import collections
153
 
import contextlib
154
 
import datetime
155
 
import itertools
156
 
import json
157
 
import os
158
 
import pprint
159
 
import sqlite3
160
 
import sys
161
 
 
162
 
__author__ = 'Kapil Thangavelu <kapil.foss@gmail.com>'
163
 
 
164
 
 
165
 
class Storage(object):
166
 
    """Simple key value database for local unit state within charms.
167
 
 
168
 
    Modifications are not persisted unless :meth:`flush` is called.
169
 
 
170
 
    To support dicts, lists, integer, floats, and booleans values
171
 
    are automatically json encoded/decoded.
172
 
    """
173
 
    def __init__(self, path=None):
174
 
        self.db_path = path
175
 
        if path is None:
176
 
            if 'UNIT_STATE_DB' in os.environ:
177
 
                self.db_path = os.environ['UNIT_STATE_DB']
178
 
            else:
179
 
                self.db_path = os.path.join(
180
 
                    os.environ.get('CHARM_DIR', ''), '.unit-state.db')
181
 
        self.conn = sqlite3.connect('%s' % self.db_path)
182
 
        self.cursor = self.conn.cursor()
183
 
        self.revision = None
184
 
        self._closed = False
185
 
        self._init()
186
 
 
187
 
    def close(self):
188
 
        if self._closed:
189
 
            return
190
 
        self.flush(False)
191
 
        self.cursor.close()
192
 
        self.conn.close()
193
 
        self._closed = True
194
 
 
195
 
    def get(self, key, default=None, record=False):
196
 
        self.cursor.execute('select data from kv where key=?', [key])
197
 
        result = self.cursor.fetchone()
198
 
        if not result:
199
 
            return default
200
 
        if record:
201
 
            return Record(json.loads(result[0]))
202
 
        return json.loads(result[0])
203
 
 
204
 
    def getrange(self, key_prefix, strip=False):
205
 
        """
206
 
        Get a range of keys starting with a common prefix as a mapping of
207
 
        keys to values.
208
 
 
209
 
        :param str key_prefix: Common prefix among all keys
210
 
        :param bool strip: Optionally strip the common prefix from the key
211
 
            names in the returned dict
212
 
        :return dict: A (possibly empty) dict of key-value mappings
213
 
        """
214
 
        self.cursor.execute("select key, data from kv where key like ?",
215
 
                            ['%s%%' % key_prefix])
216
 
        result = self.cursor.fetchall()
217
 
 
218
 
        if not result:
219
 
            return {}
220
 
        if not strip:
221
 
            key_prefix = ''
222
 
        return dict([
223
 
            (k[len(key_prefix):], json.loads(v)) for k, v in result])
224
 
 
225
 
    def update(self, mapping, prefix=""):
226
 
        """
227
 
        Set the values of multiple keys at once.
228
 
 
229
 
        :param dict mapping: Mapping of keys to values
230
 
        :param str prefix: Optional prefix to apply to all keys in `mapping`
231
 
            before setting
232
 
        """
233
 
        for k, v in mapping.items():
234
 
            self.set("%s%s" % (prefix, k), v)
235
 
 
236
 
    def unset(self, key):
237
 
        """
238
 
        Remove a key from the database entirely.
239
 
        """
240
 
        self.cursor.execute('delete from kv where key=?', [key])
241
 
        if self.revision and self.cursor.rowcount:
242
 
            self.cursor.execute(
243
 
                'insert into kv_revisions values (?, ?, ?)',
244
 
                [key, self.revision, json.dumps('DELETED')])
245
 
 
246
 
    def unsetrange(self, keys=None, prefix=""):
247
 
        """
248
 
        Remove a range of keys starting with a common prefix, from the database
249
 
        entirely.
250
 
 
251
 
        :param list keys: List of keys to remove.
252
 
        :param str prefix: Optional prefix to apply to all keys in ``keys``
253
 
            before removing.
254
 
        """
255
 
        if keys is not None:
256
 
            keys = ['%s%s' % (prefix, key) for key in keys]
257
 
            self.cursor.execute('delete from kv where key in (%s)' % ','.join(['?'] * len(keys)), keys)
258
 
            if self.revision and self.cursor.rowcount:
259
 
                self.cursor.execute(
260
 
                    'insert into kv_revisions values %s' % ','.join(['(?, ?, ?)'] * len(keys)),
261
 
                    list(itertools.chain.from_iterable((key, self.revision, json.dumps('DELETED')) for key in keys)))
262
 
        else:
263
 
            self.cursor.execute('delete from kv where key like ?',
264
 
                                ['%s%%' % prefix])
265
 
            if self.revision and self.cursor.rowcount:
266
 
                self.cursor.execute(
267
 
                    'insert into kv_revisions values (?, ?, ?)',
268
 
                    ['%s%%' % prefix, self.revision, json.dumps('DELETED')])
269
 
 
270
 
    def set(self, key, value):
271
 
        """
272
 
        Set a value in the database.
273
 
 
274
 
        :param str key: Key to set the value for
275
 
        :param value: Any JSON-serializable value to be set
276
 
        """
277
 
        serialized = json.dumps(value)
278
 
 
279
 
        self.cursor.execute('select data from kv where key=?', [key])
280
 
        exists = self.cursor.fetchone()
281
 
 
282
 
        # Skip mutations to the same value
283
 
        if exists:
284
 
            if exists[0] == serialized:
285
 
                return value
286
 
 
287
 
        if not exists:
288
 
            self.cursor.execute(
289
 
                'insert into kv (key, data) values (?, ?)',
290
 
                (key, serialized))
291
 
        else:
292
 
            self.cursor.execute('''
293
 
            update kv
294
 
            set data = ?
295
 
            where key = ?''', [serialized, key])
296
 
 
297
 
        # Save
298
 
        if not self.revision:
299
 
            return value
300
 
 
301
 
        self.cursor.execute(
302
 
            'select 1 from kv_revisions where key=? and revision=?',
303
 
            [key, self.revision])
304
 
        exists = self.cursor.fetchone()
305
 
 
306
 
        if not exists:
307
 
            self.cursor.execute(
308
 
                '''insert into kv_revisions (
309
 
                revision, key, data) values (?, ?, ?)''',
310
 
                (self.revision, key, serialized))
311
 
        else:
312
 
            self.cursor.execute(
313
 
                '''
314
 
                update kv_revisions
315
 
                set data = ?
316
 
                where key = ?
317
 
                and   revision = ?''',
318
 
                [serialized, key, self.revision])
319
 
 
320
 
        return value
321
 
 
322
 
    def delta(self, mapping, prefix):
323
 
        """
324
 
        return a delta containing values that have changed.
325
 
        """
326
 
        previous = self.getrange(prefix, strip=True)
327
 
        if not previous:
328
 
            pk = set()
329
 
        else:
330
 
            pk = set(previous.keys())
331
 
        ck = set(mapping.keys())
332
 
        delta = DeltaSet()
333
 
 
334
 
        # added
335
 
        for k in ck.difference(pk):
336
 
            delta[k] = Delta(None, mapping[k])
337
 
 
338
 
        # removed
339
 
        for k in pk.difference(ck):
340
 
            delta[k] = Delta(previous[k], None)
341
 
 
342
 
        # changed
343
 
        for k in pk.intersection(ck):
344
 
            c = mapping[k]
345
 
            p = previous[k]
346
 
            if c != p:
347
 
                delta[k] = Delta(p, c)
348
 
 
349
 
        return delta
350
 
 
351
 
    @contextlib.contextmanager
352
 
    def hook_scope(self, name=""):
353
 
        """Scope all future interactions to the current hook execution
354
 
        revision."""
355
 
        assert not self.revision
356
 
        self.cursor.execute(
357
 
            'insert into hooks (hook, date) values (?, ?)',
358
 
            (name or sys.argv[0],
359
 
             datetime.datetime.utcnow().isoformat()))
360
 
        self.revision = self.cursor.lastrowid
361
 
        try:
362
 
            yield self.revision
363
 
            self.revision = None
364
 
        except:
365
 
            self.flush(False)
366
 
            self.revision = None
367
 
            raise
368
 
        else:
369
 
            self.flush()
370
 
 
371
 
    def flush(self, save=True):
372
 
        if save:
373
 
            self.conn.commit()
374
 
        elif self._closed:
375
 
            return
376
 
        else:
377
 
            self.conn.rollback()
378
 
 
379
 
    def _init(self):
380
 
        self.cursor.execute('''
381
 
            create table if not exists kv (
382
 
               key text,
383
 
               data text,
384
 
               primary key (key)
385
 
               )''')
386
 
        self.cursor.execute('''
387
 
            create table if not exists kv_revisions (
388
 
               key text,
389
 
               revision integer,
390
 
               data text,
391
 
               primary key (key, revision)
392
 
               )''')
393
 
        self.cursor.execute('''
394
 
            create table if not exists hooks (
395
 
               version integer primary key autoincrement,
396
 
               hook text,
397
 
               date text
398
 
               )''')
399
 
        self.conn.commit()
400
 
 
401
 
    def gethistory(self, key, deserialize=False):
402
 
        self.cursor.execute(
403
 
            '''
404
 
            select kv.revision, kv.key, kv.data, h.hook, h.date
405
 
            from kv_revisions kv,
406
 
                 hooks h
407
 
            where kv.key=?
408
 
             and kv.revision = h.version
409
 
            ''', [key])
410
 
        if deserialize is False:
411
 
            return self.cursor.fetchall()
412
 
        return map(_parse_history, self.cursor.fetchall())
413
 
 
414
 
    def debug(self, fh=sys.stderr):
415
 
        self.cursor.execute('select * from kv')
416
 
        pprint.pprint(self.cursor.fetchall(), stream=fh)
417
 
        self.cursor.execute('select * from kv_revisions')
418
 
        pprint.pprint(self.cursor.fetchall(), stream=fh)
419
 
 
420
 
 
421
 
def _parse_history(d):
422
 
    return (d[0], d[1], json.loads(d[2]), d[3],
423
 
            datetime.datetime.strptime(d[-1], "%Y-%m-%dT%H:%M:%S.%f"))
424
 
 
425
 
 
426
 
class HookData(object):
427
 
    """Simple integration for existing hook exec frameworks.
428
 
 
429
 
    Records all unit information, and stores deltas for processing
430
 
    by the hook.
431
 
 
432
 
    Sample::
433
 
 
434
 
       from charmhelper.core import hookenv, unitdata
435
 
 
436
 
       changes = unitdata.HookData()
437
 
       db = unitdata.kv()
438
 
       hooks = hookenv.Hooks()
439
 
 
440
 
       @hooks.hook
441
 
       def config_changed():
442
 
           # View all changes to configuration
443
 
           for changed, (prev, cur) in changes.conf.items():
444
 
               print('config changed', changed,
445
 
                     'previous value', prev,
446
 
                     'current value',  cur)
447
 
 
448
 
           # Get some unit specific bookeeping
449
 
           if not db.get('pkg_key'):
450
 
               key = urllib.urlopen('https://example.com/pkg_key').read()
451
 
               db.set('pkg_key', key)
452
 
 
453
 
       if __name__ == '__main__':
454
 
           with changes():
455
 
               hook.execute()
456
 
 
457
 
    """
458
 
    def __init__(self):
459
 
        self.kv = kv()
460
 
        self.conf = None
461
 
        self.rels = None
462
 
 
463
 
    @contextlib.contextmanager
464
 
    def __call__(self):
465
 
        from charmhelpers.core import hookenv
466
 
        hook_name = hookenv.hook_name()
467
 
 
468
 
        with self.kv.hook_scope(hook_name):
469
 
            self._record_charm_version(hookenv.charm_dir())
470
 
            delta_config, delta_relation = self._record_hook(hookenv)
471
 
            yield self.kv, delta_config, delta_relation
472
 
 
473
 
    def _record_charm_version(self, charm_dir):
474
 
        # Record revisions.. charm revisions are meaningless
475
 
        # to charm authors as they don't control the revision.
476
 
        # so logic dependnent on revision is not particularly
477
 
        # useful, however it is useful for debugging analysis.
478
 
        charm_rev = open(
479
 
            os.path.join(charm_dir, 'revision')).read().strip()
480
 
        charm_rev = charm_rev or '0'
481
 
        revs = self.kv.get('charm_revisions', [])
482
 
        if charm_rev not in revs:
483
 
            revs.append(charm_rev.strip() or '0')
484
 
            self.kv.set('charm_revisions', revs)
485
 
 
486
 
    def _record_hook(self, hookenv):
487
 
        data = hookenv.execution_environment()
488
 
        self.conf = conf_delta = self.kv.delta(data['conf'], 'config')
489
 
        self.rels = rels_delta = self.kv.delta(data['rels'], 'rels')
490
 
        self.kv.set('env', dict(data['env']))
491
 
        self.kv.set('unit', data['unit'])
492
 
        self.kv.set('relid', data.get('relid'))
493
 
        return conf_delta, rels_delta
494
 
 
495
 
 
496
 
class Record(dict):
497
 
 
498
 
    __slots__ = ()
499
 
 
500
 
    def __getattr__(self, k):
501
 
        if k in self:
502
 
            return self[k]
503
 
        raise AttributeError(k)
504
 
 
505
 
 
506
 
class DeltaSet(Record):
507
 
 
508
 
    __slots__ = ()
509
 
 
510
 
 
511
 
Delta = collections.namedtuple('Delta', ['previous', 'current'])
512
 
 
513
 
 
514
 
_KV = None
515
 
 
516
 
 
517
 
def kv():
518
 
    global _KV
519
 
    if _KV is None:
520
 
        _KV = Storage()
521
 
    return _KV