Merge lp:~abentley/juju-release-tools/append-images into lp:juju-release-tools

Proposed by Aaron Bentley
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
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 aw0evgkw8e5c1q413zgy5pjce is used to find images. This is the official CentOS product code: https://wiki.centos.org/Cloud/AWS

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://cloud-images.ubuntu.com/releases/streams/v1/com.ubuntu.cloud:released:aws.json does not appear to have any existing gov entries, either.

To post a comment you must log in.
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=== 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)

Subscribers

People subscribed via source and target branches