Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support resource templateVersion update #2908

Merged
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
6351172
support templateVersion when patching resources
guybartal Oct 1, 2022
33f08d6
suppport resources templateVersion patching
guybartal Oct 1, 2022
e6f3e9c
Revert "suppport resources templateVersion patching"
guybartal Oct 1, 2022
e5c5d65
Merge branch 'main' into guybartal/support-template-version-patching
guybartal Oct 1, 2022
7efa18d
bump api version
guybartal Oct 1, 2022
bd3488b
Merge branch 'main' into guybartal/support-template-version-patching
guybartal Nov 22, 2022
124b77d
fix UT
guybartal Nov 22, 2022
35f04bd
resource update mechanism
guybartal Nov 24, 2022
1ea672d
force version update + shared services unit tests
guybartal Nov 24, 2022
5b6fb80
Merge branch 'main' into guybartal/support-template-version-patching
guybartal Nov 24, 2022
439ac50
bump api version
guybartal Nov 24, 2022
58724a6
update PR template
guybartal Nov 24, 2022
1c3551c
Major rule rephrased
guybartal Nov 24, 2022
e02f6e3
Merge branch 'main' into guybartal/support-template-version-patching
guybartal Nov 24, 2022
8378d40
bump api version
guybartal Nov 24, 2022
3deff17
Update api_app/_version.py
guybartal Nov 28, 2022
8db059b
Update docs/tre-workspace-authors/authoring-workspace-templates.md
guybartal Nov 28, 2022
c4661ce
Update docs/tre-workspace-authors/authoring-workspace-templates.md
guybartal Nov 28, 2022
9109aaf
Update docs/tre-workspace-authors/authoring-workspace-templates.md
guybartal Nov 28, 2022
bd8a51b
Merge branch 'main' into guybartal/support-template-version-patching
guybartal Nov 28, 2022
feda19c
disable linter rule MD046 inline
guybartal Nov 28, 2022
8525fdc
linter issues
guybartal Nov 28, 2022
c3d9a0d
linter
guybartal Nov 28, 2022
215fe45
remove logging
guybartal Nov 28, 2022
d4250b8
move forceVersionUpdate fiield to query string
guybartal Nov 28, 2022
65b1243
linter errors
guybartal Nov 28, 2022
e5b1836
fix unit test
guybartal Nov 28, 2022
d483f4b
PR review comments - rephrasing and typo fixes
guybartal Nov 29, 2022
aabffbe
replace semver lib with existing semantic-version
guybartal Nov 29, 2022
da1afef
Merge branch 'main' into guybartal/support-template-version-patching
guybartal Nov 29, 2022
58fa9ac
shorten pr template + add changelog
guybartal Nov 29, 2022
e09cef7
Merge branch 'guybartal/support-template-version-patching' of github.…
guybartal Nov 29, 2022
b9b0c37
Merge branch 'main' into guybartal/support-template-version-patching
guybartal Nov 30, 2022
23ffbd9
Merge branch 'main' into guybartal/support-template-version-patching
tamirkamara Dec 1, 2022
5829f16
Merge branch 'main' into guybartal/support-template-version-patching
guybartal Dec 4, 2022
0626ddd
Merge branch 'main' into guybartal/support-template-version-patching
guybartal Dec 4, 2022
2b0f1df
Merge branch 'main' into guybartal/support-template-version-patching
tamirkamara Dec 4, 2022
fde4d3e
Merge branch 'main' into guybartal/support-template-version-patching
tamirkamara Dec 4, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,7 @@ 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 porter bundle version if needed, 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.
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.19"
__version__ = "0.5.20"
guybartal marked this conversation as resolved.
Show resolved Hide resolved
5 changes: 4 additions & 1 deletion 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 @@ -93,6 +93,9 @@ 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:
logging.error(str(e), exc_info=e)
guybartal marked this conversation as resolved.
Show resolved Hide resolved
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
11 changes: 10 additions & 1 deletion 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 @@ -133,6 +133,9 @@ 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:
logging.error(str(e), exc_info=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 @@ -259,6 +262,9 @@ 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:
logging.error(str(e), exc_info=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 @@ -437,6 +443,9 @@ 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:
logging.error(str(e), exc_info=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 semver
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_patch: 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)
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):
parent_resource_id = None
if resource.resourceType == ResourceType.UserResource:
parent_resource_id = resource.parentWorkspaceServiceId

# validate Major upgrade
desired_version = semver.VersionInfo.parse(resource_patch.templateVersion)
guybartal marked this conversation as resolved.
Show resolved Hide resolved
current_version = semver.VersionInfo.parse(resource.templateVersion)

if not resource_patch.forceVersionUpdate:
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
1 change: 1 addition & 0 deletions api_app/models/domain/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ class ResourceHistoryItem(AzureTREModel):
resourceVersion: int
updatedWhen: float
user: dict = {}
templateVersion: Optional[str]


class Resource(AzureTREModel):
Expand Down
4 changes: 4 additions & 0 deletions api_app/models/schemas/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
class ResourcePatch(BaseModel):
isEnabled: Optional[bool]
properties: Optional[dict]
templateVersion: Optional[str]
forceVersionUpdate: Optional[bool]
tamirkamara marked this conversation as resolved.
Show resolved Hide resolved

class Config:
schema_extra = {
"example": {
"isEnabled": False,
"templateVersion": "1.0.1",
"forceVersionUpdate": False,
"properties": {
"display_name": "the display name",
"description": "a description",
Expand Down
1 change: 1 addition & 0 deletions api_app/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,4 @@ pytz~=2022.1
python-dateutil~=2.8.2
azure-mgmt-resource==21.1.0
pandas==1.4.4
semver==2.13.0
92 changes: 91 additions & 1 deletion api_app/tests_ma/test_api/test_routes/test_shared_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ async def test_patch_shared_service_patches_shared_service(self, _, update_item_

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()
Expand All @@ -172,3 +172,93 @@ async def test_patch_shared_service_patches_shared_service(self, _, update_item_
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", "forceVersionUpdate": "True"}

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), 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.'
Loading