Skip to content

Commit 1e27fa9

Browse files
Zuulopenstack-gerrit
authored andcommitted
Merge "Share snapshot image membership with instance owner" into stable/rocky
2 parents d8f1bef + 6ca6f6f commit 1e27fa9

File tree

16 files changed

+199
-26
lines changed

16 files changed

+199
-26
lines changed

nova/compute/api.py

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2908,7 +2908,7 @@ def _create_image(self, context, instance, name, image_type,
29082908
properties.update(extra_properties or {})
29092909

29102910
image_meta = self._initialize_instance_snapshot_metadata(
2911-
instance, name, properties)
2911+
context, instance, name, properties)
29122912
# if we're making a snapshot, omit the disk and container formats,
29132913
# since the image may have been converted to another format, and the
29142914
# original values won't be accurate. The driver will populate these
@@ -2918,7 +2918,7 @@ def _create_image(self, context, instance, name, image_type,
29182918
image_meta.pop('container_format', None)
29192919
return self.image_api.create(context, image_meta)
29202920

2921-
def _initialize_instance_snapshot_metadata(self, instance, name,
2921+
def _initialize_instance_snapshot_metadata(self, context, instance, name,
29222922
extra_properties=None):
29232923
"""Initialize new metadata for a snapshot of the given instance.
29242924
@@ -2930,16 +2930,35 @@ def _initialize_instance_snapshot_metadata(self, instance, name,
29302930
"""
29312931
image_meta = utils.get_image_from_system_metadata(
29322932
instance.system_metadata)
2933-
image_meta.update({'name': name,
2934-
'is_public': False})
2933+
image_meta['name'] = name
2934+
2935+
# If the user creating the snapshot is not in the same project as
2936+
# the owner of the instance, then the image visibility should be
2937+
# "shared" so the owner of the instance has access to the image, like
2938+
# in the case of an admin creating a snapshot of another user's
2939+
# server, either directly via the createImage API or via shelve.
2940+
extra_properties = extra_properties or {}
2941+
if context.project_id != instance.project_id:
2942+
# The glance API client-side code will use this to add the
2943+
# instance project as a member of the image for access.
2944+
image_meta['visibility'] = 'shared'
2945+
extra_properties['instance_owner'] = instance.project_id
2946+
# TODO(mriedem): Should owner_project_name and owner_user_name
2947+
# be removed from image_meta['properties'] here, or added to
2948+
# [DEFAULT]/non_inheritable_image_properties? It is confusing
2949+
# otherwise to see the owner project not match those values.
2950+
else:
2951+
# The request comes from the owner of the instance so make the
2952+
# image private.
2953+
image_meta['visibility'] = 'private'
29352954

29362955
# Delete properties that are non-inheritable
29372956
properties = image_meta['properties']
29382957
for key in CONF.non_inheritable_image_properties:
29392958
properties.pop(key, None)
29402959

29412960
# The properties in extra_properties have precedence
2942-
properties.update(extra_properties or {})
2961+
properties.update(extra_properties)
29432962

29442963
return image_meta
29452964

@@ -2958,7 +2977,7 @@ def snapshot_volume_backed(self, context, instance, name,
29582977
:returns: the new image metadata
29592978
"""
29602979
image_meta = self._initialize_instance_snapshot_metadata(
2961-
instance, name, extra_properties)
2980+
context, instance, name, extra_properties)
29622981
# the new image is simply a bucket of properties (particularly the
29632982
# block device mapping, kernel and ramdisk IDs) with no image data,
29642983
# hence the zero size

nova/image/glance.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -468,11 +468,18 @@ def create(self, context, image_meta, data=None):
468468
# empty data.
469469
force_activate = data is None and image_meta.get('size') == 0
470470

471+
# The "instance_owner" property is set in the API if a user, who is
472+
# not the owner of an instance, is creating the image, e.g. admin
473+
# snapshots or shelves another user's instance. This is used to add
474+
# member access to the image for the instance owner.
475+
sharing_member_id = image_meta.get('properties', {}).pop(
476+
'instance_owner', None)
471477
sent_service_image_meta = _translate_to_glance(image_meta)
472478

473479
try:
474480
image = self._create_v2(context, sent_service_image_meta,
475-
data, force_activate)
481+
data, force_activate,
482+
sharing_member_id=sharing_member_id)
476483
except glanceclient.exc.HTTPException:
477484
_reraise_translated_exception()
478485

@@ -486,6 +493,23 @@ def _add_location(self, context, image_id, location):
486493
except glanceclient.exc.HTTPBadRequest:
487494
_reraise_translated_exception()
488495

496+
def _add_image_member(self, context, image_id, member_id):
497+
"""Grant access to another project that does not own the image
498+
499+
:param context: nova auth RequestContext where context.project_id is
500+
the owner of the image
501+
:param image_id: ID of the image on which to grant access
502+
:param member_id: ID of the member project to grant access to the
503+
image; this should not be the owner of the image
504+
:returns: A Member schema object of the created image member
505+
"""
506+
try:
507+
return self._client.call(
508+
context, 2, 'create', controller='image_members',
509+
args=(image_id, member_id))
510+
except glanceclient.exc.HTTPBadRequest:
511+
_reraise_translated_exception()
512+
489513
def _upload_data(self, context, image_id, data):
490514
self._client.call(context, 2, 'upload', args=(image_id, data))
491515
return self._client.call(context, 2, 'get', args=(image_id,))
@@ -534,7 +558,7 @@ def _get_image_create_disk_format_default(self, context):
534558
return preferred_disk_formats[0]
535559

536560
def _create_v2(self, context, sent_service_image_meta, data=None,
537-
force_activate=False):
561+
force_activate=False, sharing_member_id=None):
538562
# Glance v1 allows image activation without setting disk and
539563
# container formats, v2 doesn't. It leads to the dirtiest workaround
540564
# where we have to hardcode this parameters.
@@ -556,6 +580,12 @@ def _create_v2(self, context, sent_service_image_meta, data=None,
556580
if location:
557581
image = self._add_location(context, image_id, location)
558582

583+
# Add image membership in a separate request.
584+
if sharing_member_id:
585+
LOG.debug('Adding access for member %s to image %s',
586+
sharing_member_id, image_id)
587+
self._add_image_member(context, image_id, sharing_member_id)
588+
559589
# If we have some data we have to send it in separate request and
560590
# update the image then.
561591
if data is not None:

nova/tests/functional/test_images.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
# License for the specific language governing permissions and limitations
1111
# under the License.
1212

13+
from nova.tests import uuidsentinel as uuids
14+
15+
from nova.tests import fixtures as nova_fixtures
1316
from nova.tests.functional.api import client
1417
from nova.tests.functional import test_servers
1518

@@ -58,3 +61,46 @@ def test_create_images_negative_invalid_state(self):
5861

5962
# Cleanup
6063
self._delete_server(server_id)
64+
65+
def test_admin_snapshot_user_image_access_member(self):
66+
"""Tests a scenario where a user in project A creates a server and
67+
an admin in project B creates a snapshot of the server. The user in
68+
project A should have member access to the image even though the admin
69+
project B is the owner of the image.
70+
"""
71+
# Create a server using the tenant user project.
72+
server = self._build_minimal_create_server_request()
73+
server = self.api.post_server({"server": server})
74+
server = self._wait_for_state_change(server, 'BUILD')
75+
self.assertEqual('ACTIVE', server['status'])
76+
77+
# Create an admin API fixture with a unique project ID.
78+
admin_api = self.useFixture(
79+
nova_fixtures.OSAPIFixture(
80+
project_id=uuids.admin_project)).admin_api
81+
82+
# Create a snapshot of the server using the admin project.
83+
name = 'admin-created-snapshot'
84+
admin_api.post_server_action(
85+
server['id'], {'createImage': {'name': name}})
86+
# Confirm that the image was created.
87+
images = admin_api.get_images()
88+
for image in images:
89+
if image['name'] == name:
90+
break
91+
else:
92+
self.fail('Expected snapshot image %s not found in images list %s'
93+
% (name, images))
94+
# Assert the owner is the admin project since the admin created the
95+
# snapshot. Note that the images API proxy puts stuff it does not know
96+
# about in the 'metadata' dict so that is where we will find owner.
97+
metadata = image['metadata']
98+
self.assertIn('owner', metadata)
99+
self.assertEqual(uuids.admin_project, metadata['owner'])
100+
# Assert the non-admin tenant user project is a member.
101+
self.assertIn('instance_owner', metadata)
102+
self.assertEqual(
103+
self.api_fixture.project_id, metadata['instance_owner'])
104+
# Be sure we did not get a false positive by making sure the admin and
105+
# tenant user API fixtures are not using the same project_id.
106+
self.assertNotEqual(uuids.admin_project, self.api_fixture.project_id)

nova/tests/unit/compute/test_compute_api.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2905,7 +2905,6 @@ def _test_snapshot_and_backup(self, mock_record, is_snapshot=True,
29052905
# carried from sys_meta into image property...since it should be set
29062906
# explicitly by _create_image() in compute api.
29072907
fake_image_meta = {
2908-
'is_public': True,
29092908
'name': 'base-name',
29102909
'disk_format': 'fake',
29112910
'container_format': 'fake',
@@ -2923,7 +2922,7 @@ def _test_snapshot_and_backup(self, mock_record, is_snapshot=True,
29232922
}
29242923
image_type = is_snapshot and 'snapshot' or 'backup'
29252924
sent_meta = {
2926-
'is_public': False,
2925+
'visibility': 'private',
29272926
'name': 'fake-name',
29282927
'disk_format': 'fake',
29292928
'container_format': 'fake',
@@ -3189,7 +3188,7 @@ def _test_snapshot_volume_backed(self, quiesce_required=False,
31893188
'ram_disk': 'fake_ram_disk_id'},
31903189
'size': 0,
31913190
'min_disk': '22',
3192-
'is_public': False,
3191+
'visibility': 'private',
31933192
'min_ram': '11',
31943193
}
31953194
if quiesce_required:

nova/tests/unit/image/fake.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,12 @@ def create(self, context, metadata, data=None):
209209
# is mostly a lie on a newly created image.
210210
if 'status' not in metadata:
211211
image_meta['status'] = 'active'
212+
# The owner of the image is by default the request context project_id.
213+
if context and 'owner' not in image_meta.get('properties', {}):
214+
# Note that normally "owner" is a top-level field in an image
215+
# resource in glance but we have to fake this out for the images
216+
# proxy API by throwing it into the generic "properties" dict.
217+
image_meta.get('properties', {})['owner'] = context.project_id
212218
self.images[image_id] = image_meta
213219
if data:
214220
self._imagedata[image_id] = data.read()

nova/tests/unit/image/test_glance.py

Lines changed: 58 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1501,7 +1501,7 @@ def test_create_success_v2(
15011501
}
15021502
trans_to_mock.return_value = translated
15031503
trans_from_mock.return_value = mock.sentinel.trans_from
1504-
image_mock = mock.MagicMock(spec=dict)
1504+
image_mock = {}
15051505
client = mock.MagicMock()
15061506
client.call.return_value = {'id': '123'}
15071507
ctx = mock.sentinel.ctx
@@ -1566,7 +1566,7 @@ def test_create_success_v2_with_location(
15661566
}
15671567
trans_to_mock.return_value = translated
15681568
trans_from_mock.return_value = mock.sentinel.trans_from
1569-
image_mock = mock.MagicMock(spec=dict)
1569+
image_mock = {}
15701570
client = mock.MagicMock()
15711571
client.call.return_value = translated
15721572
ctx = mock.sentinel.ctx
@@ -1577,6 +1577,62 @@ def test_create_success_v2_with_location(
15771577
trans_from_mock.assert_called_once_with(translated)
15781578
self.assertEqual(mock.sentinel.trans_from, image_meta)
15791579

1580+
@mock.patch('nova.image.glance._translate_from_glance')
1581+
@mock.patch('nova.image.glance._translate_to_glance')
1582+
def test_create_success_v2_with_sharing(
1583+
self, trans_to_mock, trans_from_mock):
1584+
"""Tests creating a snapshot image by one tenant that is shared with
1585+
the owner of the instance.
1586+
"""
1587+
translated = {
1588+
'name': mock.sentinel.name,
1589+
'visibility': 'shared'
1590+
}
1591+
trans_to_mock.return_value = translated
1592+
trans_from_mock.return_value = mock.sentinel.trans_from
1593+
image_meta = {
1594+
'name': mock.sentinel.name,
1595+
'visibility': 'shared',
1596+
'properties': {
1597+
# This triggers the image_members.create call to glance.
1598+
'instance_owner': uuids.instance_uuid
1599+
}
1600+
}
1601+
client = mock.MagicMock()
1602+
1603+
def fake_call(_ctxt, _version, method, controller=None, args=None,
1604+
kwargs=None):
1605+
if method == 'create':
1606+
if controller is None:
1607+
# Call to create the image.
1608+
translated['id'] = uuids.image_id
1609+
return translated
1610+
if controller == 'image_members':
1611+
self.assertIsNotNone(args)
1612+
self.assertEqual(
1613+
(uuids.image_id, uuids.instance_uuid), args)
1614+
# Call to share the image with the instance owner.
1615+
return mock.sentinel.member
1616+
self.fail('Unexpected glanceclient call %s.%s' %
1617+
(controller or 'images', method))
1618+
1619+
client.call.side_effect = fake_call
1620+
ctx = mock.sentinel.ctx
1621+
service = glance.GlanceImageServiceV2(client)
1622+
ret_image = service.create(ctx, image_meta)
1623+
1624+
translated_image_meta = copy.copy(image_meta)
1625+
# The instance_owner property should have been popped off and not sent
1626+
# to glance during the create() call.
1627+
translated_image_meta['properties'].pop('instance_owner', None)
1628+
trans_to_mock.assert_called_once_with(translated_image_meta)
1629+
# glanceclient should be called twice:
1630+
# - once for the image create
1631+
# - once for sharing the image with the instance owner
1632+
self.assertEqual(2, client.call.call_count)
1633+
trans_from_mock.assert_called_once_with(translated)
1634+
self.assertEqual(mock.sentinel.trans_from, ret_image)
1635+
15801636
@mock.patch('nova.image.glance._reraise_translated_exception')
15811637
@mock.patch('nova.image.glance._translate_from_glance')
15821638
@mock.patch('nova.image.glance._translate_to_glance')

nova/tests/unit/virt/hyperv/test_snapshotops.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@ def setUp(self):
3737

3838
@mock.patch('nova.image.glance.get_remote_image_service')
3939
def test_save_glance_image(self, mock_get_remote_image_service):
40-
image_metadata = {"is_public": False,
41-
"disk_format": "vhd",
40+
image_metadata = {"disk_format": "vhd",
4241
"container_format": "bare"}
4342
glance_image_service = mock.MagicMock()
4443
mock_get_remote_image_service.return_value = (glance_image_service,

nova/tests/unit/virt/libvirt/test_driver.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7355,8 +7355,7 @@ def test_create_snapshot_metadata(self):
73557355
snp_name = 'snapshot_name'
73567356
drvr = libvirt_driver.LibvirtDriver(fake.FakeVirtAPI(), False)
73577357
ret = drvr._create_snapshot_metadata(base, instance, img_fmt, snp_name)
7358-
expected = {'is_public': False,
7359-
'status': 'active',
7358+
expected = {'status': 'active',
73607359
'name': snp_name,
73617360
'properties': {
73627361
'kernel_id': instance['kernel_id'],

nova/tests/unit/virt/powervm/test_image.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,6 @@ def test_generate_snapshot_metadata(self, mock_api):
4646
mock_api.get.assert_called_with('context', 'image_id')
4747
self.assertEqual({
4848
'name': 'image_name',
49-
'is_public': False,
5049
'status': 'active',
5150
'disk_format': 'raw',
5251
'container_format': 'bare',

nova/tests/unit/virt/zvm/test_driver.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,6 @@ def test_snapshot(self, call, get_image_service, mock_open):
340340
"dest_url": "file:///path/to/target"}, '']
341341
call.side_effect = call_resp
342342
new_image_meta = {
343-
'is_public': False,
344343
'status': 'active',
345344
'properties': {
346345
'image_location': 'snapshot',

nova/virt/hyperv/snapshotops.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,7 @@ def __init__(self):
3838
def _save_glance_image(self, context, image_id, image_vhd_path):
3939
(glance_image_service,
4040
image_id) = glance.get_remote_image_service(context, image_id)
41-
image_metadata = {"is_public": False,
42-
"disk_format": "vhd",
41+
image_metadata = {"disk_format": "vhd",
4342
"container_format": "bare"}
4443
with self._pathutils.open(image_vhd_path, 'rb') as f:
4544
glance_image_service.update(context, image_id, image_metadata, f,

nova/virt/libvirt/driver.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1809,8 +1809,7 @@ def detach_interface(self, context, instance, vif):
18091809

18101810
def _create_snapshot_metadata(self, image_meta, instance,
18111811
img_fmt, snp_name):
1812-
metadata = {'is_public': False,
1813-
'status': 'active',
1812+
metadata = {'status': 'active',
18141813
'name': snp_name,
18151814
'properties': {
18161815
'kernel_id': instance.kernel_id,

nova/virt/powervm/image.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,6 @@ def generate_snapshot_metadata(context, image_api, image_id, instance):
5050
# TODO(esberglu): Update this to v2 metadata
5151
metadata = {
5252
'name': image['name'],
53-
'is_public': False,
5453
'status': 'active',
5554
'disk_format': 'raw',
5655
'container_format': 'bare',

nova/virt/vmwareapi/images.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -454,7 +454,6 @@ def upload_image_stream_optimized(context, image_id, instance, session,
454454
# which will not be the image size after upload, since it is converted
455455
# to a stream-optimized sparse disk.
456456
image_metadata = {'disk_format': constants.DISK_FORMAT_VMDK,
457-
'is_public': metadata['is_public'],
458457
'name': metadata['name'],
459458
'status': 'active',
460459
'container_format': constants.CONTAINER_FORMAT_BARE,

nova/virt/zvm/driver.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,6 @@ def snapshot(self, context, instance, image_id, update_task_state):
339339

340340
# Save image to glance
341341
new_image_meta = {
342-
'is_public': False,
343342
'status': 'active',
344343
'properties': {
345344
'image_location': 'snapshot',

0 commit comments

Comments
 (0)