Add support for shared images

Change-Id: I3822a3841e1c10717c180052f929688b9f21a841
This commit is contained in:
Kevin L. Mitchell 2011-08-15 16:20:49 -05:00
parent 78c718a9f5
commit 1e4be06cb2
28 changed files with 1789 additions and 53 deletions

View File

@ -814,6 +814,149 @@ List images that are being prefetched"""
image['status'])
@catch_error('show image members')
def image_members(options, args):
"""
%(prog)s image-members [options] <ID>
Displays a list of members with which an image is shared"""
try:
image_id = args.pop()
except IndexError:
print "Please specify the ID of the image as the first argument"
return FAILURE
c = get_client(options)
members = c.get_image_members(image_id)
sharers = 0
# Output the list of members
for memb in members:
can_share = ''
if 'can_share' in memb and memb['can_share']:
can_share = ' *'
sharers += 1
print "%s%s" % (memb['member_id'], can_share)
# Emit a footnote
if sharers > 0:
print "\n(*: Can share image)"
@catch_error('show member images')
def member_images(options, args):
"""
%(prog)s member-images [options] <MEMBER>
Displays a list of images shared with a given member"""
try:
member_id = args.pop()
except IndexError:
print "Please specify the ID of the member as the first argument"
return FAILURE
c = get_client(options)
try:
images = c.get_member_images(member_id)
except exception.NotFound:
print "No images shared with member %s" % member_id
return SUCCESS
sharers = 0
# Output the list of images
for memb in members:
can_share = ''
if 'can_share' in memb and memb['can_share']:
can_share = ' *'
sharers += 1
print "%s%s" % (memb['image_id'], can_share)
# Emit a footnote
if sharers > 0:
print "\n(*: Can share image)"
@catch_error('update image members')
def members_replace(options, args):
"""
%(prog)s members-replace [options] <ID> <MEMBER>
Replaces the members of the image <ID> to be solely <MEMBER>. If the
"--can-share" option is given, <MEMBER> will be able to further share
the image."""
try:
image_id = args.pop()
member_id = args.pop()
except IndexError:
print "Please specify the image ID and the member name"
return FAILURE
c = get_client(options)
# Update members
if not options.dry_run:
c.replace_members(image_id, dict(member_id=member_id,
can_share=options.can_share))
else:
print "Dry run. We would have done the following:"
print ('Replace members of image %(image_id)s with "%(member_id)s"'
% locals())
if options.can_share:
print "New member would have been able to further share image."
@catch_error('add image member')
def member_add(options, args):
"""
%(prog)s member-add [options] <ID> <MEMBER>
Adds the member <MEMBER> to the image <ID>. If the "--can-share"
option is given, <MEMBER> will be able to further share the image."""
try:
image_id = args.pop()
member_id = args.pop()
except IndexError:
print "Please specify the image ID and the member name"
return FAILURE
c = get_client(options)
# Replace members
if not options.dry_run:
c.add_member(image_id, member_id, options.can_share)
else:
print "Dry run. We would have done the following:"
print ('Add "%(member_id)" to membership of image %(image_id)s'
% locals())
if options.can_share:
print "New member would have been able to further share image."
@catch_error('delete image member')
def member_delete(options, args):
"""
%(prog)s member-delete [options] <ID> <MEMBER>
Deletes the specified member of the image <ID>."""
try:
image_id = args.pop()
member_id = args.pop()
except IndexError:
print "Please specify the image ID and the member name"
return FAILURE
c = get_client(options)
# Delete member
if not options.dry_run:
c.delete_member(image_id, member_id)
else:
print "Dry run. We would have done the following:"
print ('Remove "%(member_id)s" from the member list of image '
'"%(image_id)s"' % locals())
def get_client(options):
"""
Returns a new client object to a Glance server
@ -821,7 +964,8 @@ def get_client(options):
supplied to the CLI
"""
return glance_client.Client(host=options.host,
port=options.port)
port=options.port,
auth_tok=options.auth_token)
def create_options(parser):
@ -840,6 +984,10 @@ def create_options(parser):
type=int, default=9292,
help="Port the Glance API host listens on. "
"Default: %default")
parser.add_option('-A', '--auth_token', dest="auth_token",
metavar="TOKEN", default=None,
help="Authentication token to use to identify the "
"client to the glance server")
parser.add_option('--limit', dest="limit", metavar="LIMIT", default=10,
type="int", help="Page size to use while "
"requesting image metadata")
@ -857,6 +1005,8 @@ def create_options(parser):
parser.add_option('--dry-run', default=False, action="store_true",
help="Don't actually execute the command, just print "
"output showing what WOULD happen.")
parser.add_option('--can-share', default=False, action="store_true",
help="Allow member to further share image.")
def parse_options(parser, cli_args):
@ -924,8 +1074,16 @@ def lookup_command(parser, command_name):
'cache-reap-invalid': cache_reap_invalid,
'cache-reap-stalled': cache_reap_stalled}
MEMBER_COMMANDS = {
'image-members': image_members,
'member-images': member_images,
'members-replace': members_replace,
'member-add': member_add,
'member-delete': member_delete}
commands = {}
for command_set in (BASE_COMMANDS, IMAGE_COMMANDS, CACHE_COMMANDS):
for command_set in (BASE_COMMANDS, IMAGE_COMMANDS, CACHE_COMMANDS,
MEMBER_COMMANDS):
commands.update(command_set)
try:
@ -1004,6 +1162,19 @@ Cache Commands:
debugging purposes
cache-reap-stalled Reaps any stalled incomplete images
Member Commands:
image-members List members an image is shared with
member-images List images shared with a member
member-add Grants a member access to an image
member-delete Revokes a member's access to an image
members-replace Replaces all membership for an image
"""
oparser = optparse.OptionParser(version='%%prog %s'

View File

@ -0,0 +1,50 @@
..
Copyright 2010 OpenStack, LLC
All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may
not use this file except in compliance with the License. You may obtain
a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
Glance Authentication With Keystone
===================================
Glance may optionally be integrated with Keystone. Setting this up is
relatively straightforward: the Keystone distribution includes the
requisite middleware and examples of appropriately modified
``glance-api.conf`` and ``glance-registry.conf`` configuration files
in the ``examples/paste`` directory. Once you have installed Keystone
and edited your configuration files, newly created images will have
their `owner` attribute set to the tenant of the authenticated users,
and the `is_public` attribute will cause access to those images for
which it is `false` to be restricted to only the owner.
.. note::
The exception is those images for which `owner` is set to `null`,
which may only be done by those users having the ``Admin`` role.
These images may still be accessed by the public, but will not
appear in the list of public images. This allows the Glance
Registry owner to publish images for beta testing without allowing
those images to show up in lists, potentially confusing users.
Sharing Images With Others
--------------------------
It is possible to allow a private image to be shared with one or more
alternate tenants. This is done through image *memberships*, which
are available via the `members` resource of images. (For more
details, see :ref:`glanceapi`.) Essentially, a membership is an
association between an image and a tenant which has permission to
access that image. These membership associations may also have a
`can_share` attribute, which, if set to `true`, delegates the
authority to share an image to the named tenant.

View File

@ -325,3 +325,129 @@ Glance
new_meta = c.add_image(meta, open('/path/to/image.tar.gz'))
print 'Stored image. Got identifier: %s' % new_meta['id']
Requesting Image Memberships
----------------------------
We want to see a list of the other system tenants that may access a given
virtual machine image that the Glance server knows about.
Continuing from the example above, in order to get the memberships for the
image with ID 1, we can use the following code
.. code-block:: python
from glance.client import Client
c = Client("glance.example.com", 9292)
members = c.get_image_members(1)
.. note::
The return from Client.get_image_members() is a list of dictionaries. Each
dictionary has a `member_id` key, mapping to the tenant the image is shared
with, and a `can_share` key, mapping to a boolean value that identifies
whether the member can further share the image.
Requesting Member Images
------------------------
We want to see a list of the virtual machine images a given system tenant may
access.
Continuing from the example above, in order to get the images shared with
'tenant1', we can use the following code
.. code-block:: python
from glance.client import Client
c = Client("glance.example.com", 9292)
images = c.get_member_images('tenant1')
.. note::
The return from Client.get_member_images() is a list of dictionaries. Each
dictionary has an `image_id` key, mapping to an image shared with the member,
and a `can_share` key, mapping to a boolean value that identifies whether
the member can further share the image.
Adding a Member To an Image
---------------------------
We want to authorize a tenant to access a private image.
Continuing from the example above, in order to share the image with ID 1
with 'tenant1', and to allow 'tenant2' to not only access the image but to also
share it with other tenants, we can use the following code
.. code-block:: python
from glance.client import Client
c = Client("glance.example.com", 9292)
c.add_member(1, 'tenant1')
c.add_member(1, 'tenant2', True)
.. note::
The Client.add_member() function takes one optional argument, the `can_share`
value. If one is not provided and the membership already exists, its current
`can_share` setting is left alone. If the membership does not already exist,
then the `can_share` setting will default to `False`, and the membership will
be created. In all other cases, existing memberships will be modified to use
the specified `can_share` setting, and new memberships will be created with
it. The return value of Client.add_member() is not significant.
Removing a Member From an Image
-------------------------------
We want to revoke a tenant's authorization to access a private image.
Continuing from the example above, in order to revoke the access of 'tenant1'
to the image with ID 1, we can use the following code
.. code-block:: python
from glance.client import Client
c = Client("glance.example.com", 9292)
c.delete_member(1, 'tenant1')
.. note::
The return value of Client.delete_member() is not significant.
Replacing a Membership List For an Image
----------------------------------------
All existing image memberships may be revoked and replaced in a single
operation.
Continuing from the example above, in order to replace the membership list
of the image with ID 1 with two entries--the first allowing 'tenant1' to
access the image, and the second allowing 'tenant2' to access and further
share the image, we can use the following code
.. code-block:: python
from glance.client import Client
c = Client("glance.example.com", 9292)
c.replace_members(1, {'member_id': 'tenant1', 'can_share': False},
{'member_id': 'tenant2', 'can_share': True})
.. note::
The first argument to Client.replace_members() is the opaque identifier of
the image; the remaining arguments are dictionaries with the keys
`member_id` (mapping to a tenant name) and `can_share`. Note that
`can_share` may be omitted, in which case any existing membership for the
specified member will be preserved through the replace operation.
The return value of Client.replace_members() is not significant.

View File

@ -419,3 +419,59 @@ information about all the images that were deleted, as shown below::
Deleting image 1 "Some web image" ... done
Deleting image 2 "Some other web image" ... done
Completed in 0.0328 sec.
The ``image-members`` Command
-----------------------------
The ``image-members`` command displays the list of members with which a
specific image, specified with ``<ID>``, is shared, as shown below::
$> glance image-members 3 --host=65.114.169.29
tenant1
tenant2 *
(*: Can share image)
The ``member-images`` Command
-----------------------------
The ``member-images`` command displays the list of images which are shared
with a specific member, specified with ``<MEMBER>``, as shown below::
$> glance member-images tenant1 --host=65.114.169.29
1
2 *
(*: Can share image)
The ``member-add`` Command
--------------------------
The ``member-add`` command grants a member, specified with ``<MEMBER>``, access
to a private image, specified with ``<ID>``. The ``--can-share`` flag can be
given to allow the member to share the image, as shown below::
$> glance member-add 1 tenant1 --host=65.114.169.29
$> glance member-add 1 tenant2 --can-share --host=65.114.169.29
The ``member-delete`` Command
-----------------------------
The ``member-delete`` command revokes the access of a member, specified with
``<MEMBER>``, to a private image, specified with ``<ID>``, as shown below::
$> glance member-delete 1 tenant1
$> glance member-delete 1 tenant2
The ``members-replace`` Command
-------------------------------
The ``members-replace`` command revokes all existing memberships on a private
image, specified with ``<ID>``, and replaces them with a membership for one
member, specified with ``<MEMBER>``. The ``--can-share`` flag can be given to
allow the member to share the image, as shown below::
$> glance members-replace 1 tenant1 --can-share --host=65.114.169.29
The command is given in plural form to make it clear that all existing
memberships are affected by the command.

View File

@ -73,7 +73,8 @@ JSON-encoded mapping in the following format::
'updated_at': '2010-02-03 09:34:01',
'deleted_at': '',
'status': 'active',
'is_public': True,
'is_public': true,
'owner': null,
'properties': {'distro': 'Ubuntu 10.04 LTS'}},
...]}
@ -92,6 +93,12 @@ JSON-encoded mapping in the following format::
The `checksum` field is an MD5 checksum of the image file data
The `is_public` field is a boolean indicating whether the image is
publically available
The `owner` field is a string which may either be null or which will
indicate the owner of the image
Filtering Images Returned via ``GET /images`` and ``GET /images/detail``
------------------------------------------------------------------------
@ -175,7 +182,8 @@ following shows an example of the HTTP headers returned from the above
x-image-meta-updated_at 2010-02-03 09:34:01
x-image-meta-deleted_at
x-image-meta-status available
x-image-meta-is-public True
x-image-meta-is-public true
x-image-meta-owner null
x-image-meta-property-distro Ubuntu 10.04 LTS
.. note::
@ -194,6 +202,12 @@ following shows an example of the HTTP headers returned from the above
The response's `ETag` header will always be equal to the
`x-image-meta-checksum` value
The response's `x-image-meta-is-public` value is a boolean indicating
whether the image is publically available
The response's `x-image-meta-owner` value is a string which may either
be null or which will indicate the owner of the image
Retrieving a Virtual Machine Image
----------------------------------
@ -229,7 +243,8 @@ returned from the above ``GET`` request::
x-image-meta-updated_at 2010-02-03 09:34:01
x-image-meta-deleted_at
x-image-meta-status available
x-image-meta-is-public True
x-image-meta-is-public true
x-image-meta-owner null
x-image-meta-property-distro Ubuntu 10.04 LTS
.. note::
@ -251,6 +266,12 @@ returned from the above ``GET`` request::
The response's `ETag` header will always be equal to the
`x-image-meta-checksum` value
The response's `x-image-meta-is-public` value is a boolean indicating
whether the image is publically available
The response's `x-image-meta-owner` value is a string which may either
be null or which will indicate the owner of the image
The image data itself will be the body of the HTTP response returned
from the request, which will have content-type of
`application/octet-stream`.
@ -365,6 +386,16 @@ The list of metadata headers that Glance accepts are listed below.
When not present, the image is assumed to be *not public* and specific to
a user.
* ``x-image-meta-owner``
This header is optional and only meaningful for admins.
Glance normally sets the owner of an image to be the tenant or user
(depending on the "owner_is_tenant" configuration option) of the
authenticated user issuing the request. However, if the authenticated user
has the Admin role, this default may be overridden by setting this header to
null or to a string identifying the owner of the image.
* ``x-image-meta-property-*``
When Glance receives any HTTP header whose key begins with the string prefix
@ -402,3 +433,88 @@ On success, the ``PUT`` request will return the image metadata encoded as HTTP
headers.
See more about image statuses here: :doc:`Image Statuses <statuses>`
Requesting Image Memberships
----------------------------
We want to see a list of the other system tenants (or users, if
"owner_is_tenant" is False) that may access a given virtual machine image that
the Glance server knows about. We take the `uri` field of the image data,
append ``/members`` to it, and issue a ``GET`` request on the resulting URL.
Continuing from the example above, in order to get the memberships for the
first public image returned, we can issue a ``GET`` request to the Glance
server for ``http://glance.example.com/images/1/members``. What we will
get back is JSON data such as the following::
{'members': [
{'member_id': 'tenant1',
'can_share': false}
...]}
The `member_id` field identifies a tenant with which the image is shared. If
that tenant is authorized to further share the image, the `can_share` field is
`true`.
Requesting Shared Images
------------------------
We want to see a list of images which are shared with a given tenant. We issue
a ``GET`` request to ``http://glance.example.com/shared-images/tenant1``. We
will get back JSON data such as the following::
{'shared_images': [
{'image_id': 1,
'can_share': false}
...]}
The `image_id` field identifies an image shared with the tenant named by
*member_id*. If the tenant is authorized to further share the image, the
`can_share` field is `true`.
Adding a Member to an Image
---------------------------
We want to authorize a tenant to access a private image. We issue a ``PUT``
request to ``http://glance.example.com/images/1/members/tenant1``. With no
body, this will add the membership to the image, leaving existing memberships
unmodified and defaulting new memberships to have `can_share` set to `false`.
We may also optionally attach a body of the following form::
{'member':
{'can_share': true}
}
If such a body is provided, both existing and new memberships will have
`can_share` set to the provided value (either `true` or `false`). This query
will return a 204 ("No Content") status code.
Removing a Member from an Image
-------------------------------
We want to revoke a tenant's right to access a private image. We issue a
``DELETE`` request to ``http://glance.example.com/images/1/members/tenant1``.
This query will return a 204 ("No Content") status code.
Replacing a Membership List for an Image
----------------------------------------
The full membership list for a given image may be replaced. We issue a ``PUT``
request to ``http://glance.example.com/images/1/members`` with a body of the
following form::
{'memberships': [
{'member_id': 'tenant1',
'can_share': false}
...]}
All existing memberships which are not named in the replacement body are
removed, and those which are named have their `can_share` settings changed as
specified. (The `can_share` setting may be omitted, which will cause that
setting to remain unchanged in the existing memberships.) All new memberships
will be created, with `can_share` defaulting to `false` if it is not specified.

