Merge lp:~abentley/charmworld/config-storage into lp:~juju-jitsu/charmworld/trunk

Proposed by Aaron Bentley
Status: Superseded
Proposed branch: lp:~abentley/charmworld/config-storage
Merge into: lp:~juju-jitsu/charmworld/trunk
Diff against target: 1499 lines (+786/-153)
21 files modified
charmworld/jobs/ingest.py (+13/-6)
charmworld/jobs/tests/test_cstat.py (+2/-1)
charmworld/jobs/tests/test_scan.py (+34/-2)
charmworld/models.py (+54/-11)
charmworld/routes.py (+2/-1)
charmworld/search.py (+49/-15)
charmworld/testing/__init__.py (+9/-4)
charmworld/testing/data/sample_charm/config.yaml (+0/-1)
charmworld/testing/factory.py (+3/-1)
charmworld/tests/test_models.py (+67/-9)
charmworld/tests/test_search.py (+1/-1)
charmworld/views/api.py (+1/-1)
charmworld/views/charms.py (+3/-0)
charmworld/views/tests/test_charms.py (+63/-7)
migrations/migrate.py (+169/-49)
migrations/test_migrate.py (+225/-15)
migrations/versions/005_no_op.py (+0/-5)
migrations/versions/006_no_op.py (+0/-5)
migrations/versions/007_no_op.py (+0/-6)
migrations/versions/010_remap_options.py (+17/-0)
migrations/versions/tests/test_migrations.py (+74/-13)
To merge this branch: bzr merge lp:~abentley/charmworld/config-storage
Reviewer Review Type Date Requested Status
Curtis Hovey (community) code Approve
Review via email: mp+171801@code.launchpad.net

This proposal has been superseded by a proposal from 2013-07-05.

Description of the change

Change the internal representation of config options, so that the user-supplied option names are used as values, not as keys.

To post a comment you must log in.
296. By Aaron Bentley

Merged trunk into config-storage.

Revision history for this message
Curtis Hovey (sinzui) wrote :

We talked on IRC to clarify that some charms are not in a sane state and migration needs to handle cases where config is not a dict with a single item of key, sub-dict. We might want migrate to log in the future. We know from the 007 migration problem seen with production's bogus charms that the juju-log is capturing stdout.

review: Approve (code)
297. By Aaron Bentley

Merged slow-migrations into config-storage.

298. By Aaron Bentley

Fix test failures by adding error translation to delete_charm.

299. By Aaron Bentley

Merged slow-migrations into config-storage.

300. By Aaron Bentley

Fix failing test.

301. By Aaron Bentley

Convert options migration to an exodus.

302. By Aaron Bentley

Merged slow-migrations into config-storage.

303. By Aaron Bentley

Remove unused code.

304. By Aaron Bentley

Merged slow-migrations-2 into config-storage.

305. By Aaron Bentley

Merged slow-migrations-2 into config-storage.

