Skip to content

Commit

Permalink
Add ability to deactivate an image
Browse files Browse the repository at this point in the history
This patch provides the ability to 'deactivate' an image by
providing two new API calls and a new image status 'deactivated'.
Attempting to download a deactivated image will result in a
403 'Forbidden' return code. Also, image locations won't be visible
for deactivated images unless the user is admin.
All other image operations should remain unaffected.

The two new API calls are:
    - POST /images/{image_id}/actions/deactivate
    - POST /images/{image_id}/actions/reactivate

DocImpact
UpgradeImpact

Change-Id: I32b7cc7ce8404457a87c8c05041aa2a30152b930
Implements: bp deactivate-image
  • Loading branch information
Eddie Sheffield authored and Hemanth Makkapati committed Mar 13, 2015
1 parent 15fea34 commit b000c85
Show file tree
Hide file tree
Showing 21 changed files with 568 additions and 20 deletions.
4 changes: 4 additions & 0 deletions doc/source/images_src/image_status_transition.dot
Expand Up @@ -40,6 +40,10 @@ digraph {
"active" -> "queued" [label="remove location*"];
"active" -> "pending_delete" [label="delayed delete"];
"active" -> "deleted" [label="delete"];
"active" -> "deactivated" [label="deactivate"];

"deactivated" -> "active" [label="reactivate"];
"deactivated" -> "deleted" [label="delete"];

"killed" -> "deleted" [label="delete"];

Expand Down
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions etc/policy.json
Expand Up @@ -30,6 +30,9 @@
"add_task": "",
"modify_task": "",

"deactivate": "",
"reactivate": "",

"get_metadef_namespace": "",
"get_metadef_namespaces":"",
"modify_metadef_namespace":"",
Expand Down
5 changes: 5 additions & 0 deletions glance/api/middleware/cache.py
Expand Up @@ -154,6 +154,11 @@ def process_request(self, request):
return None
method = getattr(self, '_get_%s_image_metadata' % version)
image_metadata = method(request, image_id)

# Deactivated images shall not be served from cache
if image_metadata['status'] == 'deactivated':
return None

try:
self._enforce(request, 'download_image', target=image_metadata)
except exception.Forbidden:
Expand Down
14 changes: 14 additions & 0 deletions glance/api/policy.py
Expand Up @@ -165,6 +165,20 @@ def delete(self):
self.policy.enforce(self.context, 'delete_image', {})
return self.image.delete()

def deactivate(self):
LOG.debug('Attempting deactivate')
target = ImageTarget(self.image)
self.policy.enforce(self.context, 'deactivate', target=target)
LOG.debug('Deactivate allowed, continue')
self.image.deactivate()

def reactivate(self):
LOG.debug('Attempting reactivate')
target = ImageTarget(self.image)
self.policy.enforce(self.context, 'reactivate', target=target)
LOG.debug('Reactivate allowed, continue')
self.image.reactivate()

def get_data(self, *args, **kwargs):
target = ImageTarget(self.image)
self.policy.enforce(self.context, 'download_image',
Expand Down
13 changes: 10 additions & 3 deletions glance/api/v1/controller.py
Expand Up @@ -52,15 +52,22 @@ def get_image_meta_or_404(self, request, image_id):
request=request,
content_type='text/plain')

def get_active_image_meta_or_404(self, request, image_id):
def get_active_image_meta_or_error(self, request, image_id):
"""
Same as get_image_meta_or_404 except that it will raise a 404 if the
image isn't 'active'.
Same as get_image_meta_or_404 except that it will raise a 403 if the
image is deactivated or 404 if the image is otherwise not 'active'.
"""
image = self.get_image_meta_or_404(request, image_id)
if image['status'] == 'deactivated':
msg = "Image %s is deactivated" % image_id
LOG.debug(msg)
msg = _("Image %s is deactivated") % image_id
raise webob.exc.HTTPForbidden(
msg, request=request, content_type='type/plain')
if image['status'] != 'active':
msg = "Image %s is not active" % image_id
LOG.debug(msg)
msg = _("Image %s is not active") % image_id
raise webob.exc.HTTPNotFound(
msg, request=request, content_type='text/plain')
return image
Expand Down
2 changes: 1 addition & 1 deletion glance/api/v1/images.py
Expand Up @@ -476,7 +476,7 @@ def show(self, req, id):
self._enforce(req, 'get_image')

try:
image_meta = self.get_active_image_meta_or_404(req, id)
image_meta = self.get_active_image_meta_or_error(req, id)
except HTTPNotFound:
# provision for backward-compatibility breaking issue
# catch the 404 exception and raise it after enforcing
Expand Down
89 changes: 89 additions & 0 deletions glance/api/v2/image_actions.py
@@ -0,0 +1,89 @@
# Copyright 2015 OpenStack Foundation.
# 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.
import glance_store
from oslo_log import log as logging
import webob.exc

from glance.api import policy
from glance.common import exception
from glance.common import utils
from glance.common import wsgi
import glance.db
import glance.gateway
from glance import i18n
import glance.notifier


LOG = logging.getLogger(__name__)
_ = i18n._
_LI = i18n._LI


class ImageActionsController(object):
def __init__(self, db_api=None, policy_enforcer=None, notifier=None,
store_api=None):
self.db_api = db_api or glance.db.get_api()
self.policy = policy_enforcer or policy.Enforcer()
self.notifier = notifier or glance.notifier.Notifier()
self.store_api = store_api or glance_store
self.gateway = glance.gateway.Gateway(self.db_api, self.store_api,
self.notifier, self.policy)

@utils.mutating
def deactivate(self, req, image_id):
image_repo = self.gateway.get_repo(req.context)
try:
image = image_repo.get(image_id)
image.deactivate()
image_repo.save(image)
LOG.info(_LI("Image %s is deactivated") % image_id)
except exception.NotFound as e:
raise webob.exc.HTTPNotFound(explanation=e.msg)
except exception.Forbidden as e:
raise webob.exc.HTTPForbidden(explanation=e.msg)
except exception.InvalidImageStatusTransition as e:
raise webob.exc.HTTPBadRequest(explanation=e.msg)

@utils.mutating
def reactivate(self, req, image_id):
image_repo = self.gateway.get_repo(req.context)
try:
image = image_repo.get(image_id)
image.reactivate()
image_repo.save(image)
LOG.info(_LI("Image %s is reactivated") % image_id)
except exception.NotFound as e:
raise webob.exc.HTTPNotFound(explanation=e.msg)
except exception.Forbidden as e:
raise webob.exc.HTTPForbidden(explanation=e.msg)
except exception.InvalidImageStatusTransition as e:
raise webob.exc.HTTPBadRequest(explanation=e.msg)


class ResponseSerializer(wsgi.JSONResponseSerializer):

def deactivate(self, response, result):
response.status_int = 204

def reactivate(self, response, result):
response.status_int = 204


def create_resource():
"""Image data resource factory method"""
deserializer = None
serializer = ResponseSerializer()
controller = ImageActionsController()
return wsgi.Resource(controller, deserializer, serializer)
4 changes: 4 additions & 0 deletions glance/api/v2/image_data.py
Expand Up @@ -169,6 +169,10 @@ def download(self, req, image_id):
image_repo = self.gateway.get_repo(req.context)
try:
image = image_repo.get(image_id)
if image.status == 'deactivated':
msg = _('The requested image has been deactivated. '
'Image data download is forbidden.')
raise exception.Forbidden(message=msg)
if not image.locations:
raise exception.ImageDataNotFound()
except exception.ImageDataNotFound as e:
Expand Down
23 changes: 23 additions & 0 deletions glance/api/v2/router.py
Expand Up @@ -13,6 +13,7 @@
# License for the specific language governing permissions and limitations
# under the License.

from glance.api.v2 import image_actions
from glance.api.v2 import image_data
from glance.api.v2 import image_members
from glance.api.v2 import image_tags
Expand Down Expand Up @@ -447,6 +448,28 @@ def __init__(self, mapper):
allowed_methods='GET, PATCH, DELETE',
conditions={'method': ['POST', 'PUT', 'HEAD']})

