Add basic get_execution_history implementation for Step Functions (#3507)

* Add format command to makefile

* Refactor executions to be a attribute of StateMachine

* Begin to add tests for execution history

* Add tests for failed and successful event histories, with implementations

* Add failure case to environment var check

* Skip test if in server mode and update implementation coverage

* Add conditional import for mock to cover python 2

* Refactor stop execution logic into StateMachine

* Refactor event history environment variable into settings.py

* Remove typing and os import
This commit is contained in:
Ciaran Evans 2020-12-03 18:32:06 +00:00 committed by GitHub
parent ffa7f2e41a
commit 48df5bd5af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 384 additions and 47 deletions

View File

@ -8115,7 +8115,7 @@
- [X] describe_state_machine - [X] describe_state_machine
- [ ] describe_state_machine_for_execution - [ ] describe_state_machine_for_execution
- [ ] get_activity_task - [ ] get_activity_task
- [ ] get_execution_history - [X] get_execution_history
- [ ] list_activities - [ ] list_activities
- [X] list_executions - [X] list_executions
- [X] list_state_machines - [X] list_state_machines

View File

@ -20,12 +20,14 @@ lint:
flake8 moto flake8 moto
black --check moto/ tests/ black --check moto/ tests/
format:
black moto/ tests/
test-only: test-only:
rm -f .coverage rm -f .coverage
rm -rf cover rm -rf cover
@pytest -sv --cov=moto --cov-report html ./tests/ $(TEST_EXCLUDE) @pytest -sv --cov=moto --cov-report html ./tests/ $(TEST_EXCLUDE)
test: lint test-only test: lint test-only
test_server: test_server:

View File

@ -4,3 +4,12 @@ TEST_SERVER_MODE = os.environ.get("TEST_SERVER_MODE", "0").lower() == "true"
INITIAL_NO_AUTH_ACTION_COUNT = float( INITIAL_NO_AUTH_ACTION_COUNT = float(
os.environ.get("INITIAL_NO_AUTH_ACTION_COUNT", float("inf")) os.environ.get("INITIAL_NO_AUTH_ACTION_COUNT", float("inf"))
) )
def get_sf_execution_history_type():
"""
Determines which execution history events `get_execution_history` returns
:returns: str representing the type of Step Function Execution Type events should be
returned. Default value is SUCCESS, currently supports (SUCCESS || FAILURE)
"""
return os.environ.get("SF_EXECUTION_HISTORY_TYPE", "SUCCESS")

View File

@ -1,6 +1,7 @@
import json import json
import re import re
from datetime import datetime from datetime import datetime
from dateutil.tz import tzlocal
from boto3 import Session from boto3 import Session
@ -17,6 +18,7 @@ from .exceptions import (
StateMachineDoesNotExist, StateMachineDoesNotExist,
) )
from .utils import paginate, api_to_cfn_tags, cfn_to_api_tags from .utils import paginate, api_to_cfn_tags, cfn_to_api_tags
from moto import settings
class StateMachine(CloudFormationModel): class StateMachine(CloudFormationModel):
@ -27,10 +29,51 @@ class StateMachine(CloudFormationModel):
self.name = name self.name = name
self.definition = definition self.definition = definition
self.roleArn = roleArn self.roleArn = roleArn
self.executions = []
self.tags = [] self.tags = []
if tags: if tags:
self.add_tags(tags) self.add_tags(tags)
def start_execution(self, region_name, account_id, execution_name, execution_input):
self._ensure_execution_name_doesnt_exist(execution_name)
self._validate_execution_input(execution_input)
execution = Execution(
region_name=region_name,
account_id=account_id,
state_machine_name=self.name,
execution_name=execution_name,
state_machine_arn=self.arn,
execution_input=execution_input,
)
self.executions.append(execution)
return execution
def stop_execution(self, execution_arn):
execution = next(
(x for x in self.executions if x.execution_arn == execution_arn), None
)
if not execution:
raise ExecutionDoesNotExist(
"Execution Does Not Exist: '" + execution_arn + "'"
)
execution.stop()
return execution
def _ensure_execution_name_doesnt_exist(self, name):
for execution in self.executions:
if execution.name == name:
raise ExecutionAlreadyExists(
"Execution Already Exists: '" + execution.execution_arn + "'"
)
def _validate_execution_input(self, execution_input):
try:
json.loads(execution_input)
except Exception as ex:
raise InvalidExecutionInput(
"Invalid State Machine Execution Input: '" + str(ex) + "'"
)
def update(self, **kwargs): def update(self, **kwargs):
for key, value in kwargs.items(): for key, value in kwargs.items():
if value is not None: if value is not None:
@ -176,6 +219,104 @@ class Execution:
self.status = "RUNNING" self.status = "RUNNING"
self.stop_date = None self.stop_date = None
def get_execution_history(self, roleArn):
sf_execution_history_type = settings.get_sf_execution_history_type()
if sf_execution_history_type == "SUCCESS":
return [
{
"timestamp": iso_8601_datetime_with_milliseconds(
datetime(2020, 1, 1, 0, 0, 0, tzinfo=tzlocal())
),
"type": "ExecutionStarted",
"id": 1,
"previousEventId": 0,
"executionStartedEventDetails": {
"input": "{}",
"inputDetails": {"truncated": False},
"roleArn": roleArn,
},
},
{
"timestamp": iso_8601_datetime_with_milliseconds(
datetime(2020, 1, 1, 0, 0, 10, tzinfo=tzlocal())
),
"type": "PassStateEntered",
"id": 2,
"previousEventId": 0,
"stateEnteredEventDetails": {
"name": "A State",
"input": "{}",
"inputDetails": {"truncated": False},
},
},
{
"timestamp": iso_8601_datetime_with_milliseconds(
datetime(2020, 1, 1, 0, 0, 10, tzinfo=tzlocal())
),
"type": "PassStateExited",
"id": 3,
"previousEventId": 2,
"stateExitedEventDetails": {
"name": "A State",
"output": "An output",
"outputDetails": {"truncated": False},
},
},
{
"timestamp": iso_8601_datetime_with_milliseconds(
datetime(2020, 1, 1, 0, 0, 20, tzinfo=tzlocal())
),
"type": "ExecutionSucceeded",
"id": 4,
"previousEventId": 3,
"executionSucceededEventDetails": {
"output": "An output",
"outputDetails": {"truncated": False},
},
},
]
elif sf_execution_history_type == "FAILURE":
return [
{
"timestamp": iso_8601_datetime_with_milliseconds(
datetime(2020, 1, 1, 0, 0, 0, tzinfo=tzlocal())
),
"type": "ExecutionStarted",
"id": 1,
"previousEventId": 0,
"executionStartedEventDetails": {
"input": "{}",
"inputDetails": {"truncated": False},
"roleArn": roleArn,
},
},
{
"timestamp": iso_8601_datetime_with_milliseconds(
datetime(2020, 1, 1, 0, 0, 10, tzinfo=tzlocal())
),
"type": "FailStateEntered",
"id": 2,
"previousEventId": 0,
"stateEnteredEventDetails": {
"name": "A State",
"input": "{}",
"inputDetails": {"truncated": False},
},
},
{
"timestamp": iso_8601_datetime_with_milliseconds(
datetime(2020, 1, 1, 0, 0, 10, tzinfo=tzlocal())
),
"type": "ExecutionFailed",
"id": 3,
"previousEventId": 2,
"executionFailedEventDetails": {
"error": "AnError",
"cause": "An error occurred!",
},
},
]
def stop(self): def stop(self):
self.status = "ABORTED" self.status = "ABORTED"
self.stop_date = iso_8601_datetime_with_milliseconds(datetime.now()) self.stop_date = iso_8601_datetime_with_milliseconds(datetime.now())
@ -346,38 +487,23 @@ class StepFunctionBackend(BaseBackend):
return sm return sm
def start_execution(self, state_machine_arn, name=None, execution_input=None): def start_execution(self, state_machine_arn, name=None, execution_input=None):
state_machine_name = self.describe_state_machine(state_machine_arn).name state_machine = self.describe_state_machine(state_machine_arn)
self._ensure_execution_name_doesnt_exist(name) execution = state_machine.start_execution(
self._validate_execution_input(execution_input)
execution = Execution(
region_name=self.region_name, region_name=self.region_name,
account_id=self._get_account_id(), account_id=self._get_account_id(),
state_machine_name=state_machine_name,
execution_name=name or str(uuid4()), execution_name=name or str(uuid4()),
state_machine_arn=state_machine_arn,
execution_input=execution_input, execution_input=execution_input,
) )
self.executions.append(execution)
return execution return execution
def stop_execution(self, execution_arn): def stop_execution(self, execution_arn):
execution = next( self._validate_execution_arn(execution_arn)
(x for x in self.executions if x.execution_arn == execution_arn), None state_machine = self._get_state_machine_for_execution(execution_arn)
) return state_machine.stop_execution(execution_arn)
if not execution:
raise ExecutionDoesNotExist(
"Execution Does Not Exist: '" + execution_arn + "'"
)
execution.stop()
return execution
@paginate @paginate
def list_executions(self, state_machine_arn, status_filter=None): def list_executions(self, state_machine_arn, status_filter=None):
executions = [ executions = self.describe_state_machine(state_machine_arn).executions
execution
for execution in self.executions
if execution.state_machine_arn == state_machine_arn
]
if status_filter: if status_filter:
executions = list(filter(lambda e: e.status == status_filter, executions)) executions = list(filter(lambda e: e.status == status_filter, executions))
@ -385,13 +511,32 @@ class StepFunctionBackend(BaseBackend):
executions = sorted(executions, key=lambda x: x.start_date, reverse=True) executions = sorted(executions, key=lambda x: x.start_date, reverse=True)
return executions return executions
def describe_execution(self, arn): def describe_execution(self, execution_arn):
self._validate_execution_arn(arn) self._validate_execution_arn(execution_arn)
exctn = next((x for x in self.executions if x.execution_arn == arn), None) state_machine = self._get_state_machine_for_execution(execution_arn)
exctn = next(
(x for x in state_machine.executions if x.execution_arn == execution_arn),
None,
)
if not exctn: if not exctn:
raise ExecutionDoesNotExist("Execution Does Not Exist: '" + arn + "'") raise ExecutionDoesNotExist(
"Execution Does Not Exist: '" + execution_arn + "'"
)
return exctn return exctn
def get_execution_history(self, execution_arn):
self._validate_execution_arn(execution_arn)
state_machine = self._get_state_machine_for_execution(execution_arn)
execution = next(
(x for x in state_machine.executions if x.execution_arn == execution_arn),
None,
)
if not execution:
raise ExecutionDoesNotExist(
"Execution Does Not Exist: '" + execution_arn + "'"
)
return execution.get_execution_history(state_machine.roleArn)
def tag_resource(self, resource_arn, tags): def tag_resource(self, resource_arn, tags):
try: try:
state_machine = self.describe_state_machine(resource_arn) state_machine = self.describe_state_machine(resource_arn)
@ -444,20 +589,18 @@ class StepFunctionBackend(BaseBackend):
if not arn or not match: if not arn or not match:
raise InvalidArn(invalid_msg) raise InvalidArn(invalid_msg)
def _ensure_execution_name_doesnt_exist(self, name): def _get_state_machine_for_execution(self, execution_arn):
for execution in self.executions: state_machine_name = execution_arn.split(":")[6]
if execution.name == name: state_machine_arn = next(
raise ExecutionAlreadyExists( (x.arn for x in self.state_machines if x.name == state_machine_name), None
"Execution Already Exists: '" + execution.execution_arn + "'"
) )
if not state_machine_arn:
def _validate_execution_input(self, execution_input): # Assume that if the state machine arn is not present, then neither will the
try: # execution
json.loads(execution_input) raise ExecutionDoesNotExist(
except Exception as ex: "Execution Does Not Exist: '" + execution_arn + "'"
raise InvalidExecutionInput(
"Invalid State Machine Execution Input: '" + str(ex) + "'"
) )
return self.describe_state_machine(state_machine_arn)
def _get_account_id(self): def _get_account_id(self):
return ACCOUNT_ID return ACCOUNT_ID

