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

Create and delete Airlock Review VMs #2740

Merged
merged 25 commits into from
Oct 17, 2022
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
29e069e
WIP: started working on cleanup trigger
tanya-borisova Oct 6, 2022
1b9eac7
Revert "WIP: started working on cleanup trigger"
tanya-borisova Oct 6, 2022
abd22da
WIP: doing some renames, created a file for review VMs
tanya-borisova Oct 6, 2022
e08d14f
WIP: just comments
tanya-borisova Oct 10, 2022
01486d3
wip
tanya-borisova Oct 10, 2022
5040a5a
wip: first sketch of VM cleanup
tanya-borisova Oct 12, 2022
f41e113
Merge remote-tracking branch 'origin/main' into tborisova/2507-clean-…
tanya-borisova Oct 13, 2022
30f6231
wip: method for creating VMs
tanya-borisova Oct 16, 2022
4ef7801
Merge remote-tracking branch 'origin/main' into tborisova/2507-clean-…
tanya-borisova Oct 16, 2022
800bc64
wip: fix defaults
tanya-borisova Oct 16, 2022
daafd0f
WIP: create review vms is working
tanya-borisova Oct 16, 2022
b12d335
delete VMs working
tanya-borisova Oct 16, 2022
b076c39
fix comments
tanya-borisova Oct 16, 2022
5aa8db3
remove comments
tanya-borisova Oct 16, 2022
b5922ca
Fix existing tests
tanya-borisova Oct 16, 2022
8655ce2
Update tests to cover deleting of review VMs
tanya-borisova Oct 16, 2022
1edb146
Move things back to airlock_resource_helpers.py
tanya-borisova Oct 16, 2022
9db3c77
Add test for happy path
tanya-borisova Oct 16, 2022
94aa122
beef up tests and error handling
tanya-borisova Oct 16, 2022
a21ebf8
nits
tanya-borisova Oct 16, 2022
1cfd614
Add schema for airlock review user resources
tanya-borisova Oct 16, 2022
6a37b53
Fix the template_schema.json and resource_template.py
tanya-borisova Oct 17, 2022
527ae67
reorder blocks in template_schema.json
tanya-borisova Oct 17, 2022
43c4b8d
Apply comments
tanya-borisova Oct 17, 2022
94fe0c6
spelling
tanya-borisova Oct 17, 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
2 changes: 1 addition & 1 deletion api_app/_version.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.4.53"
__version__ = "0.4.5206"
162 changes: 145 additions & 17 deletions api_app/api/routes/airlock.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,31 @@
import logging

from fastapi import APIRouter, Depends, HTTPException, status
from fastapi import APIRouter, Depends, HTTPException, status, Response

from jsonschema.exceptions import ValidationError

from db.repositories.user_resources import UserResourceRepository
from db.repositories.workspace_services import WorkspaceServiceRepository
from db.repositories.operations import OperationRepository
from db.repositories.resource_templates import ResourceTemplateRepository
from db.repositories.airlock_requests import AirlockRequestRepository
from db.errors import EntityDoesNotExist, UserNotAuthorizedToUseTemplate

from api.dependencies.database import get_repository
from api.dependencies.workspaces import get_workspace_by_id_from_path, get_deployed_workspace_by_id_from_path
from api.dependencies.airlock import get_airlock_request_by_id_from_path
from models.domain.airlock_request import AirlockRequestStatus, AirlockRequestType, AirlockReviewDecision

from models.domain.airlock_request import AirlockRequest, AirlockRequestStatus, AirlockRequestType, AirlockReviewDecision, AirlockReviewUserResource
from models.schemas.operation import OperationInResponse
from models.schemas.user_resource import UserResourceInCreate
from models.schemas.airlock_request_url import AirlockRequestTokenInResponse

from db.repositories.airlock_requests import AirlockRequestRepository
from models.schemas.airlock_request import AirlockRequestInCreate, AirlockRequestInResponse, AirlockRequestWithAllowedUserActionsInList, AirlockReviewInCreate
from resources import strings
from services.authentication import get_current_workspace_owner_or_researcher_user_or_airlock_manager, get_current_workspace_owner_or_researcher_user, get_current_airlock_manager_user