image_actions_resource = image_actions.create_resource()
mapper.connect('/images/{image_id}/actions/deactivate',
controller=image_actions_resource,
action='deactivate',
conditions={'method': ['POST']})
mapper.connect('/images/{image_id}/actions/reactivate',
controller=image_actions_resource,
action='reactivate',
conditions={'method': ['POST']})
mapper.connect('/images/{image_id}/actions/deactivate',
controller=reject_method_resource,
action='reject',
allowed_methods='POST',
conditions={'method': ['GET', 'PUT', 'DELETE', 'PATCH',
'HEAD']})
mapper.connect('/images/{image_id}/actions/reactivate',
controller=reject_method_resource,
action='reject',
allowed_methods='POST',
conditions={'method': ['GET', 'PUT', 'DELETE', 'PATCH',
'HEAD']})

image_data_resource = image_data.create_resource()
mapper.connect('/images/{image_id}/file',
controller=image_data_resource,
Expand Down
18 changes: 12 additions & 6 deletions glance/db/simple/api.py
Expand Up @@ -395,7 +395,7 @@ def _image_get(context, image_id, force_show_deleted=False, status=None):
@log_call
def image_get(context, image_id, session=None, force_show_deleted=False):
image = _image_get(context, image_id, force_show_deleted)
return _normalize_locations(copy.deepcopy(image),
return _normalize_locations(context, copy.deepcopy(image),
force_show_deleted=force_show_deleted)


Expand All @@ -415,7 +415,7 @@ def image_get_all(context, filters=None, marker=None, limit=None,
force_show_deleted = True if filters.get('deleted') else False
res = []
for image in images:
img = _normalize_locations(copy.deepcopy(image),
img = _normalize_locations(context, copy.deepcopy(image),
force_show_deleted=force_show_deleted)
if return_tag:
img['tags'] = image_tag_get_all(context, img['id'])
Expand Down Expand Up @@ -622,14 +622,19 @@ def _image_locations_delete_all(context, image_id, delete_time=None):
del DATA['locations'][i]


def _normalize_locations(image, force_show_deleted=False):
def _normalize_locations(context, image, force_show_deleted=False):
"""
Generate suitable dictionary list for locations field of image.
We don't need to set other data fields of location record which return
from image query.
"""

if image['status'] == 'deactivated' and not context.is_admin:
# Locations are not returned for a deactivated image for non-admin user
image['locations'] = []
return image

if force_show_deleted:
locations = image['locations']
else:
Expand Down Expand Up @@ -668,7 +673,7 @@ def image_create(context, image_values):
DATA['images'][image_id] = image
DATA['tags'][image_id] = image.pop('tags', [])

return _normalize_locations(copy.deepcopy(image))
return _normalize_locations(context, copy.deepcopy(image))


@log_call
Expand Down Expand Up @@ -696,7 +701,7 @@ def image_update(context, image_id, image_values, purge_props=False,
image['updated_at'] = timeutils.utcnow()
_image_update(image, image_values, new_properties)
DATA['images'][image_id] = image
return _normalize_locations(copy.deepcopy(image))
return _normalize_locations(context, copy.deepcopy(image))


@log_call
Expand Down Expand Up @@ -727,7 +732,8 @@ def image_destroy(context, image_id):
for tag in tags:
image_tag_delete(context, image_id, tag)

return _normalize_locations(copy.deepcopy(DATA['images'][image_id]))
return _normalize_locations(context,
copy.deepcopy(DATA['images'][image_id]))
except KeyError:
raise exception.NotFound()

Expand Down
15 changes: 10 additions & 5 deletions glance/db/sqlalchemy/api.py
Expand Up @@ -58,7 +58,7 @@


STATUSES = ['active', 'saving', 'queued', 'killed', 'pending_delete',
'deleted']
'deleted', 'deactivated']

CONF = cfg.CONF
CONF.import_group("profiler", "glance.common.wsgi")
Expand Down Expand Up @@ -159,17 +159,22 @@ def image_destroy(context, image_id):

_image_tag_delete_all(context, image_id, delete_time, session)

return _normalize_locations(image_ref)
return _normalize_locations(context, image_ref)


def _normalize_locations(image, force_show_deleted=False):
def _normalize_locations(context, image, force_show_deleted=False):
"""
Generate suitable dictionary list for locations field of image.
We don't need to set other data fields of location record which return
from image query.
"""

if image['status'] == 'deactivated' and not context.is_admin:
# Locations are not returned for a deactivated image for non-admin user
image['locations'] = []
return image

if force_show_deleted:
locations = image['locations']
else:
Expand All @@ -191,7 +196,7 @@ def _normalize_tags(image):
def image_get(context, image_id, session=None, force_show_deleted=False):
image = _image_get(context, image_id, session=session,
force_show_deleted=force_show_deleted)
image = _normalize_locations(image.to_dict(),
image = _normalize_locations(context, image.to_dict(),
force_show_deleted=force_show_deleted)
return image

Expand Down Expand Up @@ -616,7 +621,7 @@ def image_get_all(context, filters=None, marker=None, limit=None,
images = []
for image in query.all():
image_dict = image.to_dict()
image_dict = _normalize_locations(image_dict,
image_dict = _normalize_locations(context, image_dict,
force_show_deleted=showing_deleted)
if return_tag:
image_dict = _normalize_tags(image_dict)
Expand Down
31 changes: 30 additions & 1 deletion glance/domain/__init__.py
Expand Up @@ -107,10 +107,11 @@ class Image(object):
# can be retried.
'queued': ('saving', 'active', 'deleted'),
'saving': ('active', 'killed', 'deleted', 'queued'),
'active': ('queued', 'pending_delete', 'deleted'),
'active': ('queued', 'pending_delete', 'deleted', 'deactivated'),
'killed': ('deleted',),
'pending_delete': ('deleted',),
'deleted': (),
'deactivated': ('active', 'deleted'),
}

def __init__(self, image_id, status, created_at, updated_at, **kwargs):
Expand Down Expand Up @@ -246,6 +247,34 @@ def delete(self):
else:
self.status = 'deleted'

def deactivate(self):
if self.status == 'active':
self.status = 'deactivated'
elif self.status == 'deactivated':
# Noop if already deactive
pass
else:
msg = ("Not allowed to deactivate image in status '%s'"
% self.status)
LOG.debug(msg)
msg = (_("Not allowed to deactivate image in status '%s'")
% self.status)
raise exception.Forbidden(message=msg)

def reactivate(self):
if self.status == 'deactivated':
self.status = 'active'
elif self.status == 'active':
# Noop if already active
pass
else:
msg = ("Not allowed to reactivate image in status '%s'"
% self.status)
LOG.debug(msg)
msg = (_("Not allowed to reactivate image in status '%s'")
% self.status)
raise exception.Forbidden(message=msg)

def get_data(self, *args, **kwargs):
raise NotImplementedError()

Expand Down

0 comments on commit b000c85

Please sign in to comment.