~felipe-alfaro-gmail/charms/xenial/neutron-api/trunk

« back to all changes in this revision

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

  • Committer: Felipe Alfaro Solana
  • Date: 2017-04-05 19:45:40 UTC
  • Revision ID: felipe.alfaro@gmail.com-20170405194540-85i0nhnp98ipob0y
Neutron API charm.

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