from .airlock_resource_helpers import save_and_publish_event_airlock_request, update_and_publish_event_airlock_request, enrich_requests_with_allowed_actions, get_airlock_requests_by_user_and_workspace

from .airlock_resource_helpers import save_and_publish_event_airlock_request, update_and_publish_event_airlock_request, enrich_requests_with_allowed_actions, \
get_airlock_requests_by_user_and_workspace, delete_review_user_resources
from .resource_helpers import save_and_deploy_resource, construct_location_header

from services.airlock import validate_user_allowed_to_access_storage_account, \
get_account_by_request, get_airlock_request_container_sas_token, validate_request_status
Expand Down Expand Up @@ -67,18 +76,123 @@ async def retrieve_airlock_request_by_id(airlock_request=Depends(get_airlock_req

@airlock_workspace_router.post("/workspaces/{workspace_id}/requests/{airlock_request_id}/submit", status_code=status.HTTP_200_OK, response_model=AirlockRequestInResponse, name=strings.API_SUBMIT_AIRLOCK_REQUEST, dependencies=[Depends(get_current_workspace_owner_or_researcher_user), Depends(get_workspace_by_id_from_path)])
async def create_submit_request(airlock_request=Depends(get_airlock_request_by_id_from_path), user=Depends(get_current_workspace_owner_or_researcher_user), airlock_request_repo=Depends(get_repository(AirlockRequestRepository)), workspace=Depends(get_workspace_by_id_from_path)) -> AirlockRequestInResponse:
updated_resource = await update_and_publish_event_airlock_request(airlock_request, airlock_request_repo, user, AirlockRequestStatus.Submitted, workspace)
updated_resource = await update_and_publish_event_airlock_request(airlock_request, airlock_request_repo, user, workspace, new_status=AirlockRequestStatus.Submitted)
return AirlockRequestInResponse(airlockRequest=updated_resource)


@airlock_workspace_router.post("/workspaces/{workspace_id}/requests/{airlock_request_id}/cancel", status_code=status.HTTP_200_OK, response_model=AirlockRequestInResponse, name=strings.API_CANCEL_AIRLOCK_REQUEST, dependencies=[Depends(get_current_workspace_owner_or_researcher_user), Depends(get_workspace_by_id_from_path)])
async def create_cancel_request(airlock_request=Depends(get_airlock_request_by_id_from_path), user=Depends(get_current_workspace_owner_or_researcher_user), airlock_request_repo=Depends(get_repository(AirlockRequestRepository)), workspace=Depends(get_workspace_by_id_from_path)) -> AirlockRequestInResponse:
updated_resource = await update_and_publish_event_airlock_request(airlock_request, airlock_request_repo, user, AirlockRequestStatus.Cancelled, workspace)
updated_resource = await update_and_publish_event_airlock_request(airlock_request, airlock_request_repo, user, workspace, new_status=AirlockRequestStatus.Cancelled)
return AirlockRequestInResponse(airlockRequest=updated_resource)


@airlock_workspace_router.post("/workspaces/{workspace_id}/requests/{airlock_request_id}/review-user-resource", status_code=status.HTTP_202_ACCEPTED, response_model=OperationInResponse, name=strings.API_CREATE_AIRLOCK_REVIEW_USER_RESOURCE, dependencies=[Depends(get_current_airlock_manager_user), Depends(get_workspace_by_id_from_path)])
async def create_review_user_resource(
response: Response,
airlock_request=Depends(get_airlock_request_by_id_from_path),
user=Depends(get_current_airlock_manager_user),
workspace=Depends(get_deployed_workspace_by_id_from_path),
user_resource_repo=Depends(get_repository(UserResourceRepository)),
workspace_service_repo=Depends(get_repository(WorkspaceServiceRepository)),
operation_repo=Depends(get_repository(OperationRepository)),
airlock_request_repo=Depends(get_repository(AirlockRequestRepository)),
resource_template_repo=Depends(get_repository(ResourceTemplateRepository))) -> OperationInResponse:

if airlock_request.status != AirlockRequestStatus.InReview:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST,
detail="Airlock request must be in 'in_review' status to create a Review User Resource")

try:
# Getting the right configuration
tanya-borisova marked this conversation as resolved.
Show resolved Hide resolved
if airlock_request.requestType == AirlockRequestType.Import:
config = workspace.properties["airlock_review_config"]["import"]
workspace_id = config["workspace_id"]
else:
assert airlock_request.requestType == AirlockRequestType.Export
config = workspace.properties["airlock_review_config"]["export"]
workspace_id = workspace.id
workspace_service_id = config["workspace_service_id"]
user_resource_template_name = config["user_resource_template_name"]

logging.info(f"Going to create a user resource in {workspace_id} {workspace_service_id} {user_resource_template_name}")
except (KeyError, TypeError) as e:
logging.error(f"Failed to parse configuration: {e}")
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Failed to retrive Airlock Review configuration for workspace {workspace.id}.\
tanya-borisova marked this conversation as resolved.
Show resolved Hide resolved
Please ask your TRE administrator to check the configuration. Details: {str(e)}")

# Find workspace service to create user resource in
try:
workspace_service = workspace_service_repo.get_workspace_service_by_id(workspace_id=workspace_id, service_id=workspace_service_id)
except EntityDoesNotExist as e:
logging.error(f"Failed to get workspace serivce {workspace_service_id} for workspace {workspace_id}: {str(e)}")
tanya-borisova marked this conversation as resolved.
Show resolved Hide resolved
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"Failed to retrive Airlock Review configuration for workspace {workspace.id}.\
tanya-borisova marked this conversation as resolved.
Show resolved Hide resolved
Please ask your TRE administrator to check the configuration. Details: {str(e)}")

# Getting the SAS URL
airlock_request_sas_url = get_airlock_container_link(airlock_request, user, workspace)
tanya-borisova marked this conversation as resolved.
Show resolved Hide resolved

# Now have all components for user resource, create an object for it
user_resource_create = UserResourceInCreate(
templateName=user_resource_template_name,
properties={
"display_name": "Review VM",
tanya-borisova marked this conversation as resolved.
Show resolved Hide resolved
"description": f"Review VM for request {airlock_request.requestTitle} (ID {airlock_request.id})",
"airlock_request_sas_url": airlock_request_sas_url
}
)

# Start VM creation
try:
user_resource, resource_template = user_resource_repo.create_user_resource_item(
user_resource_create, workspace_id, workspace_service_id, workspace_service.templateName, user.id, user.roles)
except (ValidationError, ValueError) as e:
logging.error(f"Failed create user resource model instance due to validation error: {e}")
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Invalid configuration for creating user resource. Please contact your TRE administrator. \
Details: {str(e)}")
except UserNotAuthorizedToUseTemplate as e:
logging.error(f"User not authorized to use template: {e}")
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=str(e))

