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

Proposed by Aaron Bentley
Status: Merged
Merged at revision: 299
Proposed branch: lp:~abentley/charmworld/config-storage
Merge into: lp:~juju-jitsu/charmworld/trunk
Prerequisite: lp:~abentley/charmworld/slow-migrations-2
Diff against target: 627 lines (+336/-48)
12 files modified
charmworld/jobs/ingest.py (+13/-6)
charmworld/jobs/tests/test_scan.py (+34/-2)
charmworld/models.py (+41/-3)
charmworld/search.py (+20/-5)
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/charms.py (+3/-0)
charmworld/views/tests/test_charms.py (+63/-7)
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+173218@code.launchpad.net

This proposal supersedes a proposal from 2013-06-27.

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.

In this update, the upgrade is performed as an exodus, rather than a fast migration. Also, some unused code is deleted.

Note: This branch must not land until a corresponding update to the charm is in place.

To post a comment you must log in.
Revision history for this message
Curtis Hovey (sinzui) wrote : Posted in a previous version of this proposal

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)
Revision history for this message
Curtis Hovey (sinzui) wrote :

Thank you.

review: Approve (code)

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:17:29 +0000
4@@ -32,11 +32,14 @@
5 get_branch_info,
6 parse_date,
7 )
8-from charmworld.models import getconnection
9-from charmworld.models import getdb
10-from charmworld.models import getfs
11-from charmworld.models import CharmFileSet
12-from charmworld.models import CharmSource
13+from charmworld.models import (
14+ CharmFileSet,
15+ CharmSource,
16+ getconnection,
17+ getdb,
18+ getfs,
19+ options_to_storage,
20+)
21 from charmworld.search import (
22 ElasticSearchClient,
23 SearchServiceNotAvailable,
24@@ -491,7 +494,11 @@
25 config_raw = cfile.read()
26
27 try:
28- config = quote_yaml(yaml.load(config_raw))
29+ config_yaml = yaml.load(config_raw)
30+ if 'options' in config_yaml:
31+ config_yaml['options'] = options_to_storage(
32+ config_yaml['options'])
33+ config = quote_yaml(config_yaml)
34 except Exception, exc:
35 raise IngestError(
36 'Invalid charm config yaml. %s: %s' % (
37
38=== modified file 'charmworld/jobs/tests/test_scan.py'
39--- charmworld/jobs/tests/test_scan.py 2013-06-24 15:45:29 +0000
40+++ charmworld/jobs/tests/test_scan.py 2013-07-05 14:17:29 +0000
41@@ -34,8 +34,40 @@
42 # 'foo.bar' is replaced with 'foo%2Ebar'.
43 self.assertFalse('dotted.name' in self.charm)
44 self.assertEqual('foo', self.charm['dotted%2Ename'])
45- self.assertFalse('foo.bar' in self.charm['config']['options'])
46- self.assertEqual('baz', self.charm['config']['options']['foo%2Ebar'])
47+
48+ def test_options_format(self):
49+ del self.charm['config']
50+ scan_charm(self.charm, self.db, self.fs, self.log)
51+ expected = [
52+ {
53+ 'name': 'source',
54+ 'default': 'lp:charmworld',
55+ 'type': 'string',
56+ 'description': 'The bzr branch to pull the charmworld source'
57+ ' from.'
58+ },
59+ {
60+ 'name': 'revno',
61+ 'default': -1,
62+ 'type': 'int',
63+ 'description': 'The revno of the bzr branch to use. -1 for'
64+ ' current tip.'
65+ },
66+ {
67+ 'name': 'execute-ingest-every',
68+ 'default': 15,
69+ 'type': 'int',
70+ 'description': 'The amount of time (in minutes) that the'
71+ ' ingest tasks for charmworld are executed'
72+ },
73+ {
74+ 'name': 'error-email',
75+ 'default': '',
76+ 'type': 'string',
77+ 'description': 'Address to mail errors to.'
78+ }
79+ ]
80+ self.assertItemsEqual(expected, self.charm['config']['options'])
81
82 def test_short_interface_specification(self):
83 # Charms can specify the interfaces they require or provide
84
85=== modified file 'charmworld/models.py'
86--- charmworld/models.py 2013-07-05 14:17:29 +0000
87+++ charmworld/models.py 2013-07-05 14:17:29 +0000
88@@ -544,7 +544,11 @@
89
90 The dict is parsed from the config_raw property's YAML.
91 """
92- return self._representation['config']
93+ config = self._representation['config']
94+ if config is not None and 'options' in config:
95+ config = dict(config)
96+ config['options'] = storage_to_options(config['options'])
97+ return config
98
99 @property
100 def config_raw(self):
101@@ -557,7 +561,7 @@
102 @property
103 def options(self):
104 """The charm's configuration options."""
105- config = self._representation['config']
106+ config = self.config
107 if config is not None and 'options' in config:
108 return config['options']
109 else:
110@@ -992,7 +996,11 @@
111
112 def save(self, charm):
113 self.collection.save(charm)
114- self.index_client.index_charm(charm)
115+ try:
116+ self.index_client.index_charm(charm)
117+ except:
118+ self.index_client.delete_charm(charm['_id'])
119+ raise
120
121 def get_store_charms(self):
122 return self.collection.find({'store_url': {'$exists': True}})
123@@ -1078,3 +1086,33 @@
124 logger.exception('Unable to index charm.')
125 else:
126 logger.info('Syncing %s to Elasticsearch' % charm_id)
127+
128+
129+def options_to_storage(options):
130+ """Convert options, as retrieved from config.yaml, into a list.
131+
132+ This format avoids user-defined keys, which are problematic in
133+ elasticsearch.
134+ """
135+ storage = []
136+ for key, value in options.items():
137+ option = {'name': key}
138+ option.update(value)
139+ storage.append(option)
140+ return storage
141+
142+
143+def storage_to_options(storage):
144+ """Convert options, as retrieved from mongodb, into a dict.
145+
146+ This format has the same structure as config.yaml.
147+ """
148+ options = {}
149+ for option in storage:
150+ new_option = dict(option)
151+ del new_option['name']
152+ value = options.setdefault(option['name'], new_option)
153+ if value is not new_option:
154+ raise ValueError('Option name "%s" occurs multiple times.' %
155+ option['name'])
156+ return options
157
158=== modified file 'charmworld/search.py'
159--- charmworld/search.py 2013-07-05 14:17:29 +0000
160+++ charmworld/search.py 2013-07-05 14:17:29 +0000
161@@ -30,7 +30,7 @@
162 'name': 10,
163 'summary': 5,
164 'description': 3,
165- 'config.options.*.description': None,
166+ 'config.options.description': None,
167 'relations': None,
168 }
169
170@@ -154,13 +154,24 @@
171 """
172 exact_index = ['categories', 'name', 'owner', 'i_provides',
173 'i_requires', 'series', 'store_url']
174+ charm_properties = dict(
175+ (name, {'type': 'string', 'index': 'not_analyzed'})
176+ for name in exact_index)
177+ charm_properties['config'] = {
178+ 'properties': {
179+ 'options': {
180+ 'properties': {
181+ 'default': {'type': 'string'},
182+ 'description': {'type': 'string'},
183+ }
184+ }
185+ }
186+ }
187+
188 with translate_error():
189 self._client.put_mapping(self.index_name, 'charm', {
190 'charm': {
191- 'properties': dict(
192- (name, {'type': 'string', 'index': 'not_analyzed'})
193- for name in exact_index
194- )
195+ 'properties': charm_properties,
196 }
197 })
198
199@@ -174,6 +185,10 @@
200 self._client.index(self.index_name, 'charm', charm, charm_id,
201 refresh=True)
202
203+ def delete_charm(self, charm_id):
204+ with translate_error():
205+ self._client.delete(self.index_name, 'charm', charm_id)
206+
207 def index_charms(self, charms):
208 if len(charms) == 0:
209 return
210
211=== modified file 'charmworld/testing/data/sample_charm/config.yaml'
212--- charmworld/testing/data/sample_charm/config.yaml 2013-02-07 17:26:59 +0000
213+++ charmworld/testing/data/sample_charm/config.yaml 2013-07-05 14:17:29 +0000
214@@ -15,4 +15,3 @@
215 type: string
216 default: ""
217 description: "Address to mail errors to."
218- foo.bar: baz
219
220=== modified file 'charmworld/testing/factory.py'
221--- charmworld/testing/factory.py 2013-07-01 14:45:02 +0000
222+++ charmworld/testing/factory.py 2013-07-05 14:17:29 +0000
223@@ -24,8 +24,9 @@
224 process_charm,
225 )
226 from charmworld.models import (
227+ CharmFileSet,
228 getfs,
229- CharmFileSet,
230+ options_to_storage,
231 )
232
233
234@@ -185,6 +186,7 @@
235 'description': 'The interval between script runs.'
236 },
237 }
238+ options = options_to_storage(options)
239 if files is None:
240 files = {
241 'readme': {
242
243=== modified file 'charmworld/tests/test_models.py'
244--- charmworld/tests/test_models.py 2013-07-01 14:45:02 +0000
245+++ charmworld/tests/test_models.py 2013-07-05 14:17:29 +0000
246@@ -14,6 +14,10 @@
247 from os.path import join
248
249 from mock import patch
250+from pyelasticsearch.exceptions import (
251+ ElasticHttpError,
252+ ElasticHttpNotFoundError,
253+)
254
255 from charmworld.models import (
256 acquire_session_secret,
257@@ -22,7 +26,9 @@
258 CharmFileSet,
259 CharmSource,
260 find_charms,
261+ options_to_storage,
262 QAData,
263+ storage_to_options,
264 sync_index,
265 UserMgr,
266 )
267@@ -437,11 +443,10 @@
268
269 def test_config(self):
270 # The config property is a dict of charm options.
271- config = {'options': {'mode': 'fast'}}
272- charm_data = factory.get_charm_json()
273- charm_data['config'] = config
274+ config = {'options': {'key': {'default': 'value', 'type': 'string'}}}
275+ charm_data = factory.get_charm_json(options=config['options'])
276 charm = Charm(charm_data)
277- self.assertIs(config, charm.config)
278+ self.assertEqual(config, charm.config)
279 # The default is a dict with a single key named 'options' set to
280 # an empty dict.
281 charm = Charm({})
282@@ -460,12 +465,10 @@
283
284 def test_options(self):
285 # The options property is a dict from the charm's config.
286- options = {'mode': 'fast'}
287- config = {'options': options}
288- charm_data = factory.get_charm_json()
289- charm_data['config'] = config
290+ options = {'key': {'default': 'value', 'type': 'string'}}
291+ charm_data = factory.get_charm_json(options=options)
292 charm = Charm(charm_data)
293- self.assertIs(options, charm.options)
294+ self.assertEqual(options, charm.options)
295 # The default is a dict.
296 charm = Charm({})
297 self.assertEqual({}, charm.options)
298@@ -1040,3 +1043,58 @@
299 messages = [r.getMessage() for r in handler.buffer]
300 self.assertIn('Unable to index charm.', messages)
301 self.assertEqual(1, len(self.index_client.api_search()))
302+
303+ def test_save_deletes_on_error(self):
304+ self.use_index_client()
305+ source = CharmSource.from_request(self)
306+ source.save({'_id': 'a', 'b': {}})
307+ with self.assertRaises(ElasticHttpError):
308+ source.save({'_id': 'a', 'b': 'c'})
309+ with self.assertRaises(ElasticHttpNotFoundError):
310+ self.index_client.get('a')
311+
312+
313+class TestOptionsStorage(TestCase):
314+
315+ yaml = {
316+ 'foo': {
317+ 'default': 'bar',
318+ 'type': 'string',
319+ 'description': 'Hello.'
320+ },
321+ 'baz': {
322+ 'default': 42,
323+ 'type': 'int',
324+ 'description': 'Hello.'
325+ },
326+ }
327+ storage = [{
328+ 'name': 'baz',
329+ 'default': 42,
330+ 'type': 'int',
331+ 'description': 'Hello.'
332+ }, {
333+ 'name': 'foo',
334+ 'default': 'bar',
335+ 'type': 'string',
336+ 'description': 'Hello.'
337+ }]
338+
339+ def test_options_to_storage(self):
340+ self.assertItemsEqual(self.storage, options_to_storage(self.yaml))
341+
342+ def test_storage_to_options(self):
343+ self.assertEqual(self.yaml, storage_to_options(self.storage))
344+
345+ def test_storage_to_options_duplicate_key(self):
346+ with self.assertRaises(ValueError) as exc_context:
347+ storage_to_options([{'name': 'same'}, {'name': 'same'}])
348+ self.assertEqual('Option name "same" occurs multiple times.',
349+ str(exc_context.exception))
350+
351+ def test_roundtrip(self):
352+ self.assertEqual(
353+ self.yaml, storage_to_options(options_to_storage(self.yaml)))
354+ self.assertItemsEqual(
355+ self.storage,
356+ options_to_storage(storage_to_options(self.storage)))
357
358=== modified file 'charmworld/tests/test_search.py'
359--- charmworld/tests/test_search.py 2013-07-01 14:45:02 +0000
360+++ charmworld/tests/test_search.py 2013-07-05 14:17:29 +0000
361@@ -173,7 +173,7 @@
362 elif key in ('provides', 'requires'):
363 query = query.values()[0].values()[0]
364 elif key == 'config':
365- query = query.values()[0].values()[0]['description']
366+ query = query.values()[0][0]['description']
367 elif key == 'store_data':
368 query = query['digest']
369 elif key == 'files':
370
371=== modified file 'charmworld/views/charms.py'
372--- charmworld/views/charms.py 2013-07-03 21:33:20 +0000
373+++ charmworld/views/charms.py 2013-07-05 14:17:29 +0000
374@@ -165,7 +165,10 @@
375
376
377 def _json_charm(charm):
378+ charm_config = charm.config
379 charm = dict(charm._representation)
380+ # Reformat config into old internal representation.
381+ charm['config'] = charm_config
382 charm_keys = set(charm.keys())
383 for k in SANITIZE.intersection(charm_keys):
384 del charm[k]
385
386=== modified file 'charmworld/views/tests/test_charms.py'
387--- charmworld/views/tests/test_charms.py 2013-07-03 21:33:20 +0000
388+++ charmworld/views/tests/test_charms.py 2013-07-05 14:17:29 +0000
389@@ -1,6 +1,7 @@
390 # Copyright 2012, 2013 Canonical Ltd. This software is licensed under the
391 # GNU Affero General Public License version 3 (see the file LICENSE).
392
393+from datetime import date
394 from logging import getLogger
395
396 from charmworld.jobs.ingest import (
397@@ -16,7 +17,6 @@
398 from charmworld.testing import ViewTestBase
399 from charmworld.testing import WebTestBase
400 from charmworld.utils import quote_key
401-from charmworld.views import SANITIZE
402 from charmworld.views.api import API2
403 from charmworld.views.charms import (
404 _charm_view,
405@@ -35,7 +35,65 @@
406 quality_edit,
407 series_collection,
408 )
409-import json
410+
411+
412+DISTRO_CHARM_DATA = {
413+ 'name': 'foo',
414+ 'series': 'precise',
415+ 'owner': 'bar',
416+ 'maintainer': 'John Doe <jdoe@example.com>',
417+ 'summary': 'Remote terminal classroom over https',
418+ 'description': 'foobar',
419+ 'store_url': 'cs:precise/foo-1',
420+ 'bzr_branch': '',
421+ 'promulgated': True,
422+ 'branch_deleted': False,
423+ 'categories': [],
424+ 'is_featured': False,
425+ 'subordinate': False,
426+ 'requires': {'database': {'interface': 'mongodb'}},
427+ 'provides': {'website': {'interface': 'https'}},
428+ 'config': {
429+ 'options': {
430+ 'script-interval': {
431+ 'default': 5,
432+ 'type': 'int',
433+ 'description': 'The interval between script runs.'
434+ }
435+ }
436+ },
437+ 'ensemble': 'formula',
438+ 'downloads': 0,
439+ 'downloads_in_past_30_days': 0,
440+ 'files': {
441+ 'readme': {
442+ 'subdir': '',
443+ 'filename': 'README',
444+ },
445+ 'install': {
446+ 'subdir': 'hooks',
447+ 'filename': 'install',
448+ },
449+ },
450+ 'tests': {},
451+ 'test_results': {},
452+ 'proof': {
453+ 'w': [
454+ ' no README file',
455+ ' (install:31) - hook accesses EC2 metadata service'
456+ ' directly'
457+ ]
458+ },
459+ 'date_created': '2012-01-01',
460+ 'error': '',
461+ 'qa': None,
462+ 'last_change': {
463+ 'committer': u'John Doe <jdoe@example.com>',
464+ 'message': u'maintainer',
465+ 'revno': 12,
466+ 'created': 1343082725.06
467+ }
468+}
469
470
471 class TestCharms(ViewTestBase):
472@@ -368,17 +426,15 @@
473 self.enable_routes()
474 ignore, charm = factory.makeCharm(
475 self.db, name='foo', owner='bar', series='precise',
476- promulgated=True)
477+ description='foobar', promulgated=True,
478+ date_created=date(2012, 1, 1))
479 request = self.getRequest()
480 request.matchdict = {
481 'charm': 'foo',
482 'series': 'precise',
483 }
484- charm_data = dict(Charm(charm)._representation)
485- for key in SANITIZE.intersection(charm_data.keys()):
486- del charm_data[key]
487 response = distro_charm_json(request)
488- self.assertEqual(charm_data, json.loads(response.body))
489+ self.assertEqual(DISTRO_CHARM_DATA, response.json_body)
490
491 def test_valid_others(self):
492 charm = factory.makeCharm(self.db, files={})[1]
493
494=== added file 'migrations/versions/010_remap_options.py'
495--- migrations/versions/010_remap_options.py 1970-01-01 00:00:00 +0000
496+++ migrations/versions/010_remap_options.py 2013-07-05 14:17:29 +0000
497@@ -0,0 +1,17 @@
498+# Copyright 2013 Canonical Ltd. This software is licensed under the
499+# GNU Affero General Public License version 3 (see the file LICENSE).
500+
501+from charmworld.models import (
502+ options_to_storage,
503+)
504+
505+
506+def exodus_update(source, target):
507+ for charm in source.collection.find():
508+ config = charm.get('config')
509+ if config is not None and 'options' in config:
510+ config['options'] = options_to_storage(config['options'])
511+ try:
512+ target.save(charm)
513+ except Exception as e:
514+ print e
515
516=== modified file 'migrations/versions/tests/test_migrations.py'
517--- migrations/versions/tests/test_migrations.py 2013-06-11 19:18:47 +0000
518+++ migrations/versions/tests/test_migrations.py 2013-07-05 14:17:29 +0000
519@@ -1,34 +1,95 @@
520 # Copyright 2012, 2013 Canonical Ltd. This software is licensed under the
521 # GNU Affero General Public License version 3 (see the file LICENSE).
522
523+from pyelasticsearch.exceptions import (
524+ ElasticHttpNotFoundError,
525+)
526 from charmworld.models import CharmSource
527 from charmworld.testing import MongoTestBase
528 from migrations.migrate import Versions
529
530
531-def run_migration(db, index_client, module_name):
532- versions = Versions('migrations/versions/')
533- versions.run_migration(db, index_client, module_name)
534-
535-
536-class TestMigration004(MongoTestBase):
537+class MigrationTestBase(MongoTestBase):
538+
539+ def setUp(self):
540+ super(MigrationTestBase, self).setUp()
541+ self.versions = Versions('migrations/versions/')
542+
543+
544+class TestMigration004(MigrationTestBase):
545
546 def test_migration(self):
547 self.use_index_client()
548 self.db.create_collection('charm-errors')
549- run_migration(self.db, self.index_client, '004_remove_charm_errors.py')
550+ self.versions.run_migration(self.db, self.index_client,
551+ '004_remove_charm_errors.py')
552 self.assertNotIn('charm-errors', self.db.collection_names())
553
554
555-class TestMigration008(MongoTestBase):
556+class TestMigration008(MigrationTestBase):
557
558 def test_migration(self):
559 self.use_index_client()
560 source = CharmSource.from_request(self)
561 source.save({'_id': 'a', 'icon': 'asdf', 'asdf': 'asdf'})
562 source.save({'_id': 'b', 'icon': 'asdf', 'asdf': 'asdf'})
563- run_migration(self.db, self.index_client, '008_delete_icon_field.py')
564- for charm in source._get_all('a'):
565- self.assertNotIn('icon', charm)
566- for charm in source._get_all('b'):
567- self.assertNotIn('icon', charm)
568+ self.versions.run_migration(self.db, self.index_client,
569+ '008_delete_icon_field.py')
570+ for charm in source._get_all('a'):
571+ self.assertNotIn('icon', charm)
572+ for charm in source._get_all('b'):
573+ self.assertNotIn('icon', charm)
574+
575+
576+class TestMigration010(MigrationTestBase):
577+
578+ def test_migration(self):
579+ self.use_index_client()
580+ source = CharmSource.from_request(self)
581+ source.save({
582+ '_id': 'a',
583+ 'config': None,
584+ })
585+ source.save({
586+ '_id': 'b',
587+ 'config': {
588+ 'options': {},
589+ },
590+ })
591+ source.save({
592+ '_id': 'c',
593+ 'config': {
594+ 'options': {'foo': {'default': 'bar'}},
595+ },
596+ })
597+ source.save({'_id': 'd'})
598+ # This is not acceptable to Elasticsearch, because config is a list.
599+ self.db.charms.save({
600+ '_id': 'e',
601+ 'config': ['a'],
602+ })
603+ self.versions.get_exodus('010_remap_options.py')(source, source)
604+ for charm in source._get_all('a'):
605+ self.assertEqual({
606+ '_id': 'a',
607+ 'config': None,
608+ }, charm)
609+ for charm in source._get_all('b'):
610+ self.assertEqual({
611+ '_id': 'b',
612+ 'config': {'options': []},
613+ }, charm)
614+ for charm in source._get_all('c'):
615+ self.assertEqual({
616+ '_id': 'c',
617+ 'config': {'options': [{
618+ 'name': 'foo',
619+ 'default': 'bar',
620+ }]}
621+ }, charm)
622+ for charm in source._get_all('d'):
623+ self.assertEqual({'_id': 'd'}, charm)
624+ with self.assertRaises(ElasticHttpNotFoundError):
625+ self.index_client.get('e')
626+ self.assertEqual({'_id': 'e', 'config': ['a']},
627+ self.db.charms.find_one({'_id': 'e'}))

Subscribers

People subscribed via source and target branches