diff --git a/configdb/client/cli.py b/configdb/client/cli.py index d26e55be76217131861d1312e7d6a3df572db1f6..cb30dbc127f7a3816c08f3cacae1e4cebe268acf 100644 --- a/configdb/client/cli.py +++ b/configdb/client/cli.py @@ -29,14 +29,21 @@ def read_from_file(value): with open(value, 'r') as fd: return fd.read() +class JsonPlain(object): + @staticmethod + def pprint(value): + """Pretty-print value as JSON.""" + print json.dumps(value, sort_keys=True, indent=4) -def pprint(value): - """Pretty-print value as JSON.""" - print json.dumps(value, sort_keys=True, indent=4) class Action(object): + view = JsonPlain + @classmethod + def set_view(cls, viewclass): + cls.view = viewclass + def parse_field_value(self, field, value): type_map = { 'string': str, @@ -154,7 +161,19 @@ class GetAction(Action): def run(self, conn, entity, args): obj = conn.get(entity.name, args._name) - pprint(obj) + self.view.pprint(obj) + +class TimestampAction(Action): + """Get the timestamp of last update on an entity.""" + + name = 'timestamp' + + def __init__(self, entity, parser): + pass + + def run(self, conn, entity, args): + print conn.get_timestamp(entity.name) + class FindAction(Action): @@ -184,7 +203,7 @@ class FindAction(Action): def run(self, conn, entity, args): objects = conn.find(entity.name, self._get_query(entity, args)) - pprint(objects) + self.view.pprint(objects) class DeleteAction(Action): @@ -204,19 +223,24 @@ class AuditAction(object): name = 'audit' descr = 'query audit logs' - + view = JsonPlain + AUDIT_ATTRS = ('entity', 'object', 'user', 'op') + @classmethod + def set_view(cls, viewclass): + cls.view = viewclass + def __init__(self, parser): for attr in self.AUDIT_ATTRS: parser.add_argument('--' + attr) - + def run(self, conn, entity, args): query = dict((x, getattr(args, x)) for x in self.AUDIT_ATTRS if getattr(args, x)) log.info('audit query: %s', query) - pprint(list(conn.get_audit(query))) + self.view.pprint(list(conn.get_audit(query))) class DumpAction(object): """Dumps all configdb data to a file""" @@ -255,6 +279,7 @@ class Parser(object): UpdateAction, GetAction, FindAction, + TimestampAction ) toplevel_actions = ( @@ -295,6 +320,8 @@ class Parser(object): help='use with --help for additional help', dest='_entity_name') for entity in self.schema.get_entities(): + if entity.name in self.schema.sys_schema_tables: + continue subparser = subparsers.add_parser(entity.name, help=entity.description) self._init_subparser(entity, subparser) diff --git a/configdb/client/connection.py b/configdb/client/connection.py index aa99d4bd18aa75d3eb84203e1fd65c7cf62b0a47..281ca6b28bcf6c9f02acfa7aa2749e9c987f70a4 100644 --- a/configdb/client/connection.py +++ b/configdb/client/connection.py @@ -204,7 +204,7 @@ class Connection(object): data = data.to_net() return [self._from_net(entity_name, x) for x in self._call(entity_name, 'find', data=data)] - + def get(self, entity_name, object_name): """Fetch a single object. @@ -251,3 +251,16 @@ class Connection(object): """ return self._request(['audit'], query) + def get_timestamp(self, entity_name): + """Fetch the timestamp of last update to a entity. + + Args: + entity_name: string, entity name + + Returns: + A String representing the unix epoch of last update + + Raises: + None + """ + return self._call(entity_name, 'timestamp') diff --git a/configdb/db/db_api.py b/configdb/db/db_api.py index f65356fc3c900bc6318e45ab5c671c44993ef6cf..3522c5b72151de68ce615d13d58929883b9802d7 100644 --- a/configdb/db/db_api.py +++ b/configdb/db/db_api.py @@ -1,4 +1,7 @@ import functools + +from time import time + from configdb import exceptions from configdb.db import acl from configdb.db import query @@ -14,6 +17,14 @@ def with_session(fn): return _with_session_wrapper +def with_timestamp(fn): + @functools.wraps(fn) + def _with_timestamp_wrapper(self, session, entity_name, *args, **kwargs): + res = fn(self, session, entity_name, *args, **kwargs) + self.update_timestamp(session, entity_name) + return res + return _with_timestamp_wrapper + def _field_validation(field, value): return field.validate(value) @@ -102,7 +113,26 @@ class AdmDbApi(object): else: setattr(obj, field_name, new_value) + def update_timestamp(self, session, entity_name): + if entity_name in self.schema.sys_schema_tables: + #Avoid updating timestamp for tables that are not part of the schema. + return True + + data = {'name': entity_name, 'ts': time() } + ts = self.schema.get_entity('__timestamp') + data = self._unpack(ts, data) + + obj = self.db.get_by_name('__timestamp', entity_name, session) + if obj: + diffs = self._diff_object(ts, obj, data) + self._apply_diff(ts, obj, diffs, session) + session.add(obj) + else: + obj = self.db.create('__timestamp', data, session) + return True + @with_session + @with_timestamp def update(self, session, entity_name, object_name, data, auth_context): """Update an existing instance.""" ent = self.schema.get_entity(entity_name) @@ -125,6 +155,7 @@ class AdmDbApi(object): return True @with_session + @with_timestamp def delete(self, session, entity_name, object_name, auth_context): """Delete an instance.""" ent = self.schema.get_entity(entity_name) @@ -141,6 +172,7 @@ class AdmDbApi(object): return True @with_session + @with_timestamp def create(self, session, entity_name, data, auth_context): """Create a new instance.""" ent = self.schema.get_entity(entity_name) @@ -160,6 +192,8 @@ class AdmDbApi(object): return True + + @with_session def get(self, session, entity_name, object_name, auth_context): """Return a specific instance.""" @@ -207,3 +241,21 @@ class AdmDbApi(object): self.schema.acl_check_entity(ent, auth_context, 'r', None) return self.db.get_audit(query, session) + + @with_session + def get_timestamp(self, session, entity_name, auth_context): + """Get the timestamp of the last update on an entity. + + """ + ent = self.schema.get_entity(entity_name) + if not ent: + raise exceptions.NotFound(entity_name) + + self.schema.acl_check_entity(ent, auth_context, 'r', None) + + obj = self.db.get_by_name('__timestamp', entity_name, session) + if not obj: + raise exceptions.NotFound(entity_name) + return obj + + diff --git a/configdb/db/schema.py b/configdb/db/schema.py index fe6c097256f9c699e065153f95ffbc0fce31c39a..311d74576a306e27a62e52865a66fd6ffd2fd786 100644 --- a/configdb/db/schema.py +++ b/configdb/db/schema.py @@ -160,12 +160,13 @@ class Schema(object): A schema consists of multiple Entities, each having multiple Fields. The definition is loaded from JSON-encoded data. """ - + sys_schema_tables = [ '__timestamp'] def __init__(self, json_data): self.entities = {} schema_data = json.loads(json_data) + self._add_timestamp() for tname, tdata in schema_data.iteritems(): - if not ENTITY_NAME_PATTERN.match(tname): + if not ENTITY_NAME_PATTERN.match(tname) or tname in self.sys_schema_tables: raise exceptions.SchemaError( 'invalid entity name "%s"' % tname) self.entities[tname] = Entity(tname, tdata) @@ -173,6 +174,10 @@ class Schema(object): self.default_acl = acl.AclMixin() self.default_acl.set_acl(DEFAULT_ACL) + def _add_timestamp(self): + ts_schema = {'name': { 'type': 'string', 'size': 32}, 'ts': {'type': 'number', 'nullable': False } } + self.entities['__timestamp'] = Entity('__timestamp', ts_schema) + def _relation_check(self): """Verify that all relations reference existing entities.""" seen = set() @@ -190,7 +195,7 @@ class Schema(object): return self.entities.get(name) def get_entities(self): - return self.entities.itervalues() + return self.entities.itervalues() def acl_check_fields(self, entity, fields, auth_context, op, obj): """Authorize an operation on the fields of an instance.""" diff --git a/configdb/server/wsgiapp.py b/configdb/server/wsgiapp.py index e6f818bc9a6df402e88b41995c48973e7d3d02d4..0301015e18fb810cf4568da2e8dc708958419cbc 100644 --- a/configdb/server/wsgiapp.py +++ b/configdb/server/wsgiapp.py @@ -156,6 +156,15 @@ def find(class_name): def delete(class_name, object_name): return g.api.delete(class_name, object_name, g.auth_ctx) +@api_app.route('/timestamp/<class_name>') +@authenticate +@json_response +def ts(class_name): + try: + res = g.api.get_timestamp(class_name, g.auth_ctx) + return str(res.ts) + except exceptions.NotFound: + return "0" @api_app.route('/audit', methods=['POST']) @authenticate diff --git a/configdb/tests/db_api_test_base.py b/configdb/tests/db_api_test_base.py index 72e17121e05d295c73e521d8b098e38e1c6bf0ba..d06a4bfe135a1d0eb3594f5b5c11c547e009ceae 100644 --- a/configdb/tests/db_api_test_base.py +++ b/configdb/tests/db_api_test_base.py @@ -204,7 +204,7 @@ class DbApiTestBase(object): # self.api.get('host', 'utz', self.ctx).name) # self.assertRaises(exceptions.NotFound, # self.api.get, 'host', 'obz', self.ctx) - + def test_update_modify_relation(self): self.assertTrue( self.api.update('host', 'obz', {'roles': ['role2']}, self.ctx)) @@ -347,3 +347,16 @@ class DbApiTestBase(object): self.api.get_audit, {'entity': 'private', 'op': 'create'}, auth_ctx) + + def test_timestamp_is_updated(self): + result = self.api.update('host', 'obz', {'ip': '2.3.4.5'}, self.ctx) + self.assertTrue(result) + ts1 = self.api.get_timestamp('host', self.ctx).ts + self.assertTrue(ts1 != 0) + result = self.api.update('host', 'obz', {'ip': '3.3.3.5'}, self.ctx) + self.assertTrue(result) + ts2 = self.api.get_timestamp('host', self.ctx).ts + self.assertTrue(ts2 > ts1) + + def test_timestamp_for_non_updated_entity(self): + self.assertRaises(ValueError, self.api.get_timestamp('role',self.ctx)) diff --git a/configdb/tests/test_schema.py b/configdb/tests/test_schema.py index 780fd33de4276af5757d548165277674dd027bb1..193ce212f830de07bb6d13bdc8d6928786d3062d 100644 --- a/configdb/tests/test_schema.py +++ b/configdb/tests/test_schema.py @@ -8,7 +8,7 @@ class SchemaTest(TestBase): def test_empty_schema_ok(self): s = schema.Schema('{}') - self.assertEquals({}, s.entities) + self.assertEquals(s.sys_schema_tables, s.entities.keys()) def test_entity_without_name(self): data = """