Merge lp:~jtv/maas/boot-image-dict into lp:~maas-committers/maas/trunk

Proposed by Jeroen T. Vermeulen
Status: Merged
Approved by: Jeroen T. Vermeulen
Approved revision: no longer in the source branch.
Merged at revision: 2237
Proposed branch: lp:~jtv/maas/boot-image-dict
Merge into: lp:~maas-committers/maas/trunk
Diff against target: 411 lines (+178/-110)
2 files modified
src/provisioningserver/import_images/boot_resources.py (+98/-74)
src/provisioningserver/import_images/tests/test_boot_resources.py (+80/-36)
To merge this branch: bzr merge lp:~jtv/maas/boot-image-dict
Reviewer Review Type Date Requested Status
Graham Binns (community) Approve
Review via email: mp+214210@code.launchpad.net

Commit message

Turn the “hierarchical dicts” in the new import code into a proper class, BootImageDict.

Description of the change

The import code is a bewildering constellation of dict-based data structures. In order to be able to test, maintain, and debug this code, we need a bit more structure. This came up in my ongoing attempt to write unit tests for the new code.

Much of the dict-ey code is about dicts that index by architecture, subarchitecture, release, and label. Another, very similar kind of dict uses the same constructor but contains something else. I'm prying the two apart, turning that first kind into a class with limited methods, so we have a clear idea of what does and doesn't happen to its contents. In the process I'm conquering the fear of hidden interactions with Simplestreams code.

One thing the encapsulation allowed me to do is to simplify the dict's structure. It now maps ImageSpec, a tuple type, to boot resource — another kind of dict that I'd like to turn into a class. And boot_reverse turns a BootImageDict inside out to return that other kind of dict that uses the same constructor. You guessed it: that too I would like to turn into a class.

The one complication was in dumping the dict to JSON: you can't have tuples as keys in a JSON object. Moreover, you can't just change the format of the maas.meta file, which is where the JSON goes, because we have no idea what its purpose is or where it is used. And so inside the new dump_json method, I do convert back to the old format. This turns out to be a lot easier than continually managing the full deeply-nested structure, as I did in an earlier stage in the evolution of this branch.

The testing for dump_json overlaps with test code that I previously added to the end-to-end test. I can probably take that back out again now, in favour of the new unit test. But I wanted to have a solid anchor for the refactoring, so that I didn't accidentally change the format.

Jeroen

To post a comment you must log in.
Revision history for this message
Graham Binns (gmb) wrote :

This is great work; thanks Jeroen. A couple of quibbles:

[1]

19 +class BootImageDict:
20 + """Mapping of boot-image data.
21 +
22 + Maps `ImageSpec` tuples to metadata for Simplestreams products.
23 +
24 + This class is deliberately a bit more restrictive and less ad-hoc than a
25 + dict. It helps keep a clear view of the data structures in this module.

You've said that it's "a bit more restrictive and less ad-hoc than a
dict" but it's still called a BootImageDict and it implements items(),
which makes it look a bit like it *might* be a dict. But if you try to
use it like a dict, it'll go boom:

>>> from provisioningserver.import_images.boot_resources import BootImageDict
>>> image_dict = BootImageDict()
>>> image_dict['spam'] = 'eggs'
TypeError: 'BootImageDict' object does not support item assignment

If it's not going to behave like a dict we shouldn't call it a dict. If
it's meant to behave like a dict it should at least implement the
requisite methods, or raise a sensible error when those methods are
called on it (as shouldn't happen).

[2]

86 + """Complement `BootImageDict` with entries from another.

Missing "one" after "Complement".

review: Approve
Revision history for this message
Jeroen T. Vermeulen (jtv) wrote :

Renamed to BootImageMapping, and “one” added. Thanks for an insightful review.

Revision history for this message
MAAS Lander (maas-lander) wrote :