operation = await save_and_deploy_resource(
resource=user_resource,
resource_repo=user_resource_repo,
operations_repo=operation_repo,
resource_template_repo=resource_template_repo,
user=user,
resource_template=resource_template)

# Update the Airlock Request with the information on the VM
updated_resource = await update_and_publish_event_airlock_request(
airlock_request,
airlock_request_repo,
user,
workspace,
review_user_resource=AirlockReviewUserResource(
workspaceId=workspace_id,
workspaceServiceId=workspace_service_id,
userResourceId=user_resource.id
))
logging.info(f"Airlock Request {updated_resource.id} updated to include {updated_resource.reviewUserResources}")

response.headers["Location"] = construct_location_header(operation)
return OperationInResponse(operation=operation)


@airlock_workspace_router.post("/workspaces/{workspace_id}/requests/{airlock_request_id}/review", status_code=status.HTTP_200_OK, response_model=AirlockRequestInResponse, name=strings.API_REVIEW_AIRLOCK_REQUEST, dependencies=[Depends(get_current_airlock_manager_user), Depends(get_workspace_by_id_from_path)])
async def create_airlock_review(airlock_review_input: AirlockReviewInCreate, airlock_request=Depends(get_airlock_request_by_id_from_path), user=Depends(get_current_airlock_manager_user), airlock_request_repo=Depends(get_repository(AirlockRequestRepository)), workspace=Depends(get_deployed_workspace_by_id_from_path)) -> AirlockRequestInResponse:
async def create_airlock_review(
airlock_review_input: AirlockReviewInCreate,
airlock_request=Depends(get_airlock_request_by_id_from_path),
user=Depends(get_current_airlock_manager_user),
workspace=Depends(get_deployed_workspace_by_id_from_path),
airlock_request_repo=Depends(get_repository(AirlockRequestRepository)),
user_resource_repo=Depends(get_repository(UserResourceRepository)),
workspace_service_repo=Depends(get_repository(WorkspaceServiceRepository)),
operation_repo=Depends(get_repository(OperationRepository)),
resource_template_repo=Depends(get_repository(ResourceTemplateRepository))) -> AirlockRequestInResponse:

