diff --git a/deli/counter/auth/policy.py b/deli/counter/auth/policy.py index cabf313..5266a81 100644 --- a/deli/counter/auth/policy.py +++ b/deli/counter/auth/policy.py @@ -189,10 +189,7 @@ # Images { "name": "images:create", - "description": "Ability to create an image", - "tags": [ - "project_member" - ] + "description": "Ability to create an image" }, { "name": "images:create:public", @@ -235,6 +232,27 @@ "project_member" ] }, + { + "name": "images:members:add", + "description": "Ability to add a member to an image", + "tags": [ + "project_member" + ] + }, + { + "name": "images:members:list", + "description": "Ability to list image members", + "tags": [ + "project_member" + ] + }, + { + "name": "images:members:delete", + "description": "Ability to delete a member from an image", + "tags": [ + "project_member" + ] + }, # Instances { diff --git a/deli/counter/http/mounts/root/routes/v1/images.py b/deli/counter/http/mounts/root/routes/v1/images.py index 8f80442..88423b5 100644 --- a/deli/counter/http/mounts/root/routes/v1/images.py +++ b/deli/counter/http/mounts/root/routes/v1/images.py @@ -3,13 +3,15 @@ import cherrypy from deli.counter.http.mounts.root.routes.v1.validation_models.images import RequestCreateImage, ResponseImage, \ - ParamsImage, ParamsListImage + ParamsImage, ParamsListImage, ParamsImageMember, RequestAddMember, ResponseImageMember from deli.http.request_methods import RequestMethods from deli.http.route import Route from deli.http.router import Router +from deli.kubernetes.resources.const import NAME_LABEL, PROJECT_LABEL, REGION_LABEL, IMAGE_VISIBILITY_LABEL, \ + IMAGE_MEMBER_LABEL from deli.kubernetes.resources.model import ResourceState from deli.kubernetes.resources.project import Project -from deli.kubernetes.resources.v1alpha1.image.model import Image +from deli.kubernetes.resources.v1alpha1.image.model import Image, ImageVisibility from deli.kubernetes.resources.v1alpha1.region.model import Region @@ -26,8 +28,9 @@ def create(self): request: RequestCreateImage = cherrypy.request.model project: Project = cherrypy.request.project - image = Image.get_by_name(project, request.name) - if image is not None: + images = Image.list( + label_selector=PROJECT_LABEL + "=" + str(project.id) + "," + NAME_LABEL + "=" + request.name) + if len(images) > 0: raise cherrypy.HTTPError(400, 'An image with the requested name already exists.') region = Region.get(request.region_id) @@ -35,16 +38,20 @@ def create(self): raise cherrypy.HTTPError(404, 'A region with the requested id does not exist.') if region.state != ResourceState.Created: - raise cherrypy.HTTPError(409, 'Can only create a network with a region in the following state: %s'.format( + raise cherrypy.HTTPError(409, 'Can only create a image with a region in the following state: %s'.format( ResourceState.Created)) # TODO: check duplicate file name + if request.visibility == ImageVisibility.PUBLIC: + self.mount.enforce_policy("images:create:public") + image = Image() image.name = request.name image.file_name = request.file_name image.project = project image.region = region + image.visibility = request.visibility image.create() return ResponseImage.from_database(image) @@ -56,23 +63,44 @@ def create(self): @cherrypy.tools.resource_object(id_param="image_id", cls=Image) @cherrypy.tools.enforce_policy(policy_name="images:get") def get(self, **_): - return ResponseImage.from_database(cherrypy.request.resource_object) + image: Image = cherrypy.request.resource_object + + if image.visibility == ImageVisibility.PRIVATE: + if image.project_id != cherrypy.request.project.id: + raise cherrypy.HTTPError(404, "The resource could not be found.") + elif image.visibility == ImageVisibility.SHARED: + if image.is_member(cherrypy.request.project.id) is False: + raise cherrypy.HTTPError(409, 'The requested image is not shared with the current project.') + + return ResponseImage.from_database(image) @Route() @cherrypy.tools.project_scope() @cherrypy.tools.model_params(cls=ParamsListImage) @cherrypy.tools.model_out_pagination(cls=ResponseImage) @cherrypy.tools.enforce_policy(policy_name="images:list") - def list(self, region_id, limit: int, marker: uuid.UUID): + def list(self, region_id, visibility: ImageVisibility, limit: int, marker: uuid.UUID): kwargs = { - 'project': cherrypy.request.project + 'label_selector': [] } + + if visibility == ImageVisibility.PRIVATE: + kwargs['label_selector'].append(IMAGE_VISIBILITY_LABEL + '=' + ImageVisibility.PRIVATE.value) + kwargs['label_selector'].append(PROJECT_LABEL + '=' + str(cherrypy.request.project.id)) + elif visibility == ImageVisibility.SHARED: + kwargs['label_selector'].append(IMAGE_VISIBILITY_LABEL + '=' + ImageVisibility.SHARED.value) + kwargs['label_selector'].append(IMAGE_MEMBER_LABEL + "/" + str(cherrypy.request.project.id) + "=1") + else: + kwargs['label_selector'].append(IMAGE_VISIBILITY_LABEL + '=' + ImageVisibility.PUBLIC.value) + if region_id is not None: region: Region = Region.get(region_id) if region is None: raise cherrypy.HTTPError(404, "A region with the requested id does not exist.") - kwargs['label_selector'] = 'sandwichcloud.io/region=' + region.id + kwargs['label_selector'].append(REGION_LABEL + '=' + region.id) + + kwargs['label_selector'] = ",".join(kwargs['label_selector']) return self.paginate(Image, ResponseImage, limit, marker, **kwargs) @@ -85,6 +113,9 @@ def delete(self, **_): cherrypy.response.status = 204 image: Image = cherrypy.request.resource_object + if image.project_id != cherrypy.request.project.id: + raise cherrypy.HTTPError(404, "The resource could not be found.") + if image.state == ResourceState.ToDelete or image.state == ResourceState.Deleting: raise cherrypy.HTTPError(400, "Image is already being deleting") @@ -92,3 +123,67 @@ def delete(self, **_): raise cherrypy.HTTPError(400, "Image has already been deleted") image.delete() + + @Route(route='{image_id}/members', methods=[RequestMethods.POST]) + @cherrypy.tools.project_scope() + @cherrypy.tools.model_params(cls=ParamsImage) + @cherrypy.tools.model_in(cls=RequestAddMember) + @cherrypy.tools.resource_object(id_param="image_id", cls=Image) + @cherrypy.tools.enforce_policy(policy_name="images:members:add") + def add_member(self): + cherrypy.response.status = 204 + request: RequestAddMember = cherrypy.request.model + image: Image = cherrypy.request.resource_object + if image.visibility != ImageVisibility.SHARED: + raise cherrypy.HTTPError(409, 'Cannot add a member to a non-shared image') + + project = Project.get(request.project_id) + if project is None: + raise cherrypy.HTTPError(404, 'A project with the requested id does not exist.') + + if image.is_member(request.project_id): + raise cherrypy.HTTPError(409, 'A project with the requested id is already a member.') + + image.add_member(request.project_id) + image.save() + + @Route(route='{image_id}/members') + @cherrypy.tools.project_scope() + @cherrypy.tools.model_params(cls=ParamsImage) + @cherrypy.tools.model_out_pagination(cls=ResponseImageMember) + @cherrypy.tools.resource_object(id_param="image_id", cls=Image) + @cherrypy.tools.enforce_policy(policy_name="images:members:list") + def list_members(self, **_): + image: Image = cherrypy.request.resource_object + if image.visibility != ImageVisibility.SHARED: + raise cherrypy.HTTPError(409, 'Cannot list members of a non-shared image') + + members = [] + + for member_id in image.member_ids(): + member = ResponseImageMember() + member.project_id = member_id + members.append(member) + + return members, False + + @Route(route='{image_id}/members/{project_id}', methods=[RequestMethods.DELETE]) + @cherrypy.tools.project_scope() + @cherrypy.tools.model_params(cls=ParamsImageMember) + @cherrypy.tools.resource_object(id_param="image_id", cls=Image) + @cherrypy.tools.enforce_policy(policy_name="images:members:delete") + def delete_member(self, project_id, **_): + cherrypy.response.status = 204 + image: Image = cherrypy.request.resource_object + if image.visibility != ImageVisibility.SHARED: + raise cherrypy.HTTPError(409, 'Cannot delete a member from a non-shared image') + + project = Project.get(project_id) + if project is None: + raise cherrypy.HTTPError(404, 'A project with the requested id does not exist.') + + if image.is_member(project_id) is False: + raise cherrypy.HTTPError(409, 'A project with the requested id is not a member.') + + image.remove_member(project_id) + image.save() diff --git a/deli/counter/http/mounts/root/routes/v1/instance.py b/deli/counter/http/mounts/root/routes/v1/instance.py index b378d74..9e0d5ce 100644 --- a/deli/counter/http/mounts/root/routes/v1/instance.py +++ b/deli/counter/http/mounts/root/routes/v1/instance.py @@ -12,7 +12,7 @@ from deli.kubernetes.resources.model import ResourceState from deli.kubernetes.resources.project import Project from deli.kubernetes.resources.v1alpha1.flavor.model import Flavor -from deli.kubernetes.resources.v1alpha1.image.model import Image +from deli.kubernetes.resources.v1alpha1.image.model import Image, ImageVisibility from deli.kubernetes.resources.v1alpha1.instance.model import Instance, VMPowerState from deli.kubernetes.resources.v1alpha1.keypair.keypair import Keypair from deli.kubernetes.resources.v1alpha1.network.model import NetworkPort, Network @@ -67,9 +67,15 @@ def create(self): raise cherrypy.HTTPError(400, 'Can only create a instance with a network in the following state: %s'.format( ResourceState.Created)) - image = Image.get(project, request.image_id) + image: Image = Image.get(project, request.image_id) if image is None: raise cherrypy.HTTPError(404, 'An image with the requested id does not exist.') + if image.visibility == ImageVisibility.PRIVATE: + if image.project_id != project.id: + raise cherrypy.HTTPError(404, 'An image with the requested id does not exist.') + elif image.visibility == ImageVisibility.SHARED: + if image.is_member(project.id) is False: + raise cherrypy.HTTPError(409, 'The requested image is not shared with the current project.') if image.region.id != region.id: raise cherrypy.HTTPError(409, 'The requested image is not within the requested region') if image.state != ResourceState.Created: @@ -190,7 +196,7 @@ def delete(self, **_): @cherrypy.tools.project_scope() @cherrypy.tools.model_params(cls=ParamsInstance) @cherrypy.tools.resource_object(id_param="instance_id", cls=Instance) - @cherrypy.tools.enforce_policy(policy_name="instances:delete") + @cherrypy.tools.enforce_policy(policy_name="nstances:action:stop") def action_start(self, **_): cherrypy.response.status = 202 @@ -210,7 +216,7 @@ def action_start(self, **_): @cherrypy.tools.model_params(cls=ParamsInstance) @cherrypy.tools.model_in(cls=RequestInstancePowerOffRestart) @cherrypy.tools.resource_object(id_param="instance_id", cls=Instance) - @cherrypy.tools.enforce_policy(policy_name="instances:delete") + @cherrypy.tools.enforce_policy(policy_name="nstances:action:start") def action_stop(self, **_): request: RequestInstancePowerOffRestart = cherrypy.request.model cherrypy.response.status = 202 @@ -231,7 +237,7 @@ def action_stop(self, **_): @cherrypy.tools.model_params(cls=ParamsInstance) @cherrypy.tools.model_in(cls=RequestInstancePowerOffRestart) @cherrypy.tools.resource_object(id_param="instance_id", cls=Instance) - @cherrypy.tools.enforce_policy(policy_name="instances:delete") + @cherrypy.tools.enforce_policy(policy_name="nstances:action:restart") def action_restart(self, **_): request: RequestInstancePowerOffRestart = cherrypy.request.model cherrypy.response.status = 202 @@ -253,7 +259,7 @@ def action_restart(self, **_): @cherrypy.tools.model_in(cls=RequestInstanceImage) @cherrypy.tools.model_out(cls=ResponseImage) @cherrypy.tools.resource_object(id_param="instance_id", cls=Instance) - @cherrypy.tools.enforce_policy(policy_name="instances:delete") + @cherrypy.tools.enforce_policy(policy_name="nstances:action:image") def action_image(self, **_): project: Project = cherrypy.request.project request: RequestInstanceImage = cherrypy.request.model @@ -270,6 +276,9 @@ def action_image(self, **_): if Image.get_by_name(project, request.name) is not None: raise cherrypy.HTTPError(400, 'An image with the requested name already exists.') - image = instance.action_image(request.name) + if request.visibility == ImageVisibility.PUBLIC: + self.mount.enforce_policy("instances:action:image:public") + + image = instance.action_image(request.name, request.visibility) return ResponseImage.from_database(image) diff --git a/deli/counter/http/mounts/root/routes/v1/validation_models/images.py b/deli/counter/http/mounts/root/routes/v1/validation_models/images.py index f53d580..9d42050 100644 --- a/deli/counter/http/mounts/root/routes/v1/validation_models/images.py +++ b/deli/counter/http/mounts/root/routes/v1/validation_models/images.py @@ -3,14 +3,20 @@ from deli.http.schematics.types import KubeName, EnumType, ArrowType from deli.kubernetes.resources.model import ResourceState -from deli.kubernetes.resources.v1alpha1.image.model import Image +from deli.kubernetes.resources.v1alpha1.image.model import Image, ImageVisibility class ParamsImage(Model): image_id = UUIDType(required=True) +class ParamsImageMember(Model): + image_id = UUIDType(required=True) + project_id = UUIDType(required=True) + + class ParamsListImage(Model): + visibility = EnumType(ImageVisibility, default=ImageVisibility.PRIVATE) region_id = UUIDType() limit = IntType(default=100, max_value=100, min_value=1) marker = UUIDType() @@ -20,13 +26,24 @@ class RequestCreateImage(Model): name = KubeName(required=True, min_length=3) file_name = StringType(required=True) region_id = KubeName(required=True) + visibility = EnumType(ImageVisibility, default=ImageVisibility.PRIVATE) + + +class RequestAddMember(Model): + project_id = UUIDType(required=True) + + +class ResponseImageMember(Model): + project_id = UUIDType(required=True) class ResponseImage(Model): id = UUIDType(required=True) + project_id = UUIDType(required=True) name = KubeName(required=True, min_length=3) file_name = StringType() region_id = UUIDType(required=True) + visibility = EnumType(ImageVisibility) state = EnumType(ResourceState, required=True) error_message = StringType() created_at = ArrowType(required=True) @@ -35,10 +52,12 @@ class ResponseImage(Model): def from_database(cls, image: Image): image_model = cls() image_model.id = image.id + image_model.project_id = image.project_id image_model.name = image.name image_model.file_name = image.file_name image_model.region_id = image.region_id + image_model.visibility = image.visibility image_model.state = image.state if image.error_message != "": diff --git a/deli/counter/http/mounts/root/routes/v1/validation_models/instances.py b/deli/counter/http/mounts/root/routes/v1/validation_models/instances.py index 27a9933..c2d317b 100644 --- a/deli/counter/http/mounts/root/routes/v1/validation_models/instances.py +++ b/deli/counter/http/mounts/root/routes/v1/validation_models/instances.py @@ -3,6 +3,7 @@ from deli.http.schematics.types import KubeString, EnumType, ArrowType, KubeName from deli.kubernetes.resources.model import ResourceState +from deli.kubernetes.resources.v1alpha1.image.model import ImageVisibility from deli.kubernetes.resources.v1alpha1.instance.model import Instance, VMPowerState, VMTask @@ -98,6 +99,7 @@ class ParamsListInstance(Model): class RequestInstanceImage(Model): name = KubeName(required=True) + visibility = EnumType(ImageVisibility, default=ImageVisibility.PRIVATE) class RequestInstancePowerOffRestart(Model): diff --git a/deli/kubernetes/resources/const.py b/deli/kubernetes/resources/const.py index 9fa7990..50df3ca 100644 --- a/deli/kubernetes/resources/const.py +++ b/deli/kubernetes/resources/const.py @@ -2,6 +2,7 @@ ID_LABEL = GROUP + '/id' NAME_LABEL = GROUP + '/name' +PROJECT_LABEL = GROUP + '/project' REGION_LABEL = GROUP + '/region' ZONE_LABEL = GROUP + '/zone' IMAGE_LABEL = GROUP + '/image' @@ -9,3 +10,5 @@ NETWORK_PORT_LABEL = GROUP + '/network_port' SERVICE_ACCOUNT_LABEL = GROUP + '/service_account' TAG_LABEL = 'tag.' + GROUP +IMAGE_VISIBILITY_LABEL = GROUP + '/visibility' +IMAGE_MEMBER_LABEL = 'image-member.' + GROUP diff --git a/deli/kubernetes/resources/v1alpha1/image/controller.py b/deli/kubernetes/resources/v1alpha1/image/controller.py index e422dbf..8ae4914 100644 --- a/deli/kubernetes/resources/v1alpha1/image/controller.py +++ b/deli/kubernetes/resources/v1alpha1/image/controller.py @@ -2,6 +2,7 @@ from deli.kubernetes.controller import ModelController from deli.kubernetes.resources.model import ResourceState +from deli.kubernetes.resources.project import Project from deli.kubernetes.resources.v1alpha1.image.model import Image @@ -65,6 +66,16 @@ def created(self, model: Image): model.delete() return + project = model.project + if project is None: + model.delete() + return + + for member_id in model.member_ids(): + if Project.get(member_id) is None: + model.remove_member(member_id) + model.save() + def to_delete(self, model): model.state = ResourceState.Deleting model.save() diff --git a/deli/kubernetes/resources/v1alpha1/image/model.py b/deli/kubernetes/resources/v1alpha1/image/model.py index a5baf9a..39d294a 100644 --- a/deli/kubernetes/resources/v1alpha1/image/model.py +++ b/deli/kubernetes/resources/v1alpha1/image/model.py @@ -1,20 +1,42 @@ +import enum import uuid -from deli.kubernetes.resources.const import REGION_LABEL -from deli.kubernetes.resources.model import ProjectResourceModel +from deli.kubernetes.resources.const import REGION_LABEL, IMAGE_VISIBILITY_LABEL, PROJECT_LABEL, IMAGE_MEMBER_LABEL +from deli.kubernetes.resources.model import GlobalResourceModel +from deli.kubernetes.resources.project import Project from deli.kubernetes.resources.v1alpha1.region.model import Region -class Image(ProjectResourceModel): +class ImageVisibility(enum.Enum): + PUBLIC = 'PUBLIC' + PRIVATE = 'PRIVATE' + SHARED = 'SHARED' + + +class Image(GlobalResourceModel): def __init__(self, raw=None): super().__init__(raw) if raw is None: + self._raw['metadata']['labels'][PROJECT_LABEL] = None self._raw['metadata']['labels'][REGION_LABEL] = None + self._raw['metadata']['labels'][IMAGE_VISIBILITY_LABEL] = ImageVisibility.PRIVATE.value self._raw['spec'] = { 'fileName': None } + @property + def project_id(self): + return uuid.UUID(self._raw['metadata']['labels'][PROJECT_LABEL]) + + @property + def project(self): + return Project.get(self._raw['metadata']['labels'][PROJECT_LABEL]) + + @project.setter + def project(self, value): + self._raw['metadata']['labels'][PROJECT_LABEL] = str(value.id) + @property def region_id(self): return uuid.UUID(self._raw['metadata']['labels'][REGION_LABEL]) @@ -34,3 +56,30 @@ def file_name(self): @file_name.setter def file_name(self, value): self._raw['spec']['fileName'] = value + + @property + def visibility(self): + return ImageVisibility(self._raw['metadata']['labels'][IMAGE_VISIBILITY_LABEL]) + + @visibility.setter + def visibility(self, value): + self._raw['metadata']['labels'][IMAGE_VISIBILITY_LABEL] = value.value + + def add_member(self, project_id): + self._raw['metadata']['labels'][IMAGE_MEMBER_LABEL + "/" + str(project_id)] = "1" + + def remove_member(self, project_id): + self._raw['metadata']['labels'].pop(IMAGE_MEMBER_LABEL + "/" + str(project_id), None) + + def member_ids(self): + member_ids = [] + + for label in self._raw['metadata']['labels']: + if label.startswith(IMAGE_MEMBER_LABEL) is False: + continue + member_ids.append(label.split("/")[1]) + + return member_ids + + def is_member(self, project_id): + return IMAGE_MEMBER_LABEL + "/" + str(project_id) in self._raw['metadata']['labels'] diff --git a/deli/kubernetes/resources/v1alpha1/instance/model.py b/deli/kubernetes/resources/v1alpha1/instance/model.py index eebfaa1..e09f9a9 100644 --- a/deli/kubernetes/resources/v1alpha1/instance/model.py +++ b/deli/kubernetes/resources/v1alpha1/instance/model.py @@ -252,12 +252,13 @@ def action_restart(self, hard=False, timeout=300): } self.save() - def action_image(self, image_name): + def action_image(self, image_name, visibility): image = Image() image.project = self.project image.region = self.region image.name = image_name image.file_name = None + image.visibility = visibility image.create() self.task = VMTask.IMAGING