View File

@ -65,6 +65,7 @@ Using Glance
glance
glanceapi
client
authentication
Developer Docs
==============

View File

@ -47,4 +47,5 @@ pipeline = context registryapp
paste.app_factory = glance.registry.server:app_factory
[filter:context]
context_class = glance.registry.context.RequestContext
paste.filter_factory = glance.common.context:filter_factory

View File

@ -36,8 +36,22 @@ class API(wsgi.Router):
mapper.resource("image", "images", controller=resource,
collection={'detail': 'GET'})
mapper.connect("/", controller=resource, action="index")
mapper.connect("/images/{id}", controller=resource, action="meta",
conditions=dict(method=["HEAD"]))
mapper.connect("/images/{id}", controller=resource,
action="meta", conditions=dict(method=["HEAD"]))
mapper.connect("/shared-images/{member}",
controller=resource, action="shared_images")
mapper.connect("/images/{image_id}/members",
controller=resource, action="members",
conditions=dict(method=["GET"]))
mapper.connect("/images/{image_id}/members",
controller=resource, action="replace_members",
conditions=dict(method=["PUT"]))
mapper.connect("/images/{image_id}/members/{member}",
controller=resource, action="add_member",
conditions=dict(method=["PUT"]))
mapper.connect("/images/{image_id}/members/{member}",
controller=resource, action="delete_member",
conditions=dict(method=["DELETE"]))
super(API, self).__init__(mapper)

View File

@ -29,7 +29,8 @@ import webob
from webob.exc import (HTTPNotFound,
HTTPConflict,
HTTPBadRequest,
HTTPForbidden)
HTTPForbidden,
HTTPUnauthorized)
from glance import api
from glance import image_cache
@ -572,6 +573,59 @@ class Controller(api.BaseController):
req.context, id)
registry.delete_image_metadata(self.options, req.context, id)
def members(self, req, image_id):
"""
Return a list of dictionaries indicating the members of the
image, i.e., those tenants the image is shared with.
:param req: the Request object coming from the wsgi layer
:param image_id: The opaque image identifier
:retval The response body is a mapping of the following form::
{'members': [
{'member_id': <MEMBER>,
'can_share': <SHARE_PERMISSION>, ...}, ...
]}
"""
try:
members = registry.get_image_members(self.options, req.context,
image_id)
except exception.NotFound:
msg = "Image with identifier %s not found" % image_id
logger.debug(msg)
raise HTTPNotFound(msg, request=req, content_type='text/plain')
except exception.NotAuthorized:
msg = "Unauthorized image access"
logger.debug(msg)
raise HTTPForbidden(msg, request=req, content_type='text/plain')
return dict(members=members)
def shared_images(self, req, member):
"""
Retrieves list of image memberships for the given member.
:param req: the Request object coming from the wsgi layer
:param member: the opaque member identifier
:retval The response body is a mapping of the following form::
{'shared_images': [
{'image_id': <IMAGE>,
'can_share': <SHARE_PERMISSION>, ...}, ...
]}
"""
try:
members = registry.get_member_images(self.options, req.context,
member)
except exception.NotFound, e:
msg = str(e)
logger.debug(msg)
raise HTTPNotFound(msg, request=req, content_type='text/plain')
except exception.NotAuthorized, e:
msg = str(e)
logger.debug(msg)
raise HTTPForbidden(msg, request=req, content_type='text/plain')
return dict(shared_images=members)
def get_store_or_400(self, request, store_name):
"""
Grabs the storage backend for the supplied store name
@ -591,6 +645,95 @@ class Controller(api.BaseController):
raise HTTPBadRequest(msg, request=request,
content_type='text/plain')
def replace_members(self, req, image_id, body):
"""
Replaces the members of the image with those specified in the
body. The body is a dict with the following format::
{"memberships": [
{"member_id": <MEMBER_ID>,
["can_share": [True|False]]}, ...
]}
"""
if req.context.read_only:
raise HTTPForbidden()
elif req.context.owner is None:
raise HTTPUnauthorized("No authenticated user")
try:
registry.replace_members(self.options, req.context,
image_id, body)
except exception.NotFound, e:
msg = str(e)
logger.debug(msg)
raise HTTPNotFound(msg, request=req, content_type='text/plain')
except exception.NotAuthorized, e:
msg = str(e)
logger.debug(msg)
raise HTTPNotFound(msg, request=req, content_type='text/plain')
return HTTPNoContent()
def add_member(self, req, image_id, member, body=None):
"""
Adds a membership to the image, or updates an existing one.
If a body is present, it is a dict with the following format::
{"member": {
"can_share": [True|False]
}}
If "can_share" is provided, the member's ability to share is
set accordingly. If it is not provided, existing memberships
remain unchanged and new memberships default to False.
"""
if req.context.read_only:
raise HTTPForbidden()
elif req.context.owner is None:
raise HTTPUnauthorized("No authenticated user")
# Figure out can_share
can_share = None
if body and 'member' in body and 'can_share' in body['member']:
can_share = bool(body['member']['can_share'])
try:
registry.add_member(self.options, req.context, image_id, member,
can_share)
except exception.NotFound, e:
msg = str(e)
logger.debug(msg)
raise HTTPNotFound(msg, request=req, content_type='text/plain')
except exception.NotAuthorized, e:
msg = str(e)
logger.debug(msg)
raise HTTPNotFound(msg, request=req, content_type='text/plain')
return HTTPNoContent()
def delete_member(self, req, image_id, member):
"""
Removes a membership from the image.
"""
if req.context.read_only:
raise HTTPForbidden()
elif req.context.owner is None:
raise HTTPUnauthorized("No authenticated user")
try:
registry.delete_member(self.options, req.context,
image_id, member)
except exception.NotFound, e:
msg = str(e)
logger.debug(msg)
raise HTTPNotFound(msg, request=req, content_type='text/plain')
except exception.NotAuthorized, e:
msg = str(e)
logger.debug(msg)
raise HTTPNotFound(msg, request=req, content_type='text/plain')
return HTTPNoContent()
class ImageDeserializer(wsgi.JSONRequestDeserializer):
"""Handles deserialization of specific controller method requests."""

View File

@ -41,8 +41,15 @@ class Controller(object):
"""Respond to a request for all OpenStack API versions."""
version_objs = [
{
"id": "v1.0",
"id": "v1.1",
"status": "CURRENT",
"links": [
{
"rel": "self",
"href": self.get_href()}]},
{
"id": "v1.0",
"status": "SUPPORTED",
"links": [
{
"rel": "self",

View File

@ -279,5 +279,93 @@ class V1Client(base_client.BaseClient):
data = json.loads(res.read())['cached_images']
return data
def get_image_members(self, image_id):
"""Returns a mapping of image memberships from Registry"""
res = self.do_request("GET", "/images/%s/members" % image_id)
data = json.loads(res.read())['members']
return data
def get_member_images(self, member_id):
"""Returns a mapping of image memberships from Registry"""
res = self.do_request("GET", "/shared-images/%s" % member_id)
data = json.loads(res.read())['shared_images']
return data
def _validate_assocs(self, assocs):
"""
Validates membership associations and returns an appropriate
list of associations to send to the server.
"""
validated = []
for assoc in assocs:
assoc_data = dict(member_id=assoc['member_id'])
if 'can_share' in assoc:
assoc_data['can_share'] = bool(assoc['can_share'])
validated.append(assoc_data)
return validated
def replace_members(self, image_id, *assocs):
"""
Replaces the membership associations for a given image_id.
Each subsequent argument is a dictionary mapping containing a
'member_id' that should have access to the image_id. A
'can_share' boolean can also be specified to allow the member
to further share the image. An example invocation allowing
'rackspace' to access image 1 and 'google' to access image 1
with permission to share::
c = glance.client.Client(...)
c.update_members(1, {'member_id': 'rackspace'},
{'member_id': 'google', 'can_share': True})
"""
# Understand the associations
body = json.dumps(self._validate_assocs(assocs))
self.do_request("PUT", "/images/%s/members" % image_id, body,
{'content-type': 'application/json'})
return True
def add_member(self, image_id, member_id, can_share=None):
"""
Adds a membership association between image_id and member_id.
If can_share is not specified and the association already
exists, no change is made; if the association does not already
exist, one is created with can_share defaulting to False.
When can_share is specified, the association is created if it
doesn't already exist, and the can_share attribute is set
accordingly. Example invocations allowing 'rackspace' to
access image 1 and 'google' to access image 1 with permission
to share::
c = glance.client.Client(...)
c.add_member(1, 'rackspace')
c.add_member(1, 'google', True)
"""
body = None
headers = {}
# Generate the body if appropriate
if can_share is not None:
body = json.dumps(dict(member=dict(can_share=bool(can_share))))
headers['content-type'] = 'application/json'
self.do_request("PUT", "/images/%s/members/%s" %
(image_id, member_id), body, headers)
return True
def delete_member(self, image_id, member_id):
"""
Deletes the membership assocation. If the
association does not exist, no action is taken; otherwise, the
indicated association is deleted. An example invocation
removing the accesses of 'rackspace' to image 1 and 'google'
to image 2::
c = glance.client.Client(...)
c.delete_member(1, 'rackspace')
c.delete_member(2, 'google')
"""
self.do_request("DELETE", "/images/%s/members/%s" %
(image_id, member_id))
return True
Client = V1Client

View File

@ -145,11 +145,11 @@ class BaseClient(object):
httplib.NO_CONTENT):
return res
elif status_code == httplib.UNAUTHORIZED:
raise exception.NotAuthorized
raise exception.NotAuthorized(res.read())
elif status_code == httplib.FORBIDDEN:
raise exception.NotAuthorized
raise exception.NotAuthorized(res.read())
elif status_code == httplib.NOT_FOUND:
raise exception.NotFound
raise exception.NotFound(res.read())
elif status_code == httplib.CONFLICT:
raise exception.Duplicate(res.read())
elif status_code == httplib.BAD_REQUEST:

View File

@ -15,8 +15,11 @@
# License for the specific language governing permissions and limitations
# under the License.
from glance.common import config
from glance.common import exception
from glance.common import utils
from glance.common import wsgi
from glance.registry.db import api as db_api
class RequestContext(object):
@ -26,35 +29,19 @@ class RequestContext(object):
"""
def __init__(self, auth_tok=None, user=None, tenant=None, is_admin=False,
read_only=False, show_deleted=False):
read_only=False, show_deleted=False, owner_is_tenant=True):
self.auth_tok = auth_tok
self.user = user
self.tenant = tenant
self.is_admin = is_admin
self.read_only = read_only
self.show_deleted = show_deleted
def is_image_visible(self, image):
"""Return True if the image is visible in this context."""
# Is admin == image visible
if self.is_admin:
return True
# No owner == image visible
if image.owner is None:
return True
# Image is_public == image visible
if image.is_public:
return True
# Private image
return self.owner is not None and self.owner == image.owner
self.owner_is_tenant = owner_is_tenant
@property
def owner(self):
"""Return the owner to correlate with an image."""
return self.tenant
return self.tenant if self.owner_is_tenant else self.user
class ContextMiddleware(wsgi.Middleware):
@ -72,6 +59,11 @@ class ContextMiddleware(wsgi.Middleware):
if 'context_class' in self.options:
ctxcls = utils.import_class(self.options['context_class'])
# Determine whether to use tenant or owner
owner_is_tenant = config.get_option(self.options, 'owner_is_tenant',
type='bool', default=True)
kwargs.setdefault('owner_is_tenant', owner_is_tenant)
return ctxcls(*args, **kwargs)
def process_request(self, req):

View File

@ -86,6 +86,31 @@ def delete_image_metadata(options, context, image_id):
return c.delete_image(image_id)
def get_image_members(options, context, image_id):
c = get_registry_client(options, context)
return c.get_image_members(image_id)
def get_member_images(options, context, member_id):
c = get_registry_client(options, context)
return c.get_member_images(member_id)
def replace_members(options, context, image_id, member_data):
c = get_registry_client(options, context)
return c.replace_members(image_id, member_data)
def add_member(options, context, image_id, member_id, can_share=None):
c = get_registry_client(options, context)
return c.add_member(image_id, member_id, can_share=can_share)
def delete_member(options, context, image_id, member_id):
c = get_registry_client(options, context)
return c.delete_member(image_id, member_id)
def _debug_print_metadata(image_meta):
data = image_meta.copy()
properties = data.pop('properties', None)

View File

@ -126,3 +126,47 @@ class RegistryClient(BaseClient):
"""
self.do_request("DELETE", "/images/%s" % image_id)
return True
def get_image_members(self, image_id):
"""Returns a list of membership associations from Registry"""
res = self.do_request("GET", "/images/%s/members" % image_id)
data = json.loads(res.read())['members']
return data
def get_member_images(self, member_id):
"""Returns a list of membership associations from Registry"""
res = self.do_request("GET", "/shared-images/%s" % member_id)
data = json.loads(res.read())['shared_images']
return data
def replace_members(self, image_id, member_data):
"""Replaces Registry's information about image membership"""
if 'memberships' not in member_data.keys():
member_data = dict(memberships=[member_data])
body = json.dumps(member_data)
headers = {'Content-Type': 'application/json', }
res = self.do_request("PUT", "/images/%s/members" % image_id,
body, headers)
return res.status == 204
def add_member(self, image_id, member_id, can_share=None):
"""Adds to Registry's information about image membership"""
body = None
headers = {}
# Build up a body if can_share is specified
if can_share is not None:
body = json.dumps(dict(member=dict(can_share=can_share)))
headers['Content-Type'] = 'application/json'
res = self.do_request("PUT", "/images/%s/members/%s" %
(image_id, member_id), body, headers)
return res.status == 204
def delete_member(self, image_id, member_id):
"""Deletes Registry's information about image membership"""
res = self.do_request("DELETE", "/images/%s/members/%s" %
(image_id, member_id))
return res.status == 204

View File

