Merge lp:~abentley/juju-release-tools/append-images into lp:juju-release-tools
- append-images
- Merge into trunk
Status: | Merged |
---|---|
Merged at revision: | 281 |
Proposed branch: | lp:~abentley/juju-release-tools/append-images |
Merge into: | lp:juju-release-tools |
Diff against target: |
524 lines (+515/-0) 2 files modified
make_aws_image_streams.py (+204/-0) tests/test_make_aws_image_streams.py (+311/-0) |
To merge this branch: | bzr merge lp:~abentley/juju-release-tools/append-images |
Related bugs: |
Reviewer | Review Type | Date Requested | Status |
---|---|---|---|
Curtis Hovey (community) | code | Approve | |
Review via email: mp+289380@code.launchpad.net |
Commit message
Support generating image streams for Centos7.
Description of the change
This branch implements support for generating Centos7 image streams.
It generates streams directly, instead of generating the intermediate JSON representation. This avoids two issues that prevented json2streams from working:
- It always uses "content-download" as the datatype.
- It fails if the JSON does not contain a 'size', even if it has no path.
It takes advantage of the Juju 2.0 credentials format, which is more convenient, even giving a standardized way of finding AWS credentials.
The product code aw0evgkw8e5c1q4
This apppears to be the only way we can be certain to select the official images.
We have no aws-gov credentials, so these regions are skipped. http://
Preview Diff
1 | === added file 'make_aws_image_streams.py' |
2 | --- make_aws_image_streams.py 1970-01-01 00:00:00 +0000 |
3 | +++ make_aws_image_streams.py 2016-03-17 14:53:09 +0000 |
4 | @@ -0,0 +1,204 @@ |
5 | +#!/usr/bin/env python3 |
6 | +from __future__ import print_function |
7 | + |
8 | +from argparse import ArgumentParser |
9 | +from datetime import datetime |
10 | +import os |
11 | +import sys |
12 | +from textwrap import dedent |
13 | +import yaml |
14 | + |
15 | +from boto import ec2 |
16 | +from simplestreams.generate_simplestreams import ( |
17 | + items2content_trees, |
18 | + ) |
19 | +from simplestreams.json2streams import ( |
20 | + Item, |
21 | + write_juju_streams, |
22 | + ) |
23 | +from simplestreams.util import timestamp |
24 | + |
25 | + |
26 | +def get_parameters(argv=None): |
27 | + """Return streams, creds_filename for this invocation. |
28 | + |
29 | + streams is the directory to write streams into. |
30 | + creds_filename is the filename to get credentials from. |
31 | + """ |
32 | + parser = ArgumentParser(description=dedent(""" |
33 | + Write image streams for AWS images. Only CentOS 7 is currently |
34 | + supported.""")) |
35 | + parser.add_argument('streams', help='The directory to write streams to.') |
36 | + args = parser.parse_args(argv) |
37 | + try: |
38 | + juju_data = os.environ['JUJU_DATA'] |
39 | + except KeyError: |
40 | + print('JUJU_DATA must be set to a directory containing' |
41 | + ' credentials.yaml.', file=sys.stderr) |
42 | + sys.exit(1) |
43 | + creds_filename = os.path.join(juju_data, 'credentials.yaml') |
44 | + return args.streams, creds_filename |
45 | + |
46 | + |
47 | +def make_aws_credentials(creds): |
48 | + """Convert credentials from juju format to AWS/Boto format.""" |
49 | + for creds in creds.values(): |
50 | + return { |
51 | + 'aws_access_key_id': creds['access-key'], |
52 | + 'aws_secret_access_key': creds['secret-key'], |
53 | + } |
54 | + else: |
55 | + raise LookupError('No credentials found!') |
56 | + |
57 | + |
58 | +def is_china(region): |
59 | + """Determine whether the supplied region is in AWS-China.""" |
60 | + return region.endpoint.endswith('.amazonaws.com.cn') |
61 | + |
62 | + |
63 | +def iter_region_connection(credentials, china_credentials): |
64 | + """Iterate through connections for all regions except gov. |
65 | + |
66 | + AWS-China regions will be connected using china_credentials. |
67 | + US-GOV regions will be skipped. |
68 | + All other regions will be connected using credentials. |
69 | + """ |
70 | + regions = ec2.regions() |
71 | + for region in regions: |
72 | + if 'us-gov' in region.name: |
73 | + continue |
74 | + if is_china(region): |
75 | + yield region.connect(**china_credentials) |
76 | + else: |
77 | + yield region.connect(**credentials) |
78 | + |
79 | + |
80 | +def iter_centos_images(credentials, china_credentials): |
81 | + """Iterate through CentOS 7 images in standard AWS and AWS China.""" |
82 | + for conn in iter_region_connection(credentials, china_credentials): |
83 | + images = conn.get_all_images(filters={ |
84 | + 'owner_alias': 'aws-marketplace', |
85 | + 'product_code': 'aw0evgkw8e5c1q413zgy5pjce', |
86 | + # 'name': 'CentOS Linux 7*', |
87 | + }) |
88 | + for image in images: |
89 | + yield image |
90 | + |
91 | + |
92 | +def make_item_name(region, vtype, store): |
93 | + """Determine the item_name, given an image's attributes. |
94 | + |
95 | + :param region: The region name |
96 | + :param vtype: The virtualization type |
97 | + :param store: The root device type. |
98 | + """ |
99 | + # This is a port of code from simplestreams/tools/make-test-data, which is |
100 | + # not provided as library code. |
101 | + dmap = { |
102 | + "north": "nn", |
103 | + "northeast": "ne", |
104 | + "east": "ee", |
105 | + "southeast": "se", |
106 | + "south": "ss", |
107 | + "southwest": "sw", |
108 | + "west": "ww", |
109 | + "northwest": "nw", |
110 | + "central": "cc", |
111 | + } |
112 | + itmap = { |
113 | + 'pv': {'instance': "pi", "ebs": "pe", "ssd": "es", "io1": "eo"}, |
114 | + 'hvm': {'instance': "hi", "ebs": "he", "ssd": "hs", "io1": "ho"} |
115 | + } |
116 | + if store == "instance-store": |
117 | + store = 'instance' |
118 | + elif '-' in store: |
119 | + store = store.split('-')[-1] |
120 | + if vtype == "paravirtual": |
121 | + vtype = "pv" |
122 | + |
123 | + # create the item key: |
124 | + # - 2 letter country code (us) . 3 for govcloud (gww) |
125 | + # - 2 letter direction (nn=north, nw=northwest, cc=central) |
126 | + # - 1 digit number |
127 | + # - 1 char for virt type |
128 | + # - 1 char for root-store type |
129 | + |
130 | + # Handle special case of 'gov' regions |
131 | + pre_cc = "" |
132 | + _region = region |
133 | + if '-gov-' in region: |
134 | + _region = region.replace('gov-', '') |
135 | + pre_cc = "g" |
136 | + |
137 | + (cc, direction, num) = _region.split("-") |
138 | + |
139 | + ikey = pre_cc + cc + dmap[direction] + num + itmap[vtype][store] |
140 | + return ikey |
141 | + |
142 | + |
143 | +def make_item(image, now): |
144 | + """Convert Centos 7 Boto image to simplestreams Item. |
145 | + |
146 | + :param now: the current datetime. |
147 | + """ |
148 | + if image.architecture != 'x86_64': |
149 | + raise ValueError( |
150 | + 'Architecture is "{}", not "x86_64".'.format(image.architecture)) |
151 | + if not image.name.startswith('CentOS Linux 7 '): |
152 | + raise ValueError( |
153 | + 'Name "{}" does not begin with "CentOS Linux 7".'.format( |
154 | + image.name)) |
155 | + item_name = make_item_name(image.region.name, image.virtualization_type, |
156 | + image.root_device_type) |
157 | + version_name = now.strftime('%Y%m%d') |
158 | + content_id = 'com.ubuntu.cloud.released:aws' |
159 | + if is_china(image.region): |
160 | + content_id = 'com.ubuntu.cloud.released:aws-cn' |
161 | + else: |
162 | + content_id = 'com.ubuntu.cloud.released:aws' |
163 | + return Item( |
164 | + content_id, 'com.ubuntu.cloud:server:centos7:amd64', version_name, |
165 | + item_name, { |
166 | + 'endpoint': 'https://{}'.format(image.region.endpoint), |
167 | + 'region': image.region.name, |
168 | + 'arch': 'amd64', |
169 | + 'os': 'centos', |
170 | + 'virt': image.virtualization_type, |
171 | + 'id': image.id, |
172 | + 'version': 'centos7', |
173 | + 'label': 'release', |
174 | + 'release': 'centos7', |
175 | + 'release_codename': 'centos7', |
176 | + 'release_title': 'Centos 7', |
177 | + 'root_store': image.root_device_type, |
178 | + }) |
179 | + |
180 | + |
181 | +def write_streams(credentials, china_credentials, now, streams): |
182 | + """Write image streams for Centos 7. |
183 | + |
184 | + :param credentials: The standard AWS credentials. |
185 | + :param china_credentials: The AWS China crentials. |
186 | + :param now: The current datetime. |
187 | + :param streams: The directory to store streams metadata in. |
188 | + """ |
189 | + items = [make_item(i, now) for i in iter_centos_images( |
190 | + credentials, china_credentials)] |
191 | + updated = timestamp() |
192 | + data = {'updated': updated, 'datatype': 'image-ids'} |
193 | + trees = items2content_trees(items, data) |
194 | + write_juju_streams(streams, trees, updated) |
195 | + |
196 | + |
197 | +def main(): |
198 | + streams, creds_filename = get_parameters() |
199 | + with open(creds_filename) as creds_file: |
200 | + all_credentials = yaml.safe_load(creds_file)['credentials'] |
201 | + credentials = make_aws_credentials(all_credentials['aws']) |
202 | + china_credentials = make_aws_credentials(all_credentials['aws-china']) |
203 | + now = datetime.utcnow() |
204 | + write_streams(credentials, china_credentials, now, streams) |
205 | + |
206 | + |
207 | +if __name__ == '__main__': |
208 | + main() |
209 | |
210 | === added file 'tests/test_make_aws_image_streams.py' |
211 | --- tests/test_make_aws_image_streams.py 1970-01-01 00:00:00 +0000 |
212 | +++ tests/test_make_aws_image_streams.py 2016-03-17 14:53:09 +0000 |
213 | @@ -0,0 +1,311 @@ |
214 | +from datetime import datetime |
215 | +import json |
216 | +import os |
217 | +from StringIO import StringIO |
218 | +from unittest import TestCase |
219 | + |
220 | +from mock import ( |
221 | + Mock, |
222 | + patch, |
223 | + ) |
224 | + |
225 | +from make_aws_image_streams import ( |
226 | + is_china, |
227 | + iter_centos_images, |
228 | + iter_region_connection, |
229 | + get_parameters, |
230 | + make_aws_credentials, |
231 | + make_item, |
232 | + make_item_name, |
233 | + write_streams, |
234 | + ) |
235 | +from utils import temp_dir |
236 | + |
237 | + |
238 | +class TestIsChina(TestCase): |
239 | + |
240 | + def test_is_china(self): |
241 | + region = Mock() |
242 | + region.endpoint = 'foo.amazonaws.com.cn' |
243 | + self.assertIs(True, is_china(region)) |
244 | + region.endpoint = 'foo.amazonaws.com' |
245 | + self.assertIs(False, is_china(region)) |
246 | + |
247 | + |
248 | +def make_mock_region(stem, name=None, endpoint=None): |
249 | + if endpoint is None: |
250 | + endpoint = '{}-end'.format(stem) |
251 | + region = Mock(endpoint=endpoint) |
252 | + if name is None: |
253 | + name = '{}-name'.format(stem) |
254 | + region.name = name |
255 | + return region |
256 | + |
257 | + |
258 | +class IterRegionConnection(TestCase): |
259 | + |
260 | + def test_iter_region_connection(self): |
261 | + east = make_mock_region('east') |
262 | + west = make_mock_region('west') |
263 | + aws = {} |
264 | + with patch('make_aws_image_streams.ec2.regions', autospec=True, |
265 | + return_value=[east, west]) as regions_mock: |
266 | + connections = [x for x in iter_region_connection(aws, None)] |
267 | + regions_mock.assert_called_once_with() |
268 | + self.assertEqual( |
269 | + [east.connect.return_value, west.connect.return_value], |
270 | + connections) |
271 | + east.connect.assert_called_once_with(**aws) |
272 | + west.connect.assert_called_once_with(**aws) |
273 | + |
274 | + def test_gov_region(self): |
275 | + east = make_mock_region('east') |
276 | + gov = make_mock_region('west', name='foo-us-gov-bar') |
277 | + aws = {} |
278 | + with patch('make_aws_image_streams.ec2.regions', autospec=True, |
279 | + return_value=[east, gov]) as regions_mock: |
280 | + connections = [x for x in iter_region_connection(aws, None)] |
281 | + regions_mock.assert_called_once_with() |
282 | + self.assertEqual( |
283 | + [east.connect.return_value], connections) |
284 | + east.connect.assert_called_once_with(**aws) |
285 | + self.assertEqual(0, gov.connect.call_count) |
286 | + |
287 | + def test_china_region(self): |
288 | + east = make_mock_region('east') |
289 | + west = make_mock_region('west', endpoint='west-end.amazonaws.com.cn') |
290 | + east.name = 'east-name' |
291 | + west.name = 'west-name' |
292 | + aws = {'name': 'aws'} |
293 | + aws_cn = {'name': 'aws-cn'} |
294 | + with patch('make_aws_image_streams.ec2.regions', autospec=True, |
295 | + return_value=[east, west]) as regions_mock: |
296 | + connections = [x for x in iter_region_connection(aws, aws_cn)] |
297 | + regions_mock.assert_called_once_with() |
298 | + self.assertEqual( |
299 | + [east.connect.return_value, west.connect.return_value], |
300 | + connections) |
301 | + east.connect.assert_called_once_with(**aws) |
302 | + west.connect.assert_called_once_with(**aws_cn) |
303 | + |
304 | + |
305 | +class IterCentosImages(TestCase): |
306 | + |
307 | + def test_iter_centos_images(self): |
308 | + aws = {'name': 'aws'} |
309 | + aws_cn = {'name': 'aws-cn'} |
310 | + east_imgs = ['east-1', 'east-2'] |
311 | + west_imgs = ['west-1', 'west-2'] |
312 | + east_conn = Mock() |
313 | + east_conn.get_all_images.return_value = east_imgs |
314 | + west_conn = Mock() |
315 | + west_conn.get_all_images.return_value = west_imgs |
316 | + with patch('make_aws_image_streams.iter_region_connection', |
317 | + return_value=[east_conn, west_conn], |
318 | + autospec=True) as irc_mock: |
319 | + imgs = list(iter_centos_images(aws, aws_cn)) |
320 | + self.assertEqual(east_imgs + west_imgs, imgs) |
321 | + irc_mock.assert_called_once_with(aws, aws_cn) |
322 | + east_conn.get_all_images.assert_called_once_with(filters={ |
323 | + 'owner_alias': 'aws-marketplace', |
324 | + 'product_code': 'aw0evgkw8e5c1q413zgy5pjce', |
325 | + }) |
326 | + west_conn.get_all_images.assert_called_once_with(filters={ |
327 | + 'owner_alias': 'aws-marketplace', |
328 | + 'product_code': 'aw0evgkw8e5c1q413zgy5pjce', |
329 | + }) |
330 | + |
331 | + |
332 | +class TestMakeAWSCredentials(TestCase): |
333 | + |
334 | + def test_happy_path(self): |
335 | + aws_credentials = make_aws_credentials({'credentials': { |
336 | + 'access-key': 'foo', |
337 | + 'secret-key': 'bar', |
338 | + }}) |
339 | + self.assertEqual({ |
340 | + 'aws_access_key_id': 'foo', |
341 | + 'aws_secret_access_key': 'bar', |
342 | + }, aws_credentials) |
343 | + |
344 | + def test_no_credentials(self): |
345 | + with self.assertRaisesRegexp(LookupError, 'No credentials found!'): |
346 | + make_aws_credentials({}) |
347 | + |
348 | + def test_multiple_credentials(self): |
349 | + # If multiple credentials are present, an arbitrary credential will be |
350 | + # used. |
351 | + aws_credentials = make_aws_credentials({ |
352 | + 'credentials-1': { |
353 | + 'access-key': 'foo', |
354 | + 'secret-key': 'bar', |
355 | + }, |
356 | + 'credentials-2': { |
357 | + 'access-key': 'baz', |
358 | + 'secret-key': 'qux', |
359 | + }, |
360 | + }) |
361 | + self.assertIn(aws_credentials, [ |
362 | + {'aws_access_key_id': 'foo', 'aws_secret_access_key': 'bar'}, |
363 | + {'aws_access_key_id': 'baz', 'aws_secret_access_key': 'qux'}, |
364 | + ]) |
365 | + |
366 | + |
367 | +def make_mock_image(region_name='us-northeast-3'): |
368 | + image = Mock(virtualization_type='hvm', id='qux', |
369 | + root_device_type='ebs', architecture='x86_64') |
370 | + image.name = 'CentOS Linux 7 foo' |
371 | + image.region.endpoint = 'foo' |
372 | + image.region.name = region_name |
373 | + return image |
374 | + |
375 | + |
376 | +class TetMakeItem(TestCase): |
377 | + |
378 | + def test_happy_path(self): |
379 | + image = make_mock_image() |
380 | + now = datetime(2001, 02, 03) |
381 | + item = make_item(image, now) |
382 | + self.assertEqual(item.content_id, 'com.ubuntu.cloud.released:aws') |
383 | + self.assertEqual(item.product_name, |
384 | + 'com.ubuntu.cloud:server:centos7:amd64') |
385 | + self.assertEqual(item.item_name, 'usne3he') |
386 | + self.assertEqual(item.version_name, '20010203') |
387 | + self.assertEqual(item.data, { |
388 | + 'endpoint': 'https://foo', |
389 | + 'region': 'us-northeast-3', |
390 | + 'arch': 'amd64', |
391 | + 'os': 'centos', |
392 | + 'virt': 'hvm', |
393 | + 'id': 'qux', |
394 | + 'version': 'centos7', |
395 | + 'label': 'release', |
396 | + 'release': 'centos7', |
397 | + 'release_codename': 'centos7', |
398 | + 'release_title': 'Centos 7', |
399 | + 'root_store': 'ebs', |
400 | + }) |
401 | + |
402 | + def test_china(self): |
403 | + image = make_mock_image() |
404 | + image.region.endpoint = 'foo.amazonaws.com.cn' |
405 | + now = datetime(2001, 02, 03) |
406 | + item = make_item(image, now) |
407 | + self.assertEqual(item.content_id, 'com.ubuntu.cloud.released:aws-cn') |
408 | + self.assertEqual(item.data['endpoint'], 'https://foo.amazonaws.com.cn') |
409 | + |
410 | + def test_not_x86_64(self): |
411 | + image = make_mock_image() |
412 | + image.architecture = 'ppc128' |
413 | + now = datetime(2001, 02, 03) |
414 | + with self.assertRaisesRegexp(ValueError, |
415 | + 'Architecture is "ppc128", not' |
416 | + ' "x86_64".'): |
417 | + make_item(image, now) |
418 | + |
419 | + def test_not_centos_7(self): |
420 | + image = make_mock_image() |
421 | + image.name = 'CentOS Linux 8' |
422 | + now = datetime(2001, 02, 03) |
423 | + with self.assertRaisesRegexp(ValueError, |
424 | + 'Name "CentOS Linux 8" does not begin' |
425 | + ' with "CentOS Linux 7".'): |
426 | + make_item(image, now) |
427 | + |
428 | + |
429 | +class TestGetParameters(TestCase): |
430 | + |
431 | + def test_happy_path(self): |
432 | + with patch.dict(os.environ, {'JUJU_DATA': 'foo'}): |
433 | + streams, creds_filename = get_parameters(['bar']) |
434 | + self.assertEqual(creds_filename, 'foo/credentials.yaml') |
435 | + self.assertEqual(streams, 'bar') |
436 | + |
437 | + def test_no_juju_data(self): |
438 | + stderr = StringIO() |
439 | + with self.assertRaises(SystemExit): |
440 | + with patch('sys.stderr', stderr): |
441 | + get_parameters(['bar']) |
442 | + self.assertEqual( |
443 | + stderr.getvalue(), |
444 | + 'JUJU_DATA must be set to a directory containing' |
445 | + ' credentials.yaml.\n') |
446 | + |
447 | + |
448 | +class TestMakeItemName(TestCase): |
449 | + |
450 | + def test_make_item_name(self): |
451 | + item_name = make_item_name('us-east-1', 'paravirtual', 'instance') |
452 | + self.assertEqual(item_name, 'usee1pi') |
453 | + item_name = make_item_name('cn-northwest-3', 'hvm', 'ebs') |
454 | + self.assertEqual(item_name, 'cnnw3he') |
455 | + |
456 | + |
457 | +def load_json(parent, filename): |
458 | + with open(os.path.join(parent, 'streams', 'v1', filename)) as f: |
459 | + return json.load(f) |
460 | + |
461 | + |
462 | +class TestWriteStreams(TestCase): |
463 | + |
464 | + def test_write_streams(self): |
465 | + now = datetime(2001, 02, 03) |
466 | + credentials = {'name': 'aws'} |
467 | + china_credentials = {'name': 'aws-cn'} |
468 | + east_conn = Mock() |
469 | + east_image = make_mock_image(region_name='us-east-1') |
470 | + east_conn.get_all_images.return_value = [east_image] |
471 | + west_conn = Mock() |
472 | + west_image = make_mock_image(region_name='us-west-1') |
473 | + west_conn.get_all_images.return_value = [west_image] |
474 | + with temp_dir() as streams: |
475 | + with patch('make_aws_image_streams.iter_region_connection', |
476 | + return_value=[east_conn, west_conn], |
477 | + autospec=True) as irc_mock: |
478 | + with patch('make_aws_image_streams.timestamp', |
479 | + return_value='now'): |
480 | + with patch('sys.stderr'): |
481 | + write_streams(credentials, china_credentials, now, |
482 | + streams) |
483 | + index = load_json(streams, 'index.json') |
484 | + index2 = load_json(streams, 'index2.json') |
485 | + releases = load_json(streams, 'com.ubuntu.cloud.released-aws.json') |
486 | + irc_mock.assert_called_once_with(credentials, china_credentials) |
487 | + self.assertEqual( |
488 | + {'format': 'index:1.0', 'updated': 'now', 'index': {}}, index) |
489 | + self.assertEqual( |
490 | + {'format': 'index:1.0', 'updated': 'now', 'index': { |
491 | + 'com.ubuntu.cloud.released:aws': { |
492 | + 'format': 'products:1.0', |
493 | + 'updated': 'now', |
494 | + 'datatype': 'image-ids', |
495 | + 'path': 'streams/v1/com.ubuntu.cloud.released-aws.json', |
496 | + 'products': ['com.ubuntu.cloud:server:centos7:amd64'], |
497 | + } |
498 | + }}, index2) |
499 | + expected = { |
500 | + 'content_id': 'com.ubuntu.cloud.released:aws', |
501 | + 'format': 'products:1.0', |
502 | + 'updated': 'now', |
503 | + 'datatype': 'image-ids', |
504 | + 'products': {'com.ubuntu.cloud:server:centos7:amd64': { |
505 | + 'root_store': 'ebs', |
506 | + 'endpoint': 'https://foo', |
507 | + 'arch': 'amd64', |
508 | + 'release_title': 'Centos 7', |
509 | + 'label': 'release', |
510 | + 'release_codename': 'centos7', |
511 | + 'version': 'centos7', |
512 | + 'virt': 'hvm', |
513 | + 'release': 'centos7', |
514 | + 'os': 'centos', |
515 | + 'id': 'qux', |
516 | + 'versions': {'20010203': { |
517 | + 'items': { |
518 | + 'usww1he': {'region': 'us-west-1'}, |
519 | + 'usee1he': {'region': 'us-east-1'}, |
520 | + } |
521 | + }}, |
522 | + }}, |
523 | + } |
524 | + self.assertEqual(releases, expected) |
Thank you.