Skip to content

Commit

Permalink
Support resource templateVersion update (microsoft#2908)
Browse files Browse the repository at this point in the history
* resource update mechanism

Co-authored-by: Tamir Kamara <26870601+tamirkamara@users.noreply.github.com>
  • Loading branch information
2 people authored and marrobi committed Dec 6, 2022
1 parent 77d4d71 commit c25dd59
Show file tree
Hide file tree
Showing 20 changed files with 544 additions and 37 deletions.
1 change: 1 addition & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ Describe the current behavior you are modifying. Please also remember to update
- Note any pending work (with links to the issues that will address them)
- Update documentation
- Update CHANGELOG.md if needed
- Increment template version if needed, for guidelines see [Authoring templates - versioning](https://microsoft.github.io/AzureTRE/tre-workspace-authors/authoring-workspace-templates/#versioning)
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ FEATURES:

ENHANCEMENTS:
* Remove Porter's Docker mixin as it's not in use ([#2889](https://github.com/microsoft/AzureTRE/pull/2889))
* Support template version update ([#2908](https://github.com/microsoft/AzureTRE/pull/2908))

BUG FIXES:
* Private endpoints for AppInsights are now provisioning successfully and consistently ([#2841](https://github.com/microsoft/AzureTRE/pull/2841))
Expand Down
2 changes: 1 addition & 1 deletion api_app/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.5.23"
__version__ = "0.6.0"
8 changes: 5 additions & 3 deletions api_app/api/routes/shared_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from jsonschema.exceptions import ValidationError

from db.repositories.operations import OperationRepository
from db.errors import DuplicateEntity, UserNotAuthorizedToUseTemplate
from db.errors import DuplicateEntity, MajorVersionUpdateDenied, UserNotAuthorizedToUseTemplate, TargetTemplateVersionDoesNotExist, VersionDowngradeDenied
from api.dependencies.database import get_repository
from api.dependencies.shared_services import get_shared_service_by_id_from_path, get_operation_by_id_from_path
from db.repositories.resource_templates import ResourceTemplateRepository
Expand Down Expand Up @@ -75,9 +75,9 @@ async def create_shared_service(response: Response, shared_service_input: Shared
response_model=OperationInResponse,
name=strings.API_UPDATE_SHARED_SERVICE,
dependencies=[Depends(get_current_admin_user), Depends(get_shared_service_by_id_from_path)])
async def patch_shared_service(shared_service_patch: ResourcePatch, response: Response, user=Depends(get_current_admin_user), shared_service_repo=Depends(get_repository(SharedServiceRepository)), shared_service=Depends(get_shared_service_by_id_from_path), resource_template_repo=Depends(get_repository(ResourceTemplateRepository)), operations_repo=Depends(get_repository(OperationRepository)), etag: str = Header(...)) -> SharedServiceInResponse:
async def patch_shared_service(shared_service_patch: ResourcePatch, response: Response, user=Depends(get_current_admin_user), shared_service_repo=Depends(get_repository(SharedServiceRepository)), shared_service=Depends(get_shared_service_by_id_from_path), resource_template_repo=Depends(get_repository(ResourceTemplateRepository)), operations_repo=Depends(get_repository(OperationRepository)), etag: str = Header(...), force_version_update: bool = False) -> SharedServiceInResponse:
try:
patched_shared_service, resource_template = shared_service_repo.patch_shared_service(shared_service, shared_service_patch, etag, resource_template_repo, user)
patched_shared_service, resource_template = shared_service_repo.patch_shared_service(shared_service, shared_service_patch, etag, resource_template_repo, user, force_version_update)
operation = await send_resource_request_message(
resource=patched_shared_service,
operations_repo=operations_repo,
Expand All @@ -93,6 +93,8 @@ async def patch_shared_service(shared_service_patch: ResourcePatch, response: Re
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=strings.ETAG_CONFLICT)
except ValidationError as v:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=v.message)
except (MajorVersionUpdateDenied, TargetTemplateVersionDoesNotExist, VersionDowngradeDenied) as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))


@shared_services_router.delete("/shared-services/{shared_service_id}", response_model=OperationInResponse, name=strings.API_DELETE_SHARED_SERVICE, dependencies=[Depends(get_current_admin_user)])
Expand Down
21 changes: 14 additions & 7 deletions api_app/api/routes/workspaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from api.dependencies.database import get_repository
from api.dependencies.workspaces import get_operation_by_id_from_path, get_workspace_by_id_from_path, get_deployed_workspace_by_id_from_path, get_deployed_workspace_service_by_id_from_path, get_workspace_service_by_id_from_path, get_user_resource_by_id_from_path

from db.errors import UserNotAuthorizedToUseTemplate
from db.errors import MajorVersionUpdateDenied, TargetTemplateVersionDoesNotExist, UserNotAuthorizedToUseTemplate, VersionDowngradeDenied
from db.repositories.operations import OperationRepository
from db.repositories.resource_templates import ResourceTemplateRepository
from db.repositories.user_resources import UserResourceRepository
Expand Down Expand Up @@ -116,9 +116,9 @@ async def create_workspace(workspace_create: WorkspaceInCreate, response: Respon


@workspaces_core_router.patch("/workspaces/{workspace_id}", status_code=status.HTTP_202_ACCEPTED, response_model=OperationInResponse, name=strings.API_UPDATE_WORKSPACE, dependencies=[Depends(get_current_admin_user)])
async def patch_workspace(workspace_patch: ResourcePatch, response: Response, user=Depends(get_current_admin_user), workspace=Depends(get_workspace_by_id_from_path), workspace_repo=Depends(get_repository(WorkspaceRepository)), resource_template_repo=Depends(get_repository(ResourceTemplateRepository)), operations_repo=Depends(get_repository(OperationRepository)), etag: str = Header(...)) -> OperationInResponse:
async def patch_workspace(workspace_patch: ResourcePatch, response: Response, user=Depends(get_current_admin_user), workspace=Depends(get_workspace_by_id_from_path), workspace_repo: WorkspaceRepository = Depends(get_repository(WorkspaceRepository)), resource_template_repo=Depends(get_repository(ResourceTemplateRepository)), operations_repo=Depends(get_repository(OperationRepository)), etag: str = Header(...), force_version_update: bool = False) -> OperationInResponse:
try:
patched_workspace, resource_template = workspace_repo.patch_workspace(workspace, workspace_patch, etag, resource_template_repo, user)
patched_workspace, resource_template = workspace_repo.patch_workspace(workspace, workspace_patch, etag, resource_template_repo, user, force_version_update)
operation = await send_resource_request_message(
resource=patched_workspace,
operations_repo=operations_repo,
Expand All @@ -133,6 +133,8 @@ async def patch_workspace(workspace_patch: ResourcePatch, response: Response, us
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=strings.ETAG_CONFLICT)
except ValidationError as v:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=v.message)
except (MajorVersionUpdateDenied, TargetTemplateVersionDoesNotExist, VersionDowngradeDenied) as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))


@workspaces_core_router.delete("/workspaces/{workspace_id}", response_model=OperationInResponse, name=strings.API_DELETE_WORKSPACE, dependencies=[Depends(get_current_admin_user)])
Expand Down Expand Up @@ -242,9 +244,9 @@ async def create_workspace_service(response: Response, workspace_service_input:


@workspace_services_workspace_router.patch("/workspaces/{workspace_id}/workspace-services/{service_id}", status_code=status.HTTP_202_ACCEPTED, response_model=OperationInResponse, name=strings.API_UPDATE_WORKSPACE_SERVICE, dependencies=[Depends(get_current_workspace_owner_or_researcher_user), Depends(get_workspace_by_id_from_path)])
async def patch_workspace_service(workspace_service_patch: ResourcePatch, response: Response, user=Depends(get_current_workspace_owner_user), workspace_service_repo=Depends(get_repository(WorkspaceServiceRepository)), workspace_service=Depends(get_workspace_service_by_id_from_path), resource_template_repo=Depends(get_repository(ResourceTemplateRepository)), operations_repo=Depends(get_repository(OperationRepository)), etag: str = Header(...)) -> OperationInResponse:
async def patch_workspace_service(workspace_service_patch: ResourcePatch, response: Response, user=Depends(get_current_workspace_owner_user), workspace_service_repo=Depends(get_repository(WorkspaceServiceRepository)), workspace_service=Depends(get_workspace_service_by_id_from_path), resource_template_repo=Depends(get_repository(ResourceTemplateRepository)), operations_repo=Depends(get_repository(OperationRepository)), etag: str = Header(...), force_version_update: bool = False) -> OperationInResponse:
try:
patched_workspace_service, resource_template = workspace_service_repo.patch_workspace_service(workspace_service, workspace_service_patch, etag, resource_template_repo, user)
patched_workspace_service, resource_template = workspace_service_repo.patch_workspace_service(workspace_service, workspace_service_patch, etag, resource_template_repo, user, force_version_update)
operation = await send_resource_request_message(
resource=patched_workspace_service,
operations_repo=operations_repo,
Expand All @@ -259,6 +261,8 @@ async def patch_workspace_service(workspace_service_patch: ResourcePatch, respon
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=strings.ETAG_CONFLICT)
except ValidationError as v:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=v.message)
except (MajorVersionUpdateDenied, TargetTemplateVersionDoesNotExist, VersionDowngradeDenied) as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))


@workspace_services_workspace_router.delete("/workspaces/{workspace_id}/workspace-services/{service_id}", response_model=OperationInResponse, name=strings.API_DELETE_WORKSPACE_SERVICE, dependencies=[Depends(get_current_workspace_owner_user)])
Expand Down Expand Up @@ -417,11 +421,12 @@ async def patch_user_resource(
user_resource_repo=Depends(get_repository(UserResourceRepository)),
resource_template_repo=Depends(get_repository(ResourceTemplateRepository)),
operations_repo=Depends(get_repository(OperationRepository)),
etag: str = Header(...)) -> OperationInResponse:
etag: str = Header(...),
force_version_update: bool = False) -> OperationInResponse:
validate_user_has_valid_role_for_user_resource(user, user_resource)

try:
patched_user_resource, resource_template = user_resource_repo.patch_user_resource(user_resource, user_resource_patch, etag, resource_template_repo, workspace_service.templateName, user)
patched_user_resource, resource_template = user_resource_repo.patch_user_resource(user_resource, user_resource_patch, etag, resource_template_repo, workspace_service.templateName, user, force_version_update)
operation = await send_resource_request_message(
resource=patched_user_resource,
operations_repo=operations_repo,
Expand All @@ -437,6 +442,8 @@ async def patch_user_resource(
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=strings.ETAG_CONFLICT)
except ValidationError as v:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=v.message)
except (MajorVersionUpdateDenied, TargetTemplateVersionDoesNotExist, VersionDowngradeDenied) as e:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(e))


# user resource actions
Expand Down
12 changes: 12 additions & 0 deletions api_app/db/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,15 @@ class InvalidInput(Exception):

class UserNotAuthorizedToUseTemplate(Exception):
"""Raised when user attempts to use a template they aren't authorized to use"""


class MajorVersionUpdateDenied(Exception):
"""Raised when user attempts to update a resource with a major version."""


class TargetTemplateVersionDoesNotExist(Exception):
"""Raised when user attempts to upgrade a resource to a version which was not registered."""


class VersionDowngradeDenied(Exception):
"""Raised when user attempts to downgrade a resource to a lower version."""
33 changes: 30 additions & 3 deletions api_app/db/repositories/resources.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import copy
import semantic_version
from datetime import datetime
from typing import Tuple, List

from azure.cosmos import CosmosClient
from azure.cosmos.exceptions import CosmosResourceNotFoundError
from core import config
from db.errors import EntityDoesNotExist, UserNotAuthorizedToUseTemplate
from db.errors import VersionDowngradeDenied, EntityDoesNotExist, MajorVersionUpdateDenied, TargetTemplateVersionDoesNotExist, UserNotAuthorizedToUseTemplate
from db.repositories.base import BaseRepository
from db.repositories.resource_templates import ResourceTemplateRepository
from jsonschema import validate
Expand Down Expand Up @@ -96,15 +97,16 @@ def validate_input_against_template(self, template_name: str, resource_input, re

return parse_obj_as(ResourceTemplate, template)

def patch_resource(self, resource: Resource, resource_patch: ResourcePatch, resource_template: ResourceTemplate, etag: str, resource_template_repo: ResourceTemplateRepository, user: User) -> Tuple[Resource, ResourceTemplate]:
def patch_resource(self, resource: Resource, resource_patch: ResourcePatch, resource_template: ResourceTemplate, etag: str, resource_template_repo: ResourceTemplateRepository, user: User, force_version_update: bool = False) -> Tuple[Resource, ResourceTemplate]:
# create a deep copy of the resource to use for history, create the history item + add to history list
resource_copy = copy.deepcopy(resource)
history_item = ResourceHistoryItem(
isEnabled=resource_copy.isEnabled,
properties=resource_copy.properties,
resourceVersion=resource_copy.resourceVersion,
updatedWhen=resource_copy.updatedWhen,
user=resource_copy.user
user=resource_copy.user,
templateVersion=resource_copy.templateVersion
)
resource.history.append(history_item)

Expand All @@ -116,6 +118,10 @@ def patch_resource(self, resource: Resource, resource_patch: ResourcePatch, reso
if resource_patch.isEnabled is not None:
resource.isEnabled = resource_patch.isEnabled

if resource_patch.templateVersion is not None:
self.validate_template_version_patch(resource, resource_patch, resource_template_repo, resource_template, force_version_update)
resource.templateVersion = resource_patch.templateVersion

if resource_patch.properties is not None and len(resource_patch.properties) > 0:
self.validate_patch(resource_patch, resource_template_repo, resource_template)

Expand All @@ -125,6 +131,27 @@ def patch_resource(self, resource: Resource, resource_patch: ResourcePatch, reso
self.update_item_with_etag(resource, etag)
return resource, resource_template

def validate_template_version_patch(self, resource: Resource, resource_patch: ResourcePatch, resource_template_repo: ResourceTemplateRepository, resource_template: ResourceTemplate, force_version_update: bool = False):
parent_resource_id = None
if resource.resourceType == ResourceType.UserResource:
parent_resource_id = resource.parentWorkspaceServiceId

# validate Major upgrade
desired_version = semantic_version.Version(resource_patch.templateVersion)
current_version = semantic_version.Version(resource.templateVersion)

if not force_version_update:
if desired_version.major > current_version.major:
raise MajorVersionUpdateDenied(f'Attempt to upgrade from {current_version} to {desired_version} denied. major version upgrade is not allowed.')
elif desired_version < current_version:
raise VersionDowngradeDenied(f'Attempt to downgrade from {current_version} to {desired_version} denied. version downgrade is not allowed.')

# validate if target template with desired version is registered
try:
resource_template_repo.get_template_by_name_and_version(resource.templateName, resource_patch.templateVersion, resource_template.resourceType, parent_resource_id)
except EntityDoesNotExist:
raise TargetTemplateVersionDoesNotExist(f"Template '{resource_template.name}' not found for resource type '{resource_template.resourceType}' with target template version '{resource_patch.templateVersion}'")

def validate_patch(self, resource_patch: ResourcePatch, resource_template_repo: ResourceTemplateRepository, resource_template: ResourceTemplate):
# get the enriched (combined) template
enriched_template = resource_template_repo.enrich_template(resource_template, is_update=True)
Expand Down
4 changes: 2 additions & 2 deletions api_app/db/repositories/shared_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def create_shared_service_item(self, shared_service_input: SharedServiceTemplate

return shared_service, template

def patch_shared_service(self, shared_service: SharedService, shared_service_patch: ResourcePatch, etag: str, resource_template_repo: ResourceTemplateRepository, user: User) -> Tuple[SharedService, ResourceTemplate]:
def patch_shared_service(self, shared_service: SharedService, shared_service_patch: ResourcePatch, etag: str, resource_template_repo: ResourceTemplateRepository, user: User, force_version_update: bool) -> Tuple[SharedService, ResourceTemplate]:
# get shared service template
shared_service_template = resource_template_repo.get_template_by_name_and_version(shared_service.templateName, shared_service.templateVersion, ResourceType.SharedService)
return self.patch_resource(shared_service, shared_service_patch, shared_service_template, etag, resource_template_repo, user)
return self.patch_resource(shared_service, shared_service_patch, shared_service_template, etag, resource_template_repo, user, force_version_update)
4 changes: 2 additions & 2 deletions api_app/db/repositories/user_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def get_user_resource_by_id(self, workspace_id: str, service_id: str, resource_i
def get_user_resource_spec_params(self):
return self.get_resource_base_spec_params()

def patch_user_resource(self, user_resource: UserResource, user_resource_patch: ResourcePatch, etag: str, resource_template_repo: ResourceTemplateRepository, parent_template_name: str, user: User) -> Tuple[UserResource, ResourceTemplate]:
def patch_user_resource(self, user_resource: UserResource, user_resource_patch: ResourcePatch, etag: str, resource_template_repo: ResourceTemplateRepository, parent_template_name: str, user: User, force_version_update: bool) -> Tuple[UserResource, ResourceTemplate]:
# get user resource template
user_resource_template = resource_template_repo.get_template_by_name_and_version(user_resource.templateName, user_resource.templateVersion, ResourceType.UserResource, parent_service_name=parent_template_name)
return self.patch_resource(user_resource, user_resource_patch, user_resource_template, etag, resource_template_repo, user)
return self.patch_resource(user_resource, user_resource_patch, user_resource_template, etag, resource_template_repo, user, force_version_update)
4 changes: 2 additions & 2 deletions api_app/db/repositories/workspace_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ def create_workspace_service_item(self, workspace_service_input: WorkspaceServic

return workspace_service, template

def patch_workspace_service(self, workspace_service: WorkspaceService, workspace_service_patch: ResourcePatch, etag: str, resource_template_repo: ResourceTemplateRepository, user: User) -> Tuple[WorkspaceService, ResourceTemplate]:
def patch_workspace_service(self, workspace_service: WorkspaceService, workspace_service_patch: ResourcePatch, etag: str, resource_template_repo: ResourceTemplateRepository, user: User, force_version_update: bool) -> Tuple[WorkspaceService, ResourceTemplate]:
# get workspace service template
workspace_service_template = resource_template_repo.get_template_by_name_and_version(workspace_service.templateName, workspace_service.templateVersion, ResourceType.WorkspaceService)
return self.patch_resource(workspace_service, workspace_service_patch, workspace_service_template, etag, resource_template_repo, user)
return self.patch_resource(workspace_service, workspace_service_patch, workspace_service_template, etag, resource_template_repo, user, force_version_update)
Loading

0 comments on commit c25dd59

Please sign in to comment.