Merge ~thedac/simplestreams:bug/1790904 into simplestreams:master

Proposed by David Ames
Status: Merged
Merged at revision: efb19f32e9f7c4b435a4119047947d5aad73b2c5
Proposed branch: ~thedac/simplestreams:bug/1790904
Merge into: simplestreams:master
Diff against target: 436 lines (+257/-22)
3 files modified
simplestreams/mirrors/glance.py (+75/-13)
simplestreams/openstack.py (+3/-0)
tests/unittests/test_glancemirror.py (+179/-9)
Reviewer Review Type Date Requested Status
Server Team CI bot continuous-integration Needs Fixing
Scott Moser (community) Approve
Ryan Beisner (community) Needs Resubmitting
Review via email: mp+354369@code.launchpad.net

Commit message

glance: Support Glance version 2.

OpenStack at Rocky has removed glance v1 entirely. V2 has been around
for some time.

This change updates simplestreams to use glance v2 when "non-legacy"
versions of the clients are used.

LP: #1790904

Description of the change

Use Glance v2

OpenStack at Rocky has removed glance v1 entirely. V2 has been around
for some time.

This change updates simplestreams to use glance v2 when "non-legacy"
versions of the clients are used.

I am open to other solutions, this change represents what needs to
be updated for v2.

To post a comment you must log in.
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Needs Fixing (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

it seems some of your changes are probably better suited to 'prepare_glance_arguments'. read its function docstring:

  Returns a dict to use as keyword arguments passed directly to GlanceClient.images.create

other comments inline.

obviously you have to make the c-i bot happy too. some test changes needed, and we'd want some tests on the new path.

Revision history for this message
Scott Moser (smoser) :
review: Needs Fixing
Revision history for this message
Ryan Beisner (1chb1n) wrote :

This MP is more an illustration as to what we think would need to change. Can we have #ubuntu-server run with the bug?

review: Needs Resubmitting
Revision history for this message
David Ames (thedac) wrote :

Addressing the comments.

Specifically moving what can be moved to 'prepare_glance_arguments'.

And using self.glance_api_version for version checking.

I'll have an update as soon as I get unit tests working properly.

Revision history for this message
Scott Moser (smoser) :
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

@thedac,
please comment on the size/hash. Doesnt that seem just wrong to you?
The only way to test if something went wrong in the upload is to launch it?

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
David Ames (thedac) wrote :

> @thedac,
> please comment on the size/hash. Doesnt that seem just wrong to you?
> The only way to test if something went wrong in the upload is to launch it?

@smoser

I have scoured the glance v2 api documentation [0]. The API has changed such
that we cannot send the expected checksum during the creation/upload process.

For instance the glanceclient.images.data method is for downloading an image.
None of the create, upload or update methods take a checksum.

What we can do is validate the checksum *after* we upload it. Let me know if
something like [1] will work for you or please suggest/implement a validation.

[0] https://docs.openstack.org/python-glanceclient/latest/reference/api/glanceclient.v2.images.html
[1] https://pastebin.ubuntu.com/p/jH8fNWj5jJ/

Revision history for this message
Scott Moser (smoser) wrote :

David,
If that works, then I guess it is generally ok.

More than just "silently" deleting the image, we should probably raise an error of some kind. I guess IOError would make sense.

I think its worth checking size also and updating the message to provide expected and found size and checksum. I realize its probably very unlikely that size matches but checksum does not.

Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

David,
Several cleanups at
 https://git.launchpad.net/~smoser/simplestreams/commit/?id=0c1fff74b3f56804dea1f0abea61d31c597e0679

Can you grab that ?

then i think we're pretty good and i'm willing to pull this and put it into dingo.

Revision history for this message
David Ames (thedac) wrote :

Scott,

I have merged your changes and pushed up to my branch. However, strangely LP does not seem to notice.

When you grab my branch from LP it has your commit in it.
git clone -b bug/1790904 https://git.launchpad.net/~thedac/simplestreams

Not sure what we need to do to kick LP.

Revision history for this message
Scott Moser (smoser) :
review: Approve
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Autolanding.
Unapproved changes made after approval.
https://jenkins.ubuntu.com/server/job/simplestreams-autoland-test/7/
Executed test runs:
    None: https://jenkins.ubuntu.com/server/job/admin-lp-git-autoland/148/console

review: Needs Fixing (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :
review: Approve (continuous-integration)
Revision history for this message
Server Team CI bot (server-team-bot) wrote :

FAILED: Autolanding.
Unapproved changes made after approval.
https://jenkins.ubuntu.com/server/job/simplestreams-autoland-test/8/
Executed test runs:
    None: https://jenkins.ubuntu.com/server/job/admin-lp-git-autoland/149/console

review: Needs Fixing (continuous-integration)
Revision history for this message
Scott Moser (smoser) wrote :

not sure why launchpad/the autolander doesnt like this branch.
so i put the same content at
 https://code.launchpad.net/~smoser/simplestreams/+git/simplestreams/+merge/358091

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1diff --git a/simplestreams/mirrors/glance.py b/simplestreams/mirrors/glance.py
2index 0e694f2..297db27 100644
3--- a/simplestreams/mirrors/glance.py
4+++ b/simplestreams/mirrors/glance.py
5@@ -151,7 +151,9 @@ class GlanceMirror(mirrors.BasicMirrorWriter):
6
7 conn_info = client.get_service_conn_info(
8 'image', **self.keystone_creds)
9- self.gclient = get_glanceclient(**conn_info)
10+ self.glance_api_version = conn_info['glance_version']
11+ self.gclient = get_glanceclient(version=self.glance_api_version,
12+ **conn_info)
13 self.tenant_id = conn_info['tenant_id']
14
15 self.region = self.keystone_creds.get('region_name', 'nullregion')
16@@ -195,12 +197,15 @@ class GlanceMirror(mirrors.BasicMirrorWriter):
17
18 images = self.gclient.images.list()
19 for image in images:
20- image = image.to_dict()
21+ if self.glance_api_version == "1":
22+ image = image.to_dict()
23+ props = image['properties']
24+ else:
25+ props = copy.deepcopy(image)
26
27 if image['owner'] != self.tenant_id:
28 continue
29
30- props = image['properties']
31 if props.get('content_id') != my_cid:
32 continue
33
34@@ -321,15 +326,22 @@ class GlanceMirror(mirrors.BasicMirrorWriter):
35 'properties': image_properties,
36 }
37
38- if 'size' in image_metadata:
39- create_kwargs['size'] = image_metadata.get('size')
40- if 'md5' in image_metadata:
41- create_kwargs['checksum'] = image_metadata.get('md5')
42- if image_md5_hash and image_size:
43- create_kwargs.update({
44- 'checksum': image_md5_hash,
45- 'size': image_size,
46- })
47+ # In v2 is_public=True is visibility='public'
48+ if self.glance_api_version == "2":
49+ del create_kwargs['is_public']
50+ create_kwargs['visibility'] = 'public'
51+
52+ # v2 automatically calculates size and checksum
53+ if self.glance_api_version == "1":
54+ if 'size' in image_metadata:
55+ create_kwargs['size'] = image_metadata.get('size')
56+ if 'md5' in image_metadata:
57+ create_kwargs['checksum'] = image_metadata.get('md5')
58+ if image_md5_hash and image_size:
59+ create_kwargs.update({
60+ 'checksum': image_md5_hash,
61+ 'size': image_size,
62+ })
63
64 if 'ftype' in image_metadata:
65 create_kwargs['disk_format'] = (
66@@ -459,9 +471,28 @@ class GlanceMirror(mirrors.BasicMirrorWriter):
67 new_size)
68
69 try:
70- create_kwargs['data'] = open(tmp_path, 'rb')
71+ if self.glance_api_version == "1":
72+ # Set data as string if v1
73+ create_kwargs['data'] = open(tmp_path, 'rb')
74+ else:
75+ # Keep properties for v2 update call
76+ _properties = create_kwargs['properties']
77+ del create_kwargs['properties']
78+
79 glance_image = self.gclient.images.create(**create_kwargs)
80 target_sstream_item['id'] = glance_image.id
81+
82+ if self.glance_api_version == "2":
83+ # Upload for v2
84+ self.gclient.images.upload(glance_image.id,
85+ open(tmp_path, 'rb'))
86+ # Update properties for v2
87+ self.gclient.images.update(glance_image.id, **_properties)
88+
89+ # Validate the image checksum and size. This will throw an
90+ # IOError if they do not match.
91+ self.validate_image(glance_image.id, new_md5, new_size)
92+
93 print("created %s: %s" % (glance_image.id, full_image_name))
94
95 finally:
96@@ -473,6 +504,37 @@ class GlanceMirror(mirrors.BasicMirrorWriter):
97 # unused in insert_products below.
98 self.insert_products(None, target, None)
99
100+ def validate_image(self, image_id, checksum, size):
101+ """Validate an image's checksum and size after upload.
102+
103+ Check for expected checksum and size.
104+ Throw an IOError if they do not match.
105+
106+ :param image_id: str Glance Image ID
107+ :param checksum: str Expected MD5 sum of the image
108+ :param size: str Expected size in bytes of the image
109+ :returns: None
110+ """
111+ _image = self.gclient.images.get(image_id)
112+ # Validate checksum
113+ if not checksum == _image.checksum:
114+ # If we do not delete the image, the next run will
115+ # be unaware the image is invalid.
116+ self.gclient.images.delete(image_id)
117+ raise IOError(
118+ "Invalid glance image: %s: Checksum mismatch: "
119+ "expected checksum: %s actual: %s. Deleting the image."
120+ % (image_id, checksum, _image.checksum))
121+ # Validate size
122+ if not size == _image.size:
123+ # If we do not delete the image, the next run will
124+ # be unaware the image is invalid.
125+ self.gclient.images.delete(image_id)
126+ raise IOError(
127+ "Invalid glance image: %s: Size mismatch: "
128+ "expected size: %s actual: %s. Deleting the image."
129+ % (image_id, size, _image.size))
130+
131 def insert_item(self, data, src, target, pedigree, contentsource):
132 """Queue item to be inserted in subsequent call to insert_version
133
134diff --git a/simplestreams/openstack.py b/simplestreams/openstack.py
135index 525f423..4899df7 100644
136--- a/simplestreams/openstack.py
137+++ b/simplestreams/openstack.py
138@@ -188,6 +188,9 @@ def get_service_conn_info(service='image', client=None, **kwargs):
139 'tenant_id': tenant_id}
140 if not _LEGACY_CLIENTS:
141 info['session'] = client.session
142+ info['glance_version'] = '2'
143+ else:
144+ info['glance_version'] = '1'
145
146 return info
147
148diff --git a/tests/unittests/test_glancemirror.py b/tests/unittests/test_glancemirror.py
149index eb537d8..5fba5ed 100644
150--- a/tests/unittests/test_glancemirror.py
151+++ b/tests/unittests/test_glancemirror.py
152@@ -98,7 +98,8 @@ class FakeOpenstack(object):
153
154 def get_service_conn_info(self, url, region_name=None, auth_url=None):
155 return {"endpoint": "http://objectstore/api/",
156- "tenant_id": "bar456"}
157+ "tenant_id": "bar456",
158+ "glance_version": "1"}
159
160
161 class FakeImage(object):
162@@ -106,16 +107,45 @@ class FakeImage(object):
163 def __init__(self, identifier):
164 self.id = identifier
165
166+ @property
167+ def checksum(self):
168+ # Using None and None as self.mirror.download_image gets this value
169+ # from the fake setup.
170+ return None
171+
172+ @property
173+ def size(self):
174+ # Using None and None as self.mirror.download_image gets this value
175+ # from the fake setup.
176+ return None
177+
178
179 class FakeImages(object):
180- """Fake GlanceClient.images implementation to track create() calls."""
181+ """Fake GlanceClient.images implementation to track method calls."""
182 def __init__(self):
183 self.create_calls = []
184+ self.delete_calls = []
185+ self.get_calls = []
186+ self.update_calls = []
187+ self.upload_calls = []
188
189 def create(self, **kwargs):
190 self.create_calls.append(kwargs)
191 return FakeImage('image-%d' % len(self.create_calls))
192
193+ def delete(self, *args, **kwargs):
194+ self.delete_calls.append(kwargs)
195+
196+ def get(self, *args, **kwargs):
197+ self.get_calls.append(kwargs)
198+ return FakeImage('image-%d' % len(self.get_calls))
199+
200+ def update(self, *args, **kwargs):
201+ self.update_calls.append(kwargs)
202+
203+ def upload(self, *args, **kwargs):
204+ self.upload_calls.append(kwargs)
205+
206
207 class FakeGlanceClient(object):
208 """Fake GlanceClient implementation to track images.create() calls."""
209@@ -306,9 +336,10 @@ class TestGlanceMirror(TestCase):
210 '{"os": "ubuntu", "version": "16.04"}',
211 properties["simplestreams_metadata"])
212
213- def test_prepare_glance_arguments(self):
214+ def test_prepare_glance_arguments_v1(self):
215 # Prepares arguments to pass to GlanceClient.images.create()
216 # based on image metadata from the simplestreams source.
217+ self.mirror.glance_api_version = "1"
218 source_entry = {}
219 create_arguments = self.mirror.prepare_glance_arguments(
220 "foobuntu-X", source_entry, image_md5_hash=None, image_size=None,
221@@ -324,6 +355,25 @@ class TestGlanceMirror(TestCase):
222 "properties": None},
223 create_arguments)
224
225+ def test_prepare_glance_arguments_v2(self):
226+ # Prepares arguments to pass to GlanceClient.images.create()
227+ # based on image metadata from the simplestreams source.
228+ self.mirror.glance_api_version = "2"
229+ source_entry = {}
230+ create_arguments = self.mirror.prepare_glance_arguments(
231+ "foobuntu-X", source_entry, image_md5_hash=None, image_size=None,
232+ image_properties=None)
233+
234+ # Arguments to always pass in contain the image name, container format,
235+ # disk format, whether image is public, and any passed-in properties.
236+ self.assertEqual(
237+ {"name": "foobuntu-X",
238+ "container_format": 'bare',
239+ "disk_format": "qcow2",
240+ "visibility": "public",
241+ "properties": None},
242+ create_arguments)
243+
244 def test_prepare_glance_arguments_disk_format(self):
245 # Disk format is based on the image 'ftype' (if defined).
246 source_entry = {"ftype": "root.tar.gz"}
247@@ -342,8 +392,9 @@ class TestGlanceMirror(TestCase):
248
249 self.assertEqual("squashfs", create_arguments["disk_format"])
250
251- def test_prepare_glance_arguments_size(self):
252+ def test_prepare_glance_arguments_size_v1(self):
253 # Size is read from image metadata if defined.
254+ self.mirror.glance_api_version = "1"
255 source_entry = {"size": 5}
256 create_arguments = self.mirror.prepare_glance_arguments(
257 "foobuntu-X", source_entry, image_md5_hash=None, image_size=None,
258@@ -351,8 +402,19 @@ class TestGlanceMirror(TestCase):
259
260 self.assertEqual(5, create_arguments["size"])
261
262- def test_prepare_glance_arguments_checksum(self):
263+ def test_prepare_glance_arguments_size_v2(self):
264+ # Size is read from image metadata if defined.
265+ self.mirror.glance_api_version = "2"
266+ source_entry = {"size": 5}
267+ create_arguments = self.mirror.prepare_glance_arguments(
268+ "foobuntu-X", source_entry, image_md5_hash=None, image_size=None,
269+ image_properties=None)
270+
271+ self.assertEqual(None, create_arguments.get("size"))
272+
273+ def test_prepare_glance_arguments_checksum_v1(self):
274 # Checksum is based on the source entry 'md5' value, if defined.
275+ self.mirror.glance_api_version = "1"
276 source_entry = {"md5": "foo123"}
277 create_arguments = self.mirror.prepare_glance_arguments(
278 "foobuntu-X", source_entry, image_md5_hash=None, image_size=None,
279@@ -360,7 +422,7 @@ class TestGlanceMirror(TestCase):
280
281 self.assertEqual("foo123", create_arguments["checksum"])
282
283- def test_prepare_glance_arguments_size_and_md5_override(self):
284+ def test_prepare_glance_arguments_size_and_md5_override_v1(self):
285 # Size and md5 hash are overridden from the passed-in values even if
286 # defined on the source entry.
287 source_entry = {"size": 5, "md5": "foo123"}
288@@ -371,7 +433,7 @@ class TestGlanceMirror(TestCase):
289 self.assertEqual(10, create_arguments["size"])
290 self.assertEqual("bar456", create_arguments["checksum"])
291
292- def test_prepare_glance_arguments_size_and_md5_no_override_hash(self):
293+ def test_prepare_glance_arguments_size_and_md5_no_override_hash_v1(self):
294 # If only one of image_md5_hash or image_size is passed directly in,
295 # the other value is not overridden either.
296 source_entry = {"size": 5, "md5": "foo123"}
297@@ -382,7 +444,7 @@ class TestGlanceMirror(TestCase):
298 self.assertEqual(5, create_arguments["size"])
299 self.assertEqual("foo123", create_arguments["checksum"])
300
301- def test_prepare_glance_arguments_size_and_md5_no_override_size(self):
302+ def test_prepare_glance_arguments_size_and_md5_no_override_size_v1(self):
303 # If only one of image_md5_hash or image_size is passed directly in,
304 # the other value is not overridden either.
305 source_entry = {"size": 5, "md5": "foo123"}
306@@ -393,6 +455,16 @@ class TestGlanceMirror(TestCase):
307 self.assertEqual(5, create_arguments["size"])
308 self.assertEqual("foo123", create_arguments["checksum"])
309
310+ def test_prepare_glance_arguments_checksum_v2(self):
311+ # Checksum is based on the source entry 'md5' value, if defined.
312+ self.mirror.glance_api_version = "2"
313+ source_entry = {"md5": "foo123"}
314+ create_arguments = self.mirror.prepare_glance_arguments(
315+ "foobuntu-X", source_entry, image_md5_hash=None, image_size=None,
316+ image_properties=None)
317+
318+ self.assertEqual(None, create_arguments.get("checksum"))
319+
320 def test_download_image(self):
321 # Downloads image from a contentsource.
322 content = "foo bazes the bar"
323@@ -538,10 +610,11 @@ class TestGlanceMirror(TestCase):
324 pedigree[2])
325 self.assertEqual(u"http://keystone/api/", target_image["endpoint"])
326
327- def test_insert_item_full(self):
328+ def test_insert_item_full_v1(self):
329 # This test uses the full sample entries from the source simplestreams
330 # index from cloud-images.u.c and resulting local simplestreams index
331 # files.
332+ self.mirror.glance_api_version = "1"
333 source_index = copy.deepcopy(TEST_SOURCE_INDEX_ENTRY)
334
335 # "Pedigree" is basically a "path" to get to the image data in
336@@ -655,3 +728,100 @@ class TestGlanceMirror(TestCase):
337 del stored_index[u"updated"]
338
339 self.assertEqual(EXPECTED_OUTPUT_INDEX, stored_index)
340+
341+ def test_insert_item_full_v2(self):
342+ # This test uses the full sample entries from the source simplestreams
343+ # index from cloud-images.u.c and resulting local simplestreams index
344+ # files.
345+ self.mirror.glance_api_version = "2"
346+ source_index = copy.deepcopy(TEST_SOURCE_INDEX_ENTRY)
347+
348+ # "Pedigree" is basically a "path" to get to the image data in
349+ # simplestreams index, going through "products", their "versions",
350+ # and nested "items".
351+ pedigree = (
352+ u'com.ubuntu.cloud:server:14.04:amd64', u'20160602', u'disk1.img')
353+ product = source_index[u'products'][pedigree[0]]
354+ ver_data = product[u'versions'][pedigree[1]]
355+ image_data = ver_data[u'items'][pedigree[2]]
356+
357+ content_source = MemoryContentSource(
358+ url="http://image-store/fooubuntu-X-disk1.img",
359+ content="image-data")
360+
361+ # Use a fake GlanceClient to track arguments passed into
362+ # GlanceClient.images.create().
363+ self.addCleanup(setattr, self.mirror, "gclient", self.mirror.gclient)
364+ self.mirror.gclient = FakeGlanceClient()
365+
366+ target = {
367+ 'content_id': 'auto.sync',
368+ 'datatype': 'image-ids',
369+ 'format': 'products:1.0',
370+ }
371+
372+ self.mirror.insert_item(
373+ image_data, source_index, target, pedigree, content_source)
374+ self.mirror.insert_version(
375+ image_data, source_index, target, pedigree[0:2])
376+
377+ passed_create_kwargs = self.mirror.gclient.images.create_calls[0]
378+ passed_update_kwargs = self.mirror.gclient.images.update_calls[0]
379+
380+ expected_create_kwargs = {
381+ 'name': ('auto-sync/'
382+ 'ubuntu-trusty-14.04-amd64-server-20160602-disk1.img'),
383+ 'disk_format': 'qcow2',
384+ 'container_format': 'bare',
385+ 'visibility': "public"}
386+
387+ expected_update_kwargs = {
388+ 'os_distro': u'ubuntu',
389+ 'item_name': u'disk1.img',
390+ 'os_version': u'14.04',
391+ 'architecture': 'x86_64',
392+ 'version_name': u'20160602',
393+ 'content_id': 'auto.sync',
394+ 'product_name': u'com.ubuntu.cloud:server:14.04:amd64',
395+ 'simplestreams_metadata': (
396+ '{"aliases": "14.04,default,lts,t,trusty", '
397+ '"arch": "amd64", "ftype": "disk1.img", '
398+ '"label": "release", "md5": '
399+ '"e5436cd36ae6cc298f081bf0f6b413f1", "os": "ubuntu", '
400+ '"pubname": "ubuntu-trusty-14.04-amd64-server-20160602", '
401+ '"release": "trusty", "release_codename": "Trusty Tahr", '
402+ '"release_title": "14.04 LTS", "sha256": '
403+ '"5b982d7d4dd1a03e88ae5f35f02ed44f'
404+ '579e2711f3e0f27ea2bff20aef8c8d9e", "size": "259850752", '
405+ '"support_eol": "2019-04-17", "supported": "True", '
406+ '"version": "14.04"}'),
407+ 'source_content_id': u'com.ubuntu.cloud:released:download'}
408+
409+ self.assertEqual(expected_create_kwargs, passed_create_kwargs)
410+ self.assertEqual(expected_update_kwargs, passed_update_kwargs)
411+
412+ # Apply the condensing as done in GlanceMirror.insert_products()
413+ # to ensure we compare with the desired resulting simplestreams data.
414+ sticky = ['ftype', 'md5', 'sha256', 'size', 'name', 'id', 'endpoint',
415+ 'region']
416+ simplestreams.util.products_condense(target, sticky)
417+
418+ self.assertEqual(EXPECTED_OUTPUT_INDEX, target)
419+
420+ def test_validate_image(self):
421+ # Ensure validate_image throws IOError when expected.
422+
423+ # Successful validation. Using None and None as
424+ # self.mirror.download_image gets these values from the fake setup.
425+ self.mirror.gclient = FakeGlanceClient()
426+ self.assertEqual(
427+ None,
428+ self.mirror.validate_image("image-id", None, None))
429+
430+ # Mismatched checksum
431+ with self.assertRaises(IOError):
432+ self.mirror.validate_image("image-id", "Wrong-checksum", None)
433+
434+ # Mismatched size
435+ with self.assertRaises(IOError):
436+ self.mirror.validate_image("image-id", None, "Wrong-size")

Subscribers

People subscribed via source and target branches

to all changes: