provision portfolio state machine
This commit is contained in:
@@ -7,11 +7,12 @@ from .audit_event import AuditEvent
|
||||
from .clin import CLIN, JEDICLINType
|
||||
from .environment import Environment
|
||||
from .environment_role import EnvironmentRole, CSPRole
|
||||
from .job_failure import EnvironmentJobFailure, EnvironmentRoleJobFailure
|
||||
from .job_failure import EnvironmentJobFailure, EnvironmentRoleJobFailure, PortfolioJobFailure
|
||||
from .notification_recipient import NotificationRecipient
|
||||
from .permissions import Permissions
|
||||
from .permission_set import PermissionSet
|
||||
from .portfolio import Portfolio
|
||||
from .portfolio_state_machine import PortfolioStateMachine, FSMStates
|
||||
from .portfolio_invitation import PortfolioInvitation
|
||||
from .portfolio_role import PortfolioRole, Status as PortfolioRoleStatus
|
||||
from .task_order import TaskOrder
|
||||
|
||||
@@ -14,3 +14,9 @@ class EnvironmentRoleJobFailure(Base, mixins.JobFailureMixin):
|
||||
__tablename__ = "environment_role_job_failures"
|
||||
|
||||
environment_role_id = Column(ForeignKey("environment_roles.id"), nullable=False)
|
||||
|
||||
class PortfolioJobFailure(Base, mixins.JobFailureMixin):
|
||||
__tablename__ = "portfolio_job_failures"
|
||||
|
||||
portfolio_id = Column(ForeignKey("portfolios.id"), nullable=False)
|
||||
|
||||
|
||||
@@ -4,3 +4,4 @@ from .permissions import PermissionsMixin
|
||||
from .deletable import DeletableMixin
|
||||
from .invites import InvitesMixin
|
||||
from .job_failure import JobFailureMixin
|
||||
from .state_machines import FSMMixin
|
||||
|
||||
109
atst/models/mixins/state_machines.py
Normal file
109
atst/models/mixins/state_machines.py
Normal file
@@ -0,0 +1,109 @@
|
||||
from enum import Enum
|
||||
|
||||
from atst.database import db
|
||||
|
||||
class StageStates(Enum):
|
||||
CREATED = "created"
|
||||
IN_PROGRESS = "in progress"
|
||||
FAILED = "failed"
|
||||
|
||||
class AzureStages(Enum):
|
||||
TENANT = "tenant"
|
||||
BILLING_PROFILE = "billing profile"
|
||||
ADMIN_SUBSCRIPTION = "admin subscription"
|
||||
|
||||
def _build_csp_states(csp_stages):
|
||||
states = {
|
||||
'UNSTARTED' : "unstarted",
|
||||
'STARTING' : "starting",
|
||||
'STARTED' : "started",
|
||||
'COMPLETED' : "completed",
|
||||
'FAILED' : "failed",
|
||||
}
|
||||
for csp_stage in csp_stages:
|
||||
for state in StageStates:
|
||||
states[csp_stage.name+"_"+state.name] = csp_stage.value+" "+state.value
|
||||
return states
|
||||
|
||||
FSMStates = Enum('FSMStates', _build_csp_states(AzureStages))
|
||||
|
||||
|
||||
def _build_transitions(csp_stages):
|
||||
transitions = []
|
||||
states = []
|
||||
compose_state = lambda csp_stage, state: getattr(FSMStates, "_".join([csp_stage.name, state.name]))
|
||||
|
||||
for stage_i, csp_stage in enumerate(csp_stages):
|
||||
for state in StageStates:
|
||||
states.append(dict(name=compose_state(csp_stage, state), tags=[csp_stage.name, state.name]))
|
||||
if state == StageStates.CREATED:
|
||||
if stage_i > 0:
|
||||
src = compose_state(list(csp_stages)[stage_i-1] , StageStates.CREATED)
|
||||
else:
|
||||
src = FSMStates.STARTED
|
||||
transitions.append(
|
||||
dict(
|
||||
trigger='create_'+csp_stage.name.lower(),
|
||||
source=src,
|
||||
dest=compose_state(csp_stage, StageStates.IN_PROGRESS),
|
||||
after='after_in_progress_callback',
|
||||
)
|
||||
)
|
||||
if state == StageStates.IN_PROGRESS:
|
||||
transitions.append(
|
||||
dict(
|
||||
trigger='finish_'+csp_stage.name.lower(),
|
||||
source=compose_state(csp_stage, state),
|
||||
dest=compose_state(csp_stage, StageStates.CREATED),
|
||||
conditions=['is_csp_data_valid'],
|
||||
)
|
||||
)
|
||||
if state == StageStates.FAILED:
|
||||
transitions.append(
|
||||
dict(
|
||||
trigger='fail_'+csp_stage.name.lower(),
|
||||
source=compose_state(csp_stage, StageStates.IN_PROGRESS),
|
||||
dest=compose_state(csp_stage, StageStates.FAILED),
|
||||
)
|
||||
)
|
||||
return states, transitions
|
||||
|
||||
class FSMMixin():
|
||||
|
||||
system_states = [
|
||||
{'name': FSMStates.UNSTARTED.name, 'tags': ['system']},
|
||||
{'name': FSMStates.STARTING.name, 'tags': ['system']},
|
||||
{'name': FSMStates.STARTED.name, 'tags': ['system']},
|
||||
{'name': FSMStates.FAILED.name, 'tags': ['system']},
|
||||
{'name': FSMStates.COMPLETED.name, 'tags': ['system']},
|
||||
]
|
||||
|
||||
system_transitions = [
|
||||
{'trigger': 'init', 'source': FSMStates.UNSTARTED, 'dest': FSMStates.STARTING},
|
||||
{'trigger': 'start', 'source': FSMStates.STARTING, 'dest': FSMStates.STARTED},
|
||||
{'trigger': 'reset', 'source': '*', 'dest': FSMStates.UNSTARTED},
|
||||
{'trigger': 'fail', 'source': '*', 'dest': FSMStates.FAILED,}
|
||||
]
|
||||
|
||||
def prepare_init(self, event): pass
|
||||
def before_init(self, event): pass
|
||||
def after_init(self, event): pass
|
||||
|
||||
def prepare_start(self, event): pass
|
||||
def before_start(self, event): pass
|
||||
def after_start(self, event): pass
|
||||
|
||||
def prepare_reset(self, event): pass
|
||||
def before_reset(self, event): pass
|
||||
def after_reset(self, event): pass
|
||||
|
||||
def fail_stage(self, stage):
|
||||
fail_trigger = 'fail'+stage
|
||||
if fail_trigger in self.machine.get_triggers(self.current_state.name):
|
||||
self.trigger(fail_trigger)
|
||||
|
||||
def finish_stage(self, stage):
|
||||
finish_trigger = 'finish_'+stage
|
||||
if finish_trigger in self.machine.get_triggers(self.current_state.name):
|
||||
self.trigger(finish_trigger)
|
||||
|
||||
@@ -11,6 +11,9 @@ from atst.domain.permission_sets import PermissionSets
|
||||
from atst.utils import first_or_none
|
||||
from atst.database import db
|
||||
|
||||
from sqlalchemy_json import NestedMutableJson
|
||||
|
||||
|
||||
|
||||
class Portfolio(
|
||||
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin
|
||||
@@ -19,16 +22,30 @@ class Portfolio(
|
||||
|
||||
id = types.Id()
|
||||
name = Column(String, nullable=False)
|
||||
description = Column(String)
|
||||
defense_component = Column(
|
||||
ARRAY(String), nullable=False
|
||||
String, nullable=False
|
||||
) # Department of Defense Component
|
||||
|
||||
app_migration = Column(String) # App Migration
|
||||
complexity = Column(ARRAY(String)) # Application Complexity
|
||||
complexity_other = Column(String)
|
||||
description = Column(String)
|
||||
dev_team = Column(ARRAY(String)) # Development Team
|
||||
dev_team_other = Column(String)
|
||||
native_apps = Column(String) # Native Apps
|
||||
team_experience = Column(String) # Team Experience
|
||||
|
||||
csp_data = Column(NestedMutableJson, nullable=True)
|
||||
|
||||
applications = relationship(
|
||||
"Application",
|
||||
back_populates="portfolio",
|
||||
primaryjoin="and_(Application.portfolio_id == Portfolio.id, Application.deleted == False)",
|
||||
)
|
||||
|
||||
state_machine = relationship("PortfolioStateMachine",
|
||||
uselist=False, back_populates="portfolio")
|
||||
|
||||
roles = relationship("PortfolioRole")
|
||||
|
||||
task_orders = relationship("TaskOrder")
|
||||
@@ -77,7 +94,7 @@ class Portfolio(
|
||||
"""
|
||||
Return the earliest period of performance start date and latest period
|
||||
of performance end date for all active task orders in a portfolio.
|
||||
@return: (datetime.date or None, datetime.date or None)
|
||||
@return: (datetime.date or None, datetime.date or None)
|
||||
"""
|
||||
start_dates = (
|
||||
task_order.start_date
|
||||
|
||||
182
atst/models/portfolio_state_machine.py
Normal file
182
atst/models/portfolio_state_machine.py
Normal file
@@ -0,0 +1,182 @@
|
||||
import importlib
|
||||
|
||||
from sqlalchemy import Column, ForeignKey, Enum as SQLAEnum
|
||||
from sqlalchemy.orm import relationship, reconstructor
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
from pydantic import ValidationError as PydanticValidationError
|
||||
from transitions import Machine
|
||||
from transitions.extensions.states import add_state_features, Tags
|
||||
|
||||
from flask import current_app as app
|
||||
|
||||
from atst.domain.csp.cloud import ConnectionException, UnknownServerException
|
||||
from atst.domain.csp import MockCSP, AzureCSP, get_stage_csp_class
|
||||
from atst.database import db
|
||||
from atst.queue import celery
|
||||
from atst.models.types import Id
|
||||
from atst.models.base import Base
|
||||
import atst.models.mixins as mixins
|
||||
from atst.models.mixins.state_machines import (
|
||||
FSMStates, AzureStages, _build_transitions
|
||||
)
|
||||
|
||||
|
||||
|
||||
@add_state_features(Tags)
|
||||
class StateMachineWithTags(Machine):
|
||||
pass
|
||||
|
||||
class PortfolioStateMachine(
|
||||
Base, mixins.TimestampsMixin, mixins.AuditableMixin, mixins.DeletableMixin, mixins.FSMMixin,
|
||||
):
|
||||
__tablename__ = "portfolio_state_machines"
|
||||
|
||||
id = Id()
|
||||
|
||||
portfolio_id = Column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("portfolios.id"),
|
||||
)
|
||||
portfolio = relationship("Portfolio", back_populates="state_machine")
|
||||
|
||||
state = Column(
|
||||
SQLAEnum(FSMStates, native_enum=False, create_constraint=False),
|
||||
default=FSMStates.UNSTARTED, nullable=False
|
||||
)
|
||||
|
||||
def __init__(self, portfolio, csp=None, **kwargs):
|
||||
self.portfolio = portfolio
|
||||
self.attach_machine()
|
||||
|
||||
def after_state_change(self, event):
|
||||
db.session.add(self)
|
||||
db.session.commit()
|
||||
|
||||
@reconstructor
|
||||
def attach_machine(self):
|
||||
"""
|
||||
This is called as a result of a sqlalchemy query.
|
||||
Attach a machine depending on the current state.
|
||||
"""
|
||||
self.machine = StateMachineWithTags(
|
||||
model = self,
|
||||
send_event=True,
|
||||
initial=self.current_state if self.state else FSMStates.UNSTARTED,
|
||||
auto_transitions=False,
|
||||
after_state_change='after_state_change',
|
||||
)
|
||||
states, transitions = _build_transitions(AzureStages)
|
||||
self.machine.add_states(self.system_states+states)
|
||||
self.machine.add_transitions(self.system_transitions+transitions)
|
||||
|
||||
@property
|
||||
def current_state(self):
|
||||
if isinstance(self.state, str):
|
||||
return getattr(FSMStates, self.state)
|
||||
return self.state
|
||||
|
||||
def trigger_next_transition(self):
|
||||
state_obj = self.machine.get_state(self.state)
|
||||
|
||||
if state_obj.is_system:
|
||||
if self.current_state in (FSMStates.UNSTARTED, FSMStates.STARTING):
|
||||
# call the first trigger availabe for these two system states
|
||||
trigger_name = self.machine.get_triggers(self.current_state.name)[0]
|
||||
self.trigger(trigger_name)
|
||||
|
||||
elif self.current_state == FSMStates.STARTED:
|
||||
# get the first trigger that starts with 'create_'
|
||||
create_trigger = list(filter(lambda trigger: trigger.startswith('create_'),
|
||||
self.machine.get_triggers(FSMStates.STARTED.name)))[0]
|
||||
self.trigger(create_trigger)
|
||||
|
||||
elif state_obj.is_IN_PROGRESS:
|
||||
pass
|
||||
|
||||
#elif state_obj.is_TENANT:
|
||||
# pass
|
||||
#elif state_obj.is_BILLING_PROFILE:
|
||||
# pass
|
||||
|
||||
|
||||
#@with_payload
|
||||
def after_in_progress_callback(self, event):
|
||||
stage = self.current_state.name.split('_IN_PROGRESS')[0].lower()
|
||||
if stage == 'tenant':
|
||||
payload = dict(
|
||||
creds={"username": "mock-cloud", "pass": "shh"},
|
||||
user_id='123',
|
||||
password='123',
|
||||
domain_name='123',
|
||||
first_name='john',
|
||||
last_name='doe',
|
||||
country_code='US',
|
||||
password_recovery_email_address='password@email.com'
|
||||
)
|
||||
elif stage == 'billing_profile':
|
||||
payload = dict(
|
||||
creds={"username": "mock-cloud", "pass": "shh"},
|
||||
)
|
||||
|
||||
payload_data_cls = get_stage_csp_class(stage, "payload")
|
||||
if not payload_data_cls:
|
||||
self.fail_stage(stage)
|
||||
try:
|
||||
payload_data = payload_data_cls(**payload)
|
||||
except PydanticValidationError as exc:
|
||||
print(exc.json())
|
||||
self.fail_stage(stage)
|
||||
|
||||
csp = event.kwargs.get('csp')
|
||||
if csp is not None:
|
||||
self.csp = AzureCSP(app).cloud
|
||||
else:
|
||||
self.csp = MockCSP(app).cloud
|
||||
|
||||
for attempt in range(5):
|
||||
try:
|
||||
response = getattr(self.csp, 'create_'+stage)(payload_data)
|
||||
except (ConnectionException, UnknownServerException) as exc:
|
||||
print('caught exception. retry', attempt)
|
||||
continue
|
||||
else: break
|
||||
else:
|
||||
# failed all attempts
|
||||
self.fail_stage(stage)
|
||||
|
||||
if self.portfolio.csp_data is None:
|
||||
self.portfolio.csp_data = {}
|
||||
self.portfolio.csp_data[stage+"_data"] = response
|
||||
db.session.add(self.portfolio)
|
||||
db.session.commit()
|
||||
|
||||
self.finish_stage(stage)
|
||||
|
||||
def is_csp_data_valid(self, event):
|
||||
# check portfolio csp details json field for fields
|
||||
|
||||
if self.portfolio.csp_data is None or \
|
||||
not isinstance(self.portfolio.csp_data, dict):
|
||||
return False
|
||||
|
||||
stage = self.current_state.name.split('_IN_PROGRESS')[0].lower()
|
||||
stage_data = self.portfolio.csp_data.get(stage+"_data")
|
||||
cls = get_stage_csp_class(stage, "result")
|
||||
if not cls:
|
||||
return False
|
||||
|
||||
try:
|
||||
cls(**stage_data)
|
||||
except PydanticValidationError as exc:
|
||||
print(exc.json())
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
#print('failed condition', self.portfolio.csp_data)
|
||||
|
||||
|
||||
@property
|
||||
def application_id(self):
|
||||
return None
|
||||
Reference in New Issue
Block a user