Add support for shared images
Change-Id: I3822a3841e1c10717c180052f929688b9f21a841
This commit is contained in:
parent
78c718a9f5
commit
1e4be06cb2
175
bin/glance
175
bin/glance
@ -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'
|
||||
|
50
doc/source/authentication.rst
Normal file
50
doc/source/authentication.rst
Normal 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.
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -65,6 +65,7 @@ Using Glance
|
||||
glance
|
||||
glanceapi
|
||||
client
|
||||
authentication
|
||||
|
||||
Developer Docs
|
||||
==============
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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."""
|
||||
|
@ -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",
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
88
glance/registry/context.py
Normal file
88
glance/registry/context.py
Normal 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
|
@ -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):
|
||||
"""
|
||||
|
@ -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)
|
@ -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)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
"""
|
||||
|
||||
|
@ -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}]}]}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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')
|
||||
|
@ -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')
|
||||
|
@ -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",
|
||||
|
Loading…
x
Reference in New Issue
Block a user