View File

@ -208,6 +208,21 @@ class StepFunctionResponse(BaseResponse):
@amzn_request_id @amzn_request_id
def stop_execution(self): def stop_execution(self):
arn = self._get_param("executionArn") arn = self._get_param("executionArn")
try:
execution = self.stepfunction_backend.stop_execution(arn) execution = self.stepfunction_backend.stop_execution(arn)
response = {"stopDate": execution.stop_date} response = {"stopDate": execution.stop_date}
return 200, {}, json.dumps(response) return 200, {}, json.dumps(response)
except AWSError as err:
return err.response()
@amzn_request_id
def get_execution_history(self):
execution_arn = self._get_param("executionArn")
try:
execution_history = self.stepfunction_backend.get_execution_history(
execution_arn
)
response = {"events": execution_history}
return 200, {}, json.dumps(response)
except AWSError as err:
return err.response()

View File

@ -2,15 +2,23 @@ from __future__ import unicode_literals
import boto3 import boto3
import json import json
import os
import sure # noqa import sure # noqa
import sys
from datetime import datetime from datetime import datetime
from dateutil.tz import tzlocal
from botocore.exceptions import ClientError from botocore.exceptions import ClientError
import pytest import pytest
from moto import mock_cloudformation, mock_sts, mock_stepfunctions from moto import mock_cloudformation, mock_sts, mock_stepfunctions
from moto.core import ACCOUNT_ID from moto.core import ACCOUNT_ID
if sys.version_info[0] < 3:
import mock
from unittest import SkipTest
else:
from unittest import SkipTest, mock
region = "us-east-1" region = "us-east-1"
simple_definition = ( simple_definition = (
'{"Comment": "An example of the Amazon States Language using a choice state.",' '{"Comment": "An example of the Amazon States Language using a choice state.",'
@ -799,10 +807,30 @@ def test_state_machine_stop_execution():
@mock_stepfunctions @mock_stepfunctions
@mock_sts @mock_sts
def test_state_machine_describe_execution_after_stoppage(): def test_state_machine_stop_raises_error_when_unknown_execution():
account_id client = boto3.client("stepfunctions", region_name=region)
client.create_state_machine(
name="test-state-machine",
definition=str(simple_definition),
roleArn=_get_default_role(),
)
with pytest.raises(ClientError) as ex:
unknown_execution = (
"arn:aws:states:"
+ region
+ ":"
+ _get_account_id()
+ ":execution:test-state-machine:unknown"
)
client.stop_execution(executionArn=unknown_execution)
ex.value.response["Error"]["Code"].should.equal("ExecutionDoesNotExist")
ex.value.response["Error"]["Message"].should.contain("Execution Does Not Exist:")
@mock_stepfunctions
@mock_sts
def test_state_machine_describe_execution_after_stoppage():
client = boto3.client("stepfunctions", region_name=region) client = boto3.client("stepfunctions", region_name=region)
#
sm = client.create_state_machine( sm = client.create_state_machine(
name="name", definition=str(simple_definition), roleArn=_get_default_role() name="name", definition=str(simple_definition), roleArn=_get_default_role()
) )
@ -815,6 +843,146 @@ def test_state_machine_describe_execution_after_stoppage():
description["stopDate"].should.be.a(datetime) description["stopDate"].should.be.a(datetime)
@mock_stepfunctions
@mock_sts
def test_state_machine_get_execution_history_throws_error_with_unknown_execution():
client = boto3.client("stepfunctions", region_name=region)
client.create_state_machine(
name="test-state-machine",
definition=str(simple_definition),
roleArn=_get_default_role(),
)
with pytest.raises(ClientError) as ex:
unknown_execution = (
"arn:aws:states:"
+ region
+ ":"
+ _get_account_id()
+ ":execution:test-state-machine:unknown"
)
client.get_execution_history(executionArn=unknown_execution)
ex.value.response["Error"]["Code"].should.equal("ExecutionDoesNotExist")
ex.value.response["Error"]["Message"].should.contain("Execution Does Not Exist:")
@mock_stepfunctions
@mock_sts
def test_state_machine_get_execution_history_contains_expected_success_events_when_started():
expected_events = [
{
"timestamp": datetime(2020, 1, 1, 0, 0, 0, tzinfo=tzlocal()),
"type": "ExecutionStarted",
"id": 1,
"previousEventId": 0,
"executionStartedEventDetails": {
"input": "{}",
"inputDetails": {"truncated": False},
"roleArn": _get_default_role(),
},
},
{
"timestamp": datetime(2020, 1, 1, 0, 0, 10, tzinfo=tzlocal()),
"type": "PassStateEntered",
"id": 2,
"previousEventId": 0,
"stateEnteredEventDetails": {
"name": "A State",
"input": "{}",
"inputDetails": {"truncated": False},
},
},
{
"timestamp": datetime(2020, 1, 1, 0, 0, 10, tzinfo=tzlocal()),
"type": "PassStateExited",
"id": 3,
"previousEventId": 2,
"stateExitedEventDetails": {
"name": "A State",
"output": "An output",
"outputDetails": {"truncated": False},
},
},
{
"timestamp": datetime(2020, 1, 1, 0, 0, 20, tzinfo=tzlocal()),
"type": "ExecutionSucceeded",
"id": 4,
"previousEventId": 3,
"executionSucceededEventDetails": {
"output": "An output",
"outputDetails": {"truncated": False},
},
},
]
client = boto3.client("stepfunctions", region_name=region)
sm = client.create_state_machine(
name="test-state-machine",
definition=simple_definition,
roleArn=_get_default_role(),
)
execution = client.start_execution(stateMachineArn=sm["stateMachineArn"])
execution_history = client.get_execution_history(
executionArn=execution["executionArn"]
)
execution_history["events"].should.have.length_of(4)
execution_history["events"].should.equal(expected_events)
@mock_stepfunctions
@mock_sts
@mock.patch.dict(os.environ, {"SF_EXECUTION_HISTORY_TYPE": "FAILURE"})
def test_state_machine_get_execution_history_contains_expected_failure_events_when_started():
if os.environ.get("TEST_SERVER_MODE", "false").lower() == "true":
raise SkipTest("Cant pass environment variable in server mode")
expected_events = [
{
"timestamp": datetime(2020, 1, 1, 0, 0, 0, tzinfo=tzlocal()),
"type": "ExecutionStarted",
"id": 1,
"previousEventId": 0,
"executionStartedEventDetails": {
"input": "{}",
"inputDetails": {"truncated": False},
"roleArn": _get_default_role(),
},
},
{
"timestamp": datetime(2020, 1, 1, 0, 0, 10, tzinfo=tzlocal()),
"type": "FailStateEntered",
"id": 2,
"previousEventId": 0,
"stateEnteredEventDetails": {
"name": "A State",
"input": "{}",
"inputDetails": {"truncated": False},
},
},
{
"timestamp": datetime(2020, 1, 1, 0, 0, 10, tzinfo=tzlocal()),
"type": "ExecutionFailed",
"id": 3,
"previousEventId": 2,
"executionFailedEventDetails": {
"error": "AnError",
"cause": "An error occurred!",
},
},
]
client = boto3.client("stepfunctions", region_name=region)
sm = client.create_state_machine(
name="test-state-machine",
definition=simple_definition,
roleArn=_get_default_role(),
)
execution = client.start_execution(stateMachineArn=sm["stateMachineArn"])
execution_history = client.get_execution_history(
executionArn=execution["executionArn"]
)
execution_history["events"].should.have.length_of(3)
execution_history["events"].should.equal(expected_events)
@mock_stepfunctions @mock_stepfunctions
@mock_cloudformation @mock_cloudformation
def test_state_machine_cloudformation(): def test_state_machine_cloudformation():