Unmerged revisions

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'charmworld/jobs/ingest.py'
--- charmworld/jobs/ingest.py 2013-07-01 14:45:02 +0000
+++ charmworld/jobs/ingest.py 2013-07-05 14:15:30 +0000
@@ -32,11 +32,14 @@
32 get_branch_info,32 get_branch_info,
33 parse_date,33 parse_date,
34)34)
35from charmworld.models import getconnection35from charmworld.models import (
36from charmworld.models import getdb36 CharmFileSet,
37from charmworld.models import getfs37 CharmSource,
38from charmworld.models import CharmFileSet38 getconnection,
39from charmworld.models import CharmSource39 getdb,
40 getfs,
41 options_to_storage,
42)
40from charmworld.search import (43from charmworld.search import (
41 ElasticSearchClient,44 ElasticSearchClient,
42 SearchServiceNotAvailable,45 SearchServiceNotAvailable,
@@ -491,7 +494,11 @@
491 config_raw = cfile.read()494 config_raw = cfile.read()
492495
493 try:496 try:
494 config = quote_yaml(yaml.load(config_raw))497 config_yaml = yaml.load(config_raw)
498 if 'options' in config_yaml:
499 config_yaml['options'] = options_to_storage(
500 config_yaml['options'])
501 config = quote_yaml(config_yaml)
495 except Exception, exc:502 except Exception, exc:
496 raise IngestError(503 raise IngestError(
497 'Invalid charm config yaml. %s: %s' % (504 'Invalid charm config yaml. %s: %s' % (
498505
=== modified file 'charmworld/jobs/tests/test_cstat.py'
--- charmworld/jobs/tests/test_cstat.py 2013-07-01 15:57:20 +0000
+++ charmworld/jobs/tests/test_cstat.py 2013-07-05 14:15:30 +0000
@@ -33,7 +33,8 @@
33 def send(request):33 def send(request):
34 response = Response()34 response = Response()
35 if ('start' in request.url):35 if ('start' in request.url):
36 response._content = '[["2010-12-24", 4], ["2010-12-25", 1]]'36 response._content = ('[["2010-12-24", 4],'
37 ' ["2010-12-25", 1]]')
37 else:38 else:
38 response._content = '[[25]]'39 response._content = '[[25]]'
39 return response40 return response
4041
=== modified file 'charmworld/jobs/tests/test_scan.py'
--- charmworld/jobs/tests/test_scan.py 2013-06-24 15:45:29 +0000
+++ charmworld/jobs/tests/test_scan.py 2013-07-05 14:15:30 +0000
@@ -34,8 +34,40 @@
34 # 'foo.bar' is replaced with 'foo%2Ebar'.34 # 'foo.bar' is replaced with 'foo%2Ebar'.
35 self.assertFalse('dotted.name' in self.charm)35 self.assertFalse('dotted.name' in self.charm)
36 self.assertEqual('foo', self.charm['dotted%2Ename'])36 self.assertEqual('foo', self.charm['dotted%2Ename'])
37 self.assertFalse('foo.bar' in self.charm['config']['options'])37
38 self.assertEqual('baz', self.charm['config']['options']['foo%2Ebar'])38 def test_options_format(self):
39 del self.charm['config']
40 scan_charm(self.charm, self.db, self.fs, self.log)
41 expected = [
42 {
43 'name': 'source',
44 'default': 'lp:charmworld',
45 'type': 'string',
46 'description': 'The bzr branch to pull the charmworld source'
47 ' from.'
48 },
49 {
50 'name': 'revno',
51 'default': -1,
52 'type': 'int',
53 'description': 'The revno of the bzr branch to use. -1 for'
54 ' current tip.'
55 },
56 {
57 'name': 'execute-ingest-every',
58 'default': 15,
59 'type': 'int',
60 'description': 'The amount of time (in minutes) that the'
61 ' ingest tasks for charmworld are executed'
62 },
63 {
64 'name': 'error-email',
65 'default': '',
66 'type': 'string',
67 'description': 'Address to mail errors to.'
68 }
69 ]
70 self.assertItemsEqual(expected, self.charm['config']['options'])
3971
40 def test_short_interface_specification(self):72 def test_short_interface_specification(self):
41 # Charms can specify the interfaces they require or provide73 # Charms can specify the interfaces they require or provide
4274
=== modified file 'charmworld/models.py'
--- charmworld/models.py 2013-07-01 14:45:02 +0000
+++ charmworld/models.py 2013-07-05 14:15:30 +0000
@@ -544,7 +544,11 @@
544544
545 The dict is parsed from the config_raw property's YAML.545 The dict is parsed from the config_raw property's YAML.
546 """546 """
547 return self._representation['config']547 config = self._representation['config']
548 if config is not None and 'options' in config:
549 config = dict(config)
550 config['options'] = storage_to_options(config['options'])
551 return config
548552
549 @property553 @property
550 def config_raw(self):554 def config_raw(self):
@@ -557,7 +561,7 @@
557 @property561 @property
558 def options(self):562 def options(self):
559 """The charm's configuration options."""563 """The charm's configuration options."""
560 config = self._representation['config']564 config = self.config
561 if config is not None and 'options' in config:565 if config is not None and 'options' in config:
562 return config['options']566 return config['options']
563 else:567 else:
@@ -969,8 +973,10 @@
969973
970class CharmSource:974class CharmSource:
971975
972 def __init__(self, db, index_client):976 def __init__(self, db, index_client, collection=None):
973 self.db = db977 if collection is None:
978 collection = db.charms
979 self.collection = collection
974 self.index_client = index_client980 self.index_client = index_client
975981
976 @classmethod982 @classmethod
@@ -984,16 +990,20 @@
984 return cls(request.db, request.index_client)990 return cls(request.db, request.index_client)
985991
986 def sync_index(self):992 def sync_index(self):
987 for charm in self.db.charms.find():993 for charm in self.collection.find():
988 yield charm['_id']994 yield charm['_id']
989 self.index_client.index_charm(charm)995 self.index_client.index_charm(charm)
990996
991 def save(self, charm):997 def save(self, charm):
992 self.db.charms.save(charm)998 self.collection.save(charm)
993 self.index_client.index_charm(charm)999 try:
1000 self.index_client.index_charm(charm)
1001 except:
1002 self.index_client.delete_charm(charm['_id'])
1003 raise
9941004
995 def get_store_charms(self):1005 def get_store_charms(self):
996 return self.db.charms.find({'store_url': {'$exists': True}})1006 return self.collection.find({'store_url': {'$exists': True}})
9971007
998 @staticmethod1008 @staticmethod
999 def exclude_obsolete(query):1009 def exclude_obsolete(query):
@@ -1002,7 +1012,7 @@
1002 def find_mia_charms(self):1012 def find_mia_charms(self):
1003 query = self.exclude_obsolete(1013 query = self.exclude_obsolete(
1004 {"store_data.errors": {"$exists": True}})1014 {"store_data.errors": {"$exists": True}})
1005 return (Charm(data) for data in self.db.charms.find(query))1015 return (Charm(data) for data in self.collection.find(query))
10061016
1007 def find_proof_error_charms(self, sub_only):1017 def find_proof_error_charms(self, sub_only):
1008 query = {"proof.e": {"$exists": True}, "promulgated": True}1018 query = {"proof.e": {"$exists": True}, "promulgated": True}
@@ -1010,13 +1020,16 @@
1010 query['subordinate'] = True1020 query['subordinate'] = True
1011 query = self.exclude_obsolete(query)1021 query = self.exclude_obsolete(query)
1012 sort = [("series", pymongo.DESCENDING), ("name", pymongo.ASCENDING)]1022 sort = [("series", pymongo.DESCENDING), ("name", pymongo.ASCENDING)]
1013 return (Charm(data) for data in self.db.charms.find(query, sort=sort))1023 return (Charm(data) for data in self.collection.find(query, sort=sort))
10141024
1015 def _get_all(self, charm_id):1025 def _get_all(self, charm_id):
1016 """"Return all stored values for a given charm."""1026 """"Return all stored values for a given charm."""
1017 yield self.db.charms.find_one({'_id': charm_id})1027 yield self.collection.find_one({'_id': charm_id})
1018 yield self.index_client.get(charm_id)1028 yield self.index_client.get(charm_id)
10191029
1030 def create_collection(self):
1031 self.collection.database.create_collection(self.collection.name)
1032
10201033
1021def acquire_session_secret(database):1034def acquire_session_secret(database):
1022 """Return a secret to use for AuthTkt sessions.1035 """Return a secret to use for AuthTkt sessions.
@@ -1073,3 +1086,33 @@
1073 logger.exception('Unable to index charm.')1086 logger.exception('Unable to index charm.')
1074 else:1087 else:
1075 logger.info('Syncing %s to Elasticsearch' % charm_id)1088 logger.info('Syncing %s to Elasticsearch' % charm_id)
1089
1090
1091def options_to_storage(options):
1092 """Convert options, as retrieved from config.yaml, into a list.
1093
1094 This format avoids user-defined keys, which are problematic in
1095 elasticsearch.
1096 """
1097 storage = []
1098 for key, value in options.items():
1099 option = {'name': key}
1100 option.update(value)
1101 storage.append(option)
1102 return storage
1103
1104
1105def storage_to_options(storage):
1106 """Convert options, as retrieved from mongodb, into a dict.
1107
1108 This format has the same structure as config.yaml.
1109 """
1110 options = {}
1111 for option in storage:
1112 new_option = dict(option)
1113 del new_option['name']
1114 value = options.setdefault(option['name'], new_option)
1115 if value is not new_option:
1116 raise ValueError('Option name "%s" occurs multiple times.' %
1117 option['name'])
1118 return options
10761119
=== modified file 'charmworld/routes.py'
--- charmworld/routes.py 2013-07-02 12:28:28 +0000
+++ charmworld/routes.py 2013-07-05 14:15:30 +0000
@@ -89,7 +89,8 @@
89 # up and running. They may need to be consulted when this is changed to a89 # up and running. They may need to be consulted when this is changed to a
90 # later API version.90 # later API version.
91 config.add_route('api_latest_single', '/api/latest/{endpoint}')91 config.add_route('api_latest_single', '/api/latest/{endpoint}')
92 config.add_view('charmworld.views.api.API2', route_name='api_latest_single')92 config.add_view('charmworld.views.api.API2',
93 route_name='api_latest_single')
93 config.add_route('api_latest', '/api/latest/{endpoint}/*remainder')94 config.add_route('api_latest', '/api/latest/{endpoint}/*remainder')
94 config.add_view('charmworld.views.api.API2', route_name='api_latest')95 config.add_view('charmworld.views.api.API2', route_name='api_latest')
9596
9697
=== modified file 'charmworld/search.py'
--- charmworld/search.py 2013-07-01 14:45:02 +0000
+++ charmworld/search.py 2013-07-05 14:15:30 +0000
@@ -30,7 +30,7 @@
30 'name': 10,30 'name': 10,
31 'summary': 5,31 'summary': 5,
32 'description': 3,32 'description': 3,
33 'config.options.*.description': None,33 'config.options.description': None,
34 'relations': None,34 'relations': None,
35}35}
3636
@@ -100,6 +100,14 @@
100 self._client = client100 self._client = client
101 self.index_name = index_name101 self.index_name = index_name
102102
103 def exists(self):
104 try:
105 self.get_status()
106 except IndexMissing:
107 return False
108 else:
109 return True
110
103 def create_index(self):111 def create_index(self):
104 self._client.create_index(self.index_name)112 self._client.create_index(self.index_name)
105113
@@ -146,13 +154,24 @@
146 """154 """
147 exact_index = ['categories', 'name', 'owner', 'i_provides',155 exact_index = ['categories', 'name', 'owner', 'i_provides',
148 'i_requires', 'series', 'store_url']156 'i_requires', 'series', 'store_url']
157 charm_properties = dict(
158 (name, {'type': 'string', 'index': 'not_analyzed'})
159 for name in exact_index)
160 charm_properties['config'] = {
161 'properties': {
162 'options': {
163 'properties': {
164 'default': {'type': 'string'},
165 'description': {'type': 'string'},
166 }
167 }
168 }
169 }
170
149 with translate_error():171 with translate_error():
150 self._client.put_mapping(self.index_name, 'charm', {172 self._client.put_mapping(self.index_name, 'charm', {
151 'charm': {173 'charm': {
152 'properties': dict(174 'properties': charm_properties,
153 (name, {'type': 'string', 'index': 'not_analyzed'})
154 for name in exact_index
155 )
156 }175 }
157 })176 })
158177
@@ -166,6 +185,10 @@
166 self._client.index(self.index_name, 'charm', charm, charm_id,185 self._client.index(self.index_name, 'charm', charm, charm_id,
167 refresh=True)186 refresh=True)
168187
188 def delete_charm(self, charm_id):
189 with translate_error():
190 self._client.delete(self.index_name, 'charm', charm_id)
191
169 def index_charms(self, charms):192 def index_charms(self, charms):
170 if len(charms) == 0:193 if len(charms) == 0:
171 return194 return
@@ -311,6 +334,10 @@
311 hits = self._unlimited_search(dsl, limit)334 hits = self._unlimited_search(dsl, limit)
312 return [hit['_source'] for hit in hits['hits']['hits']]335 return [hit['_source'] for hit in hits['hits']['hits']]
313336
337 def get_status(self):
338 with translate_error():
339 return self._client.status(index=self.index_name)['indices']
340
314 def search(self, terms, valid_charms_only=True):341 def search(self, terms, valid_charms_only=True):
315 # Avoid circular imports.342 # Avoid circular imports.
316 from charmworld.models import Charm343 from charmworld.models import Charm
@@ -323,8 +350,7 @@
323 dsl = self._get_filtered(dsl, {}, None, valid_charms_only)350 dsl = self._get_filtered(dsl, {}, None, valid_charms_only)
324351
325 with Timer() as timer:352 with Timer() as timer:
326 with translate_error():353 status = self.get_status()
327 status = self._client.status(index=self.index_name)['indices']
328 for real_index in self.get_aliased():354 for real_index in self.get_aliased():
329 break355 break
330 else:356 else:
@@ -395,6 +421,9 @@
395 result_r.setdefault(interface, []).append(payload)421 result_r.setdefault(interface, []).append(payload)
396 return result_r, result_p,422 return result_r, result_p,
397423
424 def get_related_client(self, name):
425 return self.__class__(self._client, name)
426
398 def get_aliasable_client(self, name=None):427 def get_aliasable_client(self, name=None):
399 """Return a client that can be aliased to this one.428 """Return a client that can be aliased to this one.
400429
@@ -403,7 +432,18 @@
403 """432 """
404 if name is None:433 if name is None:
405 name = '%s-%d' % (self.index_name, random.randint(1, 99999))434 name = '%s-%d' % (self.index_name, random.randint(1, 99999))
406 return self.__class__(self._client, name)435 return self.get_related_client(name)
436
437 @contextmanager
438 def replacement(self, name=None):
439 copy = self.get_aliasable_client(name)
440 copy.create_index()
441 try:
442 copy.put_mapping()
443 yield copy
444 except:
445 copy.delete_index()
446 raise
407447
408 def create_replacement(self, name=None, charms=None):448 def create_replacement(self, name=None, charms=None):
409 """Create a replacement for this index.449 """Create a replacement for this index.
@@ -417,10 +457,7 @@
417 index will be used.457 index will be used.
418 :return: The ElasticSearchClient for the supplied mapping.458 :return: The ElasticSearchClient for the supplied mapping.
419 """459 """
420 copy = self.get_aliasable_client(name)460 with self.replacement(name) as copy:
421 copy.create_index()
422 try:
423 copy.put_mapping()
424 if charms is None:461 if charms is None:
425 try:462 try:
426 charms = self.api_search(valid_charms_only=False)463 charms = self.api_search(valid_charms_only=False)
@@ -428,9 +465,6 @@
428 charms = []465 charms = []
429 copy.index_charms(charms)466 copy.index_charms(charms)
430 return copy467 return copy
431 except:
432 copy.delete_index()
433 raise
434468
435469
436def reindex(index_client, charms=None):470def reindex(index_client, charms=None):
437471
=== modified file 'charmworld/testing/__init__.py'
--- charmworld/testing/__init__.py 2013-06-24 15:08:43 +0000
+++ charmworld/testing/__init__.py 2013-07-05 14:15:30 +0000
@@ -60,9 +60,9 @@
60 self.addCleanup(context.__exit__, None, None, None)60 self.addCleanup(context.__exit__, None, None, None)
61 return result61 return result
6262
63 def use_index_client(self, put_mapping=True):63 def use_index_client(self, put_mapping=True, aliased=False):
64 self.index_client = self.use_context(64 self.index_client = self.use_context(
65 temp_index_client(put_mapping=put_mapping))65 temp_index_client(put_mapping=put_mapping, aliased=aliased))
66 return self.index_client66 return self.index_client
6767
68 @contextmanager68 @contextmanager
@@ -223,13 +223,18 @@
223223
224224
225@contextmanager225@contextmanager
226def temp_index_client(name='temp_index', put_mapping=True):226def temp_index_client(name='temp_index', put_mapping=True, aliased=False):
227 client = ElasticSearchClient.from_settings(INI, name)227 client = ElasticSearchClient.from_settings(INI, name)
228 try:228 try:
229 client.delete_index()229 client.delete_index()
230 except IndexMissing:230 except IndexMissing:
231 pass231 pass
232 client.create_index()232 if aliased:
233 alias_client = client.get_aliasable_client()
234 alias_client.create_index()
235 client.update_aliased(alias_client.index_name, [])
236 else:
237 client.create_index()
233 try:238 try:
234 if put_mapping:239 if put_mapping:
235 client.put_mapping()240 client.put_mapping()
236241
=== modified file 'charmworld/testing/data/sample_charm/config.yaml'
--- charmworld/testing/data/sample_charm/config.yaml 2013-02-07 17:26:59 +0000
+++ charmworld/testing/data/sample_charm/config.yaml 2013-07-05 14:15:30 +0000
@@ -15,4 +15,3 @@
15 type: string15 type: string
16 default: ""16 default: ""
17 description: "Address to mail errors to."17 description: "Address to mail errors to."
18 foo.bar: baz
1918
=== modified file 'charmworld/testing/factory.py'
--- charmworld/testing/factory.py 2013-07-01 14:45:02 +0000
+++ charmworld/testing/factory.py 2013-07-05 14:15:30 +0000
@@ -24,8 +24,9 @@
24 process_charm,24 process_charm,
25)25)
26from charmworld.models import (26from charmworld.models import (
27 CharmFileSet,
27 getfs,28 getfs,
28 CharmFileSet,29 options_to_storage,
29)30)
3031
3132
@@ -185,6 +186,7 @@
185 'description': 'The interval between script runs.'186 'description': 'The interval between script runs.'
186 },187 },
187 }188 }
189 options = options_to_storage(options)
188 if files is None:190 if files is None:
189 files = {191 files = {
190 'readme': {192 'readme': {
191193
=== modified file 'charmworld/tests/test_models.py'
--- charmworld/tests/test_models.py 2013-07-01 14:45:02 +0000
+++ charmworld/tests/test_models.py 2013-07-05 14:15:30 +0000
@@ -14,6 +14,10 @@
14from os.path import join14from os.path import join
1515
16from mock import patch16from mock import patch
17from pyelasticsearch.exceptions import (
18 ElasticHttpError,
19 ElasticHttpNotFoundError,
20)
1721
18from charmworld.models import (22from charmworld.models import (
19 acquire_session_secret,23 acquire_session_secret,
@@ -22,7 +26,9 @@
22 CharmFileSet,26 CharmFileSet,
23 CharmSource,27 CharmSource,
24 find_charms,28 find_charms,
29 options_to_storage,
25 QAData,30 QAData,
31 storage_to_options,
26 sync_index,32 sync_index,
27 UserMgr,33 UserMgr,
28)34)
@@ -437,11 +443,10 @@
437443
438 def test_config(self):444 def test_config(self):
439 # The config property is a dict of charm options.445 # The config property is a dict of charm options.
440 config = {'options': {'mode': 'fast'}}446 config = {'options': {'key': {'default': 'value', 'type': 'string'}}}
441 charm_data = factory.get_charm_json()447 charm_data = factory.get_charm_json(options=config['options'])
442 charm_data['config'] = config
443 charm = Charm(charm_data)448 charm = Charm(charm_data)
444 self.assertIs(config, charm.config)449 self.assertEqual(config, charm.config)
445 # The default is a dict with a single key named 'options' set to450 # The default is a dict with a single key named 'options' set to
446 # an empty dict.451 # an empty dict.
447 charm = Charm({})452 charm = Charm({})
@@ -460,12 +465,10 @@
460465
461 def test_options(self):466 def test_options(self):
462 # The options property is a dict from the charm's config.467 # The options property is a dict from the charm's config.
463 options = {'mode': 'fast'}468 options = {'key': {'default': 'value', 'type': 'string'}}
464 config = {'options': options}469 charm_data = factory.get_charm_json(options=options)
465 charm_data = factory.get_charm_json()
466 charm_data['config'] = config
467 charm = Charm(charm_data)470 charm = Charm(charm_data)
468 self.assertIs(options, charm.options)471 self.assertEqual(options, charm.options)
469 # The default is a dict.472 # The default is a dict.
470 charm = Charm({})473 charm = Charm({})
471 self.assertEqual({}, charm.options)474 self.assertEqual({}, charm.options)
@@ -1040,3 +1043,58 @@
1040 messages = [r.getMessage() for r in handler.buffer]1043 messages = [r.getMessage() for r in handler.buffer]
1041 self.assertIn('Unable to index charm.', messages)1044 self.assertIn('Unable to index charm.', messages)
1042 self.assertEqual(1, len(self.index_client.api_search()))1045 self.assertEqual(1, len(self.index_client.api_search()))
1046
1047 def test_save_deletes_on_error(self):
1048 self.use_index_client()
1049 source = CharmSource.from_request(self)
1050 source.save({'_id': 'a', 'b': {}})
1051 with self.assertRaises(ElasticHttpError):
1052 source.save({'_id': 'a', 'b': 'c'})
1053 with self.assertRaises(ElasticHttpNotFoundError):
1054 self.index_client.get('a')
1055
1056
1057class TestOptionsStorage(TestCase):
1058
1059 yaml = {
1060 'foo': {
1061 'default': 'bar',
1062 'type': 'string',
1063 'description': 'Hello.'
1064 },
1065 'baz': {
1066 'default': 42,
1067 'type': 'int',
1068 'description': 'Hello.'
1069 },
1070 }
1071 storage = [{
1072 'name': 'baz',
1073 'default': 42,
1074 'type': 'int',
1075 'description': 'Hello.'
1076 }, {
1077 'name': 'foo',
1078 'default': 'bar',
1079 'type': 'string',
1080 'description': 'Hello.'
1081 }]
1082
1083 def test_options_to_storage(self):
1084 self.assertItemsEqual(self.storage, options_to_storage(self.yaml))
1085
1086 def test_storage_to_options(self):
1087 self.assertEqual(self.yaml, storage_to_options(self.storage))
1088
1089 def test_storage_to_options_duplicate_key(self):
1090 with self.assertRaises(ValueError) as exc_context:
1091 storage_to_options([{'name': 'same'}, {'name': 'same'}])
1092 self.assertEqual('Option name "same" occurs multiple times.',
1093 str(exc_context.exception))
1094
1095 def test_roundtrip(self):
1096 self.assertEqual(
1097 self.yaml, storage_to_options(options_to_storage(self.yaml)))
1098 self.assertItemsEqual(
1099 self.storage,
1100 options_to_storage(storage_to_options(self.storage)))
10431101
=== modified file 'charmworld/tests/test_search.py'
--- charmworld/tests/test_search.py 2013-07-01 14:45:02 +0000
+++ charmworld/tests/test_search.py 2013-07-05 14:15:30 +0000
@@ -173,7 +173,7 @@
173 elif key in ('provides', 'requires'):173 elif key in ('provides', 'requires'):
174 query = query.values()[0].values()[0]174 query = query.values()[0].values()[0]
175 elif key == 'config':175 elif key == 'config':
176 query = query.values()[0].values()[0]['description']176 query = query.values()[0][0]['description']
177 elif key == 'store_data':177 elif key == 'store_data':
178 query = query['digest']178 query = query['digest']
179 elif key == 'files':179 elif key == 'files':
180180
=== modified file 'charmworld/views/api.py'
--- charmworld/views/api.py 2013-07-02 12:28:28 +0000
+++ charmworld/views/api.py 2013-07-05 14:15:30 +0000
@@ -60,7 +60,7 @@
60 message = 'This API version is no longer supported.'60 message = 'This API version is no longer supported.'
61 return Response(61 return Response(
62 message,62 message,
63 headerlist = [63 headerlist=[
64 ('Access-Control-Allow-Origin', '*'),64 ('Access-Control-Allow-Origin', '*'),
65 ('Access-Control-Allow-Headers', 'X-Requested-With'),65 ('Access-Control-Allow-Headers', 'X-Requested-With'),
66 ('Content-Type', 'text/plain'),66 ('Content-Type', 'text/plain'),
6767
=== modified file 'charmworld/views/charms.py'
--- charmworld/views/charms.py 2013-07-03 21:33:20 +0000
+++ charmworld/views/charms.py 2013-07-05 14:15:30 +0000
@@ -165,7 +165,10 @@
165165
166166
167def _json_charm(charm):167def _json_charm(charm):
168 charm_config = charm.config
168 charm = dict(charm._representation)169 charm = dict(charm._representation)
170 # Reformat config into old internal representation.
171 charm['config'] = charm_config
169 charm_keys = set(charm.keys())172 charm_keys = set(charm.keys())
170 for k in SANITIZE.intersection(charm_keys):173 for k in SANITIZE.intersection(charm_keys):
171 del charm[k]174 del charm[k]
172175
=== modified file 'charmworld/views/tests/test_charms.py'
--- charmworld/views/tests/test_charms.py 2013-07-03 21:33:20 +0000
+++ charmworld/views/tests/test_charms.py 2013-07-05 14:15:30 +0000
@@ -1,6 +1,7 @@
1# Copyright 2012, 2013 Canonical Ltd. This software is licensed under the1# Copyright 2012, 2013 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4from datetime import date
4from logging import getLogger5from logging import getLogger
56
6from charmworld.jobs.ingest import (7from charmworld.jobs.ingest import (
@@ -16,7 +17,6 @@
16from charmworld.testing import ViewTestBase17from charmworld.testing import ViewTestBase
17from charmworld.testing import WebTestBase18from charmworld.testing import WebTestBase
18from charmworld.utils import quote_key19from charmworld.utils import quote_key
19from charmworld.views import SANITIZE
20from charmworld.views.api import API220from charmworld.views.api import API2
21from charmworld.views.charms import (21from charmworld.views.charms import (
22 _charm_view,22 _charm_view,
@@ -35,7 +35,65 @@
35 quality_edit,35 quality_edit,
36 series_collection,36 series_collection,
37)37)
38import json38
39
40DISTRO_CHARM_DATA = {
41 'name': 'foo',
42 'series': 'precise',
43 'owner': 'bar',
44 'maintainer': 'John Doe <jdoe@example.com>',
45 'summary': 'Remote terminal classroom over https',
46 'description': 'foobar',
47 'store_url': 'cs:precise/foo-1',
48 'bzr_branch': '',
49 'promulgated': True,
50 'branch_deleted': False,
51 'categories': [],
52 'is_featured': False,
53 'subordinate': False,
54 'requires': {'database': {'interface': 'mongodb'}},
55 'provides': {'website': {'interface': 'https'}},
56 'config': {
57 'options': {
58 'script-interval': {
59 'default': 5,
60 'type': 'int',
61 'description': 'The interval between script runs.'
62 }
63 }
64 },
65 'ensemble': 'formula',
66 'downloads': 0,
67 'downloads_in_past_30_days': 0,
68 'files': {
69 'readme': {
70 'subdir': '',
71 'filename': 'README',
72 },
73 'install': {
74 'subdir': 'hooks',
75 'filename': 'install',
76 },
77 },
78 'tests': {},
79 'test_results': {},
80 'proof': {
81 'w': [
82 ' no README file',
83 ' (install:31) - hook accesses EC2 metadata service'
84 ' directly'
85 ]
86 },
87 'date_created': '2012-01-01',
88 'error': '',
89 'qa': None,
90 'last_change': {
91 'committer': u'John Doe <jdoe@example.com>',
92 'message': u'maintainer',
93 'revno': 12,
94 'created': 1343082725.06
95 }
96}
3997
4098
41class TestCharms(ViewTestBase):99class TestCharms(ViewTestBase):
@@ -368,17 +426,15 @@
368 self.enable_routes()426 self.enable_routes()
369 ignore, charm = factory.makeCharm(427 ignore, charm = factory.makeCharm(
370 self.db, name='foo', owner='bar', series='precise',428 self.db, name='foo', owner='bar', series='precise',
371 promulgated=True)429 description='foobar', promulgated=True,
430 date_created=date(2012, 1, 1))
372 request = self.getRequest()431 request = self.getRequest()
373 request.matchdict = {432 request.matchdict = {
374 'charm': 'foo',433 'charm': 'foo',
375 'series': 'precise',434 'series': 'precise',
376 }435 }
377 charm_data = dict(Charm(charm)._representation)
378 for key in SANITIZE.intersection(charm_data.keys()):
379 del charm_data[key]
380 response = distro_charm_json(request)436 response = distro_charm_json(request)
381 self.assertEqual(charm_data, json.loads(response.body))437 self.assertEqual(DISTRO_CHARM_DATA, response.json_body)
382438
383 def test_valid_others(self):439 def test_valid_others(self):
384 charm = factory.makeCharm(self.db, files={})[1]440 charm = factory.makeCharm(self.db, files={})[1]
385441
=== modified file 'migrations/migrate.py'
--- migrations/migrate.py 2013-06-04 15:36:45 +0000
+++ migrations/migrate.py 2013-07-05 14:15:30 +0000
@@ -7,6 +7,7 @@
77
8"""8"""
9import argparse9import argparse
10from collections import namedtuple
10from datetime import datetime11from datetime import datetime
11import imp12import imp
12from os import listdir13from os import listdir
@@ -15,8 +16,13 @@
15from os.path import join16from os.path import join
16import re17import re
1718
18from charmworld.models import getconnection19from pymongo.errors import OperationFailure
19from charmworld.models import getdb20
21from charmworld.models import (
22 CharmSource,
23 getconnection,
24 getdb,
25)
20from charmworld.search import ElasticSearchClient26from charmworld.search import ElasticSearchClient
21from charmworld.utils import get_ini27from charmworld.utils import get_ini
2228
@@ -45,6 +51,22 @@
45 return s51 return s
4652
4753
54class MigrationBeforeExodus(Exception):
55 pass
56
57
58class MissingExodusCollection(Exception):
59 pass
60
61
62class MissingExodusIndex(Exception):
63 pass
64
65
66class MultipleExodus(Exception):
67 pass
68
69
48class DataStore(object):70class DataStore(object):
49 """Communicate with the data store to determine version status."""71 """Communicate with the data store to determine version status."""
5072
@@ -68,6 +90,19 @@
68 else:90 else:
69 return None91 return None
7092
93 def get_current_version(self, init):
94 current_version = self.current_version
95
96 if current_version is not None:
97 return current_version
98
99 if init:
100 # We want to auto init the database anyway.
101 self.version_datastore()
102 return 0
103 else:
104 raise Exception('Data store is not versioned')
105
71 def update_version(self, version):106 def update_version(self, version):
72 """Update the version number in the data store."""107 """Update the version number in the data store."""
73 self.db.migration_version.save({108 self.db.migration_version.save({
@@ -93,6 +128,9 @@
93 })128 })
94129
95130
131VersionInfo = namedtuple('VersionInfo', ['num', 'filename', 'is_exodus'])
132
133
96class Versions(object):134class Versions(object):
97135
98 def __init__(self, path):136 def __init__(self, path):
@@ -103,7 +141,7 @@
103 self.versions_dir = path141 self.versions_dir = path
104 # Create temporary list of files, allowing skipped version numbers.142 # Create temporary list of files, allowing skipped version numbers.
105 files = listdir(path)143 files = listdir(path)
106 versions = {}144 self.versions = {}
107145
108 for name in files:146 for name in files:
109 if not name.endswith('.py'):147 if not name.endswith('.py'):
@@ -113,80 +151,149 @@
113 match = FILENAME_WITH_VERSION.match(name)151 match = FILENAME_WITH_VERSION.match(name)
114 if match:152 if match:
115 num = int(match.group(1))153 num = int(match.group(1))
116 versions[num] = name154 self.versions[num] = name
117 else:155 else:
118 pass # Must be a helper file or something, let's ignore it.156 pass # Must be a helper file or something, let's ignore it.
119157
120 self.versions = {}158 def iter_versions(self, current):
121 version_numbers = versions.keys()159 return (i for i in sorted(self.versions.items()) if i[0] > current)
122 version_numbers.sort()160
123161 def version_path(self, description, version):
124 self.version_indexes = version_numbers162 filename = "{0}_{1}.py".format(
125 for idx in version_numbers:163 str(version).zfill(3),
126 self.versions[idx] = versions[idx]164 str_to_filename(description)
127165 )
128 def create_new_version_file(self, description):166 path = join(self.versions_dir, filename)
167 return path, filename
168
169 def create_new_version_file(self, description, content=None):
129 """Create Python files for new version"""170 """Create Python files for new version"""
130 version = self.latest + 1171 version = self.latest + 1
131172
132 if not description:173 if not description:
133 raise ValueError('Please provide a short migration description.')174 raise ValueError('Please provide a short migration description.')
134175 if content is None:
135 filename = "{0}_{1}.py".format(176 content = SCRIPT_TEMPLATE.format(description=description)
136 str(version).zfill(3),177 path, filename = self.version_path(description, version)
137 str_to_filename(description)178 with open(path, 'w') as new_migration:
138 )179 new_migration.write(content)
139
140 filepath = join(self.versions_dir, filename)
141 with open(filepath, 'w') as new_migration:
142 new_migration.write(
143 SCRIPT_TEMPLATE.format(description=description))
144180
145 # Update our current store of version data.181 # Update our current store of version data.
146 self.versions[version] = filename182 self.versions[version] = filename
147 self.version_indexes.append(version)
148183
149 return filename184 return filename
150185
151 @property186 @property
152 def latest(self):187 def latest(self):
153 """:returns: Latest version in Collection"""188 """:returns: Latest version in Collection"""
154 return self.version_indexes[-1] if len(self.version_indexes) > 0 else 0189 return max(self.versions.keys()) if len(self.versions) > 0 else 0
155190
156 def run_migration(self, db, index_client, module_name):191 def get_exodus(self, filename):
157 module = imp.load_source(192 module = self.import_version(filename)
193 return getattr(module, 'exodus_update', None)
194
195 def list_pending(self, current):
196 return [VersionInfo(n, f, self.get_exodus(f) is not None) for n, f in
197 self.iter_versions(current)]
198
199 def _check_exodus(self, version_info):
200 exodus_seen = False
201 migration_seen = False
202 for num, filename, is_exodus in version_info:
203 if is_exodus:
204 if migration_seen:
205 raise MigrationBeforeExodus(
206 'There are migrations scheduled before an exodus.')
207 if exodus_seen:
208 raise MultipleExodus(
209 'There are multiple exoduses scheduled.')
210 exodus_seen = True
211 else:
212 migration_seen = True
213
214 def import_version(self, module_name):
215 return imp.load_source(
158 module_name.strip('.py'),216 module_name.strip('.py'),
159 join(self.versions_dir, module_name))217 join(self.versions_dir, module_name))
218
219 def run_migration(self, db, index_client, module_name):
220 module = self.import_version(module_name)
160 getattr(module, 'upgrade')(db, index_client)221 getattr(module, 'upgrade')(db, index_client)
161222
223 def get_pending_source(self, source, number):
224 db = source.collection.database
225 pending_suffix = '_pending_%03d' % number
226 pending_name = source.index_client.index_name + pending_suffix
227 pending_index = source.index_client.get_aliasable_client(
228 pending_name)
229 pending_collection = db[source.collection.name + pending_suffix]
230 return CharmSource(db, pending_index, pending_collection)
231
232 def prepare_exodus(self, source, init=False):
233 db = source.collection.database
234 datastore = DataStore(db)
235 current_version = datastore.get_current_version(init)
236 pending_migrations = self.list_pending(current_version)
237 self._check_exodus(pending_migrations)
238 if len(pending_migrations) == 0 or not pending_migrations[0].is_exodus:
239 return None
240 number, filename, is_exodus = pending_migrations[0]
241 pending = self.get_pending_source(source, number)
242 temp_collection = db[pending.collection.name + 'temp']
243 with source.index_client.replacement() as temp_index:
244 db.create_collection(temp_collection.name)
245 temp = CharmSource(db, temp_index, temp_collection)
246 self.get_exodus(filename)(source, temp)
247 aliased = pending.index_client.get_aliased()
248 temp_collection.rename(pending.collection.name, dropTarget=True)
249 pending.index_client.update_aliased(temp_index.index_name, aliased)
250 return pending
251
252 def finalize_exodus(self, source, version_number):
253 pending = self.get_pending_source(source, version_number)
254 aliased = source.index_client.get_aliased()
255 pending_aliased = pending.index_client.get_aliased()
256 if len(pending_aliased) == 0:
257 raise MissingExodusIndex(
258 'Exodus index "%s" does not exist.' %
259 pending.index_client.index_name)
260 if len(pending_aliased) != 1:
261 raise AssertionError('More than index aliased to pending.')
262 try:
263 pending.collection.rename(source.collection.name,
264 dropTarget=True)
265 except OperationFailure as e:
266 if e.message.endswith('source namespace does not exist'):
267 raise MissingExodusCollection(
268 'Exodus collection "%s" does not exist.' %
269 pending.collection.name)
270 raise
271 source.index_client.update_aliased(
272 pending_aliased[0], aliased)
273 for alias_name in aliased:
274 source.index_client.get_related_client(alias_name).delete_index()
275
162 def upgrade(self, datastore, index_client, init):276 def upgrade(self, datastore, index_client, init):
163 """Run `upgrade` methods for required version files.277 """Run `upgrade` methods for required version files.
164278
165 :param datastore: An instance of DataStore279 :param datastore: An instance of DataStore
166280
167 """281 """
168 current_version = datastore.current_version282 current_version = datastore.get_current_version(init)
169283 pending_versions = self.list_pending(current_version)
170 if current_version is None:284 self._check_exodus(pending_versions)
171 if init:285
172 # We want to auto init the database anyway.286 if len(pending_versions) == 0:
173 datastore.version_datastore()287 return None
174 current_version = 0288
289 for num, module_name, is_exodus in pending_versions:
290 if is_exodus:
291 source = CharmSource(datastore.db, index_client)
292 self.finalize_exodus(source, pending_versions[0][0])
175 else:293 else:
176 raise Exception('Data store is not versioned')294 self.run_migration(datastore.db, index_client, module_name)
177295 datastore.update_version(num)
178 if current_version >= self.latest:296 return num
179 # Nothing to do here. All migrations processed already.
180 return None
181
182 while current_version < self.latest:
183 # Let's get processing.
184 next_version = current_version + 1
185 module_name = self.versions[next_version]
186 self.run_migration(datastore.db, index_client, module_name)
187 current_version = next_version
188 datastore.update_version(current_version)
189 return current_version
190297
191298
192def parse_args():299def parse_args():
@@ -214,6 +321,13 @@
214 default=False,321 default=False,
215 help='Auto init the database if not already init.')322 help='Auto init the database if not already init.')
216323
324 parser_prepare_upgrade = subparsers.add_parser('prepare-upgrade')
325 parser_prepare_upgrade.set_defaults(func=Commands.prepare_upgrade)
326 parser_prepare_upgrade.add_argument(
327 '-i', '--init', action='store_true',
328 default=False,
329 help='Auto init the database if not already init.')
330
217 args = parser.parse_args()331 args = parser.parse_args()
218 return args332 return args
219333
@@ -253,6 +367,12 @@
253 print "Created new migration: {0}".format(filename)367 print "Created new migration: {0}".format(filename)
254368
255 @classmethod369 @classmethod
370 def prepare_upgrade(cls, ini, args):
371 source = CharmSource.from_settings(ini)
372 migrations = Versions(ini['migrations'])
373 migrations.prepare_exodus(source, args.init)
374
375 @classmethod
256 def upgrade(cls, ini, args):376 def upgrade(cls, ini, args):
257 """Upgrade the data store to the latest available migration."""377 """Upgrade the data store to the latest available migration."""
258 ds = cls.get_datastore(ini)378 ds = cls.get_datastore(ini)
259379
=== modified file 'migrations/test_migrate.py'
--- migrations/test_migrate.py 2013-06-04 15:36:45 +0000
+++ migrations/test_migrate.py 2013-07-05 14:15:30 +0000
@@ -5,16 +5,29 @@
5from os.path import join5from os.path import join
6from os.path import exists6from os.path import exists
7import pymongo7import pymongo
8from tempfile import mkdtemp8from textwrap import dedent
99
10from charmworld.models import getconnection10from charmworld.models import (
11from charmworld.models import getdb11 CharmSource,
12from charmworld.testing import TestCase12 getconnection,
13 getdb,
14)
15from charmworld.testing import (
16 MongoTestBase,
17 TestCase,
18 temp_dir
19)
13from charmworld.testing.factory import random_string20from charmworld.testing.factory import random_string
14from charmworld.utils import get_ini21from charmworld.utils import get_ini
1522
16from migrate import DataStore23from migrate import (
17from migrate import Versions24 DataStore,
25 MigrationBeforeExodus,
26 MissingExodusCollection,
27 MissingExodusIndex,
28 MultipleExodus,
29 Versions,
30)
1831
19INI = get_ini()32INI = get_ini()
20MONGO_URL = environ.get('TEST_MONGODB')33MONGO_URL = environ.get('TEST_MONGODB')
@@ -92,7 +105,7 @@
92class TestVersions(TestCase):105class TestVersions(TestCase):
93 """The local versions of the files."""106 """The local versions of the files."""
94 def setUp(self):107 def setUp(self):
95 self.tmpdir = mkdtemp()108 self.tmpdir = self.use_context(temp_dir())
96 self.version_files = make_version_files(self.tmpdir, 4)109 self.version_files = make_version_files(self.tmpdir, 4)
97 self.versions = Versions(self.tmpdir)110 self.versions = Versions(self.tmpdir)
98111
@@ -102,9 +115,9 @@
102115
103 def test_latest_wo_migrations(self):116 def test_latest_wo_migrations(self):
104 """"""117 """"""
105 tmpdir = mkdtemp()118 with temp_dir() as tmpdir:
106 versions = Versions(tmpdir)119 versions = Versions(tmpdir)
107 self.assertEqual(versions.latest, 0)120 self.assertEqual(versions.latest, 0)
108121
109 def test_latest_version(self):122 def test_latest_version(self):
110 """latest returns the number of the newest revision."""123 """latest returns the number of the newest revision."""
@@ -128,6 +141,34 @@
128 # And this new file should exist in our versions directory141 # And this new file should exist in our versions directory
129 self.assertTrue(exists(join(self.tmpdir, self.versions.versions[5])))142 self.assertTrue(exists(join(self.tmpdir, self.versions.versions[5])))
130143
144 def test_list_pending(self):
145 self.versions.create_new_version_file('foo', content=dedent("""
146 def exodus_update():
147 pass
148 """))
149 self.versions.create_new_version_file('foo', content=dedent("""
150 def update():
151 pass
152 """))
153 self.assertEqual([
154 (5, '005_foo.py', True),
155 (6, '006_foo.py', False),
156 ], self.versions.list_pending(4))
157
158 def test_check_exodus(self):
159 self.versions._check_exodus([])
160 with self.assertRaises(MigrationBeforeExodus):
161 self.versions._check_exodus([
162 (1, '001_foo.py', False), (2, '002_bar.py', True)])
163 self.versions._check_exodus([
164 (1, '001_foo.py', True), (2, '002_bar.py', False)])
165 with self.assertRaises(MultipleExodus):
166 self.versions._check_exodus([
167 (1, '001_foo.py', True), (2, '002_bar.py', True)])
168 self.versions._check_exodus([
169 (1, '001_foo.py', False), (2, '002_bar.py', False)])
170
171
131172
132class TestVersionUpgrades(TestCase):173class TestVersionUpgrades(TestCase):
133 """Functional testing of the actual upgrade process."""174 """Functional testing of the actual upgrade process."""
@@ -136,8 +177,9 @@
136 'mongo.url': MONGO_URL,177 'mongo.url': MONGO_URL,
137 })178 })
138 self.db = getdb(self.connection, 'juju_test')179 self.db = getdb(self.connection, 'juju_test')
180 self.use_index_client(aliased=True)
139 self.ds = DataStore(self.db)181 self.ds = DataStore(self.db)
140 tmpdir = mkdtemp()182 tmpdir = self.use_context(temp_dir())
141 make_version_files(tmpdir, 4, runnable=True)183 make_version_files(tmpdir, 4, runnable=True)
142 self.version = Versions(tmpdir)184 self.version = Versions(tmpdir)
143185
@@ -155,9 +197,8 @@
155197
156 def test_upgrade_wo_migrations(self):198 def test_upgrade_wo_migrations(self):
157 """Upgrade runs silently if no migrations are created yet."""199 """Upgrade runs silently if no migrations are created yet."""
158 self.use_index_client()
159 self.ds.version_datastore()200 self.ds.version_datastore()
160 tmpdir = mkdtemp()201 tmpdir = self.use_context(temp_dir())
161 versions = Versions(tmpdir)202 versions = Versions(tmpdir)
162 self.assertEqual(versions.upgrade(self.ds, self.index_client, False),203 self.assertEqual(versions.upgrade(self.ds, self.index_client, False),
163 None)204 None)
@@ -165,7 +206,6 @@
165 def test_upgrade_succeeds(self):206 def test_upgrade_succeeds(self):
166 """upgrade will run the methods and we get a version of 4."""207 """upgrade will run the methods and we get a version of 4."""
167 self.ds.version_datastore()208 self.ds.version_datastore()
168 self.use_index_client()
169 # We should get 4 versions done. The upgrade returns 4209 # We should get 4 versions done. The upgrade returns 4
170 last_version = self.version.upgrade(self.ds, self.index_client, False)210 last_version = self.version.upgrade(self.ds, self.index_client, False)
171 self.assertEqual(4, last_version)211 self.assertEqual(4, last_version)
@@ -175,10 +215,180 @@
175215
176 def test_upgrade_succeeds_from_unversioned(self):216 def test_upgrade_succeeds_from_unversioned(self):
177 """Forcing init no an unversioned db will upgrade successfully."""217 """Forcing init no an unversioned db will upgrade successfully."""
178 self.use_index_client()
179 last_version = self.version.upgrade(self.ds, self.index_client, True)218 last_version = self.version.upgrade(self.ds, self.index_client, True)
180 self.assertEqual(4, last_version)219 self.assertEqual(4, last_version)
181220
182 # and we can query the db for the revision of 4221 # and we can query the db for the revision of 4
183 self.assertEqual(4, self.ds.current_version)222 self.assertEqual(4, self.ds.current_version)
184223
224 def create_exodus_environment(self, index_client=None):
225 if index_client is None:
226 index_client = self.index_client
227 versions = Versions(self.use_context(temp_dir()))
228 versions.create_new_version_file(
229 random_string(20), content=dedent("""
230 def exodus_update(source, target):
231 pass
232 """))
233 source = CharmSource(self.db, index_client)
234 return versions, source
235
236 def test_upgrade_errors_if_no_exodus_db(self):
237 versions, source = self.create_exodus_environment()
238 aliased, = source.index_client.get_aliased()
239 pending = versions.get_pending_source(source, 1)
240 pending_alias = self.create_aliased(pending.index_client)
241 self.addCleanup(pending_alias.delete_index)
242 pending.index_client.wait_for_startup()
243 with self.assertRaises(MissingExodusCollection):
244 versions.upgrade(self.ds, source.index_client, True)
245 self.assertEqual([aliased], source.index_client.get_aliased())
246
247 def test_upgrade_errors_if_no_exodus_index(self):
248 versions, source = self.create_exodus_environment()
249 pending = versions.get_pending_source(source, 1)
250 pending.create_collection()
251 self.addCleanup(pending.collection.drop)
252 with self.assertRaises(MissingExodusIndex):
253 versions.upgrade(self.ds, self.index_client, True)
254 self.assertIn(pending.collection.name,
255 pending.collection.database.collection_names())
256
257 def create_aliased(self, index_client):
258 alias_client = index_client.get_aliasable_client()
259 alias_client.create_index()
260 index_client.update_aliased(alias_client.index_name, [])
261 return alias_client
262
263 def create_exodus_success_environment(self):
264 versions, source = self.create_exodus_environment()
265 pending = versions.get_pending_source(source, 1)
266 pending.create_collection()
267 pending_alias = self.create_aliased(pending.index_client)
268 return source, pending, versions
269
270 def test_finalize_exodus_success(self):
271 source, pending, versions = self.create_exodus_success_environment()
272 aliased = source.index_client.get_aliased()
273 pending.save({'_id': 'a', 'message': 'hello'})
274 versions.finalize_exodus(source, 1)
275 for charm in source._get_all('a'):
276 self.assertEqual({'_id': 'a', 'message': 'hello'}, charm)
277 self.assertNotIn(pending.collection.name,
278 pending.collection.database.collection_names())
279 for aliased_name in aliased:
280 aliased_client = source.index_client.get_related_client(
281 aliased_name)
282 self.assertFalse(aliased_client.exists())
283
284 def test_upgrade_succeeds_with_exodus(self):
285 source, pending, versions = self.create_exodus_success_environment()
286 pending.save({'_id': 'a', 'message': 'hello'})
287 versions.upgrade(self.ds, source.index_client, True)
288
289 def test_upgrade_errors_on_non_initial_exodus(self):
290 versions = Versions(self.use_context(temp_dir()))
291 versions.create_new_version_file(random_string(20))
292 versions.create_new_version_file(
293 random_string(20), content=dedent("""
294 def exodus_update(source, target):
295 pass
296 """))
297 with self.assertRaises(MigrationBeforeExodus):
298 versions.upgrade(self.ds, self.index_client, True)
299
300
301class TestExodus(MongoTestBase):
302
303 def setUp(self):
304 super(TestExodus, self).setUp()
305 self.use_index_client(aliased=True)
306 self.source = CharmSource.from_request(self)
307 self.versions = Versions(self.use_context(temp_dir()))
308
309 def test_prepare_exodus_checks_validity(self):
310 self.versions.create_new_version_file(
311 random_string(20), content=dedent("""
312 def update(source, target):
313 pass
314 """))
315 self.versions.create_new_version_file(
316 random_string(20), content=dedent("""
317 def exodus_update(source, target):
318 pass
319 """))
320 with self.assertRaises(MigrationBeforeExodus):
321 self.versions.prepare_exodus(self.source, init=True)
322
323 def test_prepare_exodus_initializes(self):
324 with self.assertRaises(Exception) as exc_context:
325 self.versions.prepare_exodus(self.source, init=False)
326 self.assertEqual('Data store is not versioned',
327 str(exc_context.exception))
328 self.versions.prepare_exodus(self.source, init=True)
329 ds = DataStore(self.source.collection.database)
330 self.assertEqual(0, ds.current_version)
331
332 def test_prepare_exodus_acquires_pending(self):
333 pending = self.versions.prepare_exodus(self.source, init=True)
334 self.assertIs(None, pending)
335 self.versions.create_new_version_file('foo', content=dedent("""
336 def exodus_update(source, target):
337 pass
338 """))
339 pending = self.versions.prepare_exodus(self.source)
340 self.addCleanup(pending.index_client.delete_index)
341 self.assertIsInstance(pending, CharmSource)
342
343 def test_prepare_exodus_runs_exodus_update(self):
344 self.versions.create_new_version_file('foo', content=dedent("""
345 def exodus_update(source, target):
346 target.save({'_id': 'a', 'message': 'hello'})
347 """))
348 pending = self.versions.prepare_exodus(self.source, init=True)
349 self.addCleanup(pending.index_client.delete_index)
350 for charm in pending._get_all('a'):
351 self.assertEqual({'_id': 'a', 'message': 'hello'}, charm)
352
353 def test_exodus_update_runs_against_temp_but_generates_pending(self):
354 self.versions.create_new_version_file('foo', content=dedent("""
355 def exodus_update(source, target):
356 if target.index_client.index_name == 'temp_index_pending_001':
357 raise AssertionError('Used pending name!')
358 if target.collection.name == 'charms_pending_001':
359 raise AssertionError('Used pending name!')
360 """))
361 pending = self.versions.prepare_exodus(self.source, init=True)
362 self.addCleanup(pending.index_client.delete_index)
363 self.assertEqual('temp_index_pending_001',
364 pending.index_client.index_name)
365 self.assertEqual('charms_pending_001', pending.collection.name)
366
367 def test_no_leftovers(self):
368 db = self.source.collection.database
369 index_client = self.source.index_client._client
370 index_keys = index_client.status()['indices'].keys()
371 index_count = len(index_client.status()['indices'])
372 DataStore(db).version_datastore()
373 self.source.create_collection()
374 collection_names = db.collection_names()
375 self.versions.create_new_version_file('foo', content=dedent("""
376 def exodus_update(source, target):
377 pass
378 """))
379 self.versions.prepare_exodus(self.source, init=True)
380 self.versions.finalize_exodus(self.source, 1)
381 self.assertEqual(collection_names, db.collection_names())
382 self.assertEqual(index_count, len(index_client.status()['indices']))
383
384 def test_suffix_of_source(self):
385 self.versions.create_new_version_file('foo', content=dedent("""
386 import re
387
388 def exodus_update(source, target):
389 target_name = target.index_client.index_name
390 match = re.match('(.*)-([0-9]*)', target_name)
391 if match.group(1) != source.index_client.index_name:
392 raise AssertionError('Poor name: ' + target_name)
393 """))
394 self.versions.prepare_exodus(self.source, init=True)
185395
=== removed file 'migrations/versions/005_no_op.py'
--- migrations/versions/005_no_op.py 2013-06-04 18:22:19 +0000
+++ migrations/versions/005_no_op.py 1970-01-01 00:00:00 +0000
@@ -1,5 +0,0 @@
1# Copyright 2013 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4def upgrade(db, index_client):
5 """005 failed to remove icons from charms, so now disabled."""
60
=== removed file 'migrations/versions/006_no_op.py'
--- migrations/versions/006_no_op.py 2013-06-05 18:30:47 +0000
+++ migrations/versions/006_no_op.py 1970-01-01 00:00:00 +0000
@@ -1,5 +0,0 @@
1# Copyright 2013 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4def upgrade(db, index_client):
5 """006 lacked a corresponding ingest update, so now disabled."""
60
=== removed file 'migrations/versions/007_no_op.py'
--- migrations/versions/007_no_op.py 2013-06-11 19:13:07 +0000
+++ migrations/versions/007_no_op.py 1970-01-01 00:00:00 +0000
@@ -1,6 +0,0 @@
1# Copyright 2013 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4
5def upgrade(db, index_client):
6 """007 was renamed to 008 because of a deployment issue in production"""
70
=== added file 'migrations/versions/010_remap_options.py'
--- migrations/versions/010_remap_options.py 1970-01-01 00:00:00 +0000
+++ migrations/versions/010_remap_options.py 2013-07-05 14:15:30 +0000
@@ -0,0 +1,17 @@
1# Copyright 2013 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).
3
4from charmworld.models import (
5 options_to_storage,
6)
7
8
9def exodus_update(source, target):
10 for charm in source.collection.find():
11 config = charm.get('config')
12 if config is not None and 'options' in config:
13 config['options'] = options_to_storage(config['options'])
14 try:
15 target.save(charm)
16 except Exception as e:
17 print e
018
=== modified file 'migrations/versions/tests/test_migrations.py'
--- migrations/versions/tests/test_migrations.py 2013-06-11 19:18:47 +0000
+++ migrations/versions/tests/test_migrations.py 2013-07-05 14:15:30 +0000
@@ -1,34 +1,95 @@
1# Copyright 2012, 2013 Canonical Ltd. This software is licensed under the1# Copyright 2012, 2013 Canonical Ltd. This software is licensed under the
2# GNU Affero General Public License version 3 (see the file LICENSE).2# GNU Affero General Public License version 3 (see the file LICENSE).
33
4from pyelasticsearch.exceptions import (
5 ElasticHttpNotFoundError,
6)
4from charmworld.models import CharmSource7from charmworld.models import CharmSource
5from charmworld.testing import MongoTestBase8from charmworld.testing import MongoTestBase
6from migrations.migrate import Versions9from migrations.migrate import Versions
710
811
9def run_migration(db, index_client, module_name):12class MigrationTestBase(MongoTestBase):
10 versions = Versions('migrations/versions/')13
11 versions.run_migration(db, index_client, module_name)14 def setUp(self):
1215 super(MigrationTestBase, self).setUp()
1316 self.versions = Versions('migrations/versions/')
14class TestMigration004(MongoTestBase):17
18
19class TestMigration004(MigrationTestBase):
1520
16 def test_migration(self):21 def test_migration(self):
17 self.use_index_client()22 self.use_index_client()
18 self.db.create_collection('charm-errors')23 self.db.create_collection('charm-errors')
19 run_migration(self.db, self.index_client, '004_remove_charm_errors.py')24 self.versions.run_migration(self.db, self.index_client,
25 '004_remove_charm_errors.py')
20 self.assertNotIn('charm-errors', self.db.collection_names())26 self.assertNotIn('charm-errors', self.db.collection_names())
2127
2228
23class TestMigration008(MongoTestBase):29class TestMigration008(MigrationTestBase):
2430
25 def test_migration(self):31 def test_migration(self):
26 self.use_index_client()32 self.use_index_client()
27 source = CharmSource.from_request(self)33 source = CharmSource.from_request(self)
28 source.save({'_id': 'a', 'icon': 'asdf', 'asdf': 'asdf'})34 source.save({'_id': 'a', 'icon': 'asdf', 'asdf': 'asdf'})
29 source.save({'_id': 'b', 'icon': 'asdf', 'asdf': 'asdf'})35 source.save({'_id': 'b', 'icon': 'asdf', 'asdf': 'asdf'})
30 run_migration(self.db, self.index_client, '008_delete_icon_field.py')36 self.versions.run_migration(self.db, self.index_client,
31 for charm in source._get_all('a'):37 '008_delete_icon_field.py')
32 self.assertNotIn('icon', charm)38 for charm in source._get_all('a'):
33 for charm in source._get_all('b'):39 self.assertNotIn('icon', charm)
34 self.assertNotIn('icon', charm)40 for charm in source._get_all('b'):
41 self.assertNotIn('icon', charm)
42
43
44class TestMigration010(MigrationTestBase):
45
46 def test_migration(self):
47 self.use_index_client()
48 source = CharmSource.from_request(self)
49 source.save({
50 '_id': 'a',
51 'config': None,
52 })
53 source.save({
54 '_id': 'b',
55 'config': {
56 'options': {},
57 },
58 })
59 source.save({
60 '_id': 'c',
61 'config': {
62 'options': {'foo': {'default': 'bar'}},
63 },
64 })
65 source.save({'_id': 'd'})
66 # This is not acceptable to Elasticsearch, because config is a list.
67 self.db.charms.save({
68 '_id': 'e',
69 'config': ['a'],
70 })
71 self.versions.get_exodus('010_remap_options.py')(source, source)
72 for charm in source._get_all('a'):
73 self.assertEqual({
74 '_id': 'a',
75 'config': None,
76 }, charm)
77 for charm in source._get_all('b'):
78 self.assertEqual({
79 '_id': 'b',
80 'config': {'options': []},
81 }, charm)
82 for charm in source._get_all('c'):
83 self.assertEqual({
84 '_id': 'c',
85 'config': {'options': [{
86 'name': 'foo',
87 'default': 'bar',
88 }]}
89 }, charm)
90 for charm in source._get_all('d'):
91 self.assertEqual({'_id': 'd'}, charm)
92 with self.assertRaises(ElasticHttpNotFoundError):
93 self.index_client.get('e')
94 self.assertEqual({'_id': 'e', 'config': ['a']},
95 self.db.charms.find_one({'_id': 'e'}))

Subscribers

People subscribed via source and target branches