try:
airlock_review = airlock_request_repo.create_airlock_review_item(airlock_review_input, user)
except (ValidationError, ValueError) as e:
Expand All @@ -90,19 +204,33 @@ async def create_airlock_review(airlock_review_input: AirlockReviewInCreate, air
elif airlock_review.reviewDecision.value == AirlockReviewDecision.Rejected:
review_status = AirlockRequestStatus.RejectionInProgress

updated_airlock_request = await update_and_publish_event_airlock_request(airlock_request=airlock_request, airlock_request_repo=airlock_request_repo, user=user, new_status=review_status, workspace=workspace, airlock_review=airlock_review)
# If there was a VM created for the request, clean it up as it will no longer be needed
_ = await delete_review_user_resources(
tanya-borisova marked this conversation as resolved.
Show resolved Hide resolved
airlock_request=airlock_request,
user_resource_repo=user_resource_repo,
workspace_service_repo=workspace_service_repo,
resource_template_repo=resource_template_repo,
operations_repo=operation_repo,
user=user
)

updated_airlock_request = await update_and_publish_event_airlock_request(airlock_request=airlock_request, airlock_request_repo=airlock_request_repo, user=user, workspace=workspace, new_status=review_status, airlock_review=airlock_review)
return AirlockRequestInResponse(airlockRequest=updated_airlock_request)


def get_airlock_container_link(airlock_request: AirlockRequest, user, workspace):
validate_user_allowed_to_access_storage_account(user, airlock_request)
validate_request_status(airlock_request)
account_name: str = get_account_by_request(airlock_request, workspace)
return get_airlock_request_container_sas_token(account_name, airlock_request)


@airlock_workspace_router.get("/workspaces/{workspace_id}/requests/{airlock_request_id}/link",
status_code=status.HTTP_200_OK, response_model=AirlockRequestTokenInResponse,
name=strings.API_AIRLOCK_REQUEST_LINK,
dependencies=[Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager)])
async def get_airlock_container_link(workspace=Depends(get_deployed_workspace_by_id_from_path),
airlock_request=Depends(get_airlock_request_by_id_from_path),
user=Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager)) -> AirlockRequestTokenInResponse:
validate_user_allowed_to_access_storage_account(user, airlock_request)
validate_request_status(airlock_request)
account_name: str = get_account_by_request(airlock_request, workspace)
container_url = get_airlock_request_container_sas_token(account_name, airlock_request)
async def get_airlock_container_link_method(workspace=Depends(get_deployed_workspace_by_id_from_path),
airlock_request=Depends(get_airlock_request_by_id_from_path),
user=Depends(get_current_workspace_owner_or_researcher_user_or_airlock_manager)) -> AirlockRequestTokenInResponse:
container_url = get_airlock_container_link(airlock_request, user, workspace)
return AirlockRequestTokenInResponse(containerUrl=container_url)
70 changes: 66 additions & 4 deletions api_app/api/routes/airlock_resource_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,20 @@

from fastapi import HTTPException
from starlette import status

from api.routes.resource_helpers import send_uninstall_message
from db.repositories.airlock_requests import AirlockRequestRepository
from models.domain.airlock_request import AirlockActions, AirlockFile, AirlockRequest, AirlockRequestStatus, AirlockRequestType, AirlockReview
from db.repositories.resource_templates import ResourceTemplateRepository
from db.repositories.user_resources import UserResourceRepository
from db.repositories.workspace_services import WorkspaceServiceRepository
from db.repositories.operations import OperationRepository
from event_grid.event_sender import send_status_changed_event, send_airlock_notification_event
from models.domain.authentication import User
from models.domain.workspace import Workspace
from models.schemas.airlock_request import AirlockRequestWithAllowedUserActions
from models.domain.resource import ResourceType
from models.domain.airlock_request import AirlockActions, AirlockFile, AirlockRequest, AirlockRequestStatus, AirlockRequestType, AirlockReview, AirlockReviewUserResource
from models.domain.operation import Operation

