From 21caf4c607520b2c547f2a326310b21729efcc8a Mon Sep 17 00:00:00 2001 From: Guy Bertental Date: Sun, 4 Dec 2022 23:15:32 +0200 Subject: [PATCH] Support resource templateVersion update (#2908) * resource update mechanism Co-authored-by: Tamir Kamara <26870601+tamirkamara@users.noreply.github.com> --- .github/pull_request_template.md | 1 + CHANGELOG.md | 1 + api_app/_version.py | 2 +- api_app/api/routes/shared_services.py | 8 +- api_app/api/routes/workspaces.py | 21 +- api_app/db/errors.py | 12 + api_app/db/repositories/resources.py | 33 +- api_app/db/repositories/shared_services.py | 4 +- api_app/db/repositories/user_resources.py | 4 +- api_app/db/repositories/workspace_services.py | 4 +- api_app/db/repositories/workspaces.py | 4 +- api_app/models/domain/resource.py | 1 + api_app/models/schemas/resource.py | 2 + .../test_routes/test_shared_services.py | 92 +++++- .../test_api/test_routes/test_workspaces.py | 311 +++++++++++++++++- .../test_resource_repository.py | 6 +- docs/assets/swagger_force_version_update.png | Bin 0 -> 48978 bytes docs/tre-admins/upgrading-resources.md | 55 ++++ .../authoring-workspace-templates.md | 17 +- mkdocs.yml | 3 +- 20 files changed, 544 insertions(+), 37 deletions(-) create mode 100644 docs/assets/swagger_force_version_update.png create mode 100644 docs/tre-admins/upgrading-resources.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index c7e1f59e2..d4d53163e 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0112f9d69..88ddf80f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/api_app/_version.py b/api_app/_version.py index 81c709a82..906d362f7 100644 --- a/api_app/_version.py +++ b/api_app/_version.py @@ -1 +1 @@ -__version__ = "0.5.23" +__version__ = "0.6.0" diff --git a/api_app/api/routes/shared_services.py b/api_app/api/routes/shared_services.py index ac7f309f4..fb616b8b4 100644 --- a/api_app/api/routes/shared_services.py +++ b/api_app/api/routes/shared_services.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Depends, HTTPException, Header, status, Response 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 @@ -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, @@ -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)]) diff --git a/api_app/api/routes/workspaces.py b/api_app/api/routes/workspaces.py index d479ff8a9..e15b0fd66 100644 --- a/api_app/api/routes/workspaces.py +++ b/api_app/api/routes/workspaces.py @@ -7,7 +7,7 @@ from jsonschema.exceptions import ValidationError 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 @@ -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, @@ -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)]) @@ -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, @@ -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)]) @@ -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, @@ -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 diff --git a/api_app/db/errors.py b/api_app/db/errors.py index 3f55478a2..95eff2b36 100644 --- a/api_app/db/errors.py +++ b/api_app/db/errors.py @@ -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.""" diff --git a/api_app/db/repositories/resources.py b/api_app/db/repositories/resources.py index c1e6473f4..eb1c74e53 100644 --- a/api_app/db/repositories/resources.py +++ b/api_app/db/repositories/resources.py @@ -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 @@ -96,7 +97,7 @@ class ResourceRepository(BaseRepository): 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( @@ -104,7 +105,8 @@ class ResourceRepository(BaseRepository): 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) @@ -116,6 +118,10 @@ class ResourceRepository(BaseRepository): 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) @@ -125,6 +131,27 @@ class ResourceRepository(BaseRepository): 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) diff --git a/api_app/db/repositories/shared_services.py b/api_app/db/repositories/shared_services.py index 84da40059..c6f3e639f 100644 --- a/api_app/db/repositories/shared_services.py +++ b/api_app/db/repositories/shared_services.py @@ -83,7 +83,7 @@ class SharedServiceRepository(ResourceRepository): 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) diff --git a/api_app/db/repositories/user_resources.py b/api_app/db/repositories/user_resources.py index 7670314e1..3646e7f0c 100644 --- a/api_app/db/repositories/user_resources.py +++ b/api_app/db/repositories/user_resources.py @@ -67,7 +67,7 @@ class UserResourceRepository(ResourceRepository): 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) diff --git a/api_app/db/repositories/workspace_services.py b/api_app/db/repositories/workspace_services.py index 7f6c53448..2e282319a 100644 --- a/api_app/db/repositories/workspace_services.py +++ b/api_app/db/repositories/workspace_services.py @@ -74,7 +74,7 @@ class WorkspaceServiceRepository(ResourceRepository): 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) diff --git a/api_app/db/repositories/workspaces.py b/api_app/db/repositories/workspaces.py index 2a77609c2..bb48465f3 100644 --- a/api_app/db/repositories/workspaces.py +++ b/api_app/db/repositories/workspaces.py @@ -137,10 +137,10 @@ class WorkspaceRepository(ResourceRepository): new_address_space = generate_new_cidr(networks, cidr_netmask) return new_address_space - def patch_workspace(self, workspace: Workspace, workspace_patch: ResourcePatch, etag: str, resource_template_repo: ResourceTemplateRepository, user: User) -> Tuple[Workspace, ResourceTemplate]: + def patch_workspace(self, workspace: Workspace, workspace_patch: ResourcePatch, etag: str, resource_template_repo: ResourceTemplateRepository, user: User, force_version_update: bool) -> Tuple[Workspace, ResourceTemplate]: # get the workspace template workspace_template = resource_template_repo.get_template_by_name_and_version(workspace.templateName, workspace.templateVersion, ResourceType.Workspace) - return self.patch_resource(workspace, workspace_patch, workspace_template, etag, resource_template_repo, user) + return self.patch_resource(workspace, workspace_patch, workspace_template, etag, resource_template_repo, user, force_version_update) def get_workspace_spec_params(self, full_workspace_id: str): params = self.get_resource_base_spec_params() diff --git a/api_app/models/domain/resource.py b/api_app/models/domain/resource.py index 881714265..edf475f2c 100644 --- a/api_app/models/domain/resource.py +++ b/api_app/models/domain/resource.py @@ -25,6 +25,7 @@ class ResourceHistoryItem(AzureTREModel): resourceVersion: int updatedWhen: float user: dict = {} + templateVersion: Optional[str] class Resource(AzureTREModel): diff --git a/api_app/models/schemas/resource.py b/api_app/models/schemas/resource.py index cb82569a0..d98f01437 100644 --- a/api_app/models/schemas/resource.py +++ b/api_app/models/schemas/resource.py @@ -5,11 +5,13 @@ from pydantic import BaseModel class ResourcePatch(BaseModel): isEnabled: Optional[bool] properties: Optional[dict] + templateVersion: Optional[str] class Config: schema_extra = { "example": { "isEnabled": False, + "templateVersion": "1.0.1", "properties": { "display_name": "the display name", "description": "a description", diff --git a/api_app/tests_ma/test_api/test_routes/test_shared_services.py b/api_app/tests_ma/test_api/test_routes/test_shared_services.py index 806a255e5..240cd4e43 100644 --- a/api_app/tests_ma/test_api/test_routes/test_shared_services.py +++ b/api_app/tests_ma/test_api/test_routes/test_shared_services.py @@ -163,7 +163,7 @@ class TestSharedServiceRoutesThatRequireAdminRights: modified_shared_service = sample_shared_service() modified_shared_service.isEnabled = False - modified_shared_service.history = [ResourceHistoryItem(properties=copy.deepcopy(modified_shared_service.properties), isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user())] + modified_shared_service.history = [ResourceHistoryItem(properties=copy.deepcopy(modified_shared_service.properties), isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user(), templateVersion=modified_shared_service.templateVersion)] modified_shared_service.resourceVersion = 1 modified_shared_service.updatedWhen = FAKE_UPDATE_TIMESTAMP modified_shared_service.user = create_admin_user() @@ -172,3 +172,93 @@ class TestSharedServiceRoutesThatRequireAdminRights: update_item_mock.assert_called_once_with(modified_shared_service, etag) assert response.status_code == status.HTTP_202_ACCEPTED + + # [PATCH] /shared-services/{shared_service_id} + @patch("api.routes.shared_services.SharedServiceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + @patch("api.dependencies.shared_services.SharedServiceRepository.get_shared_service_by_id", return_value=sample_shared_service(SHARED_SERVICE_ID)) + @patch("api.routes.shared_services.ResourceTemplateRepository.get_template_by_name_and_version", return_value=sample_shared_service()) + @patch("api.routes.shared_services.SharedServiceRepository.update_item_with_etag", return_value=sample_shared_service()) + @patch("api.routes.shared_services.send_resource_request_message", return_value=sample_resource_operation(resource_id=SHARED_SERVICE_ID, operation_id=OPERATION_ID)) + async def test_patch_shared_service_with_upgrade_minor_version_patches_shared_service(self, _, update_item_mock, __, ___, ____, app, client): + etag = "some-etag-value" + shared_service_patch = {"templateVersion": "0.2.0"} + + modified_shared_service = sample_shared_service() + modified_shared_service.isEnabled = True + modified_shared_service.history = [ResourceHistoryItem(properties=copy.deepcopy(modified_shared_service.properties), isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user(), templateVersion=modified_shared_service.templateVersion)] + modified_shared_service.resourceVersion = 1 + modified_shared_service.updatedWhen = FAKE_UPDATE_TIMESTAMP + modified_shared_service.user = create_admin_user() + modified_shared_service.templateVersion = "0.2.0" + + response = await client.patch(app.url_path_for(strings.API_UPDATE_SHARED_SERVICE, shared_service_id=SHARED_SERVICE_ID), json=shared_service_patch, headers={"etag": etag}) + update_item_mock.assert_called_once_with(modified_shared_service, etag) + + assert response.status_code == status.HTTP_202_ACCEPTED + + # [PATCH] /shared-services/{shared_service_id} + @patch("api.routes.shared_services.SharedServiceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + @patch("api.dependencies.shared_services.SharedServiceRepository.get_shared_service_by_id", return_value=sample_shared_service(SHARED_SERVICE_ID)) + @patch("api.routes.shared_services.ResourceTemplateRepository.get_template_by_name_and_version", return_value=sample_shared_service()) + @patch("api.routes.shared_services.SharedServiceRepository.update_item_with_etag", return_value=sample_shared_service()) + @patch("api.routes.shared_services.send_resource_request_message", return_value=sample_resource_operation(resource_id=SHARED_SERVICE_ID, operation_id=OPERATION_ID)) + async def test_patch_shared_service_with_upgrade_major_version_and_force_update_patches_shared_service(self, _, update_item_mock, __, ___, ____, app, client): + etag = "some-etag-value" + shared_service_patch = {"templateVersion": "2.0.0"} + + modified_shared_service = sample_shared_service() + modified_shared_service.isEnabled = True + modified_shared_service.history = [ResourceHistoryItem(properties=copy.deepcopy(modified_shared_service.properties), isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user(), templateVersion=modified_shared_service.templateVersion)] + modified_shared_service.resourceVersion = 1 + modified_shared_service.updatedWhen = FAKE_UPDATE_TIMESTAMP + modified_shared_service.user = create_admin_user() + modified_shared_service.templateVersion = "2.0.0" + + response = await client.patch(app.url_path_for(strings.API_UPDATE_SHARED_SERVICE, shared_service_id=SHARED_SERVICE_ID) + "?force_version_update=True", json=shared_service_patch, headers={"etag": etag}) + update_item_mock.assert_called_once_with(modified_shared_service, etag) + + assert response.status_code == status.HTTP_202_ACCEPTED + + # [PATCH] /shared-services/{shared_service_id} + @patch("api.routes.shared_services.SharedServiceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + @patch("api.dependencies.shared_services.SharedServiceRepository.get_shared_service_by_id", return_value=sample_shared_service(SHARED_SERVICE_ID)) + @patch("api.routes.shared_services.ResourceTemplateRepository.get_template_by_name_and_version", return_value=None) + @patch("api.routes.shared_services.SharedServiceRepository.update_item_with_etag", return_value=sample_shared_service()) + @patch("api.routes.shared_services.send_resource_request_message", return_value=sample_resource_operation(resource_id=SHARED_SERVICE_ID, operation_id=OPERATION_ID)) + async def test_patch_shared_service_with_upgrade_major_version_returns_bad_request(self, _, update_item_mock, __, ___, ____, app, client): + etag = "some-etag-value" + shared_service_patch = {"templateVersion": "2.0.0"} + + modified_shared_service = sample_shared_service() + modified_shared_service.isEnabled = True + modified_shared_service.history = [ResourceHistoryItem(properties=copy.deepcopy(modified_shared_service.properties), isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user(), templateVersion=modified_shared_service.templateVersion)] + modified_shared_service.resourceVersion = 1 + modified_shared_service.updatedWhen = FAKE_UPDATE_TIMESTAMP + modified_shared_service.user = create_admin_user() + + response = await client.patch(app.url_path_for(strings.API_UPDATE_SHARED_SERVICE, shared_service_id=SHARED_SERVICE_ID), json=shared_service_patch, headers={"etag": etag}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.text == 'Attempt to upgrade from 0.1.0 to 2.0.0 denied. major version upgrade is not allowed.' + + # [PATCH] /shared-services/{shared_service_id} + @patch("api.routes.shared_services.SharedServiceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + @patch("api.dependencies.shared_services.SharedServiceRepository.get_shared_service_by_id", return_value=sample_shared_service(SHARED_SERVICE_ID)) + @patch("api.routes.shared_services.ResourceTemplateRepository.get_template_by_name_and_version", return_value=None) + @patch("api.routes.shared_services.SharedServiceRepository.update_item_with_etag", return_value=sample_shared_service()) + @patch("api.routes.shared_services.send_resource_request_message", return_value=sample_resource_operation(resource_id=SHARED_SERVICE_ID, operation_id=OPERATION_ID)) + async def test_patch_shared_service_with_downgrade_version_returns_bad_request(self, _, update_item_mock, __, ___, ____, app, client): + etag = "some-etag-value" + shared_service_patch = {"templateVersion": "0.0.1"} + + modified_shared_service = sample_shared_service() + modified_shared_service.isEnabled = True + modified_shared_service.history = [ResourceHistoryItem(properties=copy.deepcopy(modified_shared_service.properties), isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user(), templateVersion=modified_shared_service.templateVersion)] + modified_shared_service.resourceVersion = 1 + modified_shared_service.updatedWhen = FAKE_UPDATE_TIMESTAMP + modified_shared_service.user = create_admin_user() + + response = await client.patch(app.url_path_for(strings.API_UPDATE_SHARED_SERVICE, shared_service_id=SHARED_SERVICE_ID), json=shared_service_patch, headers={"etag": etag}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.text == 'Attempt to downgrade from 0.1.0 to 0.0.1 denied. version downgrade is not allowed.' diff --git a/api_app/tests_ma/test_api/test_routes/test_workspaces.py b/api_app/tests_ma/test_api/test_routes/test_workspaces.py index 6aee7ac59..0dca024f4 100644 --- a/api_app/tests_ma/test_api/test_routes/test_workspaces.py +++ b/api_app/tests_ma/test_api/test_routes/test_workspaces.py @@ -452,7 +452,7 @@ class TestWorkspaceRoutesThatRequireAdminRights: modified_workspace = sample_workspace() modified_workspace.isEnabled = False - modified_workspace.history = [ResourceHistoryItem(properties={'client_id': '12345', 'scope_id': 'test_scope_id'}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user())] + modified_workspace.history = [ResourceHistoryItem(properties={'client_id': '12345', 'scope_id': 'test_scope_id'}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user(), templateVersion=modified_workspace.templateVersion)] modified_workspace.resourceVersion = 1 modified_workspace.user = create_admin_user() modified_workspace.updatedWhen = FAKE_UPDATE_TIMESTAMP @@ -462,6 +462,96 @@ class TestWorkspaceRoutesThatRequireAdminRights: update_item_mock.assert_called_once_with(modified_workspace, etag) assert response.status_code == status.HTTP_202_ACCEPTED + # [PATCH] /workspaces/{workspace_id} + @ patch("api.routes.workspaces.send_resource_request_message", return_value=sample_resource_operation(resource_id=WORKSPACE_ID, operation_id=OPERATION_ID)) + @ patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + @ patch("api.routes.workspaces.WorkspaceRepository.update_item_with_etag", return_value=sample_workspace()) + @ patch("api.routes.workspaces.ResourceTemplateRepository.get_template_by_name_and_version", return_value=None) + @ patch("api.routes.workspaces.WorkspaceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + async def test_patch_workspaces_with_upgrade_major_version_returns_bad_request(self, _, __, update_item_mock, ___, ____, app, client): + workspace_patch = {"templateVersion": "2.0.0"} + etag = "some-etag-value" + + modified_workspace = sample_workspace() + modified_workspace.isEnabled = True + modified_workspace.history = [ResourceHistoryItem(properties={'client_id': '12345', 'scope_id': 'test_scope_id'}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user(), templateVersion=modified_workspace.templateVersion)] + modified_workspace.resourceVersion = 1 + modified_workspace.user = create_admin_user() + modified_workspace.updatedWhen = FAKE_UPDATE_TIMESTAMP + + response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE, workspace_id=WORKSPACE_ID), json=workspace_patch, headers={"etag": etag}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.text == 'Attempt to upgrade from 0.1.0 to 2.0.0 denied. major version upgrade is not allowed.' + + # [PATCH] /workspaces/{workspace_id} + @ patch("api.routes.workspaces.send_resource_request_message", return_value=sample_resource_operation(resource_id=WORKSPACE_ID, operation_id=OPERATION_ID)) + @ patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + @ patch("api.routes.workspaces.WorkspaceRepository.update_item_with_etag", return_value=sample_workspace()) + @ patch("api.routes.workspaces.ResourceTemplateRepository.get_template_by_name_and_version", return_value=sample_workspace()) + @ patch("api.routes.workspaces.WorkspaceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + async def test_patch_workspaces_with_upgrade_major_version_and_force_update_returns_patched_workspace(self, _, __, update_item_mock, ___, ____, app, client): + workspace_patch = {"templateVersion": "2.0.0"} + etag = "some-etag-value" + + modified_workspace = sample_workspace() + modified_workspace.isEnabled = True + modified_workspace.history = [ResourceHistoryItem(properties={'client_id': '12345', 'scope_id': 'test_scope_id'}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user(), templateVersion=modified_workspace.templateVersion)] + modified_workspace.resourceVersion = 1 + modified_workspace.user = create_admin_user() + modified_workspace.updatedWhen = FAKE_UPDATE_TIMESTAMP + modified_workspace.templateVersion = "2.0.0" + + response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE, workspace_id=WORKSPACE_ID) + "?force_version_update=True", json=workspace_patch, headers={"etag": etag}) + + update_item_mock.assert_called_once_with(modified_workspace, etag) + assert response.status_code == status.HTTP_202_ACCEPTED + + # [PATCH] /workspaces/{workspace_id} + @ patch("api.routes.workspaces.send_resource_request_message", return_value=sample_resource_operation(resource_id=WORKSPACE_ID, operation_id=OPERATION_ID)) + @ patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + @ patch("api.routes.workspaces.WorkspaceRepository.update_item_with_etag", return_value=sample_workspace()) + @ patch("api.routes.workspaces.ResourceTemplateRepository.get_template_by_name_and_version", return_value=None) + @ patch("api.routes.workspaces.WorkspaceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + async def test_patch_workspaces_with_downgrade_version_returns_bad_request(self, _, __, update_item_mock, ___, ____, app, client): + workspace_patch = {"templateVersion": "0.0.1"} + etag = "some-etag-value" + + modified_workspace = sample_workspace() + modified_workspace.isEnabled = True + modified_workspace.history = [ResourceHistoryItem(properties={'client_id': '12345', 'scope_id': 'test_scope_id'}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user(), templateVersion=modified_workspace.templateVersion)] + modified_workspace.resourceVersion = 1 + modified_workspace.user = create_admin_user() + modified_workspace.updatedWhen = FAKE_UPDATE_TIMESTAMP + + response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE, workspace_id=WORKSPACE_ID), json=workspace_patch, headers={"etag": etag}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.text == 'Attempt to downgrade from 0.1.0 to 0.0.1 denied. version downgrade is not allowed.' + + # [PATCH] /workspaces/{workspace_id} + @ patch("api.routes.workspaces.send_resource_request_message", return_value=sample_resource_operation(resource_id=WORKSPACE_ID, operation_id=OPERATION_ID)) + @ patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + @ patch("api.routes.workspaces.WorkspaceRepository.update_item_with_etag", return_value=sample_workspace()) + @ patch("api.routes.workspaces.ResourceTemplateRepository.get_template_by_name_and_version", return_value=sample_workspace()) + @ patch("api.routes.workspaces.WorkspaceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + async def test_patch_workspaces_with_upgrade_minor_version_patches_workspace(self, _, __, update_item_mock, ___, ____, app, client): + workspace_patch = {"templateVersion": "0.2.0"} + etag = "some-etag-value" + + modified_workspace = sample_workspace() + modified_workspace.isEnabled = True + modified_workspace.history = [ResourceHistoryItem(properties={'client_id': '12345', 'scope_id': 'test_scope_id'}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user(), templateVersion=modified_workspace.templateVersion)] + modified_workspace.resourceVersion = 1 + modified_workspace.user = create_admin_user() + modified_workspace.updatedWhen = FAKE_UPDATE_TIMESTAMP + modified_workspace.templateVersion = "0.2.0" + + response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE, workspace_id=WORKSPACE_ID), json=workspace_patch, headers={"etag": etag}) + + update_item_mock.assert_called_once_with(modified_workspace, etag) + assert response.status_code == status.HTTP_202_ACCEPTED + # [PATCH] /workspaces/{workspace_id} @ patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) @ patch("api.routes.workspaces.WorkspaceRepository.update_item_with_etag", side_effect=CosmosAccessConditionFailedError) @@ -472,7 +562,7 @@ class TestWorkspaceRoutesThatRequireAdminRights: etag = "some-etag-value" modified_workspace = sample_workspace() modified_workspace.isEnabled = False - modified_workspace.history = [ResourceHistoryItem(properties={'client_id': '12345', 'scope_id': 'test_scope_id'}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user())] + modified_workspace.history = [ResourceHistoryItem(properties={'client_id': '12345', 'scope_id': 'test_scope_id'}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_admin_user(), templateVersion=modified_workspace.templateVersion)] modified_workspace.resourceVersion = 1 modified_workspace.user = create_admin_user() modified_workspace.updatedWhen = FAKE_UPDATE_TIMESTAMP @@ -704,13 +794,13 @@ class TestWorkspaceServiceRoutesThatRequireOwnerRights: @ patch("api.dependencies.workspaces.UserResourceRepository.get_user_resource_by_id", return_value=sample_user_resource_object()) @ patch("api.routes.workspaces.UserResourceRepository.update_item_with_etag", return_value=sample_user_resource_object()) @ patch("api.routes.workspaces.UserResourceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) - async def test_patch_user_resources_patches_user_resource(self, _, update_item_mock, __, ___, ____, _____, ______, _______, app, client): + async def test_patch_user_resource_patches_user_resource(self, _, update_item_mock, __, ___, ____, _____, ______, _______, app, client): user_resource_service_patch = {"isEnabled": False} etag = "some-etag-value" modified_user_resource = sample_user_resource_object() modified_user_resource.isEnabled = False - modified_user_resource.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_researcher_user())] + modified_user_resource.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_researcher_user(), templateVersion=modified_user_resource.templateVersion)] modified_user_resource.resourceVersion = 1 modified_user_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP modified_user_resource.user = create_workspace_owner_user() @@ -720,6 +810,108 @@ class TestWorkspaceServiceRoutesThatRequireOwnerRights: update_item_mock.assert_called_once_with(modified_user_resource, etag) assert response.status_code == status.HTTP_202_ACCEPTED + # [PATCH] /workspaces/{workspace_id}/workspace-services/{service_id}/user-resources/{resource_id} + @ patch("api.routes.workspaces.send_resource_request_message", return_value=sample_resource_operation(resource_id=USER_RESOURCE_ID, operation_id=OPERATION_ID)) + @ patch("api.routes.workspaces.ResourceTemplateRepository.get_template_by_name_and_version", return_value=sample_workspace_service()) + @ patch("api.routes.workspaces.validate_user_has_valid_role_for_user_resource") + @ patch("api.dependencies.workspaces.WorkspaceServiceRepository.get_workspace_service_by_id", return_value=sample_workspace_service()) + @ patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + @ patch("api.dependencies.workspaces.UserResourceRepository.get_user_resource_by_id", return_value=sample_user_resource_object()) + @ patch("api.routes.workspaces.UserResourceRepository.update_item_with_etag", return_value=sample_user_resource_object()) + @ patch("api.routes.workspaces.UserResourceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + async def test_patch_user_resource_with_upgrade_major_version_returns_bad_request(self, _, update_item_mock, __, ___, ____, _____, ______, _______, app, client): + user_resource_service_patch = {"templateVersion": "2.0.0"} + etag = "some-etag-value" + + modified_user_resource = sample_user_resource_object() + modified_user_resource.isEnabled = True + modified_user_resource.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_researcher_user(), templateVersion=modified_user_resource.templateVersion)] + modified_user_resource.resourceVersion = 1 + modified_user_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP + modified_user_resource.user = create_workspace_owner_user() + + response = await client.patch(app.url_path_for(strings.API_UPDATE_USER_RESOURCE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID, resource_id=USER_RESOURCE_ID), json=user_resource_service_patch, headers={"etag": etag}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.text == 'Attempt to upgrade from 0.1.0 to 2.0.0 denied. major version upgrade is not allowed.' + + # [PATCH] /workspaces/{workspace_id}/workspace-services/{service_id}/user-resources/{resource_id} + @ patch("api.routes.workspaces.send_resource_request_message", return_value=sample_resource_operation(resource_id=USER_RESOURCE_ID, operation_id=OPERATION_ID)) + @ patch("api.routes.workspaces.ResourceTemplateRepository.get_template_by_name_and_version", return_value=sample_workspace_service()) + @ patch("api.routes.workspaces.validate_user_has_valid_role_for_user_resource") + @ patch("api.dependencies.workspaces.WorkspaceServiceRepository.get_workspace_service_by_id", return_value=sample_workspace_service()) + @ patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + @ patch("api.dependencies.workspaces.UserResourceRepository.get_user_resource_by_id", return_value=sample_user_resource_object()) + @ patch("api.routes.workspaces.UserResourceRepository.update_item_with_etag", return_value=sample_user_resource_object()) + @ patch("api.routes.workspaces.UserResourceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + async def test_patch_user_resource_with_upgrade_major_version_and_force_update_returns_patched_user_resource(self, _, update_item_mock, __, ___, ____, _____, ______, _______, app, client): + user_resource_service_patch = {"templateVersion": "2.0.0"} + etag = "some-etag-value" + + modified_user_resource = sample_user_resource_object() + modified_user_resource.isEnabled = True + modified_user_resource.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_researcher_user(), templateVersion=modified_user_resource.templateVersion)] + modified_user_resource.resourceVersion = 1 + modified_user_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP + modified_user_resource.user = create_workspace_owner_user() + modified_user_resource.templateVersion = "2.0.0" + + response = await client.patch(app.url_path_for(strings.API_UPDATE_USER_RESOURCE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID, resource_id=USER_RESOURCE_ID) + "?force_version_update=True", json=user_resource_service_patch, headers={"etag": etag}) + + update_item_mock.assert_called_once_with(modified_user_resource, etag) + assert response.status_code == status.HTTP_202_ACCEPTED + + # [PATCH] /workspaces/{workspace_id}/workspace-services/{service_id}/user-resources/{resource_id} + @ patch("api.routes.workspaces.send_resource_request_message", return_value=sample_resource_operation(resource_id=USER_RESOURCE_ID, operation_id=OPERATION_ID)) + @ patch("api.routes.workspaces.ResourceTemplateRepository.get_template_by_name_and_version", return_value=sample_workspace_service()) + @ patch("api.routes.workspaces.validate_user_has_valid_role_for_user_resource") + @ patch("api.dependencies.workspaces.WorkspaceServiceRepository.get_workspace_service_by_id", return_value=sample_workspace_service()) + @ patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + @ patch("api.dependencies.workspaces.UserResourceRepository.get_user_resource_by_id", return_value=sample_user_resource_object()) + @ patch("api.routes.workspaces.UserResourceRepository.update_item_with_etag", return_value=sample_user_resource_object()) + @ patch("api.routes.workspaces.UserResourceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + async def test_patch_user_resource_with_downgrade_version_returns_bad_request(self, _, update_item_mock, __, ___, ____, _____, ______, _______, app, client): + user_resource_service_patch = {"templateVersion": "0.0.1"} + etag = "some-etag-value" + + modified_user_resource = sample_user_resource_object() + modified_user_resource.isEnabled = True + modified_user_resource.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_researcher_user(), templateVersion=modified_user_resource.templateVersion)] + modified_user_resource.resourceVersion = 1 + modified_user_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP + modified_user_resource.user = create_workspace_owner_user() + + response = await client.patch(app.url_path_for(strings.API_UPDATE_USER_RESOURCE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID, resource_id=USER_RESOURCE_ID), json=user_resource_service_patch, headers={"etag": etag}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.text == 'Attempt to downgrade from 0.1.0 to 0.0.1 denied. version downgrade is not allowed.' + + # [PATCH] /workspaces/{workspace_id}/workspace-services/{service_id}/user-resources/{resource_id} + @ patch("api.routes.workspaces.send_resource_request_message", return_value=sample_resource_operation(resource_id=USER_RESOURCE_ID, operation_id=OPERATION_ID)) + @ patch("api.routes.workspaces.ResourceTemplateRepository.get_template_by_name_and_version", return_value=sample_workspace_service()) + @ patch("api.routes.workspaces.validate_user_has_valid_role_for_user_resource") + @ patch("api.dependencies.workspaces.WorkspaceServiceRepository.get_workspace_service_by_id", return_value=sample_workspace_service()) + @ patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id", return_value=sample_workspace()) + @ patch("api.dependencies.workspaces.UserResourceRepository.get_user_resource_by_id", return_value=sample_user_resource_object()) + @ patch("api.routes.workspaces.UserResourceRepository.update_item_with_etag", return_value=sample_user_resource_object()) + @ patch("api.routes.workspaces.UserResourceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + async def test_patch_user_resource_with_upgrade_minor_version_patches_user_resource(self, _, update_item_mock, __, ___, ____, _____, ______, _______, app, client): + user_resource_service_patch = {"templateVersion": "0.2.0"} + etag = "some-etag-value" + + modified_user_resource = sample_user_resource_object() + modified_user_resource.isEnabled = True + modified_user_resource.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_researcher_user(), templateVersion=modified_user_resource.templateVersion)] + modified_user_resource.resourceVersion = 1 + modified_user_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP + modified_user_resource.user = create_workspace_owner_user() + modified_user_resource.templateVersion = "0.2.0" + + response = await client.patch(app.url_path_for(strings.API_UPDATE_USER_RESOURCE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID, resource_id=USER_RESOURCE_ID), json=user_resource_service_patch, headers={"etag": etag}) + + update_item_mock.assert_called_once_with(modified_user_resource, etag) + assert response.status_code == status.HTTP_202_ACCEPTED + # [PATCH] /workspaces/{workspace_id}/workspace-services/{service_id}/user-resources/{resource_id} @ patch("api.routes.workspaces.send_resource_request_message", return_value=sample_resource_operation(resource_id=USER_RESOURCE_ID, operation_id=OPERATION_ID)) @ patch("api.routes.workspaces.UserResourceRepository.update_item_with_etag", return_value=sample_user_resource_object()) @@ -734,7 +926,7 @@ class TestWorkspaceServiceRoutesThatRequireOwnerRights: modified_resource = sample_user_resource_object() modified_resource.isEnabled = False - modified_resource.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_researcher_user())] + modified_resource.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_researcher_user(), templateVersion=modified_resource.templateVersion)] modified_resource.resourceVersion = 1 modified_resource.properties["vm_size"] = "large" modified_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP @@ -812,7 +1004,7 @@ class TestWorkspaceServiceRoutesThatRequireOwnerRights: modified_workspace_service = sample_workspace_service() modified_workspace_service.isEnabled = False - modified_workspace_service.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_owner_user())] + modified_workspace_service.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_owner_user(), templateVersion=modified_workspace_service.templateVersion)] modified_workspace_service.resourceVersion = 1 modified_workspace_service.user = create_workspace_owner_user() modified_workspace_service.updatedWhen = FAKE_UPDATE_TIMESTAMP @@ -822,6 +1014,111 @@ class TestWorkspaceServiceRoutesThatRequireOwnerRights: assert response.status_code == status.HTTP_202_ACCEPTED + # [PATCH] /workspaces/{workspace_id}/services/{service_id} + @ patch("api.routes.workspaces.send_resource_request_message", return_value=sample_resource_operation(resource_id=WORKSPACE_ID, operation_id=OPERATION_ID)) + @ patch("api.routes.workspaces.ResourceTemplateRepository.get_template_by_name_and_version", return_value=sample_workspace_service()) + @ patch("api.dependencies.workspaces.WorkspaceServiceRepository.get_workspace_service_by_id", return_value=sample_workspace_service()) + @ patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id") + @ patch("api.routes.workspaces.WorkspaceServiceRepository.update_item_with_etag", return_value=sample_workspace_service()) + @ patch("api.routes.workspaces.WorkspaceServiceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + async def test_patch_workspace_service_with_upgrade_major_version_returns_bad_request(self, _, update_item_mock, get_workspace_mock, __, ___, ____, app, client): + auth_info_user_in_workspace_owner_role = {'sp_id': 'ab123', 'roles': {'WorkspaceOwner': 'ab124', 'WorkspaceResearcher': 'ab125'}} + + get_workspace_mock.return_value = sample_deployed_workspace(WORKSPACE_ID, auth_info_user_in_workspace_owner_role) + etag = "some-etag-value" + workspace_service_patch = {"templateVersion": "2.0.0"} + + modified_workspace_service = sample_workspace_service() + modified_workspace_service.isEnabled = True + modified_workspace_service.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_owner_user(), templateVersion=modified_workspace_service.templateVersion)] + modified_workspace_service.resourceVersion = 1 + modified_workspace_service.user = create_workspace_owner_user() + modified_workspace_service.updatedWhen = FAKE_UPDATE_TIMESTAMP + + response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE_SERVICE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID), json=workspace_service_patch, headers={"etag": etag}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.text == 'Attempt to upgrade from 0.1.0 to 2.0.0 denied. major version upgrade is not allowed.' + + @ patch("api.routes.workspaces.send_resource_request_message", return_value=sample_resource_operation(resource_id=WORKSPACE_ID, operation_id=OPERATION_ID)) + @ patch("api.routes.workspaces.ResourceTemplateRepository.get_template_by_name_and_version", return_value=sample_workspace_service()) + @ patch("api.dependencies.workspaces.WorkspaceServiceRepository.get_workspace_service_by_id", return_value=sample_workspace_service()) + @ patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id") + @ patch("api.routes.workspaces.WorkspaceServiceRepository.update_item_with_etag", return_value=sample_workspace_service()) + @ patch("api.routes.workspaces.WorkspaceServiceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + async def test_patch_workspace_service_with_upgrade_major_version_and_force_update_returns_patched_workspace_service(self, _, update_item_mock, get_workspace_mock, __, ___, ____, app, client): + auth_info_user_in_workspace_owner_role = {'sp_id': 'ab123', 'roles': {'WorkspaceOwner': 'ab124', 'WorkspaceResearcher': 'ab125'}} + + get_workspace_mock.return_value = sample_deployed_workspace(WORKSPACE_ID, auth_info_user_in_workspace_owner_role) + etag = "some-etag-value" + workspace_service_patch = {"templateVersion": "2.0.0"} + + modified_workspace_service = sample_workspace_service() + modified_workspace_service.isEnabled = True + modified_workspace_service.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_owner_user(), templateVersion=modified_workspace_service.templateVersion)] + modified_workspace_service.resourceVersion = 1 + modified_workspace_service.user = create_workspace_owner_user() + modified_workspace_service.updatedWhen = FAKE_UPDATE_TIMESTAMP + modified_workspace_service.templateVersion = "2.0.0" + + response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE_SERVICE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID) + "?force_version_update=True", json=workspace_service_patch, headers={"etag": etag}) + + update_item_mock.assert_called_once_with(modified_workspace_service, etag) + assert response.status_code == status.HTTP_202_ACCEPTED + + # [PATCH] /workspaces/{workspace_id}/services/{service_id} + @ patch("api.routes.workspaces.send_resource_request_message", return_value=sample_resource_operation(resource_id=WORKSPACE_ID, operation_id=OPERATION_ID)) + @ patch("api.routes.workspaces.ResourceTemplateRepository.get_template_by_name_and_version", return_value=sample_workspace_service()) + @ patch("api.dependencies.workspaces.WorkspaceServiceRepository.get_workspace_service_by_id", return_value=sample_workspace_service()) + @ patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id") + @ patch("api.routes.workspaces.WorkspaceServiceRepository.update_item_with_etag", return_value=sample_workspace_service()) + @ patch("api.routes.workspaces.WorkspaceServiceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + async def test_patch_workspace_service_with_downgrade_version_returns_bad_request(self, _, update_item_mock, get_workspace_mock, __, ___, ____, app, client): + auth_info_user_in_workspace_owner_role = {'sp_id': 'ab123', 'roles': {'WorkspaceOwner': 'ab124', 'WorkspaceResearcher': 'ab125'}} + + get_workspace_mock.return_value = sample_deployed_workspace(WORKSPACE_ID, auth_info_user_in_workspace_owner_role) + etag = "some-etag-value" + workspace_service_patch = {"templateVersion": "0.0.1"} + + modified_workspace_service = sample_workspace_service() + modified_workspace_service.isEnabled = True + modified_workspace_service.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_owner_user(), templateVersion=modified_workspace_service.templateVersion)] + modified_workspace_service.resourceVersion = 1 + modified_workspace_service.user = create_workspace_owner_user() + modified_workspace_service.updatedWhen = FAKE_UPDATE_TIMESTAMP + + response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE_SERVICE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID), json=workspace_service_patch, headers={"etag": etag}) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.text == 'Attempt to downgrade from 0.1.0 to 0.0.1 denied. version downgrade is not allowed.' + + # [PATCH] /workspaces/{workspace_id}/services/{service_id} + @ patch("api.routes.workspaces.send_resource_request_message", return_value=sample_resource_operation(resource_id=WORKSPACE_ID, operation_id=OPERATION_ID)) + @ patch("api.routes.workspaces.ResourceTemplateRepository.get_template_by_name_and_version", return_value=sample_workspace_service()) + @ patch("api.dependencies.workspaces.WorkspaceServiceRepository.get_workspace_service_by_id", return_value=sample_workspace_service()) + @ patch("api.dependencies.workspaces.WorkspaceRepository.get_workspace_by_id") + @ patch("api.routes.workspaces.WorkspaceServiceRepository.update_item_with_etag", return_value=sample_workspace_service()) + @ patch("api.routes.workspaces.WorkspaceServiceRepository.get_timestamp", return_value=FAKE_UPDATE_TIMESTAMP) + async def test_patch_workspace_service_with_upgrade_minor_version_patches_workspace(self, _, update_item_mock, get_workspace_mock, __, ___, ____, app, client): + auth_info_user_in_workspace_owner_role = {'sp_id': 'ab123', 'roles': {'WorkspaceOwner': 'ab124', 'WorkspaceResearcher': 'ab125'}} + + get_workspace_mock.return_value = sample_deployed_workspace(WORKSPACE_ID, auth_info_user_in_workspace_owner_role) + etag = "some-etag-value" + workspace_service_patch = {"templateVersion": "0.2.0"} + + modified_workspace_service = sample_workspace_service() + modified_workspace_service.isEnabled = True + modified_workspace_service.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_owner_user(), templateVersion=modified_workspace_service.templateVersion)] + modified_workspace_service.resourceVersion = 1 + modified_workspace_service.user = create_workspace_owner_user() + modified_workspace_service.updatedWhen = FAKE_UPDATE_TIMESTAMP + modified_workspace_service.templateVersion = "0.2.0" + + response = await client.patch(app.url_path_for(strings.API_UPDATE_WORKSPACE_SERVICE, workspace_id=WORKSPACE_ID, service_id=SERVICE_ID), json=workspace_service_patch, headers={"etag": etag}) + update_item_mock.assert_called_once_with(modified_workspace_service, etag) + + assert response.status_code == status.HTTP_202_ACCEPTED + class TestWorkspaceServiceRoutesThatRequireOwnerOrResearcherRights: @pytest.fixture(autouse=True, scope='class') @@ -1066,7 +1363,7 @@ class TestWorkspaceServiceRoutesThatRequireOwnerOrResearcherRights: modified_user_resource = sample_user_resource_object() modified_user_resource.isEnabled = False - modified_user_resource.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_researcher_user())] + modified_user_resource.history = [ResourceHistoryItem(properties={}, isEnabled=True, resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, user=create_workspace_researcher_user(), templateVersion=modified_user_resource.templateVersion)] modified_user_resource.resourceVersion = 1 modified_user_resource.updatedWhen = FAKE_UPDATE_TIMESTAMP modified_user_resource.user = create_workspace_researcher_user() diff --git a/api_app/tests_ma/test_db/test_repositories/test_resource_repository.py b/api_app/tests_ma/test_db/test_repositories/test_resource_repository.py index 9c3b1dbb5..186b16c80 100644 --- a/api_app/tests_ma/test_db/test_repositories/test_resource_repository.py +++ b/api_app/tests_ma/test_db/test_repositories/test_resource_repository.py @@ -330,7 +330,8 @@ def test_patch_resource_preserves_property_history(_, __, resource_repo): resourceVersion=0, updatedWhen=FAKE_CREATE_TIMESTAMP, properties={'display_name': 'initial display name', 'description': 'initial description', 'computed_prop': 'computed_val'}, - user=user)] + user=user, + templateVersion=resource.templateVersion)] expected_resource.properties['display_name'] = 'updated name' expected_resource.resourceVersion = 1 expected_resource.user = user @@ -348,7 +349,8 @@ def test_patch_resource_preserves_property_history(_, __, resource_repo): resourceVersion=1, updatedWhen=FAKE_UPDATE_TIMESTAMP, properties={'display_name': 'updated name', 'description': 'initial description', 'computed_prop': 'computed_val'}, - user=user + user=user, + templateVersion=resource.templateVersion ) ) diff --git a/docs/assets/swagger_force_version_update.png b/docs/assets/swagger_force_version_update.png new file mode 100644 index 0000000000000000000000000000000000000000..6ab2f6e5218ac063475f466d6fe9670b4345e392 GIT binary patch literal 48978 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 o_r^Zp)h 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 +# Upgrading Resources Version + +Azure TRE workspaces, workspace services, workspace shared services, and user resources are [Porter](https://porter.sh/) bundles. Porter bundles are based on [Cloud Native Application Bundles (CNAB)](https://cnab.io/). + +When a new bundle version becomes available, users can upgrade their resources to a newer version after building, publishing and registering the bundle template. + +Upgrades (and downgrades) are based on [CNAB bundle upgrade action](https://getporter.org/bundle/manifest/#bundle-actions). + +Bundle template versions follow [semantic versioning rules](../tre-workspace-authors/authoring-workspace-templates.md#versioning). + +!!! Note + Only minor and patch version upgrades are automatically allowed within the Azure TRE upgrade mechanism. Major versions upgrades and any version downgrades are blocked as they are assumed to contain breaking changes or changes that require additional consideration. + + For users who wish to upgrade a major version, we highly recommend to read the changelog, review what has changed and take some appropriate action before upgrading using [force version update](#force-version-update). + +## How to upgrade a resource using Swagger UI + +Resources can be upgrade using Swagger UI, in the following example we show how to upgrade a workspace version from 1.0.0 to 1.0.1, other resources upgrades are similar. + +1. First make sure the desired template version is registered, [follow these steps if not](../tre-admins/registering-templates.md). + +1. Navigate to the Swagger UI at `/api/docs`. + +1. Log into the Swagger UI using `Authorize`. + +1. Click `Try it out` on the `GET` `/api/workspace/{workspace_id}` operation. + +1. Provide your `workspace_id` in the parameters section and click `Execute`. + +1. Copy the `_etag` property from the response body. + +1. Click `Try it out` on the `PATCH` `/api/workspace/{workspace_id}` operation. + +1. Provide your `workspace_id` and `_etag` parameters which you've just copied. + +1. Provide the following payload with the desired version in the `Request body` parameter and click `Execute`. + + ```json + { + "templateVersion": "1.0.1", + } + ``` +1. Review server response, it should include a new `operation` document with `upgrade` as an `action` and `updating` as `status` for upgrading the workspace and a message states that the Job is starting. + +1. Once the upgrade is complete another operation will be created and can be viewed by executing `GET` `/api/workspace/{workspace_id}/operations`, review it and make sure its `status` is `updated`. + +### Force version update +If you wish to upgrade a major version, or downgrade to any version, you can override the blocking in the upgrade mechanism by passing `force_version_update=true` query parameter to the resource `Patch` action. + +For example force version patching a workspace: + +![Force version update](../assets/swagger_force_version_update.png) + + diff --git a/docs/tre-workspace-authors/authoring-workspace-templates.md b/docs/tre-workspace-authors/authoring-workspace-templates.md index 8b80364d3..6a62e4f17 100644 --- a/docs/tre-workspace-authors/authoring-workspace-templates.md +++ b/docs/tre-workspace-authors/authoring-workspace-templates.md @@ -1,8 +1,8 @@ -# Authoring workspaces templates +# Authoring templates -Azure TRE workspaces, workspace services, and user resources are [Porter](https://porter.sh/) bundles. Porter bundles are based on [Cloud Native Application Bundles (CNAB)](https://cnab.io/). +Azure TRE workspaces, workspace services, shared services, and user resources are [Porter](https://porter.sh/) bundles. Porter bundles are based on [Cloud Native Application Bundles (CNAB)](https://cnab.io/). -Workspace authors are free to choose the technology stack for provisioning resources (e.g., ARM templates, Terraform etc.), but the Azure TRE framework sets certain requirements for the bundle manifests, which specify the credentials, input and output parameters, deployment actions among other things. +Authors are free to choose the technology stack for provisioning resources (e.g., ARM templates, Terraform etc.), but the Azure TRE framework sets certain requirements for the bundle manifests, which specify the credentials, input and output parameters, deployment actions among other things. This document describes the requirements, and the process to author a template. @@ -120,7 +120,16 @@ Templates authors need to make sure that underling Azure resources are tagged wi Workspace versions are the bundle versions specified in [the metadata](https://porter.sh/author-bundles/#bundle-metadata). The bundle versions should match the image tags in the container registry (see [Publishing workspace bundle](#publishing-workspace-bundle)). -TRE does not provide means to update an existing workspace to a newer version. Instead, the user has to first uninstall the old version and then install the new one. The CNAB **upgrade** or a Porter custom ("`update`") action may be used in the future version of TRE to do this automatically. +Bundle versions should follow [Semantic Versioning](https://semver.org/), given a version number **MAJOR.MINOR.PATCH**, increment the: + +1. **MAJOR** version when you make a breaking change, potential data loss, changes that don't easily/automatically upgrade, or significant changes which require someone to review what has changed and take some appropriate action, or functionality of the component has significantly changed and users might need training. + +2. **MINOR** version when you add minor functionality which can be automatically upgraded. + +3. **PATCH** version when you make backward-compatible bug or typo fixes. + + +For resource version upgrades see [Upgrading Resources Version](../tre-admins/upgrading-resources.md). ## Publishing workspace bundle diff --git a/mkdocs.yml b/mkdocs.yml index 2fc84f58b..c450948c8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -125,7 +125,8 @@ nav: - Install Resources via API: - Install Base Workspace: tre-admins/setup-instructions/installing-base-workspace.md - Install Workspace Service and User Resource: tre-admins/setup-instructions/installing-workspace-service-and-user-resource.md - - Upgrading AzureTRE version: tre-admins/upgrading-tre.md + - Upgrading AzureTRE Version: tre-admins/upgrading-tre.md + - Upgrading Resources Version: tre-admins/upgrading-resources.md - Configuring Airlock Reviews: tre-admins/configure-airlock-review.md - Development: # Docs related to the developing code for the AzureTRE