Skip to content

Commit

Permalink
LITE-28075 Add abort action for deployment requests
Browse files Browse the repository at this point in the history
  • Loading branch information
jonatrios committed Aug 1, 2023
1 parent 2aae069 commit aa1b98c
Show file tree
Hide file tree
Showing 9 changed files with 341 additions and 3 deletions.
2 changes: 2 additions & 0 deletions connect_ext_ppr/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,6 @@ class ExtensionValidationError(ExtensionErrorBase):
2: "{field}: This values {values} are invalid.",
3: "At least one choice needs to be specified.",
4: "Cannot applied PPR to {entity} {values}.",
5: "Transition not allowed: can not set {field_name} from `{source}` to"
" '{target}', allowed {field_name} sources for '{target}' are '{allowed}'.",
}
18 changes: 18 additions & 0 deletions connect_ext_ppr/models/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from connect_ext_ppr.models.enums import DeploymentRequestStatusChoices, DeploymentStatusChoices
from connect_ext_ppr.models.ppr import PPRVersion
from connect_ext_ppr.models.replicas import Product
from connect_ext_ppr.models.models_utils import transition


class Deployment(Model):
Expand Down Expand Up @@ -62,6 +63,8 @@ class DeploymentRequest(Model):
finished_at = db.Column(db.DateTime(), nullable=True)
aborted_at = db.Column(db.DateTime(), nullable=True)
aborted_by = db.Column(db.String(20), nullable=True)
aborting_at = db.Column(db.DateTime(), nullable=True)
aborting_by = db.Column(db.String(20), nullable=True)

ppr = relationship('PPRVersion', foreign_keys="DeploymentRequest.ppr_id")
deployment = relationship(
Expand All @@ -70,6 +73,21 @@ class DeploymentRequest(Model):
innerjoin=True,
)

@transition('status', target=STATUSES.aborting, sources=[STATUSES.pending, STATUSES.processing])
def aborting(self, by):
self.aborting_at = datetime.utcnow()
self.aborting_by = by

@transition('status', target=STATUSES.aborted, sources=[STATUSES.aborting])
def abort(self):
self.aborted_at = datetime.utcnow()
self.aborted_by = self.aborting_by

@transition('status', target=STATUSES.aborted, sources=[STATUSES.aborting])
def abort_by_api(self, by):
self.aborted_at = datetime.utcnow()
self.aborted_by = by


class MarketplaceConfiguration(Model):
__tablename__ = 'marketplace_configuration'
Expand Down
34 changes: 34 additions & 0 deletions connect_ext_ppr/models/models_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import types

from connect_ext_ppr.errors import ExtensionValidationError


class transition:

def __init__(self, track_field, target, sources) -> None:
self.track_field = track_field
self.target = target
self.sources = sources if isinstance(sources, list) else [sources]

def __call__(self, fn):
def inner(*args, **kwargs):
self._validate_transition(args[0])
setattr(self.instance, self.track_field, self.target)
return fn(*args, **kwargs)
return inner

def __get__(self, instance, owner=None):
return types.MethodType(self, instance) if instance is not None else self

def _validate_transition(self, instance):
self.instance = instance
current_state = getattr(self.instance, self.track_field)
if current_state not in self.sources:
raise ExtensionValidationError.VAL_005(
format_kwargs={
'source': current_state,
'field_name': self.track_field,
'target': self.target,
'allowed': ', '.join(self.sources),
},
)
6 changes: 6 additions & 0 deletions connect_ext_ppr/models/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from connect_ext_ppr.db import Model
from connect_ext_ppr.models.enums import TasksStatusChoices, TaskTypesChoices
from connect_ext_ppr.models.deployment import DeploymentRequest
from connect_ext_ppr.models.models_utils import transition


class Task(Model):
Expand All @@ -31,3 +32,8 @@ class Task(Model):
finished_at = db.Column(db.DateTime(), nullable=True)
aborted_at = db.Column(db.DateTime(), nullable=True)
aborted_by = db.Column(db.String(20), nullable=True)

@transition('status', target=STATUSES.aborted, sources=[STATUSES.pending])
def abort(self, by):
self.aborted_at = datetime.utcnow()
self.aborted_by = by
2 changes: 1 addition & 1 deletion connect_ext_ppr/tasks_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ def main_process(deployment_request_id, config):
db.refresh(deployment_request, with_for_update=True)

if deployment_request.status == DeploymentRequestStatusChoices.aborting:
deployment_request.status = DeploymentRequestStatusChoices.aborted
deployment_request.abort()
elif was_succesfull:
deployment_request.status = DeploymentRequestStatusChoices.done
else:
Expand Down
4 changes: 4 additions & 0 deletions connect_ext_ppr/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,10 @@ def get_deployment_request_schema(deployment_request, hub):
'at': deployment_request.aborted_at,
'by': deployment_request.aborted_by,
},
'aborting': {
'at': deployment_request.aborting_at,
'by': deployment_request.aborting_by,
},
}