from resources import strings
from services.authentication import get_access_service
Expand Down Expand Up @@ -42,12 +50,28 @@ async def save_and_publish_event_airlock_request(airlock_request: AirlockRequest
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=strings.EVENT_GRID_GENERAL_ERROR_MESSAGE)


async def update_and_publish_event_airlock_request(airlock_request: AirlockRequest, airlock_request_repo: AirlockRequestRepository, user: User, new_status: AirlockRequestStatus, workspace: Workspace, request_files: List[AirlockFile] = None, status_message: str = None, airlock_review: AirlockReview = None):
async def update_and_publish_event_airlock_request(
airlock_request: AirlockRequest,
airlock_request_repo: AirlockRequestRepository,
user: User,
workspace: Workspace,
new_status: AirlockRequestStatus = None,
tanya-borisova marked this conversation as resolved.
Show resolved Hide resolved
request_files: List[AirlockFile] = None,
status_message: str = None,
airlock_review: AirlockReview = None,
review_user_resource: AirlockReviewUserResource = None) -> AirlockRequest:
try:
logging.debug(f"Updating airlock request item: {airlock_request.id}")
updated_airlock_request = airlock_request_repo.update_airlock_request(original_request=airlock_request, user=user, new_status=new_status, request_files=request_files, status_message=status_message, airlock_review=airlock_review)
updated_airlock_request = airlock_request_repo.update_airlock_request(
original_request=airlock_request,
user=user,
new_status=new_status,
request_files=request_files,
status_message=status_message,
airlock_review=airlock_review,
review_user_resource=review_user_resource)
except Exception as e:
logging.error(f'Failed updating airlock_request item {airlock_request}: {e}')
logging.error(f'Failed updating airlock_request item : {e}')
# If the validation failed, the error was not related to the saving itself
if e.status_code == 400:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=strings.AIRLOCK_REQUEST_ILLEGAL_STATUS_CHANGE)
Expand Down Expand Up @@ -114,3 +138,41 @@ def enrich_requests_with_allowed_actions(requests: List[AirlockRequest], user: U
allowed_actions = get_allowed_actions(request, user, airlock_request_repo)
enriched_requests.append(AirlockRequestWithAllowedUserActions(airlockRequest=request, allowed_user_actions=allowed_actions))
return enriched_requests


async def delete_review_user_resources(
airlock_request: AirlockRequest,
user_resource_repo: UserResourceRepository,
workspace_service_repo: WorkspaceServiceRepository,
resource_template_repo: ResourceTemplateRepository,
operations_repo: OperationRepository,
user: User) -> List[Operation]:
operations: List[Operation] = []
for review_ur in airlock_request.reviewUserResources:
user_resource = user_resource_repo.get_user_resource_by_id(
workspace_id=review_ur.workspaceId,
service_id=review_ur.workspaceServiceId,
resource_id=review_ur.userResourceId
)

workspace_service = workspace_service_repo.get_workspace_service_by_id(workspace_id=user_resource.workspaceId, service_id=user_resource.parentWorkspaceServiceId)

resource_template = resource_template_repo.get_template_by_name_and_version(
user_resource.templateName,
user_resource.templateVersion,
ResourceType.UserResource,
workspace_service.templateName)

logging.info(f"Deleting user resource {user_resource.id} in workspace service {workspace_service.id}")
operations.append(await send_uninstall_message(
resource=user_resource,
resource_repo=user_resource_repo,
operations_repo=operations_repo,
resource_type=ResourceType.UserResource,
resource_template_repo=resource_template_repo,
user=user,
resource_template=resource_template))
logging.info(f"Started operation {operations[-1]}")

logging.info(f"Started {len(operations)} operations on deleting user resources")
return operations
Loading