@ -0,0 +1,88 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2011 OpenStack LLC.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from glance.common import context
from glance.common import exception
from glance.registry.db import api as db_api
class RequestContext(context.RequestContext):
"""
Stores information about the security context under which the user
accesses the system, as well as additional request information.
Also provides tests for image visibility and sharability.
"""
def is_image_visible(self, image):
"""Return True if the image is visible in this context."""
# Is admin == image visible
if self.is_admin:
return True
# No owner == image visible
if image.owner is None:
return True
# Image is_public == image visible
if image.is_public:
return True
# Perform tests based on whether we have an owner
if self.owner is not None:
if self.owner == image.owner:
return True
# Figure out if this image is shared with that tenant
try:
db_api.image_member_find(self, image.id, self.owner)
return True
except exception.NotFound:
pass
# Private image
return False
def is_image_sharable(self, image, **kwargs):
"""Return True if the image can be shared to others in this context."""
# Only allow sharing if we have an owner
if self.owner is None:
return False
# Is admin == image sharable
if self.is_admin:
return True
# If we own the image, we can share it
if self.owner == image.owner:
return True
# Let's get the membership association
if 'membership' in kwargs:
membership = kwargs['membership']
if membership is None:
# Not shared with us anyway
return False
else:
try:
membership = db_api.image_member_find(self, image.id,
self.owner)
except exception.NotFound:
# Not shared with us anyway
return False
# It's the can_share attribute we're now interested in
return membership.can_share

View File

