Merge lp:~abentley/charmworld/config-storage into lp:~juju-jitsu/charmworld/trunk
- config-storage
- Merge into 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 |
Related bugs: |
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.
Commit message
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.
- 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
1 | === modified file 'charmworld/jobs/ingest.py' | |||
2 | --- charmworld/jobs/ingest.py 2013-07-01 14:45:02 +0000 | |||
3 | +++ charmworld/jobs/ingest.py 2013-07-05 14:15:30 +0000 | |||
4 | @@ -32,11 +32,14 @@ | |||
5 | 32 | get_branch_info, | 32 | get_branch_info, |
6 | 33 | parse_date, | 33 | parse_date, |
7 | 34 | ) | 34 | ) |
13 | 35 | from charmworld.models import getconnection | 35 | from charmworld.models import ( |
14 | 36 | from charmworld.models import getdb | 36 | CharmFileSet, |
15 | 37 | from charmworld.models import getfs | 37 | CharmSource, |
16 | 38 | from charmworld.models import CharmFileSet | 38 | getconnection, |
17 | 39 | from charmworld.models import CharmSource | 39 | getdb, |
18 | 40 | getfs, | ||
19 | 41 | options_to_storage, | ||
20 | 42 | ) | ||
21 | 40 | from charmworld.search import ( | 43 | from charmworld.search import ( |
22 | 41 | ElasticSearchClient, | 44 | ElasticSearchClient, |
23 | 42 | SearchServiceNotAvailable, | 45 | SearchServiceNotAvailable, |
24 | @@ -491,7 +494,11 @@ | |||
25 | 491 | config_raw = cfile.read() | 494 | config_raw = cfile.read() |
26 | 492 | 495 | ||
27 | 493 | try: | 496 | try: |
29 | 494 | config = quote_yaml(yaml.load(config_raw)) | 497 | config_yaml = yaml.load(config_raw) |
30 | 498 | if 'options' in config_yaml: | ||
31 | 499 | config_yaml['options'] = options_to_storage( | ||
32 | 500 | config_yaml['options']) | ||
33 | 501 | config = quote_yaml(config_yaml) | ||
34 | 495 | except Exception, exc: | 502 | except Exception, exc: |
35 | 496 | raise IngestError( | 503 | raise IngestError( |
36 | 497 | 'Invalid charm config yaml. %s: %s' % ( | 504 | 'Invalid charm config yaml. %s: %s' % ( |
37 | 498 | 505 | ||
38 | === modified file 'charmworld/jobs/tests/test_cstat.py' | |||
39 | --- charmworld/jobs/tests/test_cstat.py 2013-07-01 15:57:20 +0000 | |||
40 | +++ charmworld/jobs/tests/test_cstat.py 2013-07-05 14:15:30 +0000 | |||
41 | @@ -33,7 +33,8 @@ | |||
42 | 33 | def send(request): | 33 | def send(request): |
43 | 34 | response = Response() | 34 | response = Response() |
44 | 35 | if ('start' in request.url): | 35 | if ('start' in request.url): |
46 | 36 | response._content = '[["2010-12-24", 4], ["2010-12-25", 1]]' | 36 | response._content = ('[["2010-12-24", 4],' |
47 | 37 | ' ["2010-12-25", 1]]') | ||
48 | 37 | else: | 38 | else: |
49 | 38 | response._content = '[[25]]' | 39 | response._content = '[[25]]' |
50 | 39 | return response | 40 | return response |
51 | 40 | 41 | ||
52 | === modified file 'charmworld/jobs/tests/test_scan.py' | |||
53 | --- charmworld/jobs/tests/test_scan.py 2013-06-24 15:45:29 +0000 | |||
54 | +++ charmworld/jobs/tests/test_scan.py 2013-07-05 14:15:30 +0000 | |||
55 | @@ -34,8 +34,40 @@ | |||
56 | 34 | # 'foo.bar' is replaced with 'foo%2Ebar'. | 34 | # 'foo.bar' is replaced with 'foo%2Ebar'. |
57 | 35 | self.assertFalse('dotted.name' in self.charm) | 35 | self.assertFalse('dotted.name' in self.charm) |
58 | 36 | self.assertEqual('foo', self.charm['dotted%2Ename']) | 36 | self.assertEqual('foo', self.charm['dotted%2Ename']) |
61 | 37 | self.assertFalse('foo.bar' in self.charm['config']['options']) | 37 | |
62 | 38 | self.assertEqual('baz', self.charm['config']['options']['foo%2Ebar']) | 38 | def test_options_format(self): |
63 | 39 | del self.charm['config'] | ||
64 | 40 | scan_charm(self.charm, self.db, self.fs, self.log) | ||
65 | 41 | expected = [ | ||
66 | 42 | { | ||
67 | 43 | 'name': 'source', | ||
68 | 44 | 'default': 'lp:charmworld', | ||
69 | 45 | 'type': 'string', | ||
70 | 46 | 'description': 'The bzr branch to pull the charmworld source' | ||
71 | 47 | ' from.' | ||
72 | 48 | }, | ||
73 | 49 | { | ||
74 | 50 | 'name': 'revno', | ||
75 | 51 | 'default': -1, | ||
76 | 52 | 'type': 'int', | ||
77 | 53 | 'description': 'The revno of the bzr branch to use. -1 for' | ||
78 | 54 | ' current tip.' | ||
79 | 55 | }, | ||
80 | 56 | { | ||
81 | 57 | 'name': 'execute-ingest-every', | ||
82 | 58 | 'default': 15, | ||
83 | 59 | 'type': 'int', | ||
84 | 60 | 'description': 'The amount of time (in minutes) that the' | ||
85 | 61 | ' ingest tasks for charmworld are executed' | ||
86 | 62 | }, | ||
87 | 63 | { | ||
88 | 64 | 'name': 'error-email', | ||
89 | 65 | 'default': '', | ||
90 | 66 | 'type': 'string', | ||
91 | 67 | 'description': 'Address to mail errors to.' | ||
92 | 68 | } | ||
93 | 69 | ] | ||
94 | 70 | self.assertItemsEqual(expected, self.charm['config']['options']) | ||
95 | 39 | 71 | ||
96 | 40 | def test_short_interface_specification(self): | 72 | def test_short_interface_specification(self): |
97 | 41 | # Charms can specify the interfaces they require or provide | 73 | # Charms can specify the interfaces they require or provide |
98 | 42 | 74 | ||
99 | === modified file 'charmworld/models.py' | |||
100 | --- charmworld/models.py 2013-07-01 14:45:02 +0000 | |||
101 | +++ charmworld/models.py 2013-07-05 14:15:30 +0000 | |||
102 | @@ -544,7 +544,11 @@ | |||
103 | 544 | 544 | ||
104 | 545 | The dict is parsed from the config_raw property's YAML. | 545 | The dict is parsed from the config_raw property's YAML. |
105 | 546 | """ | 546 | """ |
107 | 547 | return self._representation['config'] | 547 | config = self._representation['config'] |
108 | 548 | if config is not None and 'options' in config: | ||
109 | 549 | config = dict(config) | ||
110 | 550 | config['options'] = storage_to_options(config['options']) | ||
111 | 551 | return config | ||
112 | 548 | 552 | ||
113 | 549 | @property | 553 | @property |
114 | 550 | def config_raw(self): | 554 | def config_raw(self): |
115 | @@ -557,7 +561,7 @@ | |||
116 | 557 | @property | 561 | @property |
117 | 558 | def options(self): | 562 | def options(self): |
118 | 559 | """The charm's configuration options.""" | 563 | """The charm's configuration options.""" |
120 | 560 | config = self._representation['config'] | 564 | config = self.config |
121 | 561 | if config is not None and 'options' in config: | 565 | if config is not None and 'options' in config: |
122 | 562 | return config['options'] | 566 | return config['options'] |
123 | 563 | else: | 567 | else: |
124 | @@ -969,8 +973,10 @@ | |||
125 | 969 | 973 | ||
126 | 970 | class CharmSource: | 974 | class CharmSource: |
127 | 971 | 975 | ||
130 | 972 | def __init__(self, db, index_client): | 976 | def __init__(self, db, index_client, collection=None): |
131 | 973 | self.db = db | 977 | if collection is None: |
132 | 978 | collection = db.charms | ||
133 | 979 | self.collection = collection | ||
134 | 974 | self.index_client = index_client | 980 | self.index_client = index_client |
135 | 975 | 981 | ||
136 | 976 | @classmethod | 982 | @classmethod |
137 | @@ -984,16 +990,20 @@ | |||
138 | 984 | return cls(request.db, request.index_client) | 990 | return cls(request.db, request.index_client) |
139 | 985 | 991 | ||
140 | 986 | def sync_index(self): | 992 | def sync_index(self): |
142 | 987 | for charm in self.db.charms.find(): | 993 | for charm in self.collection.find(): |
143 | 988 | yield charm['_id'] | 994 | yield charm['_id'] |
144 | 989 | self.index_client.index_charm(charm) | 995 | self.index_client.index_charm(charm) |
145 | 990 | 996 | ||
146 | 991 | def save(self, charm): | 997 | def save(self, charm): |
149 | 992 | self.db.charms.save(charm) | 998 | self.collection.save(charm) |
150 | 993 | self.index_client.index_charm(charm) | 999 | try: |
151 | 1000 | self.index_client.index_charm(charm) | ||
152 | 1001 | except: | ||
153 | 1002 | self.index_client.delete_charm(charm['_id']) | ||
154 | 1003 | raise | ||
155 | 994 | 1004 | ||
156 | 995 | def get_store_charms(self): | 1005 | def get_store_charms(self): |
158 | 996 | return self.db.charms.find({'store_url': {'$exists': True}}) | 1006 | return self.collection.find({'store_url': {'$exists': True}}) |
159 | 997 | 1007 | ||
160 | 998 | @staticmethod | 1008 | @staticmethod |
161 | 999 | def exclude_obsolete(query): | 1009 | def exclude_obsolete(query): |
162 | @@ -1002,7 +1012,7 @@ | |||
163 | 1002 | def find_mia_charms(self): | 1012 | def find_mia_charms(self): |
164 | 1003 | query = self.exclude_obsolete( | 1013 | query = self.exclude_obsolete( |
165 | 1004 | {"store_data.errors": {"$exists": True}}) | 1014 | {"store_data.errors": {"$exists": True}}) |
167 | 1005 | return (Charm(data) for data in self.db.charms.find(query)) | 1015 | return (Charm(data) for data in self.collection.find(query)) |
168 | 1006 | 1016 | ||
169 | 1007 | def find_proof_error_charms(self, sub_only): | 1017 | def find_proof_error_charms(self, sub_only): |
170 | 1008 | query = {"proof.e": {"$exists": True}, "promulgated": True} | 1018 | query = {"proof.e": {"$exists": True}, "promulgated": True} |
171 | @@ -1010,13 +1020,16 @@ | |||
172 | 1010 | query['subordinate'] = True | 1020 | query['subordinate'] = True |
173 | 1011 | query = self.exclude_obsolete(query) | 1021 | query = self.exclude_obsolete(query) |
174 | 1012 | sort = [("series", pymongo.DESCENDING), ("name", pymongo.ASCENDING)] | 1022 | sort = [("series", pymongo.DESCENDING), ("name", pymongo.ASCENDING)] |
176 | 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)) |
177 | 1014 | 1024 | ||
178 | 1015 | def _get_all(self, charm_id): | 1025 | def _get_all(self, charm_id): |
179 | 1016 | """"Return all stored values for a given charm.""" | 1026 | """"Return all stored values for a given charm.""" |
181 | 1017 | yield self.db.charms.find_one({'_id': charm_id}) | 1027 | yield self.collection.find_one({'_id': charm_id}) |
182 | 1018 | yield self.index_client.get(charm_id) | 1028 | yield self.index_client.get(charm_id) |
183 | 1019 | 1029 | ||
184 | 1030 | def create_collection(self): | ||
185 | 1031 | self.collection.database.create_collection(self.collection.name) | ||
186 | 1032 | |||
187 | 1020 | 1033 | ||
188 | 1021 | def acquire_session_secret(database): | 1034 | def acquire_session_secret(database): |
189 | 1022 | """Return a secret to use for AuthTkt sessions. | 1035 | """Return a secret to use for AuthTkt sessions. |
190 | @@ -1073,3 +1086,33 @@ | |||
191 | 1073 | logger.exception('Unable to index charm.') | 1086 | logger.exception('Unable to index charm.') |
192 | 1074 | else: | 1087 | else: |
193 | 1075 | logger.info('Syncing %s to Elasticsearch' % charm_id) | 1088 | logger.info('Syncing %s to Elasticsearch' % charm_id) |
194 | 1089 | |||
195 | 1090 | |||
196 | 1091 | def options_to_storage(options): | ||
197 | 1092 | """Convert options, as retrieved from config.yaml, into a list. | ||
198 | 1093 | |||
199 | 1094 | This format avoids user-defined keys, which are problematic in | ||
200 | 1095 | elasticsearch. | ||
201 | 1096 | """ | ||
202 | 1097 | storage = [] | ||
203 | 1098 | for key, value in options.items(): | ||
204 | 1099 | option = {'name': key} | ||
205 | 1100 | option.update(value) | ||
206 | 1101 | storage.append(option) | ||
207 | 1102 | return storage | ||
208 | 1103 | |||
209 | 1104 | |||
210 | 1105 | def storage_to_options(storage): | ||
211 | 1106 | """Convert options, as retrieved from mongodb, into a dict. | ||
212 | 1107 | |||
213 | 1108 | This format has the same structure as config.yaml. | ||
214 | 1109 | """ | ||
215 | 1110 | options = {} | ||
216 | 1111 | for option in storage: | ||
217 | 1112 | new_option = dict(option) | ||
218 | 1113 | del new_option['name'] | ||
219 | 1114 | value = options.setdefault(option['name'], new_option) | ||
220 | 1115 | if value is not new_option: | ||
221 | 1116 | raise ValueError('Option name "%s" occurs multiple times.' % | ||
222 | 1117 | option['name']) | ||
223 | 1118 | return options | ||
224 | 1076 | 1119 | ||
225 | === modified file 'charmworld/routes.py' | |||
226 | --- charmworld/routes.py 2013-07-02 12:28:28 +0000 | |||
227 | +++ charmworld/routes.py 2013-07-05 14:15:30 +0000 | |||
228 | @@ -89,7 +89,8 @@ | |||
229 | 89 | # up and running. They may need to be consulted when this is changed to a | 89 | # up and running. They may need to be consulted when this is changed to a |
230 | 90 | # later API version. | 90 | # later API version. |
231 | 91 | config.add_route('api_latest_single', '/api/latest/{endpoint}') | 91 | config.add_route('api_latest_single', '/api/latest/{endpoint}') |
233 | 92 | config.add_view('charmworld.views.api.API2', route_name='api_latest_single') | 92 | config.add_view('charmworld.views.api.API2', |
234 | 93 | route_name='api_latest_single') | ||
235 | 93 | config.add_route('api_latest', '/api/latest/{endpoint}/*remainder') | 94 | config.add_route('api_latest', '/api/latest/{endpoint}/*remainder') |
236 | 94 | config.add_view('charmworld.views.api.API2', route_name='api_latest') | 95 | config.add_view('charmworld.views.api.API2', route_name='api_latest') |
237 | 95 | 96 | ||
238 | 96 | 97 | ||
239 | === modified file 'charmworld/search.py' | |||
240 | --- charmworld/search.py 2013-07-01 14:45:02 +0000 | |||
241 | +++ charmworld/search.py 2013-07-05 14:15:30 +0000 | |||
242 | @@ -30,7 +30,7 @@ | |||
243 | 30 | 'name': 10, | 30 | 'name': 10, |
244 | 31 | 'summary': 5, | 31 | 'summary': 5, |
245 | 32 | 'description': 3, | 32 | 'description': 3, |
247 | 33 | 'config.options.*.description': None, | 33 | 'config.options.description': None, |
248 | 34 | 'relations': None, | 34 | 'relations': None, |
249 | 35 | } | 35 | } |
250 | 36 | 36 | ||
251 | @@ -100,6 +100,14 @@ | |||
252 | 100 | self._client = client | 100 | self._client = client |
253 | 101 | self.index_name = index_name | 101 | self.index_name = index_name |
254 | 102 | 102 | ||
255 | 103 | def exists(self): | ||
256 | 104 | try: | ||
257 | 105 | self.get_status() | ||
258 | 106 | except IndexMissing: | ||
259 | 107 | return False | ||
260 | 108 | else: | ||
261 | 109 | return True | ||
262 | 110 | |||
263 | 103 | def create_index(self): | 111 | def create_index(self): |
264 | 104 | self._client.create_index(self.index_name) | 112 | self._client.create_index(self.index_name) |
265 | 105 | 113 | ||
266 | @@ -146,13 +154,24 @@ | |||
267 | 146 | """ | 154 | """ |
268 | 147 | exact_index = ['categories', 'name', 'owner', 'i_provides', | 155 | exact_index = ['categories', 'name', 'owner', 'i_provides', |
269 | 148 | 'i_requires', 'series', 'store_url'] | 156 | 'i_requires', 'series', 'store_url'] |
270 | 157 | charm_properties = dict( | ||
271 | 158 | (name, {'type': 'string', 'index': 'not_analyzed'}) | ||
272 | 159 | for name in exact_index) | ||
273 | 160 | charm_properties['config'] = { | ||
274 | 161 | 'properties': { | ||
275 | 162 | 'options': { | ||
276 | 163 | 'properties': { | ||
277 | 164 | 'default': {'type': 'string'}, | ||
278 | 165 | 'description': {'type': 'string'}, | ||
279 | 166 | } | ||
280 | 167 | } | ||
281 | 168 | } | ||
282 | 169 | } | ||
283 | 170 | |||
284 | 149 | with translate_error(): | 171 | with translate_error(): |
285 | 150 | self._client.put_mapping(self.index_name, 'charm', { | 172 | self._client.put_mapping(self.index_name, 'charm', { |
286 | 151 | 'charm': { | 173 | 'charm': { |
291 | 152 | 'properties': dict( | 174 | 'properties': charm_properties, |
288 | 153 | (name, {'type': 'string', 'index': 'not_analyzed'}) | ||
289 | 154 | for name in exact_index | ||
290 | 155 | ) | ||
292 | 156 | } | 175 | } |
293 | 157 | }) | 176 | }) |
294 | 158 | 177 | ||
295 | @@ -166,6 +185,10 @@ | |||
296 | 166 | self._client.index(self.index_name, 'charm', charm, charm_id, | 185 | self._client.index(self.index_name, 'charm', charm, charm_id, |
297 | 167 | refresh=True) | 186 | refresh=True) |
298 | 168 | 187 | ||
299 | 188 | def delete_charm(self, charm_id): | ||
300 | 189 | with translate_error(): | ||
301 | 190 | self._client.delete(self.index_name, 'charm', charm_id) | ||
302 | 191 | |||
303 | 169 | def index_charms(self, charms): | 192 | def index_charms(self, charms): |
304 | 170 | if len(charms) == 0: | 193 | if len(charms) == 0: |
305 | 171 | return | 194 | return |
306 | @@ -311,6 +334,10 @@ | |||
307 | 311 | hits = self._unlimited_search(dsl, limit) | 334 | hits = self._unlimited_search(dsl, limit) |
308 | 312 | return [hit['_source'] for hit in hits['hits']['hits']] | 335 | return [hit['_source'] for hit in hits['hits']['hits']] |
309 | 313 | 336 | ||
310 | 337 | def get_status(self): | ||
311 | 338 | with translate_error(): | ||
312 | 339 | return self._client.status(index=self.index_name)['indices'] | ||
313 | 340 | |||
314 | 314 | def search(self, terms, valid_charms_only=True): | 341 | def search(self, terms, valid_charms_only=True): |
315 | 315 | # Avoid circular imports. | 342 | # Avoid circular imports. |
316 | 316 | from charmworld.models import Charm | 343 | from charmworld.models import Charm |
317 | @@ -323,8 +350,7 @@ | |||
318 | 323 | dsl = self._get_filtered(dsl, {}, None, valid_charms_only) | 350 | dsl = self._get_filtered(dsl, {}, None, valid_charms_only) |
319 | 324 | 351 | ||
320 | 325 | with Timer() as timer: | 352 | with Timer() as timer: |
323 | 326 | with translate_error(): | 353 | status = self.get_status() |
322 | 327 | status = self._client.status(index=self.index_name)['indices'] | ||
324 | 328 | for real_index in self.get_aliased(): | 354 | for real_index in self.get_aliased(): |
325 | 329 | break | 355 | break |
326 | 330 | else: | 356 | else: |
327 | @@ -395,6 +421,9 @@ | |||
328 | 395 | result_r.setdefault(interface, []).append(payload) | 421 | result_r.setdefault(interface, []).append(payload) |
329 | 396 | return result_r, result_p, | 422 | return result_r, result_p, |
330 | 397 | 423 | ||
331 | 424 | def get_related_client(self, name): | ||
332 | 425 | return self.__class__(self._client, name) | ||
333 | 426 | |||
334 | 398 | def get_aliasable_client(self, name=None): | 427 | def get_aliasable_client(self, name=None): |
335 | 399 | """Return a client that can be aliased to this one. | 428 | """Return a client that can be aliased to this one. |
336 | 400 | 429 | ||
337 | @@ -403,7 +432,18 @@ | |||
338 | 403 | """ | 432 | """ |
339 | 404 | if name is None: | 433 | if name is None: |
340 | 405 | name = '%s-%d' % (self.index_name, random.randint(1, 99999)) | 434 | name = '%s-%d' % (self.index_name, random.randint(1, 99999)) |
342 | 406 | return self.__class__(self._client, name) | 435 | return self.get_related_client(name) |
343 | 436 | |||
344 | 437 | @contextmanager | ||
345 | 438 | def replacement(self, name=None): | ||
346 | 439 | copy = self.get_aliasable_client(name) | ||
347 | 440 | copy.create_index() | ||
348 | 441 | try: | ||
349 | 442 | copy.put_mapping() | ||
350 | 443 | yield copy | ||
351 | 444 | except: | ||
352 | 445 | copy.delete_index() | ||
353 | 446 | raise | ||
354 | 407 | 447 | ||
355 | 408 | def create_replacement(self, name=None, charms=None): | 448 | def create_replacement(self, name=None, charms=None): |
356 | 409 | """Create a replacement for this index. | 449 | """Create a replacement for this index. |
357 | @@ -417,10 +457,7 @@ | |||
358 | 417 | index will be used. | 457 | index will be used. |
359 | 418 | :return: The ElasticSearchClient for the supplied mapping. | 458 | :return: The ElasticSearchClient for the supplied mapping. |
360 | 419 | """ | 459 | """ |
365 | 420 | copy = self.get_aliasable_client(name) | 460 | with self.replacement(name) as copy: |
362 | 421 | copy.create_index() | ||
363 | 422 | try: | ||
364 | 423 | copy.put_mapping() | ||
366 | 424 | if charms is None: | 461 | if charms is None: |
367 | 425 | try: | 462 | try: |
368 | 426 | charms = self.api_search(valid_charms_only=False) | 463 | charms = self.api_search(valid_charms_only=False) |
369 | @@ -428,9 +465,6 @@ | |||
370 | 428 | charms = [] | 465 | charms = [] |
371 | 429 | copy.index_charms(charms) | 466 | copy.index_charms(charms) |
372 | 430 | return copy | 467 | return copy |
373 | 431 | except: | ||
374 | 432 | copy.delete_index() | ||
375 | 433 | raise | ||
376 | 434 | 468 | ||
377 | 435 | 469 | ||
378 | 436 | def reindex(index_client, charms=None): | 470 | def reindex(index_client, charms=None): |
379 | 437 | 471 | ||
380 | === modified file 'charmworld/testing/__init__.py' | |||
381 | --- charmworld/testing/__init__.py 2013-06-24 15:08:43 +0000 | |||
382 | +++ charmworld/testing/__init__.py 2013-07-05 14:15:30 +0000 | |||
383 | @@ -60,9 +60,9 @@ | |||
384 | 60 | self.addCleanup(context.__exit__, None, None, None) | 60 | self.addCleanup(context.__exit__, None, None, None) |
385 | 61 | return result | 61 | return result |
386 | 62 | 62 | ||
388 | 63 | def use_index_client(self, put_mapping=True): | 63 | def use_index_client(self, put_mapping=True, aliased=False): |
389 | 64 | self.index_client = self.use_context( | 64 | self.index_client = self.use_context( |
391 | 65 | temp_index_client(put_mapping=put_mapping)) | 65 | temp_index_client(put_mapping=put_mapping, aliased=aliased)) |
392 | 66 | return self.index_client | 66 | return self.index_client |
393 | 67 | 67 | ||
394 | 68 | @contextmanager | 68 | @contextmanager |
395 | @@ -223,13 +223,18 @@ | |||
396 | 223 | 223 | ||
397 | 224 | 224 | ||
398 | 225 | @contextmanager | 225 | @contextmanager |
400 | 226 | def temp_index_client(name='temp_index', put_mapping=True): | 226 | def temp_index_client(name='temp_index', put_mapping=True, aliased=False): |
401 | 227 | client = ElasticSearchClient.from_settings(INI, name) | 227 | client = ElasticSearchClient.from_settings(INI, name) |
402 | 228 | try: | 228 | try: |
403 | 229 | client.delete_index() | 229 | client.delete_index() |
404 | 230 | except IndexMissing: | 230 | except IndexMissing: |
405 | 231 | pass | 231 | pass |
407 | 232 | client.create_index() | 232 | if aliased: |
408 | 233 | alias_client = client.get_aliasable_client() | ||
409 | 234 | alias_client.create_index() | ||
410 | 235 | client.update_aliased(alias_client.index_name, []) | ||
411 | 236 | else: | ||
412 | 237 | client.create_index() | ||
413 | 233 | try: | 238 | try: |
414 | 234 | if put_mapping: | 239 | if put_mapping: |
415 | 235 | client.put_mapping() | 240 | client.put_mapping() |
416 | 236 | 241 | ||
417 | === modified file 'charmworld/testing/data/sample_charm/config.yaml' | |||
418 | --- charmworld/testing/data/sample_charm/config.yaml 2013-02-07 17:26:59 +0000 | |||
419 | +++ charmworld/testing/data/sample_charm/config.yaml 2013-07-05 14:15:30 +0000 | |||
420 | @@ -15,4 +15,3 @@ | |||
421 | 15 | type: string | 15 | type: string |
422 | 16 | default: "" | 16 | default: "" |
423 | 17 | description: "Address to mail errors to." | 17 | description: "Address to mail errors to." |
424 | 18 | foo.bar: baz | ||
425 | 19 | 18 | ||
426 | === modified file 'charmworld/testing/factory.py' | |||
427 | --- charmworld/testing/factory.py 2013-07-01 14:45:02 +0000 | |||
428 | +++ charmworld/testing/factory.py 2013-07-05 14:15:30 +0000 | |||
429 | @@ -24,8 +24,9 @@ | |||
430 | 24 | process_charm, | 24 | process_charm, |
431 | 25 | ) | 25 | ) |
432 | 26 | from charmworld.models import ( | 26 | from charmworld.models import ( |
433 | 27 | CharmFileSet, | ||
434 | 27 | getfs, | 28 | getfs, |
436 | 28 | CharmFileSet, | 29 | options_to_storage, |
437 | 29 | ) | 30 | ) |
438 | 30 | 31 | ||
439 | 31 | 32 | ||
440 | @@ -185,6 +186,7 @@ | |||
441 | 185 | 'description': 'The interval between script runs.' | 186 | 'description': 'The interval between script runs.' |
442 | 186 | }, | 187 | }, |
443 | 187 | } | 188 | } |
444 | 189 | options = options_to_storage(options) | ||
445 | 188 | if files is None: | 190 | if files is None: |
446 | 189 | files = { | 191 | files = { |
447 | 190 | 'readme': { | 192 | 'readme': { |
448 | 191 | 193 | ||
449 | === modified file 'charmworld/tests/test_models.py' | |||
450 | --- charmworld/tests/test_models.py 2013-07-01 14:45:02 +0000 | |||
451 | +++ charmworld/tests/test_models.py 2013-07-05 14:15:30 +0000 | |||
452 | @@ -14,6 +14,10 @@ | |||
453 | 14 | from os.path import join | 14 | from os.path import join |
454 | 15 | 15 | ||
455 | 16 | from mock import patch | 16 | from mock import patch |
456 | 17 | from pyelasticsearch.exceptions import ( | ||
457 | 18 | ElasticHttpError, | ||
458 | 19 | ElasticHttpNotFoundError, | ||
459 | 20 | ) | ||
460 | 17 | 21 | ||
461 | 18 | from charmworld.models import ( | 22 | from charmworld.models import ( |
462 | 19 | acquire_session_secret, | 23 | acquire_session_secret, |
463 | @@ -22,7 +26,9 @@ | |||
464 | 22 | CharmFileSet, | 26 | CharmFileSet, |
465 | 23 | CharmSource, | 27 | CharmSource, |
466 | 24 | find_charms, | 28 | find_charms, |
467 | 29 | options_to_storage, | ||
468 | 25 | QAData, | 30 | QAData, |
469 | 31 | storage_to_options, | ||
470 | 26 | sync_index, | 32 | sync_index, |
471 | 27 | UserMgr, | 33 | UserMgr, |
472 | 28 | ) | 34 | ) |
473 | @@ -437,11 +443,10 @@ | |||
474 | 437 | 443 | ||
475 | 438 | def test_config(self): | 444 | def test_config(self): |
476 | 439 | # The config property is a dict of charm options. | 445 | # The config property is a dict of charm options. |
480 | 440 | config = {'options': {'mode': 'fast'}} | 446 | config = {'options': {'key': {'default': 'value', 'type': 'string'}}} |
481 | 441 | charm_data = factory.get_charm_json() | 447 | charm_data = factory.get_charm_json(options=config['options']) |
479 | 442 | charm_data['config'] = config | ||
482 | 443 | charm = Charm(charm_data) | 448 | charm = Charm(charm_data) |
484 | 444 | self.assertIs(config, charm.config) | 449 | self.assertEqual(config, charm.config) |
485 | 445 | # The default is a dict with a single key named 'options' set to | 450 | # The default is a dict with a single key named 'options' set to |
486 | 446 | # an empty dict. | 451 | # an empty dict. |
487 | 447 | charm = Charm({}) | 452 | charm = Charm({}) |
488 | @@ -460,12 +465,10 @@ | |||
489 | 460 | 465 | ||
490 | 461 | def test_options(self): | 466 | def test_options(self): |
491 | 462 | # The options property is a dict from the charm's config. | 467 | # The options property is a dict from the charm's config. |
496 | 463 | options = {'mode': 'fast'} | 468 | options = {'key': {'default': 'value', 'type': 'string'}} |
497 | 464 | config = {'options': options} | 469 | charm_data = factory.get_charm_json(options=options) |
494 | 465 | charm_data = factory.get_charm_json() | ||
495 | 466 | charm_data['config'] = config | ||
498 | 467 | charm = Charm(charm_data) | 470 | charm = Charm(charm_data) |
500 | 468 | self.assertIs(options, charm.options) | 471 | self.assertEqual(options, charm.options) |
501 | 469 | # The default is a dict. | 472 | # The default is a dict. |
502 | 470 | charm = Charm({}) | 473 | charm = Charm({}) |
503 | 471 | self.assertEqual({}, charm.options) | 474 | self.assertEqual({}, charm.options) |
504 | @@ -1040,3 +1043,58 @@ | |||
505 | 1040 | messages = [r.getMessage() for r in handler.buffer] | 1043 | messages = [r.getMessage() for r in handler.buffer] |
506 | 1041 | self.assertIn('Unable to index charm.', messages) | 1044 | self.assertIn('Unable to index charm.', messages) |
507 | 1042 | self.assertEqual(1, len(self.index_client.api_search())) | 1045 | self.assertEqual(1, len(self.index_client.api_search())) |
508 | 1046 | |||
509 | 1047 | def test_save_deletes_on_error(self): | ||
510 | 1048 | self.use_index_client() | ||
511 | 1049 | source = CharmSource.from_request(self) | ||
512 | 1050 | source.save({'_id': 'a', 'b': {}}) | ||
513 | 1051 | with self.assertRaises(ElasticHttpError): | ||
514 | 1052 | source.save({'_id': 'a', 'b': 'c'}) | ||
515 | 1053 | with self.assertRaises(ElasticHttpNotFoundError): | ||
516 | 1054 | self.index_client.get('a') | ||
517 | 1055 | |||
518 | 1056 | |||
519 | 1057 | class TestOptionsStorage(TestCase): | ||
520 | 1058 | |||
521 | 1059 | yaml = { | ||
522 | 1060 | 'foo': { | ||
523 | 1061 | 'default': 'bar', | ||
524 | 1062 | 'type': 'string', | ||
525 | 1063 | 'description': 'Hello.' | ||
526 | 1064 | }, | ||
527 | 1065 | 'baz': { | ||
528 | 1066 | 'default': 42, | ||
529 | 1067 | 'type': 'int', | ||
530 | 1068 | 'description': 'Hello.' | ||
531 | 1069 | }, | ||
532 | 1070 | } | ||
533 | 1071 | storage = [{ | ||
534 | 1072 | 'name': 'baz', | ||
535 | 1073 | 'default': 42, | ||
536 | 1074 | 'type': 'int', | ||
537 | 1075 | 'description': 'Hello.' | ||
538 | 1076 | }, { | ||
539 | 1077 | 'name': 'foo', | ||
540 | 1078 | 'default': 'bar', | ||
541 | 1079 | 'type': 'string', | ||
542 | 1080 | 'description': 'Hello.' | ||
543 | 1081 | }] | ||
544 | 1082 | |||
545 | 1083 | def test_options_to_storage(self): | ||
546 | 1084 | self.assertItemsEqual(self.storage, options_to_storage(self.yaml)) | ||
547 | 1085 | |||
548 | 1086 | def test_storage_to_options(self): | ||
549 | 1087 | self.assertEqual(self.yaml, storage_to_options(self.storage)) | ||
550 | 1088 | |||
551 | 1089 | def test_storage_to_options_duplicate_key(self): | ||
552 | 1090 | with self.assertRaises(ValueError) as exc_context: | ||
553 | 1091 | storage_to_options([{'name': 'same'}, {'name': 'same'}]) | ||
554 | 1092 | self.assertEqual('Option name "same" occurs multiple times.', | ||
555 | 1093 | str(exc_context.exception)) | ||
556 | 1094 | |||
557 | 1095 | def test_roundtrip(self): | ||
558 | 1096 | self.assertEqual( | ||
559 | 1097 | self.yaml, storage_to_options(options_to_storage(self.yaml))) | ||
560 | 1098 | self.assertItemsEqual( | ||
561 | 1099 | self.storage, | ||
562 | 1100 | options_to_storage(storage_to_options(self.storage))) | ||
563 | 1043 | 1101 | ||
564 | === modified file 'charmworld/tests/test_search.py' | |||
565 | --- charmworld/tests/test_search.py 2013-07-01 14:45:02 +0000 | |||
566 | +++ charmworld/tests/test_search.py 2013-07-05 14:15:30 +0000 | |||
567 | @@ -173,7 +173,7 @@ | |||
568 | 173 | elif key in ('provides', 'requires'): | 173 | elif key in ('provides', 'requires'): |
569 | 174 | query = query.values()[0].values()[0] | 174 | query = query.values()[0].values()[0] |
570 | 175 | elif key == 'config': | 175 | elif key == 'config': |
572 | 176 | query = query.values()[0].values()[0]['description'] | 176 | query = query.values()[0][0]['description'] |
573 | 177 | elif key == 'store_data': | 177 | elif key == 'store_data': |
574 | 178 | query = query['digest'] | 178 | query = query['digest'] |
575 | 179 | elif key == 'files': | 179 | elif key == 'files': |
576 | 180 | 180 | ||
577 | === modified file 'charmworld/views/api.py' | |||
578 | --- charmworld/views/api.py 2013-07-02 12:28:28 +0000 | |||
579 | +++ charmworld/views/api.py 2013-07-05 14:15:30 +0000 | |||
580 | @@ -60,7 +60,7 @@ | |||
581 | 60 | message = 'This API version is no longer supported.' | 60 | message = 'This API version is no longer supported.' |
582 | 61 | return Response( | 61 | return Response( |
583 | 62 | message, | 62 | message, |
585 | 63 | headerlist = [ | 63 | headerlist=[ |
586 | 64 | ('Access-Control-Allow-Origin', '*'), | 64 | ('Access-Control-Allow-Origin', '*'), |
587 | 65 | ('Access-Control-Allow-Headers', 'X-Requested-With'), | 65 | ('Access-Control-Allow-Headers', 'X-Requested-With'), |
588 | 66 | ('Content-Type', 'text/plain'), | 66 | ('Content-Type', 'text/plain'), |
589 | 67 | 67 | ||
590 | === modified file 'charmworld/views/charms.py' | |||
591 | --- charmworld/views/charms.py 2013-07-03 21:33:20 +0000 | |||
592 | +++ charmworld/views/charms.py 2013-07-05 14:15:30 +0000 | |||
593 | @@ -165,7 +165,10 @@ | |||
594 | 165 | 165 | ||
595 | 166 | 166 | ||
596 | 167 | def _json_charm(charm): | 167 | def _json_charm(charm): |
597 | 168 | charm_config = charm.config | ||
598 | 168 | charm = dict(charm._representation) | 169 | charm = dict(charm._representation) |
599 | 170 | # Reformat config into old internal representation. | ||
600 | 171 | charm['config'] = charm_config | ||
601 | 169 | charm_keys = set(charm.keys()) | 172 | charm_keys = set(charm.keys()) |
602 | 170 | for k in SANITIZE.intersection(charm_keys): | 173 | for k in SANITIZE.intersection(charm_keys): |
603 | 171 | del charm[k] | 174 | del charm[k] |
604 | 172 | 175 | ||
605 | === modified file 'charmworld/views/tests/test_charms.py' | |||
606 | --- charmworld/views/tests/test_charms.py 2013-07-03 21:33:20 +0000 | |||
607 | +++ charmworld/views/tests/test_charms.py 2013-07-05 14:15:30 +0000 | |||
608 | @@ -1,6 +1,7 @@ | |||
609 | 1 | # Copyright 2012, 2013 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2012, 2013 Canonical Ltd. This software is licensed under the |
610 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
611 | 3 | 3 | ||
612 | 4 | from datetime import date | ||
613 | 4 | from logging import getLogger | 5 | from logging import getLogger |
614 | 5 | 6 | ||
615 | 6 | from charmworld.jobs.ingest import ( | 7 | from charmworld.jobs.ingest import ( |
616 | @@ -16,7 +17,6 @@ | |||
617 | 16 | from charmworld.testing import ViewTestBase | 17 | from charmworld.testing import ViewTestBase |
618 | 17 | from charmworld.testing import WebTestBase | 18 | from charmworld.testing import WebTestBase |
619 | 18 | from charmworld.utils import quote_key | 19 | from charmworld.utils import quote_key |
620 | 19 | from charmworld.views import SANITIZE | ||
621 | 20 | from charmworld.views.api import API2 | 20 | from charmworld.views.api import API2 |
622 | 21 | from charmworld.views.charms import ( | 21 | from charmworld.views.charms import ( |
623 | 22 | _charm_view, | 22 | _charm_view, |
624 | @@ -35,7 +35,65 @@ | |||
625 | 35 | quality_edit, | 35 | quality_edit, |
626 | 36 | series_collection, | 36 | series_collection, |
627 | 37 | ) | 37 | ) |
629 | 38 | import json | 38 | |
630 | 39 | |||
631 | 40 | DISTRO_CHARM_DATA = { | ||
632 | 41 | 'name': 'foo', | ||
633 | 42 | 'series': 'precise', | ||
634 | 43 | 'owner': 'bar', | ||
635 | 44 | 'maintainer': 'John Doe <jdoe@example.com>', | ||
636 | 45 | 'summary': 'Remote terminal classroom over https', | ||
637 | 46 | 'description': 'foobar', | ||
638 | 47 | 'store_url': 'cs:precise/foo-1', | ||
639 | 48 | 'bzr_branch': '', | ||
640 | 49 | 'promulgated': True, | ||
641 | 50 | 'branch_deleted': False, | ||
642 | 51 | 'categories': [], | ||
643 | 52 | 'is_featured': False, | ||
644 | 53 | 'subordinate': False, | ||
645 | 54 | 'requires': {'database': {'interface': 'mongodb'}}, | ||
646 | 55 | 'provides': {'website': {'interface': 'https'}}, | ||
647 | 56 | 'config': { | ||
648 | 57 | 'options': { | ||
649 | 58 | 'script-interval': { | ||
650 | 59 | 'default': 5, | ||
651 | 60 | 'type': 'int', | ||
652 | 61 | 'description': 'The interval between script runs.' | ||
653 | 62 | } | ||
654 | 63 | } | ||
655 | 64 | }, | ||
656 | 65 | 'ensemble': 'formula', | ||
657 | 66 | 'downloads': 0, | ||
658 | 67 | 'downloads_in_past_30_days': 0, | ||
659 | 68 | 'files': { | ||
660 | 69 | 'readme': { | ||
661 | 70 | 'subdir': '', | ||
662 | 71 | 'filename': 'README', | ||
663 | 72 | }, | ||
664 | 73 | 'install': { | ||
665 | 74 | 'subdir': 'hooks', | ||
666 | 75 | 'filename': 'install', | ||
667 | 76 | }, | ||
668 | 77 | }, | ||
669 | 78 | 'tests': {}, | ||
670 | 79 | 'test_results': {}, | ||
671 | 80 | 'proof': { | ||
672 | 81 | 'w': [ | ||
673 | 82 | ' no README file', | ||
674 | 83 | ' (install:31) - hook accesses EC2 metadata service' | ||
675 | 84 | ' directly' | ||
676 | 85 | ] | ||
677 | 86 | }, | ||
678 | 87 | 'date_created': '2012-01-01', | ||
679 | 88 | 'error': '', | ||
680 | 89 | 'qa': None, | ||
681 | 90 | 'last_change': { | ||
682 | 91 | 'committer': u'John Doe <jdoe@example.com>', | ||
683 | 92 | 'message': u'maintainer', | ||
684 | 93 | 'revno': 12, | ||
685 | 94 | 'created': 1343082725.06 | ||
686 | 95 | } | ||
687 | 96 | } | ||
688 | 39 | 97 | ||
689 | 40 | 98 | ||
690 | 41 | class TestCharms(ViewTestBase): | 99 | class TestCharms(ViewTestBase): |
691 | @@ -368,17 +426,15 @@ | |||
692 | 368 | self.enable_routes() | 426 | self.enable_routes() |
693 | 369 | ignore, charm = factory.makeCharm( | 427 | ignore, charm = factory.makeCharm( |
694 | 370 | self.db, name='foo', owner='bar', series='precise', | 428 | self.db, name='foo', owner='bar', series='precise', |
696 | 371 | promulgated=True) | 429 | description='foobar', promulgated=True, |
697 | 430 | date_created=date(2012, 1, 1)) | ||
698 | 372 | request = self.getRequest() | 431 | request = self.getRequest() |
699 | 373 | request.matchdict = { | 432 | request.matchdict = { |
700 | 374 | 'charm': 'foo', | 433 | 'charm': 'foo', |
701 | 375 | 'series': 'precise', | 434 | 'series': 'precise', |
702 | 376 | } | 435 | } |
703 | 377 | charm_data = dict(Charm(charm)._representation) | ||
704 | 378 | for key in SANITIZE.intersection(charm_data.keys()): | ||
705 | 379 | del charm_data[key] | ||
706 | 380 | response = distro_charm_json(request) | 436 | response = distro_charm_json(request) |
708 | 381 | self.assertEqual(charm_data, json.loads(response.body)) | 437 | self.assertEqual(DISTRO_CHARM_DATA, response.json_body) |
709 | 382 | 438 | ||
710 | 383 | def test_valid_others(self): | 439 | def test_valid_others(self): |
711 | 384 | charm = factory.makeCharm(self.db, files={})[1] | 440 | charm = factory.makeCharm(self.db, files={})[1] |
712 | 385 | 441 | ||
713 | === modified file 'migrations/migrate.py' | |||
714 | --- migrations/migrate.py 2013-06-04 15:36:45 +0000 | |||
715 | +++ migrations/migrate.py 2013-07-05 14:15:30 +0000 | |||
716 | @@ -7,6 +7,7 @@ | |||
717 | 7 | 7 | ||
718 | 8 | """ | 8 | """ |
719 | 9 | import argparse | 9 | import argparse |
720 | 10 | from collections import namedtuple | ||
721 | 10 | from datetime import datetime | 11 | from datetime import datetime |
722 | 11 | import imp | 12 | import imp |
723 | 12 | from os import listdir | 13 | from os import listdir |
724 | @@ -15,8 +16,13 @@ | |||
725 | 15 | from os.path import join | 16 | from os.path import join |
726 | 16 | import re | 17 | import re |
727 | 17 | 18 | ||
730 | 18 | from charmworld.models import getconnection | 19 | from pymongo.errors import OperationFailure |
731 | 19 | from charmworld.models import getdb | 20 | |
732 | 21 | from charmworld.models import ( | ||
733 | 22 | CharmSource, | ||
734 | 23 | getconnection, | ||
735 | 24 | getdb, | ||
736 | 25 | ) | ||
737 | 20 | from charmworld.search import ElasticSearchClient | 26 | from charmworld.search import ElasticSearchClient |
738 | 21 | from charmworld.utils import get_ini | 27 | from charmworld.utils import get_ini |
739 | 22 | 28 | ||
740 | @@ -45,6 +51,22 @@ | |||
741 | 45 | return s | 51 | return s |
742 | 46 | 52 | ||
743 | 47 | 53 | ||
744 | 54 | class MigrationBeforeExodus(Exception): | ||
745 | 55 | pass | ||
746 | 56 | |||
747 | 57 | |||
748 | 58 | class MissingExodusCollection(Exception): | ||
749 | 59 | pass | ||
750 | 60 | |||
751 | 61 | |||
752 | 62 | class MissingExodusIndex(Exception): | ||
753 | 63 | pass | ||
754 | 64 | |||
755 | 65 | |||
756 | 66 | class MultipleExodus(Exception): | ||
757 | 67 | pass | ||
758 | 68 | |||
759 | 69 | |||
760 | 48 | class DataStore(object): | 70 | class DataStore(object): |
761 | 49 | """Communicate with the data store to determine version status.""" | 71 | """Communicate with the data store to determine version status.""" |
762 | 50 | 72 | ||
763 | @@ -68,6 +90,19 @@ | |||
764 | 68 | else: | 90 | else: |
765 | 69 | return None | 91 | return None |
766 | 70 | 92 | ||
767 | 93 | def get_current_version(self, init): | ||
768 | 94 | current_version = self.current_version | ||
769 | 95 | |||
770 | 96 | if current_version is not None: | ||
771 | 97 | return current_version | ||
772 | 98 | |||
773 | 99 | if init: | ||
774 | 100 | # We want to auto init the database anyway. | ||
775 | 101 | self.version_datastore() | ||
776 | 102 | return 0 | ||
777 | 103 | else: | ||
778 | 104 | raise Exception('Data store is not versioned') | ||
779 | 105 | |||
780 | 71 | def update_version(self, version): | 106 | def update_version(self, version): |
781 | 72 | """Update the version number in the data store.""" | 107 | """Update the version number in the data store.""" |
782 | 73 | self.db.migration_version.save({ | 108 | self.db.migration_version.save({ |
783 | @@ -93,6 +128,9 @@ | |||
784 | 93 | }) | 128 | }) |
785 | 94 | 129 | ||
786 | 95 | 130 | ||
787 | 131 | VersionInfo = namedtuple('VersionInfo', ['num', 'filename', 'is_exodus']) | ||
788 | 132 | |||
789 | 133 | |||
790 | 96 | class Versions(object): | 134 | class Versions(object): |
791 | 97 | 135 | ||
792 | 98 | def __init__(self, path): | 136 | def __init__(self, path): |
793 | @@ -103,7 +141,7 @@ | |||
794 | 103 | self.versions_dir = path | 141 | self.versions_dir = path |
795 | 104 | # Create temporary list of files, allowing skipped version numbers. | 142 | # Create temporary list of files, allowing skipped version numbers. |
796 | 105 | files = listdir(path) | 143 | files = listdir(path) |
798 | 106 | versions = {} | 144 | self.versions = {} |
799 | 107 | 145 | ||
800 | 108 | for name in files: | 146 | for name in files: |
801 | 109 | if not name.endswith('.py'): | 147 | if not name.endswith('.py'): |
802 | @@ -113,80 +151,149 @@ | |||
803 | 113 | match = FILENAME_WITH_VERSION.match(name) | 151 | match = FILENAME_WITH_VERSION.match(name) |
804 | 114 | if match: | 152 | if match: |
805 | 115 | num = int(match.group(1)) | 153 | num = int(match.group(1)) |
807 | 116 | versions[num] = name | 154 | self.versions[num] = name |
808 | 117 | else: | 155 | else: |
809 | 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. |
810 | 119 | 157 | ||
820 | 120 | self.versions = {} | 158 | def iter_versions(self, current): |
821 | 121 | version_numbers = versions.keys() | 159 | return (i for i in sorted(self.versions.items()) if i[0] > current) |
822 | 122 | version_numbers.sort() | 160 | |
823 | 123 | 161 | def version_path(self, description, version): | |
824 | 124 | self.version_indexes = version_numbers | 162 | filename = "{0}_{1}.py".format( |
825 | 125 | for idx in version_numbers: | 163 | str(version).zfill(3), |
826 | 126 | self.versions[idx] = versions[idx] | 164 | str_to_filename(description) |
827 | 127 | 165 | ) | |
828 | 128 | def create_new_version_file(self, description): | 166 | path = join(self.versions_dir, filename) |
829 | 167 | return path, filename | ||
830 | 168 | |||
831 | 169 | def create_new_version_file(self, description, content=None): | ||
832 | 129 | """Create Python files for new version""" | 170 | """Create Python files for new version""" |
833 | 130 | version = self.latest + 1 | 171 | version = self.latest + 1 |
834 | 131 | 172 | ||
835 | 132 | if not description: | 173 | if not description: |
836 | 133 | raise ValueError('Please provide a short migration description.') | 174 | raise ValueError('Please provide a short migration description.') |
847 | 134 | 175 | if content is None: | |
848 | 135 | filename = "{0}_{1}.py".format( | 176 | content = SCRIPT_TEMPLATE.format(description=description) |
849 | 136 | str(version).zfill(3), | 177 | path, filename = self.version_path(description, version) |
850 | 137 | str_to_filename(description) | 178 | with open(path, 'w') as new_migration: |
851 | 138 | ) | 179 | new_migration.write(content) |
842 | 139 | |||
843 | 140 | filepath = join(self.versions_dir, filename) | ||
844 | 141 | with open(filepath, 'w') as new_migration: | ||
845 | 142 | new_migration.write( | ||
846 | 143 | SCRIPT_TEMPLATE.format(description=description)) | ||
852 | 144 | 180 | ||
853 | 145 | # Update our current store of version data. | 181 | # Update our current store of version data. |
854 | 146 | self.versions[version] = filename | 182 | self.versions[version] = filename |
855 | 147 | self.version_indexes.append(version) | ||
856 | 148 | 183 | ||
857 | 149 | return filename | 184 | return filename |
858 | 150 | 185 | ||
859 | 151 | @property | 186 | @property |
860 | 152 | def latest(self): | 187 | def latest(self): |
861 | 153 | """:returns: Latest version in Collection""" | 188 | """:returns: Latest version in Collection""" |
866 | 154 | return self.version_indexes[-1] if len(self.version_indexes) > 0 else 0 | 189 | return max(self.versions.keys()) if len(self.versions) > 0 else 0 |
867 | 155 | 190 | ||
868 | 156 | def run_migration(self, db, index_client, module_name): | 191 | def get_exodus(self, filename): |
869 | 157 | module = imp.load_source( | 192 | module = self.import_version(filename) |
870 | 193 | return getattr(module, 'exodus_update', None) | ||
871 | 194 | |||
872 | 195 | def list_pending(self, current): | ||
873 | 196 | return [VersionInfo(n, f, self.get_exodus(f) is not None) for n, f in | ||
874 | 197 | self.iter_versions(current)] | ||
875 | 198 | |||
876 | 199 | def _check_exodus(self, version_info): | ||
877 | 200 | exodus_seen = False | ||
878 | 201 | migration_seen = False | ||
879 | 202 | for num, filename, is_exodus in version_info: | ||
880 | 203 | if is_exodus: | ||
881 | 204 | if migration_seen: | ||
882 | 205 | raise MigrationBeforeExodus( | ||
883 | 206 | 'There are migrations scheduled before an exodus.') | ||
884 | 207 | if exodus_seen: | ||
885 | 208 | raise MultipleExodus( | ||
886 | 209 | 'There are multiple exoduses scheduled.') | ||
887 | 210 | exodus_seen = True | ||
888 | 211 | else: | ||
889 | 212 | migration_seen = True | ||
890 | 213 | |||
891 | 214 | def import_version(self, module_name): | ||
892 | 215 | return imp.load_source( | ||
893 | 158 | module_name.strip('.py'), | 216 | module_name.strip('.py'), |
894 | 159 | join(self.versions_dir, module_name)) | 217 | join(self.versions_dir, module_name)) |
895 | 218 | |||
896 | 219 | def run_migration(self, db, index_client, module_name): | ||
897 | 220 | module = self.import_version(module_name) | ||
898 | 160 | getattr(module, 'upgrade')(db, index_client) | 221 | getattr(module, 'upgrade')(db, index_client) |
899 | 161 | 222 | ||
900 | 223 | def get_pending_source(self, source, number): | ||
901 | 224 | db = source.collection.database | ||
902 | 225 | pending_suffix = '_pending_%03d' % number | ||
903 | 226 | pending_name = source.index_client.index_name + pending_suffix | ||
904 | 227 | pending_index = source.index_client.get_aliasable_client( | ||
905 | 228 | pending_name) | ||
906 | 229 | pending_collection = db[source.collection.name + pending_suffix] | ||
907 | 230 | return CharmSource(db, pending_index, pending_collection) | ||
908 | 231 | |||
909 | 232 | def prepare_exodus(self, source, init=False): | ||
910 | 233 | db = source.collection.database | ||
911 | 234 | datastore = DataStore(db) | ||
912 | 235 | current_version = datastore.get_current_version(init) | ||
913 | 236 | pending_migrations = self.list_pending(current_version) | ||
914 | 237 | self._check_exodus(pending_migrations) | ||
915 | 238 | if len(pending_migrations) == 0 or not pending_migrations[0].is_exodus: | ||
916 | 239 | return None | ||
917 | 240 | number, filename, is_exodus = pending_migrations[0] | ||
918 | 241 | pending = self.get_pending_source(source, number) | ||
919 | 242 | temp_collection = db[pending.collection.name + 'temp'] | ||
920 | 243 | with source.index_client.replacement() as temp_index: | ||
921 | 244 | db.create_collection(temp_collection.name) | ||
922 | 245 | temp = CharmSource(db, temp_index, temp_collection) | ||
923 | 246 | self.get_exodus(filename)(source, temp) | ||
924 | 247 | aliased = pending.index_client.get_aliased() | ||
925 | 248 | temp_collection.rename(pending.collection.name, dropTarget=True) | ||
926 | 249 | pending.index_client.update_aliased(temp_index.index_name, aliased) | ||
927 | 250 | return pending | ||
928 | 251 | |||
929 | 252 | def finalize_exodus(self, source, version_number): | ||
930 | 253 | pending = self.get_pending_source(source, version_number) | ||
931 | 254 | aliased = source.index_client.get_aliased() | ||
932 | 255 | pending_aliased = pending.index_client.get_aliased() | ||
933 | 256 | if len(pending_aliased) == 0: | ||
934 | 257 | raise MissingExodusIndex( | ||
935 | 258 | 'Exodus index "%s" does not exist.' % | ||
936 | 259 | pending.index_client.index_name) | ||
937 | 260 | if len(pending_aliased) != 1: | ||
938 | 261 | raise AssertionError('More than index aliased to pending.') | ||
939 | 262 | try: | ||
940 | 263 | pending.collection.rename(source.collection.name, | ||
941 | 264 | dropTarget=True) | ||
942 | 265 | except OperationFailure as e: | ||
943 | 266 | if e.message.endswith('source namespace does not exist'): | ||
944 | 267 | raise MissingExodusCollection( | ||
945 | 268 | 'Exodus collection "%s" does not exist.' % | ||
946 | 269 | pending.collection.name) | ||
947 | 270 | raise | ||
948 | 271 | source.index_client.update_aliased( | ||
949 | 272 | pending_aliased[0], aliased) | ||
950 | 273 | for alias_name in aliased: | ||
951 | 274 | source.index_client.get_related_client(alias_name).delete_index() | ||
952 | 275 | |||
953 | 162 | def upgrade(self, datastore, index_client, init): | 276 | def upgrade(self, datastore, index_client, init): |
954 | 163 | """Run `upgrade` methods for required version files. | 277 | """Run `upgrade` methods for required version files. |
955 | 164 | 278 | ||
956 | 165 | :param datastore: An instance of DataStore | 279 | :param datastore: An instance of DataStore |
957 | 166 | 280 | ||
958 | 167 | """ | 281 | """ |
966 | 168 | current_version = datastore.current_version | 282 | current_version = datastore.get_current_version(init) |
967 | 169 | 283 | pending_versions = self.list_pending(current_version) | |
968 | 170 | if current_version is None: | 284 | self._check_exodus(pending_versions) |
969 | 171 | if init: | 285 | |
970 | 172 | # We want to auto init the database anyway. | 286 | if len(pending_versions) == 0: |
971 | 173 | datastore.version_datastore() | 287 | return None |
972 | 174 | current_version = 0 | 288 | |
973 | 289 | for num, module_name, is_exodus in pending_versions: | ||
974 | 290 | if is_exodus: | ||
975 | 291 | source = CharmSource(datastore.db, index_client) | ||
976 | 292 | self.finalize_exodus(source, pending_versions[0][0]) | ||
977 | 175 | else: | 293 | else: |
992 | 176 | raise Exception('Data store is not versioned') | 294 | self.run_migration(datastore.db, index_client, module_name) |
993 | 177 | 295 | datastore.update_version(num) | |
994 | 178 | if current_version >= self.latest: | 296 | return num |
981 | 179 | # Nothing to do here. All migrations processed already. | ||
982 | 180 | return None | ||
983 | 181 | |||
984 | 182 | while current_version < self.latest: | ||
985 | 183 | # Let's get processing. | ||
986 | 184 | next_version = current_version + 1 | ||
987 | 185 | module_name = self.versions[next_version] | ||
988 | 186 | self.run_migration(datastore.db, index_client, module_name) | ||
989 | 187 | current_version = next_version | ||
990 | 188 | datastore.update_version(current_version) | ||
991 | 189 | return current_version | ||
995 | 190 | 297 | ||
996 | 191 | 298 | ||
997 | 192 | def parse_args(): | 299 | def parse_args(): |
998 | @@ -214,6 +321,13 @@ | |||
999 | 214 | default=False, | 321 | default=False, |
1000 | 215 | help='Auto init the database if not already init.') | 322 | help='Auto init the database if not already init.') |
1001 | 216 | 323 | ||
1002 | 324 | parser_prepare_upgrade = subparsers.add_parser('prepare-upgrade') | ||
1003 | 325 | parser_prepare_upgrade.set_defaults(func=Commands.prepare_upgrade) | ||
1004 | 326 | parser_prepare_upgrade.add_argument( | ||
1005 | 327 | '-i', '--init', action='store_true', | ||
1006 | 328 | default=False, | ||
1007 | 329 | help='Auto init the database if not already init.') | ||
1008 | 330 | |||
1009 | 217 | args = parser.parse_args() | 331 | args = parser.parse_args() |
1010 | 218 | return args | 332 | return args |
1011 | 219 | 333 | ||
1012 | @@ -253,6 +367,12 @@ | |||
1013 | 253 | print "Created new migration: {0}".format(filename) | 367 | print "Created new migration: {0}".format(filename) |
1014 | 254 | 368 | ||
1015 | 255 | @classmethod | 369 | @classmethod |
1016 | 370 | def prepare_upgrade(cls, ini, args): | ||
1017 | 371 | source = CharmSource.from_settings(ini) | ||
1018 | 372 | migrations = Versions(ini['migrations']) | ||
1019 | 373 | migrations.prepare_exodus(source, args.init) | ||
1020 | 374 | |||
1021 | 375 | @classmethod | ||
1022 | 256 | def upgrade(cls, ini, args): | 376 | def upgrade(cls, ini, args): |
1023 | 257 | """Upgrade the data store to the latest available migration.""" | 377 | """Upgrade the data store to the latest available migration.""" |
1024 | 258 | ds = cls.get_datastore(ini) | 378 | ds = cls.get_datastore(ini) |
1025 | 259 | 379 | ||
1026 | === modified file 'migrations/test_migrate.py' | |||
1027 | --- migrations/test_migrate.py 2013-06-04 15:36:45 +0000 | |||
1028 | +++ migrations/test_migrate.py 2013-07-05 14:15:30 +0000 | |||
1029 | @@ -5,16 +5,29 @@ | |||
1030 | 5 | from os.path import join | 5 | from os.path import join |
1031 | 6 | from os.path import exists | 6 | from os.path import exists |
1032 | 7 | import pymongo | 7 | import pymongo |
1034 | 8 | from tempfile import mkdtemp | 8 | from textwrap import dedent |
1035 | 9 | 9 | ||
1039 | 10 | from charmworld.models import getconnection | 10 | from charmworld.models import ( |
1040 | 11 | from charmworld.models import getdb | 11 | CharmSource, |
1041 | 12 | from charmworld.testing import TestCase | 12 | getconnection, |
1042 | 13 | getdb, | ||
1043 | 14 | ) | ||
1044 | 15 | from charmworld.testing import ( | ||
1045 | 16 | MongoTestBase, | ||
1046 | 17 | TestCase, | ||
1047 | 18 | temp_dir | ||
1048 | 19 | ) | ||
1049 | 13 | from charmworld.testing.factory import random_string | 20 | from charmworld.testing.factory import random_string |
1050 | 14 | from charmworld.utils import get_ini | 21 | from charmworld.utils import get_ini |
1051 | 15 | 22 | ||
1054 | 16 | from migrate import DataStore | 23 | from migrate import ( |
1055 | 17 | from migrate import Versions | 24 | DataStore, |
1056 | 25 | MigrationBeforeExodus, | ||
1057 | 26 | MissingExodusCollection, | ||
1058 | 27 | MissingExodusIndex, | ||
1059 | 28 | MultipleExodus, | ||
1060 | 29 | Versions, | ||
1061 | 30 | ) | ||
1062 | 18 | 31 | ||
1063 | 19 | INI = get_ini() | 32 | INI = get_ini() |
1064 | 20 | MONGO_URL = environ.get('TEST_MONGODB') | 33 | MONGO_URL = environ.get('TEST_MONGODB') |
1065 | @@ -92,7 +105,7 @@ | |||
1066 | 92 | class TestVersions(TestCase): | 105 | class TestVersions(TestCase): |
1067 | 93 | """The local versions of the files.""" | 106 | """The local versions of the files.""" |
1068 | 94 | def setUp(self): | 107 | def setUp(self): |
1070 | 95 | self.tmpdir = mkdtemp() | 108 | self.tmpdir = self.use_context(temp_dir()) |
1071 | 96 | self.version_files = make_version_files(self.tmpdir, 4) | 109 | self.version_files = make_version_files(self.tmpdir, 4) |
1072 | 97 | self.versions = Versions(self.tmpdir) | 110 | self.versions = Versions(self.tmpdir) |
1073 | 98 | 111 | ||
1074 | @@ -102,9 +115,9 @@ | |||
1075 | 102 | 115 | ||
1076 | 103 | def test_latest_wo_migrations(self): | 116 | def test_latest_wo_migrations(self): |
1077 | 104 | """""" | 117 | """""" |
1081 | 105 | tmpdir = mkdtemp() | 118 | with temp_dir() as tmpdir: |
1082 | 106 | versions = Versions(tmpdir) | 119 | versions = Versions(tmpdir) |
1083 | 107 | self.assertEqual(versions.latest, 0) | 120 | self.assertEqual(versions.latest, 0) |
1084 | 108 | 121 | ||
1085 | 109 | def test_latest_version(self): | 122 | def test_latest_version(self): |
1086 | 110 | """latest returns the number of the newest revision.""" | 123 | """latest returns the number of the newest revision.""" |
1087 | @@ -128,6 +141,34 @@ | |||
1088 | 128 | # And this new file should exist in our versions directory | 141 | # And this new file should exist in our versions directory |
1089 | 129 | self.assertTrue(exists(join(self.tmpdir, self.versions.versions[5]))) | 142 | self.assertTrue(exists(join(self.tmpdir, self.versions.versions[5]))) |
1090 | 130 | 143 | ||
1091 | 144 | def test_list_pending(self): | ||
1092 | 145 | self.versions.create_new_version_file('foo', content=dedent(""" | ||
1093 | 146 | def exodus_update(): | ||
1094 | 147 | pass | ||
1095 | 148 | """)) | ||
1096 | 149 | self.versions.create_new_version_file('foo', content=dedent(""" | ||
1097 | 150 | def update(): | ||
1098 | 151 | pass | ||
1099 | 152 | """)) | ||
1100 | 153 | self.assertEqual([ | ||
1101 | 154 | (5, '005_foo.py', True), | ||
1102 | 155 | (6, '006_foo.py', False), | ||
1103 | 156 | ], self.versions.list_pending(4)) | ||
1104 | 157 | |||
1105 | 158 | def test_check_exodus(self): | ||
1106 | 159 | self.versions._check_exodus([]) | ||
1107 | 160 | with self.assertRaises(MigrationBeforeExodus): | ||
1108 | 161 | self.versions._check_exodus([ | ||
1109 | 162 | (1, '001_foo.py', False), (2, '002_bar.py', True)]) | ||
1110 | 163 | self.versions._check_exodus([ | ||
1111 | 164 | (1, '001_foo.py', True), (2, '002_bar.py', False)]) | ||
1112 | 165 | with self.assertRaises(MultipleExodus): | ||
1113 | 166 | self.versions._check_exodus([ | ||
1114 | 167 | (1, '001_foo.py', True), (2, '002_bar.py', True)]) | ||
1115 | 168 | self.versions._check_exodus([ | ||
1116 | 169 | (1, '001_foo.py', False), (2, '002_bar.py', False)]) | ||
1117 | 170 | |||
1118 | 171 | |||
1119 | 131 | 172 | ||
1120 | 132 | class TestVersionUpgrades(TestCase): | 173 | class TestVersionUpgrades(TestCase): |
1121 | 133 | """Functional testing of the actual upgrade process.""" | 174 | """Functional testing of the actual upgrade process.""" |
1122 | @@ -136,8 +177,9 @@ | |||
1123 | 136 | 'mongo.url': MONGO_URL, | 177 | 'mongo.url': MONGO_URL, |
1124 | 137 | }) | 178 | }) |
1125 | 138 | self.db = getdb(self.connection, 'juju_test') | 179 | self.db = getdb(self.connection, 'juju_test') |
1126 | 180 | self.use_index_client(aliased=True) | ||
1127 | 139 | self.ds = DataStore(self.db) | 181 | self.ds = DataStore(self.db) |
1129 | 140 | tmpdir = mkdtemp() | 182 | tmpdir = self.use_context(temp_dir()) |
1130 | 141 | make_version_files(tmpdir, 4, runnable=True) | 183 | make_version_files(tmpdir, 4, runnable=True) |
1131 | 142 | self.version = Versions(tmpdir) | 184 | self.version = Versions(tmpdir) |
1132 | 143 | 185 | ||
1133 | @@ -155,9 +197,8 @@ | |||
1134 | 155 | 197 | ||
1135 | 156 | def test_upgrade_wo_migrations(self): | 198 | def test_upgrade_wo_migrations(self): |
1136 | 157 | """Upgrade runs silently if no migrations are created yet.""" | 199 | """Upgrade runs silently if no migrations are created yet.""" |
1137 | 158 | self.use_index_client() | ||
1138 | 159 | self.ds.version_datastore() | 200 | self.ds.version_datastore() |
1140 | 160 | tmpdir = mkdtemp() | 201 | tmpdir = self.use_context(temp_dir()) |
1141 | 161 | versions = Versions(tmpdir) | 202 | versions = Versions(tmpdir) |
1142 | 162 | self.assertEqual(versions.upgrade(self.ds, self.index_client, False), | 203 | self.assertEqual(versions.upgrade(self.ds, self.index_client, False), |
1143 | 163 | None) | 204 | None) |
1144 | @@ -165,7 +206,6 @@ | |||
1145 | 165 | def test_upgrade_succeeds(self): | 206 | def test_upgrade_succeeds(self): |
1146 | 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.""" |
1147 | 167 | self.ds.version_datastore() | 208 | self.ds.version_datastore() |
1148 | 168 | self.use_index_client() | ||
1149 | 169 | # We should get 4 versions done. The upgrade returns 4 | 209 | # We should get 4 versions done. The upgrade returns 4 |
1150 | 170 | last_version = self.version.upgrade(self.ds, self.index_client, False) | 210 | last_version = self.version.upgrade(self.ds, self.index_client, False) |
1151 | 171 | self.assertEqual(4, last_version) | 211 | self.assertEqual(4, last_version) |
1152 | @@ -175,10 +215,180 @@ | |||
1153 | 175 | 215 | ||
1154 | 176 | def test_upgrade_succeeds_from_unversioned(self): | 216 | def test_upgrade_succeeds_from_unversioned(self): |
1155 | 177 | """Forcing init no an unversioned db will upgrade successfully.""" | 217 | """Forcing init no an unversioned db will upgrade successfully.""" |
1156 | 178 | self.use_index_client() | ||
1157 | 179 | last_version = self.version.upgrade(self.ds, self.index_client, True) | 218 | last_version = self.version.upgrade(self.ds, self.index_client, True) |
1158 | 180 | self.assertEqual(4, last_version) | 219 | self.assertEqual(4, last_version) |
1159 | 181 | 220 | ||
1160 | 182 | # and we can query the db for the revision of 4 | 221 | # and we can query the db for the revision of 4 |
1161 | 183 | self.assertEqual(4, self.ds.current_version) | 222 | self.assertEqual(4, self.ds.current_version) |
1162 | 184 | 223 | ||
1163 | 224 | def create_exodus_environment(self, index_client=None): | ||
1164 | 225 | if index_client is None: | ||
1165 | 226 | index_client = self.index_client | ||
1166 | 227 | versions = Versions(self.use_context(temp_dir())) | ||
1167 | 228 | versions.create_new_version_file( | ||
1168 | 229 | random_string(20), content=dedent(""" | ||
1169 | 230 | def exodus_update(source, target): | ||
1170 | 231 | pass | ||
1171 | 232 | """)) | ||
1172 | 233 | source = CharmSource(self.db, index_client) | ||
1173 | 234 | return versions, source | ||
1174 | 235 | |||
1175 | 236 | def test_upgrade_errors_if_no_exodus_db(self): | ||
1176 | 237 | versions, source = self.create_exodus_environment() | ||
1177 | 238 | aliased, = source.index_client.get_aliased() | ||
1178 | 239 | pending = versions.get_pending_source(source, 1) | ||
1179 | 240 | pending_alias = self.create_aliased(pending.index_client) | ||
1180 | 241 | self.addCleanup(pending_alias.delete_index) | ||
1181 | 242 | pending.index_client.wait_for_startup() | ||
1182 | 243 | with self.assertRaises(MissingExodusCollection): | ||
1183 | 244 | versions.upgrade(self.ds, source.index_client, True) | ||
1184 | 245 | self.assertEqual([aliased], source.index_client.get_aliased()) | ||
1185 | 246 | |||
1186 | 247 | def test_upgrade_errors_if_no_exodus_index(self): | ||
1187 | 248 | versions, source = self.create_exodus_environment() | ||
1188 | 249 | pending = versions.get_pending_source(source, 1) | ||
1189 | 250 | pending.create_collection() | ||
1190 | 251 | self.addCleanup(pending.collection.drop) | ||
1191 | 252 | with self.assertRaises(MissingExodusIndex): | ||
1192 | 253 | versions.upgrade(self.ds, self.index_client, True) | ||
1193 | 254 | self.assertIn(pending.collection.name, | ||
1194 | 255 | pending.collection.database.collection_names()) | ||
1195 | 256 | |||
1196 | 257 | def create_aliased(self, index_client): | ||
1197 | 258 | alias_client = index_client.get_aliasable_client() | ||
1198 | 259 | alias_client.create_index() | ||
1199 | 260 | index_client.update_aliased(alias_client.index_name, []) | ||
1200 | 261 | return alias_client | ||
1201 | 262 | |||
1202 | 263 | def create_exodus_success_environment(self): | ||
1203 | 264 | versions, source = self.create_exodus_environment() | ||
1204 | 265 | pending = versions.get_pending_source(source, 1) | ||
1205 | 266 | pending.create_collection() | ||
1206 | 267 | pending_alias = self.create_aliased(pending.index_client) | ||
1207 | 268 | return source, pending, versions | ||
1208 | 269 | |||
1209 | 270 | def test_finalize_exodus_success(self): | ||
1210 | 271 | source, pending, versions = self.create_exodus_success_environment() | ||
1211 | 272 | aliased = source.index_client.get_aliased() | ||
1212 | 273 | pending.save({'_id': 'a', 'message': 'hello'}) | ||
1213 | 274 | versions.finalize_exodus(source, 1) | ||
1214 | 275 | for charm in source._get_all('a'): | ||
1215 | 276 | self.assertEqual({'_id': 'a', 'message': 'hello'}, charm) | ||
1216 | 277 | self.assertNotIn(pending.collection.name, | ||
1217 | 278 | pending.collection.database.collection_names()) | ||
1218 | 279 | for aliased_name in aliased: | ||
1219 | 280 | aliased_client = source.index_client.get_related_client( | ||
1220 | 281 | aliased_name) | ||
1221 | 282 | self.assertFalse(aliased_client.exists()) | ||
1222 | 283 | |||
1223 | 284 | def test_upgrade_succeeds_with_exodus(self): | ||
1224 | 285 | source, pending, versions = self.create_exodus_success_environment() | ||
1225 | 286 | pending.save({'_id': 'a', 'message': 'hello'}) | ||
1226 | 287 | versions.upgrade(self.ds, source.index_client, True) | ||
1227 | 288 | |||
1228 | 289 | def test_upgrade_errors_on_non_initial_exodus(self): | ||
1229 | 290 | versions = Versions(self.use_context(temp_dir())) | ||
1230 | 291 | versions.create_new_version_file(random_string(20)) | ||
1231 | 292 | versions.create_new_version_file( | ||
1232 | 293 | random_string(20), content=dedent(""" | ||
1233 | 294 | def exodus_update(source, target): | ||
1234 | 295 | pass | ||
1235 | 296 | """)) | ||
1236 | 297 | with self.assertRaises(MigrationBeforeExodus): | ||
1237 | 298 | versions.upgrade(self.ds, self.index_client, True) | ||
1238 | 299 | |||
1239 | 300 | |||
1240 | 301 | class TestExodus(MongoTestBase): | ||
1241 | 302 | |||
1242 | 303 | def setUp(self): | ||
1243 | 304 | super(TestExodus, self).setUp() | ||
1244 | 305 | self.use_index_client(aliased=True) | ||
1245 | 306 | self.source = CharmSource.from_request(self) | ||
1246 | 307 | self.versions = Versions(self.use_context(temp_dir())) | ||
1247 | 308 | |||
1248 | 309 | def test_prepare_exodus_checks_validity(self): | ||
1249 | 310 | self.versions.create_new_version_file( | ||
1250 | 311 | random_string(20), content=dedent(""" | ||
1251 | 312 | def update(source, target): | ||
1252 | 313 | pass | ||
1253 | 314 | """)) | ||
1254 | 315 | self.versions.create_new_version_file( | ||
1255 | 316 | random_string(20), content=dedent(""" | ||
1256 | 317 | def exodus_update(source, target): | ||
1257 | 318 | pass | ||
1258 | 319 | """)) | ||
1259 | 320 | with self.assertRaises(MigrationBeforeExodus): | ||
1260 | 321 | self.versions.prepare_exodus(self.source, init=True) | ||
1261 | 322 | |||
1262 | 323 | def test_prepare_exodus_initializes(self): | ||
1263 | 324 | with self.assertRaises(Exception) as exc_context: | ||
1264 | 325 | self.versions.prepare_exodus(self.source, init=False) | ||
1265 | 326 | self.assertEqual('Data store is not versioned', | ||
1266 | 327 | str(exc_context.exception)) | ||
1267 | 328 | self.versions.prepare_exodus(self.source, init=True) | ||
1268 | 329 | ds = DataStore(self.source.collection.database) | ||
1269 | 330 | self.assertEqual(0, ds.current_version) | ||
1270 | 331 | |||
1271 | 332 | def test_prepare_exodus_acquires_pending(self): | ||
1272 | 333 | pending = self.versions.prepare_exodus(self.source, init=True) | ||
1273 | 334 | self.assertIs(None, pending) | ||
1274 | 335 | self.versions.create_new_version_file('foo', content=dedent(""" | ||
1275 | 336 | def exodus_update(source, target): | ||
1276 | 337 | pass | ||
1277 | 338 | """)) | ||
1278 | 339 | pending = self.versions.prepare_exodus(self.source) | ||
1279 | 340 | self.addCleanup(pending.index_client.delete_index) | ||
1280 | 341 | self.assertIsInstance(pending, CharmSource) | ||
1281 | 342 | |||
1282 | 343 | def test_prepare_exodus_runs_exodus_update(self): | ||
1283 | 344 | self.versions.create_new_version_file('foo', content=dedent(""" | ||
1284 | 345 | def exodus_update(source, target): | ||
1285 | 346 | target.save({'_id': 'a', 'message': 'hello'}) | ||
1286 | 347 | """)) | ||
1287 | 348 | pending = self.versions.prepare_exodus(self.source, init=True) | ||
1288 | 349 | self.addCleanup(pending.index_client.delete_index) | ||
1289 | 350 | for charm in pending._get_all('a'): | ||
1290 | 351 | self.assertEqual({'_id': 'a', 'message': 'hello'}, charm) | ||
1291 | 352 | |||
1292 | 353 | def test_exodus_update_runs_against_temp_but_generates_pending(self): | ||
1293 | 354 | self.versions.create_new_version_file('foo', content=dedent(""" | ||
1294 | 355 | def exodus_update(source, target): | ||
1295 | 356 | if target.index_client.index_name == 'temp_index_pending_001': | ||
1296 | 357 | raise AssertionError('Used pending name!') | ||
1297 | 358 | if target.collection.name == 'charms_pending_001': | ||
1298 | 359 | raise AssertionError('Used pending name!') | ||
1299 | 360 | """)) | ||
1300 | 361 | pending = self.versions.prepare_exodus(self.source, init=True) | ||
1301 | 362 | self.addCleanup(pending.index_client.delete_index) | ||
1302 | 363 | self.assertEqual('temp_index_pending_001', | ||
1303 | 364 | pending.index_client.index_name) | ||
1304 | 365 | self.assertEqual('charms_pending_001', pending.collection.name) | ||
1305 | 366 | |||
1306 | 367 | def test_no_leftovers(self): | ||
1307 | 368 | db = self.source.collection.database | ||
1308 | 369 | index_client = self.source.index_client._client | ||
1309 | 370 | index_keys = index_client.status()['indices'].keys() | ||
1310 | 371 | index_count = len(index_client.status()['indices']) | ||
1311 | 372 | DataStore(db).version_datastore() | ||
1312 | 373 | self.source.create_collection() | ||
1313 | 374 | collection_names = db.collection_names() | ||
1314 | 375 | self.versions.create_new_version_file('foo', content=dedent(""" | ||
1315 | 376 | def exodus_update(source, target): | ||
1316 | 377 | pass | ||
1317 | 378 | """)) | ||
1318 | 379 | self.versions.prepare_exodus(self.source, init=True) | ||
1319 | 380 | self.versions.finalize_exodus(self.source, 1) | ||
1320 | 381 | self.assertEqual(collection_names, db.collection_names()) | ||
1321 | 382 | self.assertEqual(index_count, len(index_client.status()['indices'])) | ||
1322 | 383 | |||
1323 | 384 | def test_suffix_of_source(self): | ||
1324 | 385 | self.versions.create_new_version_file('foo', content=dedent(""" | ||
1325 | 386 | import re | ||
1326 | 387 | |||
1327 | 388 | def exodus_update(source, target): | ||
1328 | 389 | target_name = target.index_client.index_name | ||
1329 | 390 | match = re.match('(.*)-([0-9]*)', target_name) | ||
1330 | 391 | if match.group(1) != source.index_client.index_name: | ||
1331 | 392 | raise AssertionError('Poor name: ' + target_name) | ||
1332 | 393 | """)) | ||
1333 | 394 | self.versions.prepare_exodus(self.source, init=True) | ||
1334 | 185 | 395 | ||
1335 | === removed file 'migrations/versions/005_no_op.py' | |||
1336 | --- migrations/versions/005_no_op.py 2013-06-04 18:22:19 +0000 | |||
1337 | +++ migrations/versions/005_no_op.py 1970-01-01 00:00:00 +0000 | |||
1338 | @@ -1,5 +0,0 @@ | |||
1339 | 1 | # Copyright 2013 Canonical Ltd. This software is licensed under the | ||
1340 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
1341 | 3 | |||
1342 | 4 | def upgrade(db, index_client): | ||
1343 | 5 | """005 failed to remove icons from charms, so now disabled.""" | ||
1344 | 6 | 0 | ||
1345 | === removed file 'migrations/versions/006_no_op.py' | |||
1346 | --- migrations/versions/006_no_op.py 2013-06-05 18:30:47 +0000 | |||
1347 | +++ migrations/versions/006_no_op.py 1970-01-01 00:00:00 +0000 | |||
1348 | @@ -1,5 +0,0 @@ | |||
1349 | 1 | # Copyright 2013 Canonical Ltd. This software is licensed under the | ||
1350 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
1351 | 3 | |||
1352 | 4 | def upgrade(db, index_client): | ||
1353 | 5 | """006 lacked a corresponding ingest update, so now disabled.""" | ||
1354 | 6 | 0 | ||
1355 | === removed file 'migrations/versions/007_no_op.py' | |||
1356 | --- migrations/versions/007_no_op.py 2013-06-11 19:13:07 +0000 | |||
1357 | +++ migrations/versions/007_no_op.py 1970-01-01 00:00:00 +0000 | |||
1358 | @@ -1,6 +0,0 @@ | |||
1359 | 1 | # Copyright 2013 Canonical Ltd. This software is licensed under the | ||
1360 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
1361 | 3 | |||
1362 | 4 | |||
1363 | 5 | def upgrade(db, index_client): | ||
1364 | 6 | """007 was renamed to 008 because of a deployment issue in production""" | ||
1365 | 7 | 0 | ||
1366 | === added file 'migrations/versions/010_remap_options.py' | |||
1367 | --- migrations/versions/010_remap_options.py 1970-01-01 00:00:00 +0000 | |||
1368 | +++ migrations/versions/010_remap_options.py 2013-07-05 14:15:30 +0000 | |||
1369 | @@ -0,0 +1,17 @@ | |||
1370 | 1 | # Copyright 2013 Canonical Ltd. This software is licensed under the | ||
1371 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | ||
1372 | 3 | |||
1373 | 4 | from charmworld.models import ( | ||
1374 | 5 | options_to_storage, | ||
1375 | 6 | ) | ||
1376 | 7 | |||
1377 | 8 | |||
1378 | 9 | def exodus_update(source, target): | ||
1379 | 10 | for charm in source.collection.find(): | ||
1380 | 11 | config = charm.get('config') | ||
1381 | 12 | if config is not None and 'options' in config: | ||
1382 | 13 | config['options'] = options_to_storage(config['options']) | ||
1383 | 14 | try: | ||
1384 | 15 | target.save(charm) | ||
1385 | 16 | except Exception as e: | ||
1386 | 17 | print e | ||
1387 | 0 | 18 | ||
1388 | === modified file 'migrations/versions/tests/test_migrations.py' | |||
1389 | --- migrations/versions/tests/test_migrations.py 2013-06-11 19:18:47 +0000 | |||
1390 | +++ migrations/versions/tests/test_migrations.py 2013-07-05 14:15:30 +0000 | |||
1391 | @@ -1,34 +1,95 @@ | |||
1392 | 1 | # Copyright 2012, 2013 Canonical Ltd. This software is licensed under the | 1 | # Copyright 2012, 2013 Canonical Ltd. This software is licensed under the |
1393 | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). | 2 | # GNU Affero General Public License version 3 (see the file LICENSE). |
1394 | 3 | 3 | ||
1395 | 4 | from pyelasticsearch.exceptions import ( | ||
1396 | 5 | ElasticHttpNotFoundError, | ||
1397 | 6 | ) | ||
1398 | 4 | from charmworld.models import CharmSource | 7 | from charmworld.models import CharmSource |
1399 | 5 | from charmworld.testing import MongoTestBase | 8 | from charmworld.testing import MongoTestBase |
1400 | 6 | from migrations.migrate import Versions | 9 | from migrations.migrate import Versions |
1401 | 7 | 10 | ||
1402 | 8 | 11 | ||
1409 | 9 | def run_migration(db, index_client, module_name): | 12 | class MigrationTestBase(MongoTestBase): |
1410 | 10 | versions = Versions('migrations/versions/') | 13 | |
1411 | 11 | versions.run_migration(db, index_client, module_name) | 14 | def setUp(self): |
1412 | 12 | 15 | super(MigrationTestBase, self).setUp() | |
1413 | 13 | 16 | self.versions = Versions('migrations/versions/') | |
1414 | 14 | class TestMigration004(MongoTestBase): | 17 | |
1415 | 18 | |||
1416 | 19 | class TestMigration004(MigrationTestBase): | ||
1417 | 15 | 20 | ||
1418 | 16 | def test_migration(self): | 21 | def test_migration(self): |
1419 | 17 | self.use_index_client() | 22 | self.use_index_client() |
1420 | 18 | self.db.create_collection('charm-errors') | 23 | self.db.create_collection('charm-errors') |
1422 | 19 | run_migration(self.db, self.index_client, '004_remove_charm_errors.py') | 24 | self.versions.run_migration(self.db, self.index_client, |
1423 | 25 | '004_remove_charm_errors.py') | ||
1424 | 20 | self.assertNotIn('charm-errors', self.db.collection_names()) | 26 | self.assertNotIn('charm-errors', self.db.collection_names()) |
1425 | 21 | 27 | ||
1426 | 22 | 28 | ||
1428 | 23 | class TestMigration008(MongoTestBase): | 29 | class TestMigration008(MigrationTestBase): |
1429 | 24 | 30 | ||
1430 | 25 | def test_migration(self): | 31 | def test_migration(self): |
1431 | 26 | self.use_index_client() | 32 | self.use_index_client() |
1432 | 27 | source = CharmSource.from_request(self) | 33 | source = CharmSource.from_request(self) |
1433 | 28 | source.save({'_id': 'a', 'icon': 'asdf', 'asdf': 'asdf'}) | 34 | source.save({'_id': 'a', 'icon': 'asdf', 'asdf': 'asdf'}) |
1434 | 29 | source.save({'_id': 'b', 'icon': 'asdf', 'asdf': 'asdf'}) | 35 | source.save({'_id': 'b', 'icon': 'asdf', 'asdf': 'asdf'}) |
1440 | 30 | run_migration(self.db, self.index_client, '008_delete_icon_field.py') | 36 | self.versions.run_migration(self.db, self.index_client, |
1441 | 31 | for charm in source._get_all('a'): | 37 | '008_delete_icon_field.py') |
1442 | 32 | self.assertNotIn('icon', charm) | 38 | for charm in source._get_all('a'): |
1443 | 33 | for charm in source._get_all('b'): | 39 | self.assertNotIn('icon', charm) |
1444 | 34 | self.assertNotIn('icon', charm) | 40 | for charm in source._get_all('b'): |
1445 | 41 | self.assertNotIn('icon', charm) | ||
1446 | 42 | |||
1447 | 43 | |||
1448 | 44 | class TestMigration010(MigrationTestBase): | ||
1449 | 45 | |||
1450 | 46 | def test_migration(self): | ||
1451 | 47 | self.use_index_client() | ||
1452 | 48 | source = CharmSource.from_request(self) | ||
1453 | 49 | source.save({ | ||
1454 | 50 | '_id': 'a', | ||
1455 | 51 | 'config': None, | ||
1456 | 52 | }) | ||
1457 | 53 | source.save({ | ||
1458 | 54 | '_id': 'b', | ||
1459 | 55 | 'config': { | ||
1460 | 56 | 'options': {}, | ||
1461 | 57 | }, | ||
1462 | 58 | }) | ||
1463 | 59 | source.save({ | ||
1464 | 60 | '_id': 'c', | ||
1465 | 61 | 'config': { | ||
1466 | 62 | 'options': {'foo': {'default': 'bar'}}, | ||
1467 | 63 | }, | ||
1468 | 64 | }) | ||
1469 | 65 | source.save({'_id': 'd'}) | ||
1470 | 66 | # This is not acceptable to Elasticsearch, because config is a list. | ||
1471 | 67 | self.db.charms.save({ | ||
1472 | 68 | '_id': 'e', | ||
1473 | 69 | 'config': ['a'], | ||
1474 | 70 | }) | ||
1475 | 71 | self.versions.get_exodus('010_remap_options.py')(source, source) | ||
1476 | 72 | for charm in source._get_all('a'): | ||
1477 | 73 | self.assertEqual({ | ||
1478 | 74 | '_id': 'a', | ||
1479 | 75 | 'config': None, | ||
1480 | 76 | }, charm) | ||
1481 | 77 | for charm in source._get_all('b'): | ||
1482 | 78 | self.assertEqual({ | ||
1483 | 79 | '_id': 'b', | ||
1484 | 80 | 'config': {'options': []}, | ||
1485 | 81 | }, charm) | ||
1486 | 82 | for charm in source._get_all('c'): | ||
1487 | 83 | self.assertEqual({ | ||
1488 | 84 | '_id': 'c', | ||
1489 | 85 | 'config': {'options': [{ | ||
1490 | 86 | 'name': 'foo', | ||
1491 | 87 | 'default': 'bar', | ||
1492 | 88 | }]} | ||
1493 | 89 | }, charm) | ||
1494 | 90 | for charm in source._get_all('d'): | ||
1495 | 91 | self.assertEqual({'_id': 'd'}, charm) | ||
1496 | 92 | with self.assertRaises(ElasticHttpNotFoundError): | ||
1497 | 93 | self.index_client.get('e') | ||
1498 | 94 | self.assertEqual({'_id': 'e', 'config': ['a']}, | ||
1499 | 95 | self.db.charms.find_one({'_id': 'e'})) |
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.