There are additional revisions which have not been approved in review. Please seek review and approval of these new revisions.

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
=== modified file 'src/provisioningserver/import_images/boot_resources.py'
--- src/provisioningserver/import_images/boot_resources.py 2014-04-04 06:51:23 +0000
+++ src/provisioningserver/import_images/boot_resources.py 2014-04-07 15:13:16 +0000
@@ -92,23 +92,44 @@
92 ])92 ])
9393
9494
95def iterate_boot_resources(boot_dict):95class BootImageMapping:
96 """Iterate a multi-level dict of boot images.96 """Mapping of boot-image data.
9797
98 Yields each combination of architecture, subarchitecture, release, and98 Maps `ImageSpec` tuples to metadata for Simplestreams products.
99 label for which `boot` has an entry, as an `ImageSpec`.99
100100 This class is deliberately a bit more restrictive and less ad-hoc than a
101 :param boot: Four-level dict of dicts representing boot images: the top101 dict. It helps keep a clear view of the data structures in this module.
102 level maps the architectures to sub-dicts, each of which maps
103 subarchitectures to further dicts, each of which in turn maps
104 releases to yet more dicts, each of which maps release labels to any
105 kind of item it likes.
106 """102 """
107 for arch, subarches in sorted(boot_dict.items()):103
108 for subarch, releases in sorted(subarches.items()):104 def __init__(self):
109 for release, labels in sorted(releases.items()):105 self.mapping = {}
110 for label in sorted(labels.keys()):106
111 yield ImageSpec(arch, subarch, release, label)107 def items(self):
108 """Iterate over `ImageSpec` keys, and their stored values."""
109 for image_spec, item in sorted(self.mapping.items()):
110 yield image_spec, item
111
112 def setdefault(self, image_spec, item):
113 """Set metadata for `image_spec` to item, if not already set."""
114 assert isinstance(image_spec, ImageSpec)
115 self.mapping.setdefault(image_spec, item)
116
117 def dump_json(self):
118 """Produce JSON representing the mapped boot images.
119
120 Tries to keep the output deterministic, so that identical data is
121 likely to produce identical JSON.
122 """
123 # The meta files represent the mapping as a nested hierarchy of dicts.
124 # Keep that format.
125 data = {}
126 for image, resource in self.items():
127 arch, subarch, release, label = image
128 data.setdefault(arch, {})
129 data[arch].setdefault(subarch, {})
130 data[arch][subarch].setdefault(release, {})
131 data[arch][subarch][release][label] = resource
132 return json.dumps(data, sort_keys=True)
112133
113134
114def value_passes_filter_list(filter_list, property_value):135def value_passes_filter_list(filter_list, property_value):
@@ -156,67 +177,70 @@
156 return False177 return False
157178
158179
159def boot_merge(boot1, boot2, filters=None):180def boot_merge(destination, additions, filters=None):
160 """Add entries from the second multi-level dict to the first one.181 """Complement one `BootImageMapping` with entries from another.
161182
162 Function copies d[arch][subarch][release]=value chains from the second183 This adds entries from `additions` (that match `filters`, if given) to
163 dictionary to the first one if they don't exist there and pass optional184 `destination`, but only for those image specs for which `destination` does
164 check done by filters.185 not have entries yet.
165186
166 :param boot1: first dict which will be extended in-place.187 :param destination: `BootImageMapping` to be updated. It will be extended
167 :param boot2: second dict which will be used as a source of new entries.188 in-place.
168 :param filters: list of dicts each of which contains 'arch', 'subarch',189 :param additions: A second `BootImageMapping`, which will be used as a
169 'release' keys; function takes d[arch][subarch][release] chain to the190 source of additional entries.
170 first dict only if filters contain at least one dict with191 :param filters: List of dicts, each of which contains 'arch', 'subarch',
171 arch in d['arches'], subarch in d['subarch'], d['release'] == release;192 and 'release' keys. If given, entries are only considered for copying
172 dict may have '*' as a value for 'arch' and 'release' keys and as a193 from `additions` to `destination` if they match at least one of the
173 member of 'subarch' list -- in that case key-specific check always194 filters. Entries in the filter may be the string `*` (or for entries
174 passes.195 that are lists, may contain the string `*`) to make them match any
196 value.
175 """197 """
176 for arch, subarch, release, label in iterate_boot_resources(boot2):198 for image, resource in additions.items():
199 arch, subarch, release, label = image
177 if image_passes_filter(filters, arch, subarch, release, label):200 if image_passes_filter(filters, arch, subarch, release, label):
178 logger.debug(201 logger.debug(
179 "Merging boot resource for %s/%s/%s/%s.",202 "Merging boot resource for %s/%s/%s/%s.",
180 arch, subarch, release, label)203 arch, subarch, release, label)
181 boot_resource = boot2[arch][subarch][release][label]
182 # Do not override an existing entry with the same204 # Do not override an existing entry with the same
183 # arch/subarch/release/label: the first entry found takes205 # arch/subarch/release/label: the first entry found takes
184 # precedence.206 # precedence.
185 if not boot1[arch][subarch][release][label]:207 destination.setdefault(image, resource)
186 boot1[arch][subarch][release][label] = boot_resource208
187209
188210def boot_reverse(boot_images_dict):
189def boot_reverse(boot):211 """Determine the subarches supported by each boot resource.
190 """Determine a set of subarches which should be deployed by boot resource.212
191213 Many subarches may be deployed by a single boot resource. We note only
192 Function reverses h[arch][subarch][release]=boot_resource hierarchy to form214 subarchitectures here and ignore architectures because the metadata format
193 boot resource to subarch relation. Many subarches may be deployed by a215 tightly couples a boot resource to its architecture.
194 single boot resource (in which case boot_resource=[subarch1, subarch2]216
195 relation will be created). We note only subarchitectures and ignore217 We can figure out for which architecture we need to use a specific boot
196 architectures because boot resource is tightly coupled with architecture218 resource by looking at its description in the metadata. We can't do the
197 it can deploy according to metadata format. We can figure out for which219 same with subarch, because we may want to use a boot resource only for a
198 architecture we need to use a specific boot resource by looking at its220 specific subset of subarches.
199 description in metadata. We can't do the same with subarch because we may221
200 want to use boot resource only for a specific subset of subarches it can be222 This function represents the relationship between boot resources and
201 used for. To represent boot resource to subarch relation we generate the223 subarchitectures as a multi-level dictionary::
202 following multi-level dictionary: d[content_id][product_name]=[subarches]224
203 where 'content_id' and 'product_name' values come from metadata information225 d[content_id][product_name] = [subarches]
204 and allow us to uniquely identify a specific boot resource.226
205227 Here, `content_id` and `product_name` come from the metadata information
206 :param boot: Hierarchy of dicts d[arch][subarch][release]=boot_resource228 and uniquely identify a specific boot resource.
229
230 :param boot: A `BootImageMapping` containing the images' metadata.
207 :return Hierarchy of dictionaries d[content_id][product_name]=[subarches]231 :return Hierarchy of dictionaries d[content_id][product_name]=[subarches]
208 which describes boot resource to subarches relation for all available232 which describes boot resource to subarches relation for all available
209 boot resources (products).233 boot resources (products).
210 """234 """
211 reverse = create_empty_hierarchy()235 reverse = create_empty_hierarchy()
212236
213 for arch, subarch, release, label in iterate_boot_resources(boot):237 for image, boot_resource in boot_images_dict.items():
214 boot_resource = boot[arch][subarch][release][label]
215 content_id = boot_resource['content_id']238 content_id = boot_resource['content_id']
216 product_name = boot_resource['product_name']239 product_name = boot_resource['product_name']
217 version_name = boot_resource['version_name']240 version_name = boot_resource['version_name']
218 existent = list(reverse[content_id][product_name][version_name])241 existent = list(reverse[content_id][product_name][version_name])
219 reverse[content_id][product_name][version_name] = [subarch] + existent242 reverse[content_id][product_name][version_name] = (
243 [image.subarch] + existent)
220244
221 return reverse245 return reverse
222246
@@ -293,15 +317,15 @@
293 """Gather metadata about boot images available in a Simplestreams repo.317 """Gather metadata about boot images available in a Simplestreams repo.
294318
295 Used inside `download_image_descriptions`. Stores basic metadata about319 Used inside `download_image_descriptions`. Stores basic metadata about
296 each image it finds upstream in a given dict, indexed by arch, subarch,320 each image it finds upstream in a given `BootImageMapping`. Each stored
297 release, and label. Each item is a dict containing the basic metadata321 item is a dict containing the basic metadata for retrieving a boot image.
298 for retrieving a boot image.322
299323 Simplestreams' `BasicMirrorWriter` in itself is stateless. It relies on
300 :ivar boot_images_dict: A nested `defaultdict` for boot images.324 a subclass (such as this one) to store data.
301 Image metadata will be stored here as it is discovered. Simplestreams325
302 does not interact with this variable, and `BasicMirrorWriter` in itself326 :ivar boot_images_dict: A `BootImageMapping`. Image metadata will be
303 is stateless. It relies on a subclass (such as this one) to store327 stored here as it is discovered. Simplestreams does not interact with
304 data.328 this variable.
305 """329 """
306330
307 def __init__(self, boot_images_dict):331 def __init__(self, boot_images_dict):
@@ -321,11 +345,11 @@
321 arch, subarches = item['arch'], item['subarches']345 arch, subarches = item['arch'], item['subarches']
322 release = item['release']346 release = item['release']
323 label = item['label']347 label = item['label']
348 base_image = ImageSpec(arch, None, release, label)
324 compact_item = clean_up_repo_item(item)349 compact_item = clean_up_repo_item(item)
325 for subarch in subarches.split(','):350 for subarch in subarches.split(','):
326 if not self.boot_images_dict[arch][subarch][release][label]:351 self.boot_images_dict.setdefault(
327 self.boot_images_dict[arch][subarch][release][label] = (352 base_image._replace(subarch=subarch), compact_item)
328 compact_item)
329353
330354
331def download_image_descriptions(path, keyring=None):355def download_image_descriptions(path, keyring=None):
@@ -339,7 +363,7 @@
339 mirror, rpath = path_from_mirror_url(path, None)363 mirror, rpath = path_from_mirror_url(path, None)
340 policy = get_signing_policy(rpath, keyring)364 policy = get_signing_policy(rpath, keyring)
341 reader = UrlMirrorReader(mirror, policy=policy)365 reader = UrlMirrorReader(mirror, policy=policy)
342 boot_images_dict = create_empty_hierarchy()366 boot_images_dict = BootImageMapping()
343 dumper = RepoDumper(boot_images_dict)367 dumper = RepoDumper(boot_images_dict)
344 dumper.sync(reader, rpath)368 dumper.sync(reader, rpath)
345 return boot_images_dict369 return boot_images_dict
@@ -347,7 +371,7 @@
347371
348def download_all_image_descriptions(config):372def download_all_image_descriptions(config):
349 """Download image metadata for all sources in `config`."""373 """Download image metadata for all sources in `config`."""
350 boot = create_empty_hierarchy()374 boot = BootImageMapping()
351 for source in config['boot']['sources']:375 for source in config['boot']['sources']:
352 repo_boot = download_image_descriptions(376 repo_boot = download_image_descriptions(
353 source['path'], keyring=source['keyring'])377 source['path'], keyring=source['keyring'])
@@ -548,7 +572,7 @@
548 storage = config['boot']['storage']572 storage = config['boot']['storage']
549573
550 boot = download_all_image_descriptions(config)574 boot = download_all_image_descriptions(config)
551 meta_file_content = json.dumps(boot, sort_keys=True)575 meta_file_content = boot.dump_json()
552 if meta_contains(storage, meta_file_content):576 if meta_contains(storage, meta_file_content):
553 # The current maas.meta already contains the new config. No need to577 # The current maas.meta already contains the new config. No need to
554 # rewrite anything.578 # rewrite anything.
555579
=== modified file 'src/provisioningserver/import_images/tests/test_boot_resources.py'
--- src/provisioningserver/import_images/tests/test_boot_resources.py 2014-04-07 08:09:47 +0000
+++ src/provisioningserver/import_images/tests/test_boot_resources.py 2014-04-07 15:13:16 +0000
@@ -46,22 +46,75 @@
46 )46 )
4747
4848
49class TestIterateBootResources(MAASTestCase):49class TestBootImageMapping(MAASTestCase):
50 """Tests for `iterate_boot_resources`."""50 """Tests for `BootImageMapping`."""
5151
52 def test_empty_hierarchy_yields_nothing(self):52 def test_initially_empty(self):
53 self.assertItemsEqual(53 self.assertItemsEqual([], boot_resources.BootImageMapping().items())
54 [],54
55 boot_resources.iterate_boot_resources(55 def test_items_returns_items(self):
56 boot_resources.create_empty_hierarchy()))56 image = make_image_spec()
5757 resource = factory.make_name('resource')
58 def test_finds_boot_resource(self):58 image_dict = set_resource(image_spec=image, resource=resource)
59 image_spec = make_image_spec()59 self.assertItemsEqual([(image, resource)], image_dict.items())
60 arch, subarch, release, label = image_spec60
61 self.assertItemsEqual(61 def test_setdefault_sets_unset_item(self):
62 [image_spec],62 image_dict = boot_resources.BootImageMapping()
63 boot_resources.iterate_boot_resources(63 image = make_image_spec()
64 {arch: {subarch: {release: {label: factory.make_name()}}}}))64 resource = factory.make_name('resource')
65 image_dict.setdefault(image, resource)
66 self.assertItemsEqual([(image, resource)], image_dict.items())
67
68 def test_setdefault_leaves_set_item_unchanged(self):
69 image = make_image_spec()
70 old_resource = factory.make_name('resource')
71 image_dict = set_resource(image_spec=image, resource=old_resource)
72 image_dict.setdefault(image, factory.make_name('newresource'))
73 self.assertItemsEqual([(image, old_resource)], image_dict.items())
74
75 def test_dump_json_is_consistent(self):
76 image = make_image_spec()
77 resource = factory.make_name('resource')
78 image_dict_1 = set_resource(image_spec=image, resource=resource)
79 image_dict_2 = set_resource(image_spec=image, resource=resource)
80 self.assertEqual(image_dict_1.dump_json(), image_dict_2.dump_json())
81
82 def test_dump_json_represents_empty_dict_as_empty_object(self):
83 self.assertEqual('{}', boot_resources.BootImageMapping().dump_json())
84
85 def test_dump_json_represents_entry(self):
86 image = make_image_spec()
87 resource = factory.make_name('resource')
88 image_dict = set_resource(image_spec=image, resource=resource)
89 self.assertEqual(
90 {
91 image.arch: {
92 image.subarch: {
93 image.release: {image.label: resource},
94 },
95 },
96 },
97 json.loads(image_dict.dump_json()))
98
99 def test_dump_json_combines_similar_entries(self):
100 image = make_image_spec()
101 other_release = factory.make_name('other-release')
102 resource1 = factory.make_name('resource')
103 resource2 = factory.make_name('other-resource')
104 image_dict = boot_resources.BootImageMapping()
105 set_resource(image_dict, image, resource1)
106 set_resource(
107 image_dict, image._replace(release=other_release), resource2)
108 self.assertEqual(
109 {
110 image.arch: {
111 image.subarch: {
112 image.release: {image.label: resource1},
113 other_release: {image.label: resource2},
114 },
115 },
116 },
117 json.loads(image_dict.dump_json()))
65118
66119
67class TestValuePassesFilterList(MAASTestCase):120class TestValuePassesFilterList(MAASTestCase):
@@ -151,7 +204,7 @@
151 image1 = make_image_spec()204 image1 = make_image_spec()
152 image2 = make_image_spec()205 image2 = make_image_spec()
153 resource = self.make_boot_resource()206 resource = self.make_boot_resource()
154 boot_dict = {}207 boot_dict = boot_resources.BootImageMapping()
155 # Create two images in boot_dict, both containing the same resource.208 # Create two images in boot_dict, both containing the same resource.
156 for image in [image1, image2]:209 for image in [image1, image2]:
157 set_resource(210 set_resource(
@@ -262,23 +315,14 @@
262315
263316
264def set_resource(boot_dict=None, image_spec=None, resource=None):317def set_resource(boot_dict=None, image_spec=None, resource=None):
265 """Add a boot resource to `boot_dict`, creating it if necessary."""318 """Add boot resource to a `BootImageMapping`, creating it if necessary."""
266 if boot_dict is None:319 if boot_dict is None:
267 boot_dict = {}320 boot_dict = boot_resources.BootImageMapping()
268 if image_spec is None:321 if image_spec is None:
269 image_spec = make_image_spec()322 image_spec = make_image_spec()
270 if resource is None:323 if resource is None:
271 resource = factory.make_name('boot-resource')324 resource = factory.make_name('boot-resource')
272 arch, subarch, release, label = image_spec325 boot_dict.mapping[image_spec] = resource
273 # Drill down into the dict; along the way, create any missing levels of
274 # nested dicts.
275 nested_dict = boot_dict
276 for level in (arch, subarch, release):
277 nested_dict.setdefault(level, {})
278 nested_dict = nested_dict[level]
279 # At the bottom level, indexed by "label," insert "resource" as the
280 # value.
281 nested_dict[label] = resource
282 return boot_dict326 return boot_dict
283327
284328
@@ -288,12 +332,12 @@
288 def test_integrates(self):332 def test_integrates(self):
289 # End-to-end scenario for boot_merge: start with an empty boot333 # End-to-end scenario for boot_merge: start with an empty boot
290 # resources dict, and receive one resource from Simplestreams.334 # resources dict, and receive one resource from Simplestreams.
291 total_resources = boot_resources.create_empty_hierarchy()335 total_resources = boot_resources.BootImageMapping()
292 resources_from_repo = set_resource()336 resources_from_repo = set_resource()
293 boot_resources.boot_merge(total_resources, resources_from_repo.copy())337 boot_resources.boot_merge(total_resources, resources_from_repo)
294 # Since we started with an empty dict, the result contains the same338 # Since we started with an empty dict, the result contains the same
295 # item that we got from Simplestreams, and nothing else.339 # item that we got from Simplestreams, and nothing else.
296 self.assertEqual(resources_from_repo, total_resources)340 self.assertEqual(resources_from_repo.mapping, total_resources.mapping)
297341
298 def test_obeys_filters(self):342 def test_obeys_filters(self):
299 filters = [343 filters = [
@@ -312,13 +356,13 @@
312356
313 def test_does_not_overwrite_existing_entry(self):357 def test_does_not_overwrite_existing_entry(self):
314 image = make_image_spec()358 image = make_image_spec()
315 original_resources = set_resource(359 total_resources = set_resource(
316 resource="Original resource", image_spec=image)360 resource="Original resource", image_spec=image)
317 total_resources = original_resources.copy()361 original_resources = total_resources.mapping.copy()
318 resources_from_repo = set_resource(362 resources_from_repo = set_resource(
319 resource="New resource", image_spec=image)363 resource="New resource", image_spec=image)
320 boot_resources.boot_merge(total_resources, resources_from_repo.copy())364 boot_resources.boot_merge(total_resources, resources_from_repo)
321 self.assertEqual(original_resources, total_resources)365 self.assertEqual(original_resources, total_resources.mapping)
322366
323367
324class TestGetSigningPolicy(MAASTestCase):368class TestGetSigningPolicy(MAASTestCase):