@ -117,6 +117,9 @@ def image_destroy(context, image_id):
for prop_ref in image_ref.properties:
image_property_delete(context, prop_ref, session=session)
for memb_ref in image_ref.members:
image_member_delete(context, memb_ref, session=session)
def image_get(context, image_id, session=None):
"""Get an image or raise if it does not exist."""
@ -131,6 +134,7 @@ def image_get(context, image_id, session=None):
try:
image = session.query(models.Image).\
options(joinedload(models.Image.properties)).\
options(joinedload(models.Image.members)).\
filter_by(deleted=_deleted(context)).\
filter_by(id=image_id).\
one()
@ -152,6 +156,7 @@ def image_get_all_pending_delete(context, delete_time=None, limit=None):
session = get_session()
query = session.query(models.Image).\
options(joinedload(models.Image.properties)).\
options(joinedload(models.Image.members)).\
filter_by(deleted=True).\
filter(models.Image.status == 'pending_delete')
@ -185,6 +190,7 @@ def image_get_all(context, filters=None, marker=None, limit=None,
session = get_session()
query = session.query(models.Image).\
options(joinedload(models.Image.properties)).\
options(joinedload(models.Image.members)).\
filter_by(deleted=_deleted(context)).\
filter(models.Image.status != 'killed')
@ -207,10 +213,14 @@ def image_get_all(context, filters=None, marker=None, limit=None,
del filters['size_max']
if 'is_public' in filters and filters['is_public'] is not None:
the_filter = models.Image.is_public == filters['is_public']
the_filter = [models.Image.is_public == filters['is_public']]
if filters['is_public'] and context.owner is not None:
the_filter = or_(the_filter, models.Image.owner == context.owner)
query = query.filter(the_filter)
the_filter.extend([(models.Image.owner == context.owner),
models.Image.members.any(member=context.owner)])
if len(the_filter) > 1:
query = query.filter(or_(*the_filter))
else:
query = query.filter(the_filter[0])
del filters['is_public']
for (k, v) in filters.pop('properties', {}).items():
@ -403,6 +413,122 @@ def image_property_delete(context, prop_ref, session=None):
return prop_ref
def image_member_create(context, values, session=None):
"""Create an ImageMember object"""
memb_ref = models.ImageMember()
return _image_member_update(context, memb_ref, values, session=session)
def image_member_update(context, memb_ref, values, session=None):
"""Update an ImageMember object"""
return _image_member_update(context, memb_ref, values, session=session)
def _image_member_update(context, memb_ref, values, session=None):
"""
Used internally by image_member_create and image_member_update
"""
_drop_protected_attrs(models.ImageMember, values)
values["deleted"] = False
values.setdefault('can_share', False)
memb_ref.update(values)
memb_ref.save(session=session)
return memb_ref
def image_member_delete(context, memb_ref, session=None):
"""Delete an ImageMember object"""
memb_ref.update(dict(deleted=True))
memb_ref.save(session=session)
return memb_ref
def image_member_get(context, member_id, session=None):
"""Get an image member or raise if it does not exist."""
session = session or get_session()
try:
member = session.query(models.ImageMember).\
options(joinedload(models.ImageMember.image)).\
filter_by(deleted=_deleted(context)).\
filter_by(id=member_id).\
one()
except exc.NoResultFound:
raise exception.NotFound("No membership found with ID %s" % member_id)
# Make sure they can look at it
if not context.is_image_visible(member.image):
raise exception.NotAuthorized("Image not visible to you")
return member
def image_member_find(context, image_id, member, session=None):
"""Find a membership association between image and member."""
session = session or get_session()
try:
# Note lack of permissions check; this function is called from
# RequestContext.is_image_visible(), so avoid recursive calls
return session.query(models.ImageMember).\
options(joinedload(models.ImageMember.image)).\
filter_by(deleted=_deleted(context)).\
filter_by(image_id=image_id).\
filter_by(member=member).\
one()
except exc.NoResultFound:
raise exception.NotFound("No membership found for image %s member %s" %
(image_id, member))
def image_member_get_memberships(context, member, marker=None, limit=None,
sort_key='created_at', sort_dir='desc'):
"""
Get all image memberships for the given member.
:param member: the member to look up memberships for
:param marker: membership id after which to start page
:param limit: maximum number of memberships to return
:param sort_key: membership attribute by which results should be sorted
:param sort_dir: direction in which results should be sorted (asc, desc)
"""
session = get_session()
query = session.query(models.ImageMember).\
options(joinedload(models.ImageMember.image)).\
filter_by(deleted=_deleted(context)).\
filter_by(member=member)
sort_dir_func = {
'asc': asc,
'desc': desc,
}[sort_dir]
sort_key_attr = getattr(models.ImageMember, sort_key)
query = query.order_by(sort_dir_func(sort_key_attr)).\
order_by(sort_dir_func(models.ImageMember.id))
if marker != None:
# memberships returned should be created before the membership
# defined by marker
marker_membership = image_member_get(context, marker)
marker_value = getattr(marker_membership, sort_key)
if sort_dir == 'desc':
query = query.filter(
or_(sort_key_attr < marker_value,
and_(sort_key_attr == marker_value,
models.ImageMember.id < marker)))
else:
query = query.filter(
or_(sort_key_attr > marker_value,
and_(sort_key_attr == marker_value,
models.ImageMember.id > marker)))
if limit != None:
query = query.limit(limit)
return query.all()
# pylint: disable-msg=C0111
def _deleted(context):
"""

View File

@ -0,0 +1,83 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2011 OpenStack LLC.
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from migrate.changeset import *
from sqlalchemy import *
from glance.registry.db.migrate_repo.schema import (
Boolean, DateTime, BigInteger, Integer, String, Text,
create_tables, drop_tables, from_migration_import)
def get_images_table(meta):
"""
No changes to the images table from 007...
"""
(get_images_table,) = from_migration_import(
'007_add_owner', ['get_images_table'])
images = get_images_table(meta)
return images
def get_image_properties_table(meta):
"""
No changes to the image properties table from 007...
"""
(get_image_properties_table,) = from_migration_import(
'007_add_owner', ['get_image_properties_table'])
image_properties = get_image_properties_table(meta)
return image_properties
def get_image_members_table(meta):
images = get_images_table(meta)
image_members = Table('image_members', meta,
Column('id', Integer(), primary_key=True, nullable=False),
Column('image_id', Integer(), ForeignKey('images.id'), nullable=False,
index=True),
Column('member', String(255), nullable=False),
Column('can_share', Boolean(), nullable=False, default=False),
Column('created_at', DateTime(), nullable=False),
Column('updated_at', DateTime()),
Column('deleted_at', DateTime()),
Column('deleted', Boolean(), nullable=False, default=False,
index=True),
UniqueConstraint('image_id', 'member'),
mysql_engine='InnoDB',
useexisting=True)
Index('ix_image_members_image_id_member', image_members.c.image_id,
image_members.c.member)
return image_members
def upgrade(migrate_engine):
meta = MetaData()
meta.bind = migrate_engine
tables = [get_image_members_table(meta)]
create_tables(tables)
def downgrade(migrate_engine):
meta = MetaData()
meta.bind = migrate_engine
tables = [get_image_members_table(meta)]
drop_tables(tables)

View File

@ -121,11 +121,24 @@ class ImageProperty(BASE, ModelBase):
value = Column(Text)
class ImageMember(BASE, ModelBase):
"""Represents an image members in the datastore"""
__tablename__ = 'image_members'
__table_args__ = (UniqueConstraint('image_id', 'member'), {})
id = Column(Integer, primary_key=True)
image_id = Column(Integer, ForeignKey('images.id'), nullable=False)
image = relationship(Image, backref=backref('members'))
member = Column(String(255), nullable=False)
can_share = Column(Boolean, nullable=False, default=False)
def register_models(engine):
"""
Creates database tables for all models with the given engine
"""
models = (Image, ImageProperty)
models = (Image, ImageProperty, ImageMember)
for model in models:
model.metadata.create_all(engine)

View File

@ -359,12 +359,237 @@ class Controller(object):
request=req,
content_type='text/plain')
def members(self, req, image_id):
"""
Get the members of an image.
"""
try:
image = db_api.image_get(req.context, image_id)
except exception.NotFound:
raise exc.HTTPNotFound()
except exception.NotAuthorized:
# If it's private and doesn't belong to them, don't let on
# that it exists
logger.info("Access by %s to image %s denied" %
(req.context.user, image_id))
raise exc.HTTPNotFound()
def create_resource(options):
return dict(members=make_member_list(image['members'],
member_id='member',
can_share='can_share'))
def shared_images(self, req, member):
"""
Retrieves images shared with the given member.
"""
params = {}
try:
memberships = db_api.image_member_get_memberships(req.context,
member,
**params)
except exception.NotFound, e:
msg = "Invalid marker. Membership could not be found."
raise exc.HTTPBadRequest(explanation=msg)
return dict(shared_images=make_member_list(memberships,
image_id='image_id',
can_share='can_share'))
def replace_members(self, req, image_id, body):
"""
Replaces the members of the image with those specified in the
body. The body is a dict with the following format::
{"memberships": [
{"member_id": <MEMBER_ID>,
["can_share": [True|False]]}, ...
]}
"""
if req.context.read_only:
raise exc.HTTPForbidden()
elif req.context.owner is None:
raise exc.HTTPUnauthorized("No authenticated user")
# Make sure the image exists
try:
image = db_api.image_get(req.context, image_id)
except exception.NotFound:
raise exc.HTTPNotFound()
except exception.NotAuthorized:
# If it's private and doesn't belong to them, don't let on
# that it exists
logger.info("Access by %s to image %s denied" %
(req.context.user, image_id))
raise exc.HTTPNotFound()
# Can they manipulate the membership?
if not req.context.is_image_sharable(image):
raise exc.HTTPForbidden("No permission to share that image")
# Get the membership list
try:
memb_list = body['memberships']
except Exception, e:
# Malformed entity...
msg = "Invalid membership association: %s" % e
raise exc.HTTPBadRequest(explanation=msg)
add = []
existing = {}
# Walk through the incoming memberships
for memb in memb_list:
try:
datum = dict(image_id=image['id'],
member=memb['member_id'],
can_share=None)
except Exception, e:
# Malformed entity...
msg = "Invalid membership association: %s" % e
raise exc.HTTPBadRequest(explanation=msg)
# Figure out what can_share should be
if 'can_share' in memb:
datum['can_share'] = bool(memb['can_share'])
# Try to find the corresponding membership
try:
membership = db_api.image_member_find(req.context,
datum['image_id'],
datum['member_id'])
# Are we overriding can_share?
if datum['can_share'] is None:
datum['can_share'] = membership['can_share']
existing[membership['id']] = {
'values': datum,
'membership': membership,
}
except exception.NotFound:
# Default can_share
datum['can_share'] = bool(datum['can_share'])
add.append(datum)
# We now have a filtered list of memberships to add and
# memberships to modify. Let's start by walking through all
# the existing image memberships...
for memb in image['members']:
if memb['id'] in existing:
# Just update the membership in place
update = existing[memb['id']]['values']
db_api.image_member_update(req.context, memb, update)
else:
# Outdated one; needs to be deleted
db_api.image_member_delete(memb)
# Now add the non-existant ones
for memb in add:
db_api.image_member_create(req.context, memb)
# Make an appropriate result
return exc.HTTPNoContent()
def add_member(self, req, image_id, member, body=None):
"""
Adds a membership to the image, or updates an existing one.
If a body is present, it is a dict with the following format::
{"member": {
"can_share": [True|False]
}}
If "can_share" is provided, the member's ability to share is
set accordingly. If it is not provided, existing memberships
remain unchanged and new memberships default to False.
"""
if req.context.read_only:
raise exc.HTTPForbidden()
elif req.context.owner is None:
raise exc.HTTPUnauthorized("No authenticated user")
# Make sure the image exists
try:
image = db_api.image_get(req.context, image_id)
except exception.NotFound:
raise exc.HTTPNotFound()
except exception.NotAuthorized:
# If it's private and doesn't belong to them, don't let on
# that it exists
logger.info("Access by %s to image %s denied" %
(req.context.user, image_id))
raise exc.HTTPNotFound()
# Can they manipulate the membership?
if not req.context.is_image_sharable(image):
raise exc.HTTPForbidden("No permission to share that image")
# Determine the applicable can_share value
can_share = None
if body:
try:
can_share = bool(body['member']['can_share'])
except Exception, e:
# Malformed entity...
msg = "Invalid membership association: %s" % e
raise exc.HTTPBadRequest(explanation=msg)
# Look up an existing membership...
try:
membership = db_api.image_member_find(req.context,
image_id, member)
if can_share is not None:
values = dict(can_share=can_share)
db_api.image_member_update(req.context, membership, values)
except exception.NotFound:
values = dict(image_id=image['id'], member=member,
can_share=bool(can_share))
db_api.image_member_create(req.context, values)
# Make an appropriate result
return exc.HTTPNoContent()
def delete_member(self, req, image_id, member):
"""
Removes a membership from the image.
"""
if req.context.read_only:
raise exc.HTTPForbidden()
elif req.context.owner is None:
raise exc.HTTPUnauthorized("No authenticated user")
# Make sure the image exists
try:
image = db_api.image_get(req.context, image_id)
except exception.NotFound:
raise exc.HTTPNotFound()
except exception.NotAuthorized:
# If it's private and doesn't belong to them, don't let on
# that it exists
logger.info("Access by %s to image %s denied" %
(req.context.user, image_id))
raise exc.HTTPNotFound()
# Can they manipulate the membership?
if not req.context.is_image_sharable(image):
raise exc.HTTPForbidden("No permission to share that image")
# Look up an existing membership
try:
membership = db_api.image_member_find(req.context,
image_id, member)
db_api.image_member_delete(req.context, membership)
except exception.NotFound:
pass
# Make an appropriate result
return exc.HTTPNoContent()
def create_resource(controller):
"""Images resource factory method."""
deserializer = wsgi.JSONRequestDeserializer()
serializer = wsgi.JSONResponseSerializer()
return wsgi.Resource(Controller(options), deserializer, serializer)
return wsgi.Resource(controller, deserializer, serializer)
class API(wsgi.Router):
@ -372,10 +597,24 @@ class API(wsgi.Router):
def __init__(self, options):
mapper = routes.Mapper()
resource = create_resource(options)
resource = create_resource(Controller(options))
mapper.resource("image", "images", controller=resource,
collection={'detail': 'GET'})
collection={'detail': 'GET'})
mapper.connect("/", controller=resource, action="index")
mapper.connect("/shared-images/{member}",
controller=resource, action="shared_images")
mapper.connect("/images/{image_id}/members",
controller=resource, action="members",
conditions=dict(method=["GET"]))
mapper.connect("/images/{image_id}/members",
controller=resource, action="replace_members",
conditions=dict(method=["PUT"]))
mapper.connect("/images/{image_id}/members/{member}",
controller=resource, action="add_member",
conditions=dict(method=["PUT"]))
mapper.connect("/images/{image_id}/members/{member}",
controller=resource, action="delete_member",
conditions=dict(method=["DELETE"]))
super(API, self).__init__(mapper)
@ -401,6 +640,20 @@ def make_image_dict(image):
return image_dict
def make_member_list(members, **attr_map):
"""
Create a dict representation of a list of members which we can use
to serialize the members list. Keyword arguments map the names of
optional attributes to include to the database attribute.
"""
def _fetch_memb(memb, attr_map):
return dict([(k, memb[v]) for k, v in attr_map if v in memb.keys()])
# Return the list of members with the given attribute mapping
return [_fetch_memb(memb, attr_map) for memb in members]
def app_factory(global_conf, **local_conf):
"""
paste.deploy app factory for creating Glance reference implementation

View File

@ -26,7 +26,7 @@ import glance.store.http
import glance.store.s3
import glance.store.swift
from glance.common import config
from glance.common import context
from glance.registry import context
from glance.common import exception
from glance.registry.db import api as db_api

View File

@ -213,6 +213,7 @@ pipeline = context registryapp
paste.app_factory = glance.registry.server:app_factory
[filter:context]
context_class = glance.registry.context.RequestContext
paste.filter_factory = glance.common.context:filter_factory
"""

View File

@ -405,8 +405,13 @@ class TestApi(functional.FunctionalTest):
self.start_servers()
versions = {'versions': [{
"id": "v1.0",
"id": "v1.1",
"status": "CURRENT",
"links": [{
"rel": "self",
"href": "http://0.0.0.0:%d/v1/" % self.api_port}]}, {
"id": "v1.0",
"status": "SUPPORTED",
"links": [{
"rel": "self",
"href": "http://0.0.0.0:%d/v1/" % self.api_port}]}]}

View File

@ -105,8 +105,9 @@ def stub_out_registry_and_store_server(stubs):
def getresponse(self):
sql_connection = os.environ.get('GLANCE_SQL_CONNECTION',
"sqlite://")
context_class = 'glance.registry.context.RequestContext'
options = {'sql_connection': sql_connection, 'verbose': VERBOSE,
'debug': DEBUG}
'debug': DEBUG, 'context_class': context_class}
api = context.ContextMiddleware(rserver.API(options), options)
res = self.req.get_response(api)

View File

@ -27,6 +27,7 @@ import webob
from glance.api import v1 as server
from glance.common import context
from glance.registry import context as rcontext
from glance.registry import server as rserver
from glance.registry.db import api as db_api
from glance.registry.db import models as db_models
@ -38,7 +39,8 @@ OPTIONS = {'sql_connection': 'sqlite://',
'registry_host': '0.0.0.0',
'registry_port': '9191',
'default_store': 'file',
'filesystem_store_datadir': stubs.FAKE_FILESYSTEM_ROOTDIR}
'filesystem_store_datadir': stubs.FAKE_FILESYSTEM_ROOTDIR,
'context_class': 'glance.registry.context.RequestContext'}
class TestRegistryAPI(unittest.TestCase):
@ -77,7 +79,7 @@ class TestRegistryAPI(unittest.TestCase):
'size': 19,
'location': "file:///tmp/glance-tests/2",
'properties': {}}]
self.context = context.RequestContext(is_admin=True)
self.context = rcontext.RequestContext(is_admin=True)
db_api.configure_db(OPTIONS)
self.destroy_fixtures()
self.create_fixtures()
@ -1519,6 +1521,86 @@ class TestRegistryAPI(unittest.TestCase):
self.assertEquals(res.status_int,
webob.exc.HTTPNotFound.code)
def test_get_image_members(self):
"""
Tests members listing for existing images
"""
req = webob.Request.blank('/images/2/members')
req.method = 'GET'
res = req.get_response(self.api)
self.assertEquals(res.status_int, 200)
memb_list = json.loads(res.body)
num_members = len(memb_list['members'])
self.assertEquals(num_members, 0)
def test_get_image_members_not_existing(self):
"""
Tests proper exception is raised if attempt to get members of
non-existing image
"""
req = webob.Request.blank('/images/3/members')
req.method = 'GET'
res = req.get_response(self.api)
self.assertEquals(res.status_int,
webob.exc.HTTPNotFound.code)
def test_get_member_images(self):
"""
Tests image listing for members
"""
req = webob.Request.blank('/shared-images/pattieblack')
req.method = 'GET'
res = req.get_response(self.api)
self.assertEquals(res.status_int, 200)
memb_list = json.loads(res.body)
num_members = len(memb_list['shared_images'])
self.assertEquals(num_members, 0)
def test_replace_members(self):
"""
Tests replacing image members raises right exception
"""
fixture = dict(member_id='pattieblack')
req = webob.Request.blank('/images/2/members')
req.method = 'PUT'
req.content_type = 'application/json'
req.body = json.dumps(dict(image_memberships=fixture))
res = req.get_response(self.api)
self.assertEquals(res.status_int, webob.exc.HTTPUnauthorized.code)
def test_add_member(self):
"""
Tests adding image members raises right exception
"""
req = webob.Request.blank('/images/2/members/pattieblack')
req.method = 'PUT'
res = req.get_response(self.api)
self.assertEquals(res.status_int, webob.exc.HTTPUnauthorized.code)
def test_delete_member(self):
"""
Tests deleting image members raises right exception
"""
req = webob.Request.blank('/images/2/members/pattieblack')
req.method = 'DELETE'
res = req.get_response(self.api)
self.assertEquals(res.status_int, webob.exc.HTTPUnauthorized.code)
class TestGlanceAPI(unittest.TestCase):
def setUp(self):
@ -1558,7 +1640,7 @@ class TestGlanceAPI(unittest.TestCase):
'size': 19,
'location': "file:///tmp/glance-tests/2",
'properties': {}}]
self.context = context.RequestContext(is_admin=True)
self.context = rcontext.RequestContext(is_admin=True)
db_api.configure_db(OPTIONS)
self.destroy_fixtures()
self.create_fixtures()
@ -1898,3 +1980,83 @@ class TestGlanceAPI(unittest.TestCase):
req = webob.Request.blank('/images/detail?marker=10')
res = req.get_response(self.api)
self.assertEquals(res.status_int, 400)
def test_get_image_members(self):
"""
Tests members listing for existing images
"""
req = webob.Request.blank('/images/2/members')
req.method = 'GET'
res = req.get_response(self.api)
self.assertEquals(res.status_int, 200)
memb_list = json.loads(res.body)
num_members = len(memb_list['members'])
self.assertEquals(num_members, 0)
def test_get_image_members_not_existing(self):
"""
Tests proper exception is raised if attempt to get members of
non-existing image
"""
req = webob.Request.blank('/images/3/members')
req.method = 'GET'
res = req.get_response(self.api)
self.assertEquals(res.status_int,
webob.exc.HTTPNotFound.code)
def test_get_member_images(self):
"""
Tests image listing for members
"""
req = webob.Request.blank('/shared-images/pattieblack')
req.method = 'GET'
res = req.get_response(self.api)
self.assertEquals(res.status_int, 200)
memb_list = json.loads(res.body)
num_members = len(memb_list['shared_images'])
self.assertEquals(num_members, 0)
def test_replace_members(self):
"""
Tests replacing image members raises right exception
"""
fixture = dict(member_id='pattieblack')
req = webob.Request.blank('/images/2/members')
req.method = 'PUT'
req.content_type = 'application/json'
req.body = json.dumps(dict(image_memberships=fixture))
res = req.get_response(self.api)
self.assertEquals(res.status_int, webob.exc.HTTPUnauthorized.code)
def test_add_member(self):
"""
Tests adding image members raises right exception
"""
req = webob.Request.blank('/images/2/members/pattieblack')
req.method = 'PUT'
res = req.get_response(self.api)
self.assertEquals(res.status_int, webob.exc.HTTPUnauthorized.code)
def test_delete_member(self):
"""
Tests deleting image members raises right exception
"""
req = webob.Request.blank('/images/2/members/pattieblack')
req.method = 'DELETE'
res = req.get_response(self.api)
self.assertEquals(res.status_int, webob.exc.HTTPUnauthorized.code)

View File

@ -30,6 +30,7 @@ from glance.common import exception
from glance.registry.db import api as db_api
from glance.registry.db import models as db_models
from glance.registry import client as rclient
from glance.registry import context as rcontext
from glance.tests import stubs
OPTIONS = {'sql_connection': 'sqlite://'}
@ -59,7 +60,7 @@ class TestRegistryClient(unittest.TestCase):
self.stubs = stubout.StubOutForTesting()
stubs.stub_out_registry_and_store_server(self.stubs)
db_api.configure_db(OPTIONS)
self.context = context.RequestContext(is_admin=True)
self.context = rcontext.RequestContext(is_admin=True)
self.FIXTURES = [
{'id': 1,
'name': 'fake image #1',
@ -928,6 +929,40 @@ class TestRegistryClient(unittest.TestCase):
self.client.delete_image,
3)
def test_get_image_members(self):
"""Tests getting image members"""
memb_list = self.client.get_image_members(2)
num_members = len(memb_list)
self.assertEquals(num_members, 0)
def test_get_image_members_not_existing(self):
"""Tests getting non-existant image members"""
self.assertRaises(exception.NotFound,
self.client.get_image_members,
3)
def test_get_member_images(self):
"""Tests getting member images"""
memb_list = self.client.get_member_images('pattieblack')
num_members = len(memb_list)
self.assertEquals(num_members, 0)
def test_replace_members(self):
"""Tests replacing image members"""
self.assertRaises(exception.NotAuthorized,
self.client.replace_members, 2,
dict(member_id='pattieblack'))
def test_add_member(self):
"""Tests adding image members"""
self.assertRaises(exception.NotAuthorized,
self.client.add_member, 2, 'pattieblack')
def test_delete_member(self):
"""Tests deleting image members"""
self.assertRaises(exception.NotAuthorized,
self.client.delete_member, 2, 'pattieblack')
class TestClient(unittest.TestCase):
@ -972,7 +1007,7 @@ class TestClient(unittest.TestCase):
'size': 19,
'location': "file:///tmp/glance-tests/2",
'properties': {}}]
self.context = context.RequestContext(is_admin=True)
self.context = rcontext.RequestContext(is_admin=True)
self.destroy_fixtures()
self.create_fixtures()
@ -1656,3 +1691,37 @@ class TestClient(unittest.TestCase):
self.assertRaises(exception.NotFound,
self.client.delete_image,
3)
def test_get_member_images(self):
"""Tests getting image members"""
memb_list = self.client.get_image_members(2)
num_members = len(memb_list)
self.assertEquals(num_members, 0)
def test_get_image_members_not_existing(self):
"""Tests getting non-existant image members"""
self.assertRaises(exception.NotFound,
self.client.get_image_members,
3)
def test_get_member_images(self):
"""Tests getting member images"""
memb_list = self.client.get_member_images('pattieblack')
num_members = len(memb_list)
self.assertEquals(num_members, 0)
def test_replace_members(self):
"""Tests replacing image members"""
self.assertRaises(exception.NotAuthorized,
self.client.replace_members, 2,
dict(member_id='pattieblack'))
def test_add_member(self):
"""Tests adding image members"""
self.assertRaises(exception.NotAuthorized,
self.client.add_member, 2, 'pattieblack')
def test_delete_member(self):
"""Tests deleting image members"""
self.assertRaises(exception.NotAuthorized,
self.client.delete_member, 2, 'pattieblack')

View File

@ -17,7 +17,9 @@
import unittest
from glance.common import context
import stubout
from glance.registry import context
class FakeImage(object):
@ -27,17 +29,29 @@ class FakeImage(object):
"""
def __init__(self, owner, is_public):
self.id = None
self.owner = owner
self.is_public = is_public
class FakeMembership(object):
"""
Fake membership for providing the membership attributes needed for
TestContext.
"""
def __init__(self, can_share=False):
self.can_share = can_share
class TestContext(unittest.TestCase):
def do_visible(self, exp_res, img_owner, img_public, **kwargs):
"""
Perform a context test. Creates a (fake) image with the
specified owner and is_public attributes, then creates a
context with the given keyword arguments and expects exp_res
as the result of an is_image_visible() call on the context.
Perform a context visibility test. Creates a (fake) image
with the specified owner and is_public attributes, then
creates a context with the given keyword arguments and expects
exp_res as the result of an is_image_visible() call on the
context.
"""
img = FakeImage(img_owner, img_public)
@ -45,6 +59,26 @@ class TestContext(unittest.TestCase):
self.assertEqual(ctx.is_image_visible(img), exp_res)
def do_sharable(self, exp_res, img_owner, membership=None, **kwargs):
"""
Perform a context sharability test. Creates a (fake) image
with the specified owner and is_public attributes, then
creates a context with the given keyword arguments and expects
exp_res as the result of an is_image_sharable() call on the
context. If membership is not None, its value will be passed
in as the 'membership' keyword argument of
is_image_sharable().
"""
img = FakeImage(img_owner, True)
ctx = context.RequestContext(**kwargs)
sharable_args = {}
if membership is not None:
sharable_args['membership'] = membership
self.assertEqual(ctx.is_image_sharable(img, **sharable_args), exp_res)
def test_empty_public(self):
"""
Tests that an empty context (with is_admin set to True) can
@ -73,6 +107,15 @@ class TestContext(unittest.TestCase):
"""
self.do_visible(True, 'pattieblack', False, is_admin=True)
def test_empty_shared(self):
"""
Tests that an empty context (with is_admin set to True) can
not share an image, with or without membership.
"""
self.do_sharable(False, 'pattieblack', None, is_admin=True)
self.do_sharable(False, 'pattieblack', FakeMembership(True),
is_admin=True)
def test_anon_public(self):
"""
Tests that an anonymous context (with is_admin set to False)
@ -101,6 +144,14 @@ class TestContext(unittest.TestCase):
"""
self.do_visible(False, 'pattieblack', False)
def test_anon_shared(self):
"""
Tests that an empty context (with is_admin set to True) can
not share an image, with or without membership.
"""
self.do_sharable(False, 'pattieblack', None)
self.do_sharable(False, 'pattieblack', FakeMembership(True))
def test_auth_public(self):
"""
Tests that an authenticated context (with is_admin set to
@ -146,3 +197,46 @@ class TestContext(unittest.TestCase):
set to False.
"""
self.do_visible(True, 'pattieblack', False, tenant='pattieblack')
def test_auth_sharable(self):
"""
Tests that an authenticated context (with is_admin set to
False) cannot share an image it neither owns nor is shared
with it.
"""
self.do_sharable(False, 'pattieblack', None, tenant='froggy')
def test_auth_sharable_admin(self):
"""
Tests that an authenticated context (with is_admin set to
True) can share an image it neither owns nor is shared with
it.
"""
self.do_sharable(True, 'pattieblack', None, tenant='froggy',
is_admin=True)
def test_auth_sharable_owned(self):
"""
Tests that an authenticated context (with is_admin set to
False) can share an image it owns, even if it is not shared
with it.
"""
self.do_sharable(True, 'pattieblack', None, tenant='pattieblack')
def test_auth_sharable_cannot_share(self):
"""
Tests that an authenticated context (with is_admin set to
False) cannot share an image it does not own even if it is
shared with it, but with can_share = False.
"""
self.do_sharable(False, 'pattieblack', FakeMembership(False),
tenant='froggy')
def test_auth_sharable_can_share(self):
"""
Tests that an authenticated context (with is_admin set to
False) can share an image it does not own if it is shared with
it with can_share = True.
"""
self.do_sharable(True, 'pattieblack', FakeMembership(True),
tenant='froggy')

View File

@ -41,8 +41,15 @@ class VersionsTest(unittest.TestCase):
results = json.loads(res.body)["versions"]
expected = [
{
"id": "v1.0",
"id": "v1.1",
"status": "CURRENT",
"links": [
{
"rel": "self",
"href": "http://0.0.0.0:9292/v1/"}]},
{
"id": "v1.0",
"status": "SUPPORTED",
"links": [
{
"rel": "self",