return DeploymentRequestSchema(
Expand Down
47 changes: 46 additions & 1 deletion connect_ext_ppr/webapp.py
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,6 @@ def list_deployment_request_tasks(
self,
depl_req_id: str,
db: VerboseBaseSession = Depends(get_db),
client: ConnectClient = Depends(get_installation_client),
installation: dict = Depends(get_installation),
):
dr = (
Expand All @@ -264,6 +263,52 @@ def list_deployment_request_tasks(
status_code=status.HTTP_404_NOT_FOUND,
)

@router.post(
'/deployments/requests/{depl_req_id}/abort',
summary='Abort a deployment request',
response_model=DeploymentRequestSchema,
)
def abort(
self,
depl_req_id: str,
db: VerboseBaseSession = Depends(get_db),
client: ConnectClient = Depends(get_installation_client),
installation: dict = Depends(get_installation),
request: Request = None,
):
dr = (
db.query(DeploymentRequest)
.filter(
DeploymentRequest.deployment.has(account_id=installation['owner']['id']),
DeploymentRequest.id == depl_req_id,
)
.one_or_none()
)
if dr:
origin_state = dr.status
tasks = (
db
.query(Task)
.filter_by(deployment_request=dr.id, status=Task.STATUSES.pending)
.with_for_update()
)
user_data = get_user_data_from_auth_token(request.headers['connect-auth'])
by = user_data['id']
dr.aborting(by)
db.flush()
for task in tasks:
task.abort(by)
db.flush()
if origin_state == DeploymentRequest.STATUSES.pending:
dr.abort_by_api(by)
db.commit()
hub = get_hub(client, dr.deployment.hub_id)
return get_deployment_request_schema(dr, hub)
raise ExtensionHttpError.EXT_001(
format_kwargs={'obj_id': depl_req_id},
status_code=status.HTTP_404_NOT_FOUND,
)

@router.get(
'/deployments/{deployment_id}',
summary='Deployment details',
Expand Down
199 changes: 198 additions & 1 deletion tests/api/test_deployment_requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ def test_list_deployment_request_tasks_not_found(
dep1 = deployment_factory(account_id=installation['owner']['id'])
dep2 = deployment_factory(account_id='PA-123-456')

dr1 = deployment_request_factory(deployment=dep1)
dr1 = deployment_request_factory(deployment=dep1, status='done')
bad_dr = deployment_request_factory(deployment=dep2)

task_factory(deployment_request=dr1)
Expand Down Expand Up @@ -703,6 +703,7 @@ def test_create_deployment_request_w_open_request(
'id': 'HB-0000-0001',
'name': 'Another Hub for the best',
}

mocker.patch('connect_ext_ppr.webapp.get_client_object', side_effect=[hub_data])

dep = deployment_factory(account_id=installation['owner']['id'], hub_id=hub_data['id'])
Expand Down Expand Up @@ -734,3 +735,199 @@ def test_create_deployment_request_w_open_request(
assert response.json()['errors'] == [
'Cannot create a new request, an open one already exists.',
]


def test_abort_deployment_request_aborted(
dbsession,
mocker,
deployment_factory,
deployment_request_factory,
installation,
api_client,
task_factory,
):
hub_data = {
'id': 'HB-0000-0001',
'name': 'Another Hub for the best',
}

mocker.patch(
'connect_ext_ppr.webapp.get_hub',
return_value=hub_data,
)

dep1 = deployment_factory(account_id=installation['owner']['id'], hub_id=hub_data['id'])
dep2 = deployment_factory(account_id='PA-123-456')

dr1 = deployment_request_factory(deployment=dep1, status='pending')
deployment_request_factory(deployment=dep1)
deployment_request_factory(deployment=dep2)

t1 = task_factory(deployment_request=dr1, status='pending')
t2 = task_factory(deployment_request=dr1, task_index='002', status='pending')

response = api_client.post(
f'/api/deployments/requests/{dr1.id}/abort',
installation=installation,
headers={
"connect-auth": (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1Ijp7Im9pZCI6IlNVLTI5NS02ODktN"
"jI4IiwibmFtZSI6Ik5lcmkifX0.U_T6vuXnD293hcWNTJZ9QBViteNv8JXUL2gM0BezQ-k"
),
},
)
response_item = response.json()
events = response_item.pop('events')
assert response.status_code == 200
assert dr1.status == 'aborted'
assert response_item == {
'id': dr1.id,
'deployment': {
'id': dep1.id,
'product': {
'id': dep1.product.id,
'name': dep1.product.name,
'icon': dep1.product.logo,
},
'hub': hub_data,
},
'ppr': {
'id': dr1.ppr_id,
'version': dr1.ppr.version,
},
'status': dr1.status.value,
'manually': dr1.manually,
'delegate_l2': dr1.delegate_l2,

}
assert list(events.keys()) == ['created', 'aborted', 'aborting']
assert list(events['created'].keys()) == ['at', 'by']
assert list(events['aborted'].keys()) == ['at', 'by']
assert list(events['aborting'].keys()) == ['at', 'by']
for task in (t1, t2):
assert task.status == 'aborted'
assert task.aborted_at > dr1.aborting_at
assert task.aborted_by == dr1.aborted_by


def test_abort_deployment_request_aborting(
dbsession,
mocker,
deployment_factory,
deployment_request_factory,
installation,
api_client,
task_factory,
):
hub_data = {
'id': 'HB-0000-0001',
'name': 'Another Hub for the best',
}

mocker.patch(
'connect_ext_ppr.webapp.get_hub',
return_value=hub_data,
)

dep1 = deployment_factory(account_id=installation['owner']['id'], hub_id=hub_data['id'])
dep2 = deployment_factory(account_id='PA-123-456')

dr1 = deployment_request_factory(deployment=dep1, status='processing')
deployment_request_factory(deployment=dep1)
deployment_request_factory(deployment=dep2)

t1 = task_factory(deployment_request=dr1, status='done')
t2 = task_factory(deployment_request=dr1, task_index='002', status='pending')

response = api_client.post(
f'/api/deployments/requests/{dr1.id}/abort',
installation=installation,
headers={
"connect-auth": (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1Ijp7Im9pZCI6IlNVLTI5NS02ODktN"
"jI4IiwibmFtZSI6Ik5lcmkifX0.U_T6vuXnD293hcWNTJZ9QBViteNv8JXUL2gM0BezQ-k"
),
},
)
response_item = response.json()
events = response_item.pop('events')
assert response.status_code == 200
assert dr1.status == 'aborting'
assert response_item == {
'id': dr1.id,
'deployment': {
'id': dep1.id,
'product': {
'id': dep1.product.id,
'name': dep1.product.name,
'icon': dep1.product.logo,
},
'hub': hub_data,
},
'ppr': {
'id': dr1.ppr_id,
'version': dr1.ppr.version,
},
'status': dr1.status.value,
'manually': dr1.manually,
'delegate_l2': dr1.delegate_l2,

}
assert not dr1.aborted_at
assert not dr1.aborted_by
assert list(events.keys()) == ['created', 'aborting']
assert list(events['created'].keys()) == ['at', 'by']
assert list(events['aborting'].keys()) == ['at', 'by']
for task, task_status in ((t1, 'done'), (t2, 'aborted')):
assert task.status == task_status
assert not t1.aborted_at
assert not t1.aborted_by
assert t2.aborted_at > dr1.aborting_at
assert t2.aborted_by == dr1.aborting_by


def test_abort_deployment_request_not_allow(
dbsession,
mocker,
deployment_factory,
deployment_request_factory,
installation,
api_client,
task_factory,
):
hub_data = {
'id': 'HB-0000-0001',
'name': 'Another Hub for the best',
}
dep1 = deployment_factory(account_id=installation['owner']['id'], hub_id=hub_data['id'])
dep2 = deployment_factory(account_id='PA-123-456')

origin_status = 'done'
dr1 = deployment_request_factory(deployment=dep1, status=origin_status)
deployment_request_factory(deployment=dep1)
deployment_request_factory(deployment=dep2)

t1 = task_factory(deployment_request=dr1, status='pending')
t2 = task_factory(deployment_request=dr1, task_index='002', status='pending')

response = api_client.post(
f'/api/deployments/requests/{dr1.id}/abort',
installation=installation,
headers={
"connect-auth": (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1Ijp7Im9pZCI6IlNVLTI5NS02ODktN"
"jI4IiwibmFtZSI6Ik5lcmkifX0.U_T6vuXnD293hcWNTJZ9QBViteNv8JXUL2gM0BezQ-k"
),
},
)
error = response.json()

assert response.status_code == 400
assert (t1.status, t2.status) == ('pending', 'pending')
assert dr1.status == origin_status
assert error == {
'error_code': 'VAL_005', 'errors': [
"Transition not allowed: can not set status from `done` to 'aborting'"
", allowed status sources for 'aborting' are 'pending, processing'.",
],
}
Loading

0 comments on commit aa1b98c

Please sign in to comment.