Feature: Resource State Transition (#4734)
This commit is contained in:
parent
3a6223083d
commit
12421068bd
@ -30,10 +30,7 @@ import shlex
|
||||
# Add any Sphinx extension module names here, as strings. They can be
|
||||
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
|
||||
# ones.
|
||||
extensions = [
|
||||
'sphinx.ext.autodoc',
|
||||
'sphinx.ext.autosectionlabel'
|
||||
]
|
||||
extensions = ["sphinx.ext.autodoc", "sphinx.ext.autosectionlabel"]
|
||||
|
||||
# Add any paths that contain templates here, relative to this directory.
|
||||
templates_path = ["_templates"]
|
||||
|
31
docs/docs/configuration/environment_variables.rst
Normal file
31
docs/docs/configuration/environment_variables.rst
Normal file
@ -0,0 +1,31 @@
|
||||
.. _environment_variables:
|
||||
|
||||
.. role:: raw-html(raw)
|
||||
:format: html
|
||||
|
||||
=======================
|
||||
Environment Variables
|
||||
=======================
|
||||
|
||||
The following is a non-exhaustive list of the environment variables that can be used to configure Moto.
|
||||
|
||||
|
||||
|
||||
+-------------------------------+----------+-----------+-------------------------------------------------------------------------------------------------+
|
||||
| Key | Value | Default | Explanation |
|
||||
+===============================+==========+===========+=================================================================================================+
|
||||
| TEST_SERVER_MODE | bool | False | Useful when you want to run decorated tests against an existing MotoServer. :raw-html:`<br />` |
|
||||
| | | | All boto3-clients/resources created within the test will point to `http://localhost:5000`. |
|
||||
+-------------------------------+----------+-----------+-------------------------------------------------------------------------------------------------+
|
||||
| INITIAL_NO_AUTH_ACTION_COUNT | int | 0 | See :ref:`iam access control`. |
|
||||
+-------------------------------+----------+-----------+-------------------------------------------------------------------------------------------------+
|
||||
| DEFAULT_CONTAINER_REGISTRY | str | docker.io | Registry that contains the Docker containers. :raw-html:`<br />` |
|
||||
| | | | Used by AWSLambda and Batch. |
|
||||
+-------------------------------+----------+-----------+-------------------------------------------------------------------------------------------------+
|
||||
| MOTO_ALLOW_NONEXISTENT_REGION | bool | False | |
|
||||
+-------------------------------+----------+-----------+-------------------------------------------------------------------------------------------------+
|
||||
| | | | |
|
||||
+-------------------------------+----------+-----------+-------------------------------------------------------------------------------------------------+
|
||||
| MOTO_S3_CUSTOM_ENDPOINTS | str | | See :ref:`s3`. |
|
||||
+-------------------------------+----------+-----------+-------------------------------------------------------------------------------------------------+
|
||||
|
16
docs/docs/configuration/index.rst
Normal file
16
docs/docs/configuration/index.rst
Normal file
@ -0,0 +1,16 @@
|
||||
.. _configuration:
|
||||
|
||||
======================
|
||||
Configuration Options
|
||||
======================
|
||||
|
||||
Moto has a variety of ways to configure the mock behaviour.
|
||||
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
|
||||
environment_variables
|
||||
state_transition/index
|
||||
state_transition/models
|
||||
|
156
docs/docs/configuration/state_transition/index.rst
Normal file
156
docs/docs/configuration/state_transition/index.rst
Normal file
@ -0,0 +1,156 @@
|
||||
.. _state transition:
|
||||
|
||||
.. role:: raw-html(raw)
|
||||
:format: html
|
||||
|
||||
=============================
|
||||
State Transitions
|
||||
=============================
|
||||
|
||||
When developing against AWS, many API calls are asynchronous. Many resources will take some time to complete, and you'll need to write business logic to ensure the application can deal with all possible states. What is the desired behaviour when the status is `initializing`? What should happen when the status is finally `ready`? What should happen when the resource is still not `ready` after an hour?
|
||||
|
||||
Let's look at an example. Say you want to create a DAX cluster, and wait until it's available - or throw an error if this takes too long.
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
def create_and_wait_for_cluster(name):
|
||||
client.create_cluster(ClusterName=name, ...)
|
||||
|
||||
cluster_status = get_cluster_status(name)
|
||||
while cluster_status != "available":
|
||||
sleep()
|
||||
|
||||
if five_minutes_have_passed():
|
||||
error()
|
||||
|
||||
cluster_status = get_cluster_status(name)
|
||||
|
||||
Because Moto handles everything in-memory, and no actual servers are created, there is no need to wait until the cluster is ready - it could be ready immediately. :raw-html:`<br />`
|
||||
Not having to wait for a resource to be ready is of course the major benefit of using Moto, but it also means that the entire example above is impossible to test.
|
||||
|
||||
Moto exposes an API that can artificially delay these state transitions, allowing you to let Moto resemble the asynchronous nature of AWS as closely as you need.
|
||||
|
||||
Sticking with the example above, you may want to test what happens if the cluster takes 5 seconds to create:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
from moto.moto_api import state_manager
|
||||
|
||||
state_manager.set_transition(model_name="dax::cluster", transition={"progression": "time", "duration": 5})
|
||||
|
||||
create_and_wait_for_cluster("my_new_cluster")
|
||||
|
||||
In order to test what happens in the event of a timeout, we can order the cluster to only be ready after 10 minutes:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
from moto.moto_api import state_manager
|
||||
|
||||
state_manager.set_transition(model_name="dax::cluster", transition={"progression": "time", "duration": 600})
|
||||
|
||||
try:
|
||||
create_and_wait_for_cluster("my_new_cluster")
|
||||
except:
|
||||
verify_the_correct_error_was_thrown()
|
||||
|
||||
In other tests, you may simply want the cluster to be ready as quickly as possible:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
from moto.moto_api import state_manager
|
||||
|
||||
state_manager.set_transition(model_name="dax::cluster", transition={"progression": "immediate"})
|
||||
|
||||
|
||||
So far we've seen two possible transitions:
|
||||
- The state progresses immediately
|
||||
- The state progresses after x seconds
|
||||
|
||||
There is a third possibility, where the state progresses after calling `describe_object` a specific number of times. :raw-html:`<br />`
|
||||
This can be useful if you want to verify that the state does change, but you don't want your unit test to take too long.
|
||||
|
||||
.. note::
|
||||
We will use the `boto3.client(..).describe_object` method as an example throughout this page. :raw-html:`<br />`
|
||||
This should be seen as a agnostic version of service-specific methods to verify the status of a resource, such as `boto.client("dax").describe_clusters()` or `boto.client("support").describe_cases()`.
|
||||
|
||||
Changing the state after a certain number of invocations can be done like this:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
state_manager.set_transition(model_name="dax::cluster", transition={"progression": "manual", "times": 3})
|
||||
|
||||
The transition is called `manual` because it requires you to manually invoke the `describe_object`-method before the status is progressed. :raw-html:`<br />`
|
||||
To show how this would work in practice, let's look at an example test:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
client.create_cluster(ClusterName=name, ...)
|
||||
# The first time we retrieve the status
|
||||
status = client.describe_clusters(ClusterNames=[name])["Clusters"][0]["Status"]
|
||||
assert status == "creating"
|
||||
# Second time we retrieve the status
|
||||
status = client.describe_clusters(ClusterNames=[name])["Clusters"][0]["Status"]
|
||||
assert status == "creating"
|
||||
# This is the third time that we're retrieving the status - this time it will advance to the next status
|
||||
status = client.describe_clusters(ClusterNames=[name])["Clusters"][0]["Status"]
|
||||
assert status == "available"
|
||||
|
||||
This should be done cleanly in a while-loop of-course, similar to the `create_and_wait_for_cluster` defined above - but this is a good way to showcase the behaviour.
|
||||
|
||||
|
||||
Registered models
|
||||
########################
|
||||
|
||||
:doc:`A list of all supported models can be found here. <models>`
|
||||
|
||||
Older versions of Moto may not support all models that are listed here. :raw-html:`<br />`
|
||||
To see a list of supported models for your Moto-version, call the `get_registered_models`-method:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
with mock_all():
|
||||
print(state_manager.get_registered_models())
|
||||
|
||||
Note the `mock_all`-decorator! Models are registered when the mock for that resource is started. If you call this method outside of a mock, you may see an empty list.
|
||||
|
||||
If you'd like to see state transition support for a resource that's not yet supported, feel free to open an issue or PR.
|
||||
|
||||
|
||||
State Transitions in ServerMode
|
||||
########################################
|
||||
|
||||
Configuration state transitions can be done in ServerMode as well, by making a HTTP request to the MotoAPI.
|
||||
This is an example request for `dax::cluster` to wait 5 seconds before the cluster becomes ready:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
post_body = dict(model_name="dax::cluster", transition={"progression": "time", "duration": 5})
|
||||
resp = requests.post("http://localhost:5000/moto-api/state-manager/set-transition", data=json.dumps(post_body))
|
||||
|
||||
An example request to see the currently configured transition for a specific model:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
requests.get("http://localhost:5000/moto-api/state-manager/get-transition?model_name=dax::cluster")
|
||||
|
||||
|
||||
We will not list all configuration options here again, but all models and transitions types (as specified above) follow the same format.
|
||||
|
||||
Reset
|
||||
########
|
||||
|
||||
It is possible to reset the state manager, and undo any custom transitions that were set. :raw-html:`<br />`
|
||||
Using Python:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
from moto.moto_api import state_manager
|
||||
|
||||
state_manager.unset_transition(model_name="dax::cluster")
|
||||
|
||||
Or if you're using Moto in ServerMode:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
post_body = dict(model_name="dax::cluster")
|
||||
resp = requests.post("http://localhost:5000/moto-api/state-manager/unset-transition", data=json.dumps(post_body))
|
113
docs/docs/configuration/state_transition/models.rst
Normal file
113
docs/docs/configuration/state_transition/models.rst
Normal file
@ -0,0 +1,113 @@
|
||||
.. _state transition_models:
|
||||
|
||||
.. role:: raw-html(raw)
|
||||
:format: html
|
||||
|
||||
============================================
|
||||
Supported Models for State Transitions
|
||||
============================================
|
||||
|
||||
|
||||
Service: Batch
|
||||
-----------------
|
||||
|
||||
**Model**: `batch::job` :raw-html:`<br />`
|
||||
Available States: :raw-html:`<br />`
|
||||
|
||||
"SUBMITTED" --> "PENDING" --> "RUNNABLE" --> "STARTING" --> "RUNNING" :raw-html:`<br />`
|
||||
"RUNNING" --> SUCCEEDED|FAILED
|
||||
|
||||
Transition type: `immediate` :raw-html:`<br />`
|
||||
Advancement:
|
||||
|
||||
When a user calls `submit_job`, Moto will go through a few steps to prepare the job, and when ready, execute that job in a Docker container.
|
||||
There are some steps to go through while the status is `SUBMITTED`, there are some steps to follow when the status is `PENDING`, etcetera.
|
||||
|
||||
Moto will try to advance the status itself - the moment this succeeds, the next step is executed.
|
||||
As the default transition is `immediate`, the status will advance immediately, and these steps will be executed as quickly as possible. This ensures that the job will be executed as quickly as possible.
|
||||
|
||||
Delaying the execution can be done as usual, by forcing Moto to wait x seconds before transitioning to the next stage. This can be useful if you need to 'catch' a job in a specific stage.
|
||||
|
||||
Service: Cloudfront
|
||||
---------------------
|
||||
|
||||
**Model**: `cloudfront::distribution` :raw-html:`<br />`
|
||||
Available States: :raw-html:`<br />`
|
||||
|
||||
"InProgress" --> "Deployed"
|
||||
|
||||
Transition type: Manual - describe the resource 1 time before the state advances :raw-html:`<br />`
|
||||
Advancement:
|
||||
|
||||
Call `boto3.client("cloudfront").get_distribution(..)` to advance a single distribution, or `boto3.client("cloudfront").list_distributions(..)` to advance all distributions.
|
||||
|
||||
|
||||
Service: DAX
|
||||
---------------
|
||||
|
||||
**Model**: `dax::cluster` :raw-html:`<br />`
|
||||
Available States:
|
||||
|
||||
"creating" --> "available" :raw-html:`<br />`
|
||||
"deleting" --> "deleted"
|
||||
|
||||
Transition type: Manual - describe the resource 4 times before the state advances :raw-html:`<br />`
|
||||
Advancement:
|
||||
|
||||
Call `boto3.client("dax").describe_clusters(..)`.
|
||||
|
||||
Service: Support
|
||||
------------------
|
||||
|
||||
**Model**: `support::case` :raw-html:`<br />`
|
||||
Available states:
|
||||
|
||||
"opened" --> "pending-customer-action" --> "reopened" --> "resolved" --> "unassigned" --> "work-in-progress" --> "opened"
|
||||
|
||||
Transition type: Manual - describe the resource 1 time before the state advances :raw-html:`<br />`
|
||||
Advancement:
|
||||
|
||||
Call `boto3.client("support").describe_cases(..)`
|
||||
|
||||
Service: Transcribe
|
||||
---------------------
|
||||
|
||||
**Model**: `transcribe::vocabulary` :raw-html:`<br />`
|
||||
Available states:
|
||||
|
||||
None --> "PENDING --> "READY"
|
||||
|
||||
Transition type: Manual - describe the resource 1 time before the state advances :raw-html:`<br />`
|
||||
Advancement:
|
||||
|
||||
Call `boto3.client("transcribe").get_vocabulary(..)`
|
||||
|
||||
**Model**: `transcribe::medicalvocabulary` :raw-html:`<br />`
|
||||
Available states:
|
||||
|
||||
None --> "PENDING --> "READY"
|
||||
|
||||
Transition type: Manual - describe the resource 1 time before the state advances :raw-html:`<br />`
|
||||
Advancement:
|
||||
|
||||
Call `boto3.client("transcribe").get_medical_vocabulary(..)`
|
||||
|
||||
**Model**: `transcribe::transcriptionjob` :raw-html:`<br />`
|
||||
Available states:
|
||||
|
||||
None --> "QUEUED" --> "IN_PROGRESS" --> "COMPLETED"
|
||||
|
||||
Transition type: Manual - describe the resource 1 time before the state advances :raw-html:`<br />`
|
||||
Advancement:
|
||||
|
||||
Call `boto3.client("transcribe").get_transcription_job(..)`
|
||||
|
||||
**Model**: `transcribe::medicaltranscriptionjob` :raw-html:`<br />`
|
||||
Available states:
|
||||
|
||||
None --> "QUEUED" --> "IN_PROGRESS" --> "COMPLETED"
|
||||
|
||||
Transition type: Manual - describe the resource 1 time before the state advances :raw-html:`<br />`
|
||||
Advancement:
|
||||
|
||||
Call `boto3.client("transcribe").get_medical_transcription_job(..)`
|
@ -0,0 +1,78 @@
|
||||
.. _new state transitions:
|
||||
|
||||
===============================
|
||||
State Transition Management
|
||||
===============================
|
||||
|
||||
When developing a model where the resource is not available immediately, such as EC2 instances, a configuration option is available to specify whether you want mocked resources to be available immediately (to speed up unit testing), or whether you want an artificial delay to more closely mimick AWS' behaviour where resources are only available/ready after some time.
|
||||
|
||||
See the user-documentation here: :ref:`state transition`
|
||||
|
||||
In order for a new model to support this behaviour out of the box, it needs to be configured and registered with the State Manager.
|
||||
The following steps need to be taken for this to be effective:
|
||||
|
||||
- Extend the new model with the ManagedState-class
|
||||
- Call the ManagedState-constructor with information on which state transitions are supported
|
||||
- Decide when to advance the status
|
||||
- Register the model with the StateManager
|
||||
|
||||
An example model could look like this:
|
||||
|
||||
.. sourcecode:: python
|
||||
|
||||
from moto.moto_api._internal.managed_state_model import ManagedState
|
||||
|
||||
class NewModel(ManagedState):
|
||||
def __init__(self):
|
||||
ManagedState.__init__(self,
|
||||
# A unique name should be chosen to uniquely identify this model
|
||||
# Any name is acceptable - a typical format would be 'API:type'
|
||||
# Examples: 'S3::bucket', 'APIGateway::Method', 'DynamoDB::Table'
|
||||
model_name="new::model",
|
||||
# List all the possible status-transitions here
|
||||
transitions=[("initializing", "starting"),
|
||||
("starting", "ready")])
|
||||
|
||||
def to_json(self):
|
||||
# ManagedState gives us a 'status'-attribute out of the box
|
||||
# On the first iteration, this will be set to the first status of the first transition
|
||||
return {
|
||||
"name": ...,
|
||||
"status": self.status,
|
||||
...
|
||||
}
|
||||
|
||||
from moto.moto_api import state_manager
|
||||
|
||||
class Backend():
|
||||
def __init__():
|
||||
# This is how we register the model, and specify the default transition-behaviour
|
||||
# Typically this is done when constructing the Backend-class
|
||||
state_manager.register_default_transition(
|
||||
# This name should be the same as the name used in NewModel
|
||||
model_name="new::model",
|
||||
# Any transition-config is possible - this is a good default option though
|
||||
transition={"progression": "immediate"},
|
||||
)
|
||||
|
||||
def list_resources():
|
||||
for ec2_instance in all_resources:
|
||||
# For users who configured models of this type to transition manually, this is where we advance the status
|
||||
# Say the transition is registered like so: {"progression": "manual", "times": 3}
|
||||
#
|
||||
# The user calls 'list_resources' 3 times, the advance-method is called 3 times, and the state manager advances the state after the 3rd time.
|
||||
# This all happens out of the box - just make sure that the `advance()`-method is invoked when appropriate
|
||||
#
|
||||
# If the transition is set to progress immediately, this method does exactly nothing.
|
||||
#
|
||||
# If the user decides to change the progression to be time-based, where the status changed every y seconds, this method does exactly nothing.
|
||||
# It will has to be called though, for people who do have the manual progression configured
|
||||
model.advance()
|
||||
return all_models
|
||||
|
||||
def describe_resource():
|
||||
resource = ...
|
||||
# Depending on the API, there may be different ways for the user to retrieve the same information
|
||||
# Make sure that each way (describe, list, get_, ) calls the advance()-method, and the resource can actually progress to the next state
|
||||
resource.advance()
|
||||
return resource
|
@ -36,6 +36,12 @@ Additional Resources
|
||||
docs/iam
|
||||
docs/aws_config
|
||||
|
||||
.. toctree::
|
||||
:hidden:
|
||||
:caption: Configuration
|
||||
|
||||
docs/configuration/index
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 1
|
||||
:hidden:
|
||||
@ -66,3 +72,4 @@ Additional Resources
|
||||
docs/contributing/development_tips/urls
|
||||
docs/contributing/development_tips/tests
|
||||
docs/contributing/development_tips/utilities
|
||||
docs/contributing/development_tips/new_state_transitions
|
||||
|
@ -1,5 +1,6 @@
|
||||
import re
|
||||
from itertools import cycle
|
||||
from time import sleep
|
||||
import datetime
|
||||
import time
|
||||
import uuid
|
||||
@ -29,6 +30,8 @@ from moto.ec2._models.instance_types import INSTANCE_FAMILIES as EC2_INSTANCE_FA
|
||||
from moto.iam.exceptions import IAMNotFoundException
|
||||
from moto.core import ACCOUNT_ID as DEFAULT_ACCOUNT_ID
|
||||
from moto.core.utils import unix_time_millis, BackendDict
|
||||
from moto.moto_api import state_manager
|
||||
from moto.moto_api._internal.managed_state_model import ManagedState
|
||||
from moto.utilities.docker_utilities import DockerModel
|
||||
from moto import settings
|
||||
|
||||
@ -411,7 +414,7 @@ class JobDefinition(CloudFormationModel):
|
||||
return backend.get_job_definition_by_arn(arn)
|
||||
|
||||
|
||||
class Job(threading.Thread, BaseModel, DockerModel):
|
||||
class Job(threading.Thread, BaseModel, DockerModel, ManagedState):
|
||||
def __init__(
|
||||
self,
|
||||
name,
|
||||
@ -435,13 +438,22 @@ class Job(threading.Thread, BaseModel, DockerModel):
|
||||
"""
|
||||
threading.Thread.__init__(self)
|
||||
DockerModel.__init__(self)
|
||||
ManagedState.__init__(
|
||||
self,
|
||||
"batch::job",
|
||||
[
|
||||
("SUBMITTED", "PENDING"),
|
||||
("PENDING", "RUNNABLE"),
|
||||
("RUNNABLE", "STARTING"),
|
||||
("STARTING", "RUNNING"),
|
||||
],
|
||||
)
|
||||
|
||||
self.job_name = name
|
||||
self.job_id = str(uuid.uuid4())
|
||||
self.job_definition = job_def
|
||||
self.container_overrides = container_overrides or {}
|
||||
self.job_queue = job_queue
|
||||
self.job_state = "SUBMITTED" # One of SUBMITTED | PENDING | RUNNABLE | STARTING | RUNNING | SUCCEEDED | FAILED
|
||||
self.job_queue.jobs.append(self)
|
||||
self.job_created_at = datetime.datetime.now()
|
||||
self.job_started_at = datetime.datetime(1970, 1, 1)
|
||||
@ -469,7 +481,7 @@ class Job(threading.Thread, BaseModel, DockerModel):
|
||||
"jobId": self.job_id,
|
||||
"jobName": self.job_name,
|
||||
"createdAt": datetime2int_milliseconds(self.job_created_at),
|
||||
"status": self.job_state,
|
||||
"status": self.status,
|
||||
"jobDefinition": self.job_definition.arn,
|
||||
}
|
||||
if self.job_stopped_reason is not None:
|
||||
@ -556,8 +568,13 @@ class Job(threading.Thread, BaseModel, DockerModel):
|
||||
:return:
|
||||
"""
|
||||
try:
|
||||
self.job_state = "PENDING"
|
||||
self.advance()
|
||||
while self.status == "SUBMITTED":
|
||||
# Wait until we've moved onto state 'PENDING'
|
||||
sleep(0.5)
|
||||
|
||||
# Wait until all dependent jobs have finished
|
||||
# If any of the dependent jobs have failed, not even start
|
||||
if self.depends_on and not self._wait_for_dependencies():
|
||||
return
|
||||
|
||||
@ -590,7 +607,10 @@ class Job(threading.Thread, BaseModel, DockerModel):
|
||||
]
|
||||
name = "{0}-{1}".format(self.job_name, self.job_id)
|
||||
|
||||
self.job_state = "RUNNABLE"
|
||||
self.advance()
|
||||
while self.status == "PENDING":
|
||||
# Wait until the state is no longer pending, but 'RUNNABLE'
|
||||
sleep(0.5)
|
||||
# TODO setup ecs container instance
|
||||
|
||||
self.job_started_at = datetime.datetime.now()
|
||||
@ -619,7 +639,15 @@ class Job(threading.Thread, BaseModel, DockerModel):
|
||||
run_kwargs["network_mode"] = network_mode
|
||||
|
||||
log_config = docker.types.LogConfig(type=docker.types.LogConfig.types.JSON)
|
||||
self.job_state = "STARTING"
|
||||
self.advance()
|
||||
while self.status == "RUNNABLE":
|
||||
# Wait until the state is no longer runnable, but 'STARTING'
|
||||
sleep(0.5)
|
||||
|
||||
self.advance()
|
||||
while self.status == "STARTING":
|
||||
# Wait until the state is no longer runnable, but 'RUNNING'
|
||||
sleep(0.5)
|
||||
container = self.docker_client.containers.run(
|
||||
image,
|
||||
cmd,
|
||||
@ -632,7 +660,6 @@ class Job(threading.Thread, BaseModel, DockerModel):
|
||||
extra_hosts=extra_hosts,
|
||||
**run_kwargs,
|
||||
)
|
||||
self.job_state = "RUNNING"
|
||||
try:
|
||||
container.reload()
|
||||
|
||||
@ -730,10 +757,10 @@ class Job(threading.Thread, BaseModel, DockerModel):
|
||||
|
||||
def _mark_stopped(self, success=True):
|
||||
# Ensure that job_stopped/job_stopped_at-attributes are set first
|
||||
# The describe-method needs them immediately when job_state is set
|
||||
# The describe-method needs them immediately when status is set
|
||||
self.job_stopped = True
|
||||
self.job_stopped_at = datetime.datetime.now()
|
||||
self.job_state = "SUCCEEDED" if success else "FAILED"
|
||||
self.status = "SUCCEEDED" if success else "FAILED"
|
||||
self._stop_attempt()
|
||||
|
||||
def _start_attempt(self):
|
||||
@ -769,9 +796,9 @@ class Job(threading.Thread, BaseModel, DockerModel):
|
||||
for dependent_id in dependent_ids:
|
||||
if dependent_id in self.all_jobs:
|
||||
dependent_job = self.all_jobs[dependent_id]
|
||||
if dependent_job.job_state == "SUCCEEDED":
|
||||
if dependent_job.status == "SUCCEEDED":
|
||||
successful_dependencies.add(dependent_id)
|
||||
if dependent_job.job_state == "FAILED":
|
||||
if dependent_job.status == "FAILED":
|
||||
logger.error(
|
||||
"Terminating job {0} due to failed dependency {1}".format(
|
||||
self.name, dependent_job.name
|
||||
@ -800,6 +827,10 @@ class BatchBackend(BaseBackend):
|
||||
self._job_definitions = {}
|
||||
self._jobs = {}
|
||||
|
||||
state_manager.register_default_transition(
|
||||
"batch::job", transition={"progression": "manual", "times": 1}
|
||||
)
|
||||
|
||||
@property
|
||||
def iam_backend(self):
|
||||
"""
|
||||
@ -836,7 +867,7 @@ class BatchBackend(BaseBackend):
|
||||
region_name = self.region_name
|
||||
|
||||
for job in self._jobs.values():
|
||||
if job.job_state not in ("FAILED", "SUCCEEDED"):
|
||||
if job.status not in ("FAILED", "SUCCEEDED"):
|
||||
job.stop = True
|
||||
# Try to join
|
||||
job.join(0.2)
|
||||
@ -1574,7 +1605,7 @@ class BatchBackend(BaseBackend):
|
||||
)
|
||||
|
||||
for job in job_queue.jobs:
|
||||
if job_status is not None and job.job_state != job_status:
|
||||
if job_status is not None and job.status != job_status:
|
||||
continue
|
||||
|
||||
jobs.append(job)
|
||||
@ -1583,7 +1614,7 @@ class BatchBackend(BaseBackend):
|
||||
|
||||
def cancel_job(self, job_id, reason):
|
||||
job = self.get_job_by_id(job_id)
|
||||
if job.job_state in ["SUBMITTED", "PENDING", "RUNNABLE"]:
|
||||
if job.status in ["SUBMITTED", "PENDING", "RUNNABLE"]:
|
||||
job.terminate(reason)
|
||||
# No-Op for jobs that have already started - user has to explicitly terminate those
|
||||
|
||||
|
@ -2,6 +2,8 @@ import random
|
||||
import string
|
||||
|
||||
from moto.core import ACCOUNT_ID, BaseBackend, BaseModel
|
||||
from moto.moto_api import state_manager
|
||||
from moto.moto_api._internal.managed_state_model import ManagedState
|
||||
from uuid import uuid4
|
||||
|
||||
from .exceptions import (
|
||||
@ -129,7 +131,7 @@ class DistributionConfig:
|
||||
self.is_ipv6_enabled = True
|
||||
|
||||
|
||||
class Distribution(BaseModel):
|
||||
class Distribution(BaseModel, ManagedState):
|
||||
@staticmethod
|
||||
def random_id(uppercase=True):
|
||||
ascii_set = string.ascii_uppercase if uppercase else string.ascii_lowercase
|
||||
@ -140,6 +142,11 @@ class Distribution(BaseModel):
|
||||
return resource_id
|
||||
|
||||
def __init__(self, config):
|
||||
# Configured ManagedState
|
||||
super().__init__(
|
||||
"cloudfront::distribution", transitions=[("InProgress", "Deployed")]
|
||||
)
|
||||
# Configure internal properties
|
||||
self.distribution_id = Distribution.random_id()
|
||||
self.arn = (
|
||||
f"arn:aws:cloudfront:{ACCOUNT_ID}:distribution/{self.distribution_id}"
|
||||
@ -152,16 +159,8 @@ class Distribution(BaseModel):
|
||||
self.last_modified_time = "2021-11-27T10:34:26.802Z"
|
||||
self.in_progress_invalidation_batches = 0
|
||||
self.has_active_trusted_key_groups = False
|
||||
self.status = "InProgress"
|
||||
self.domain_name = f"{Distribution.random_id(uppercase=False)}.cloudfront.net"
|
||||
|
||||
def advance(self):
|
||||
"""
|
||||
Advance the status of this Distribution, to mimick AWS' behaviour
|
||||
"""
|
||||
if self.status == "InProgress":
|
||||
self.status = "Deployed"
|
||||
|
||||
@property
|
||||
def location(self):
|
||||
return f"https://cloudfront.amazonaws.com/2020-05-31/distribution/{self.distribution_id}"
|
||||
@ -175,6 +174,10 @@ class CloudFrontBackend(BaseBackend):
|
||||
def __init__(self):
|
||||
self.distributions = dict()
|
||||
|
||||
state_manager.register_default_transition(
|
||||
"cloudfront::distribution", transition={"progression": "manual", "times": 1}
|
||||
)
|
||||
|
||||
def create_distribution(self, distribution_config):
|
||||
"""
|
||||
This has been tested against an S3-distribution with the simplest possible configuration.
|
||||
|
@ -1,6 +1,8 @@
|
||||
"""DAXBackend class with methods for supported APIs."""
|
||||
from moto.core import ACCOUNT_ID, BaseBackend, BaseModel
|
||||
from moto.core.utils import BackendDict, get_random_hex, unix_time
|
||||
from moto.moto_api import state_manager
|
||||
from moto.moto_api._internal.managed_state_model import ManagedState
|
||||
from moto.utilities.tagging_service import TaggingService
|
||||
from moto.utilities.paginator import paginate
|
||||
|
||||
@ -63,7 +65,7 @@ class DaxEndpoint:
|
||||
return dct
|
||||
|
||||
|
||||
class DaxCluster(BaseModel):
|
||||
class DaxCluster(BaseModel, ManagedState):
|
||||
def __init__(
|
||||
self,
|
||||
region,
|
||||
@ -74,12 +76,17 @@ class DaxCluster(BaseModel):
|
||||
iam_role_arn,
|
||||
sse_specification,
|
||||
):
|
||||
# Configure ManagedState
|
||||
super().__init__(
|
||||
model_name="dax::cluster",
|
||||
transitions=[("creating", "available"), ("deleting", "deleted")],
|
||||
)
|
||||
# Set internal properties
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.arn = f"arn:aws:dax:{region}:{ACCOUNT_ID}:cache/{self.name}"
|
||||
self.node_type = node_type
|
||||
self.replication_factor = replication_factor
|
||||
self.status = "creating"
|
||||
self.cluster_hex = get_random_hex(6)
|
||||
self.endpoint = DaxEndpoint(
|
||||
name=name, cluster_hex=self.cluster_hex, region=region
|
||||
@ -94,10 +101,6 @@ class DaxCluster(BaseModel):
|
||||
]
|
||||
self.sse_specification = sse_specification
|
||||
|
||||
# Internal counter to keep track of when this cluster is available/deleted
|
||||
# Used in conjunction with `advance()`
|
||||
self._tick = 0
|
||||
|
||||
def _create_new_node(self, idx):
|
||||
return DaxNode(endpoint=self.endpoint, name=self.name, index=idx)
|
||||
|
||||
@ -119,19 +122,6 @@ class DaxCluster(BaseModel):
|
||||
def is_deleted(self):
|
||||
return self.status == "deleted"
|
||||
|
||||
def advance(self):
|
||||
if self.status == "creating":
|
||||
if self._tick < 3:
|
||||
self._tick += 1
|
||||
else:
|
||||
self.status = "available"
|
||||
self._tick = 0
|
||||
if self.status == "deleting":
|
||||
if self._tick < 3:
|
||||
self._tick += 1
|
||||
else:
|
||||
self.status = "deleted"
|
||||
|
||||
def to_json(self):
|
||||
use_full_repr = self.status == "available"
|
||||
dct = {
|
||||
@ -166,6 +156,10 @@ class DAXBackend(BaseBackend):
|
||||
self._clusters = dict()
|
||||
self._tagger = TaggingService()
|
||||
|
||||
state_manager.register_default_transition(
|
||||
model_name="dax::cluster", transition={"progression": "manual", "times": 4}
|
||||
)
|
||||
|
||||
@property
|
||||
def clusters(self):
|
||||
self._clusters = {
|
||||
|
7
moto/moto_api/__init__.py
Normal file
7
moto/moto_api/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
from moto.moto_api import _internal
|
||||
|
||||
"""
|
||||
Global StateManager that everyone uses
|
||||
Use this manager to configure how AWS models transition between states. (initializing -> starting, starting -> ready, etc.)
|
||||
"""
|
||||
state_manager = _internal.state_manager.StateManager()
|
@ -1,4 +1,5 @@
|
||||
from .models import moto_api_backend
|
||||
from .state_manager import StateManager # noqa
|
||||
|
||||
|
||||
moto_api_backends = {"global": moto_api_backend}
|
||||
|
67
moto/moto_api/_internal/managed_state_model.py
Normal file
67
moto/moto_api/_internal/managed_state_model.py
Normal file
@ -0,0 +1,67 @@
|
||||
from datetime import datetime, timedelta
|
||||
from moto.moto_api import state_manager
|
||||
|
||||
|
||||
class ManagedState:
|
||||
"""
|
||||
Subclass this class to configure state-transitions
|
||||
"""
|
||||
|
||||
def __init__(self, model_name, transitions):
|
||||
# Indicate the possible transitions for this model
|
||||
# Example: [(initializing,queued), (queued, starting), (starting, ready)]
|
||||
self._transitions = transitions
|
||||
# Current status of this model. Implementations should call `status`
|
||||
# The initial status is assumed to be the first transition
|
||||
self._status, _ = transitions[0]
|
||||
# Internal counter that keeps track of how often this model has been described
|
||||
# Used for transition-type=manual
|
||||
self._tick = 0
|
||||
# Time when the status was last progressed to this model
|
||||
# Used for transition-type=time
|
||||
self._time_progressed = datetime.now()
|
||||
# Name of this model. This will be used in the API
|
||||
self.model_name = model_name
|
||||
|
||||
def advance(self):
|
||||
self._tick += 1
|
||||
|
||||
@property
|
||||
def status(self):
|
||||
"""
|
||||
Transitions the status as appropriate before returning
|
||||
"""
|
||||
transition_config = state_manager.get_transition(self.model_name)
|
||||
if transition_config["progression"] == "immediate":
|
||||
self._status = self._get_last_status(previous=self._status)
|
||||
|
||||
if transition_config["progression"] == "manual":
|
||||
if self._tick >= transition_config["times"]:
|
||||
self._status = self._get_next_status(previous=self._status)
|
||||
self._tick = 0
|
||||
|
||||
if transition_config["progression"] == "time":
|
||||
next_transition_at = self._time_progressed + timedelta(
|
||||
seconds=transition_config["seconds"]
|
||||
)
|
||||
if datetime.now() > next_transition_at:
|
||||
self._status = self._get_next_status(previous=self._status)
|
||||
self._time_progressed = datetime.now()
|
||||
|
||||
return self._status
|
||||
|
||||
@status.setter
|
||||
def status(self, value):
|
||||
self._status = value
|
||||
|
||||
def _get_next_status(self, previous):
|
||||
return next(
|
||||
(nxt for prev, nxt in self._transitions if previous == prev), previous
|
||||
)
|
||||
|
||||
def _get_last_status(self, previous):
|
||||
next_state = self._get_next_status(previous)
|
||||
while next_state != previous:
|
||||
previous = next_state
|
||||
next_state = self._get_next_status(previous)
|
||||
return next_state
|
@ -12,5 +12,20 @@ class MotoAPIBackend(BaseBackend):
|
||||
backend.reset()
|
||||
self.__init__()
|
||||
|
||||
def get_transition(self, model_name):
|
||||
from moto.moto_api import state_manager
|
||||
|
||||
return state_manager.get_transition(model_name)
|
||||
|
||||
def set_transition(self, model_name, transition):
|
||||
from moto.moto_api import state_manager
|
||||
|
||||
state_manager.set_transition(model_name, transition)
|
||||
|
||||
def unset_transition(self, model_name):
|
||||
from moto.moto_api import state_manager
|
||||
|
||||
state_manager.unset_transition(model_name)
|
||||
|
||||
|
||||
moto_api_backend = MotoAPIBackend()
|
||||
|
@ -65,3 +65,44 @@ class MotoAPIResponse(BaseResponse):
|
||||
from flask import render_template
|
||||
|
||||
return render_template("dashboard.html")
|
||||
|
||||
def get_transition(
|
||||
self, request, full_url, headers
|
||||
): # pylint: disable=unused-argument
|
||||
from .models import moto_api_backend
|
||||
|
||||
qs_dict = dict(
|
||||
x.split("=") for x in request.query_string.decode("utf-8").split("&")
|
||||
)
|
||||
model_name = qs_dict["model_name"]
|
||||
|
||||
resp = moto_api_backend.get_transition(model_name=model_name)
|
||||
|
||||
return 200, {}, json.dumps(resp)
|
||||
|
||||
def set_transition(
|
||||
self, request, full_url, headers
|
||||
): # pylint: disable=unused-argument
|
||||
from .models import moto_api_backend
|
||||
|
||||
request_body_size = int(headers["Content-Length"])
|
||||
body = request.environ["wsgi.input"].read(request_body_size).decode("utf-8")
|
||||
body = json.loads(body)
|
||||
model_name = body["model_name"]
|
||||
transition = body["transition"]
|
||||
|
||||
moto_api_backend.set_transition(model_name, transition)
|
||||
return 201, {}, ""
|
||||
|
||||
def unset_transition(
|
||||
self, request, full_url, headers
|
||||
): # pylint: disable=unused-argument
|
||||
from .models import moto_api_backend
|
||||
|
||||
request_body_size = int(headers["Content-Length"])
|
||||
body = request.environ["wsgi.input"].read(request_body_size).decode("utf-8")
|
||||
body = json.loads(body)
|
||||
model_name = body["model_name"]
|
||||
|
||||
moto_api_backend.unset_transition(model_name)
|
||||
return 201, {}, ""
|
||||
|
42
moto/moto_api/_internal/state_manager.py
Normal file
42
moto/moto_api/_internal/state_manager.py
Normal file
@ -0,0 +1,42 @@
|
||||
DEFAULT_TRANSITION = {"progression": "immediate"}
|
||||
|
||||
|
||||
class StateManager:
|
||||
def __init__(self):
|
||||
self._default_transitions = dict()
|
||||
self._transitions = dict()
|
||||
|
||||
def register_default_transition(self, model_name, transition):
|
||||
"""
|
||||
Register the default transition for a specific model.
|
||||
This should only be called by Moto backends - use the `set_transition` method to override this default transition in your own tests.
|
||||
"""
|
||||
self._default_transitions[model_name] = transition
|
||||
|
||||
def set_transition(self, model_name, transition):
|
||||
"""
|
||||
Set a transition for a specific model. Any transition added here will take precedence over the default transition that was registered.
|
||||
|
||||
See https://docs.getmoto.org/en/latest/docs/configuration/state_transition/index.html for the possible transition-configurations.
|
||||
"""
|
||||
self._transitions[model_name] = transition
|
||||
|
||||
def unset_transition(self, model_name):
|
||||
"""
|
||||
Unset (remove) a custom transition that was set. This is a safe and idempotent operation.
|
||||
The default transition that was registered will not be altered by this operation.
|
||||
"""
|
||||
self._transitions.pop(model_name, None)
|
||||
|
||||
def get_transition(self, model_name):
|
||||
"""
|
||||
Return the configuration for a specific model. This will return a user-specified configuration, a default configuration of none exists, or the default transition if none exists.
|
||||
"""
|
||||
if model_name in self._transitions:
|
||||
return self._transitions[model_name]
|
||||
if model_name in self._default_transitions:
|
||||
return self._default_transitions[model_name]
|
||||
return DEFAULT_TRANSITION
|
||||
|
||||
def get_registered_models(self):
|
||||
return list(self._default_transitions.keys())
|
@ -9,4 +9,7 @@ url_paths = {
|
||||
"{0}/moto-api/data.json": response_instance.model_data,
|
||||
"{0}/moto-api/reset": response_instance.reset_response,
|
||||
"{0}/moto-api/reset-auth": response_instance.reset_auth_response,
|
||||
"{0}/moto-api/state-manager/get-transition": response_instance.get_transition,
|
||||
"{0}/moto-api/state-manager/set-transition": response_instance.set_transition,
|
||||
"{0}/moto-api/state-manager/unset-transition": response_instance.unset_transition,
|
||||
}
|
||||
|
@ -1,4 +1,6 @@
|
||||
from moto.core import BaseBackend
|
||||
from moto.moto_api import state_manager
|
||||
from moto.moto_api._internal.managed_state_model import ManagedState
|
||||
from moto.utilities.utils import load_resource
|
||||
import datetime
|
||||
import random
|
||||
@ -8,12 +10,23 @@ checks_json = "resources/describe_trusted_advisor_checks.json"
|
||||
ADVISOR_CHECKS = load_resource(__name__, checks_json)
|
||||
|
||||
|
||||
class SupportCase(object):
|
||||
class SupportCase(ManagedState):
|
||||
def __init__(self, **kwargs):
|
||||
# Configure ManagedState
|
||||
super().__init__(
|
||||
"support::case",
|
||||
transitions=[
|
||||
("opened", "pending-customer-action"),
|
||||
("pending-customer-action", "reopened"),
|
||||
("reopened", "resolved"),
|
||||
("resolved", "unassigned"),
|
||||
("unassigned", "work-in-progress"),
|
||||
("work-in-progress", "opened"),
|
||||
],
|
||||
)
|
||||
self.case_id = kwargs.get("case_id")
|
||||
self.display_id = "foo_display_id"
|
||||
self.subject = kwargs.get("subject")
|
||||
self.status = "opened"
|
||||
self.service_code = kwargs.get("service_code")
|
||||
self.category_code = kwargs.get("category_code")
|
||||
self.severity_code = kwargs.get("severity_code")
|
||||
@ -54,6 +67,10 @@ class SupportBackend(BaseBackend):
|
||||
self.check_status = {}
|
||||
self.cases = {}
|
||||
|
||||
state_manager.register_default_transition(
|
||||
model_name="support::case", transition={"progression": "manual", "times": 1}
|
||||
)
|
||||
|
||||
def reset(self):
|
||||
region_name = self.region_name
|
||||
self.__dict__ = {}
|
||||
@ -105,23 +122,7 @@ class SupportBackend(BaseBackend):
|
||||
Fake an advancement through case statuses
|
||||
"""
|
||||
|
||||
if self.cases[case_id].status == "opened":
|
||||
self.cases[case_id].status = "pending-customer-action"
|
||||
|
||||
elif self.cases[case_id].status == "pending-customer-action":
|
||||
self.cases[case_id].status = "reopened"
|
||||
|
||||
elif self.cases[case_id].status == "reopened":
|
||||
self.cases[case_id].status = "resolved"
|
||||
|
||||
elif self.cases[case_id].status == "resolved":
|
||||
self.cases[case_id].status = "unassigned"
|
||||
|
||||
elif self.cases[case_id].status == "unassigned":
|
||||
self.cases[case_id].status = "work-in-progress"
|
||||
|
||||
elif self.cases[case_id].status == "work-in-progress":
|
||||
self.cases[case_id].status = "opened"
|
||||
self.cases[case_id].advance()
|
||||
|
||||
def advance_case_severity_codes(self, case_id):
|
||||
"""
|
||||
|
@ -2,6 +2,8 @@ import uuid
|
||||
from datetime import datetime, timedelta
|
||||
from moto.core import BaseBackend, BaseModel
|
||||
from moto.core.utils import BackendDict
|
||||
from moto.moto_api import state_manager
|
||||
from moto.moto_api._internal.managed_state_model import ManagedState
|
||||
from moto.sts.models import ACCOUNT_ID
|
||||
from .exceptions import ConflictException, BadRequestException
|
||||
|
||||
@ -27,7 +29,7 @@ class BaseObject(BaseModel):
|
||||
return self.gen_response_object()
|
||||
|
||||
|
||||
class FakeTranscriptionJob(BaseObject):
|
||||
class FakeTranscriptionJob(BaseObject, ManagedState):
|
||||
def __init__(
|
||||
self,
|
||||
region_name,
|
||||
@ -46,9 +48,17 @@ class FakeTranscriptionJob(BaseObject):
|
||||
identify_language,
|
||||
language_options,
|
||||
):
|
||||
ManagedState.__init__(
|
||||
self,
|
||||
"transcribe::transcriptionjob",
|
||||
transitions=[
|
||||
(None, "QUEUED"),
|
||||
("QUEUED", "IN_PROGRESS"),
|
||||
("IN_PROGRESS", "COMPLETED"),
|
||||
],
|
||||
)
|
||||
self._region_name = region_name
|
||||
self.transcription_job_name = transcription_job_name
|
||||
self.transcription_job_status = None
|
||||
self.language_code = language_code
|
||||
self.media_sample_rate_hertz = media_sample_rate_hertz
|
||||
self.media_format = media_format
|
||||
@ -127,6 +137,7 @@ class FakeTranscriptionJob(BaseObject):
|
||||
}
|
||||
response_fields = response_field_dict[response_type]
|
||||
response_object = self.gen_response_object()
|
||||
response_object["TranscriptionJobStatus"] = self.status
|
||||
if response_type != "LIST":
|
||||
return {
|
||||
"TranscriptionJob": {
|
||||
@ -142,13 +153,15 @@ class FakeTranscriptionJob(BaseObject):
|
||||
if k in response_fields and v is not None and v != [None]
|
||||
}
|
||||
|
||||
def advance_job_status(self):
|
||||
# On each call advances the fake job status
|
||||
def advance(self):
|
||||
old_status = self.status
|
||||
super().advance()
|
||||
new_status = self.status
|
||||
|
||||
if not self.transcription_job_status:
|
||||
self.transcription_job_status = "QUEUED"
|
||||
elif self.transcription_job_status == "QUEUED":
|
||||
self.transcription_job_status = "IN_PROGRESS"
|
||||
if old_status == new_status:
|
||||
return
|
||||
|
||||
if new_status == "IN_PROGRESS":
|
||||
self.start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if not self.media_sample_rate_hertz:
|
||||
self.media_sample_rate_hertz = 44100
|
||||
@ -165,8 +178,7 @@ class FakeTranscriptionJob(BaseObject):
|
||||
self.language_code = self.language_options[0]
|
||||
else:
|
||||
self.language_code = "en-US"
|
||||
elif self.transcription_job_status == "IN_PROGRESS":
|
||||
self.transcription_job_status = "COMPLETED"
|
||||
elif new_status == "COMPLETED":
|
||||
self.completion_time = (datetime.now() + timedelta(seconds=10)).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
@ -197,16 +209,21 @@ class FakeTranscriptionJob(BaseObject):
|
||||
self.transcript = {"TranscriptFileUri": transcript_file_uri}
|
||||
|
||||
|
||||
class FakeVocabulary(BaseObject):
|
||||
class FakeVocabulary(BaseObject, ManagedState):
|
||||
def __init__(
|
||||
self, region_name, vocabulary_name, language_code, phrases, vocabulary_file_uri
|
||||
):
|
||||
# Configured ManagedState
|
||||
super().__init__(
|
||||
"transcribe::vocabulary",
|
||||
transitions=[(None, "PENDING"), ("PENDING", "READY")],
|
||||
)
|
||||
# Configure internal properties
|
||||
self._region_name = region_name
|
||||
self.vocabulary_name = vocabulary_name
|
||||
self.language_code = language_code
|
||||
self.phrases = phrases
|
||||
self.vocabulary_file_uri = vocabulary_file_uri
|
||||
self.vocabulary_state = None
|
||||
self.last_modified_time = None
|
||||
self.failure_reason = None
|
||||
self.download_uri = "https://s3.{0}.amazonaws.com/aws-transcribe-dictionary-model-{0}-prod/{1}/{2}/{3}/input.txt".format( # noqa: E501
|
||||
@ -239,24 +256,23 @@ class FakeVocabulary(BaseObject):
|
||||
}
|
||||
response_fields = response_field_dict[response_type]
|
||||
response_object = self.gen_response_object()
|
||||
response_object["VocabularyState"] = self.status
|
||||
return {
|
||||
k: v
|
||||
for k, v in response_object.items()
|
||||
if k in response_fields and v is not None and v != [None]
|
||||
}
|
||||
|
||||
def advance_job_status(self):
|
||||
# On each call advances the fake job status
|
||||
def advance(self):
|
||||
old_status = self.status
|
||||
super().advance()
|
||||
new_status = self.status
|
||||
|
||||
if not self.vocabulary_state:
|
||||
self.vocabulary_state = "PENDING"
|
||||
self.last_modified_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
elif self.vocabulary_state == "PENDING":
|
||||
self.vocabulary_state = "READY"
|
||||
if old_status != new_status:
|
||||
self.last_modified_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
class FakeMedicalTranscriptionJob(BaseObject):
|
||||
class FakeMedicalTranscriptionJob(BaseObject, ManagedState):
|
||||
def __init__(
|
||||
self,
|
||||
region_name,
|
||||
@ -271,9 +287,17 @@ class FakeMedicalTranscriptionJob(BaseObject):
|
||||
specialty,
|
||||
job_type,
|
||||
):
|
||||
ManagedState.__init__(
|
||||
self,
|
||||
"transcribe::medicaltranscriptionjob",
|
||||
transitions=[
|
||||
(None, "QUEUED"),
|
||||
("QUEUED", "IN_PROGRESS"),
|
||||
("IN_PROGRESS", "COMPLETED"),
|
||||
],
|
||||
)
|
||||
self._region_name = region_name
|
||||
self.medical_transcription_job_name = medical_transcription_job_name
|
||||
self.transcription_job_status = None
|
||||
self.language_code = language_code
|
||||
self.media_sample_rate_hertz = media_sample_rate_hertz
|
||||
self.media_format = media_format
|
||||
@ -335,6 +359,7 @@ class FakeMedicalTranscriptionJob(BaseObject):
|
||||
}
|
||||
response_fields = response_field_dict[response_type]
|
||||
response_object = self.gen_response_object()
|
||||
response_object["TranscriptionJobStatus"] = self.status
|
||||
if response_type != "LIST":
|
||||
return {
|
||||
"MedicalTranscriptionJob": {
|
||||
@ -350,13 +375,15 @@ class FakeMedicalTranscriptionJob(BaseObject):
|
||||
if k in response_fields and v is not None and v != [None]
|
||||
}
|
||||
|
||||
def advance_job_status(self):
|
||||
# On each call advances the fake job status
|
||||
def advance(self):
|
||||
old_status = self.status
|
||||
super().advance()
|
||||
new_status = self.status
|
||||
|
||||
if not self.transcription_job_status:
|
||||
self.transcription_job_status = "QUEUED"
|
||||
elif self.transcription_job_status == "QUEUED":
|
||||
self.transcription_job_status = "IN_PROGRESS"
|
||||
if old_status == new_status:
|
||||
return
|
||||
|
||||
if new_status == "IN_PROGRESS":
|
||||
self.start_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
if not self.media_sample_rate_hertz:
|
||||
self.media_sample_rate_hertz = 44100
|
||||
@ -365,8 +392,7 @@ class FakeMedicalTranscriptionJob(BaseObject):
|
||||
self.media_format = (
|
||||
file_ext if file_ext in ["mp3", "mp4", "wav", "flac"] else "mp3"
|
||||
)
|
||||
elif self.transcription_job_status == "IN_PROGRESS":
|
||||
self.transcription_job_status = "COMPLETED"
|
||||
elif new_status == "COMPLETED":
|
||||
self.completion_time = (datetime.now() + timedelta(seconds=10)).strftime(
|
||||
"%Y-%m-%d %H:%M:%S"
|
||||
)
|
||||
@ -379,63 +405,28 @@ class FakeMedicalTranscriptionJob(BaseObject):
|
||||
}
|
||||
|
||||
|
||||
class FakeMedicalVocabulary(BaseObject):
|
||||
class FakeMedicalVocabulary(FakeVocabulary):
|
||||
def __init__(
|
||||
self, region_name, vocabulary_name, language_code, vocabulary_file_uri
|
||||
):
|
||||
super().__init__(
|
||||
region_name,
|
||||
vocabulary_name,
|
||||
language_code=language_code,
|
||||
phrases=None,
|
||||
vocabulary_file_uri=vocabulary_file_uri,
|
||||
)
|
||||
self.model_name = "transcribe::medicalvocabulary"
|
||||
self._region_name = region_name
|
||||
self.vocabulary_name = vocabulary_name
|
||||
self.language_code = language_code
|
||||
self.vocabulary_file_uri = vocabulary_file_uri
|
||||
self.vocabulary_state = None
|
||||
self.last_modified_time = None
|
||||
self.failure_reason = None
|
||||
self.download_uri = "https://s3.us-east-1.amazonaws.com/aws-transcribe-dictionary-model-{}-prod/{}/medical/{}/{}/input.txt".format( # noqa: E501
|
||||
region_name, ACCOUNT_ID, self.vocabulary_name, uuid.uuid4()
|
||||
)
|
||||
|
||||
def response_object(self, response_type):
|
||||
response_field_dict = {
|
||||
"CREATE": [
|
||||
"VocabularyName",
|
||||
"LanguageCode",
|
||||
"VocabularyState",
|
||||
"LastModifiedTime",
|
||||
"FailureReason",
|
||||
],
|
||||
"GET": [
|
||||
"VocabularyName",
|
||||
"LanguageCode",
|
||||
"VocabularyState",
|
||||
"LastModifiedTime",
|
||||
"FailureReason",
|
||||
"DownloadUri",
|
||||
],
|
||||
"LIST": [
|
||||
"VocabularyName",
|
||||
"LanguageCode",
|
||||
"LastModifiedTime",
|
||||
"VocabularyState",
|
||||
],
|
||||
}
|
||||
response_fields = response_field_dict[response_type]
|
||||
response_object = self.gen_response_object()
|
||||
return {
|
||||
k: v
|
||||
for k, v in response_object.items()
|
||||
if k in response_fields and v is not None and v != [None]
|
||||
}
|
||||
|
||||
def advance_job_status(self):
|
||||
# On each call advances the fake job status
|
||||
|
||||
if not self.vocabulary_state:
|
||||
self.vocabulary_state = "PENDING"
|
||||
self.last_modified_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
elif self.vocabulary_state == "PENDING":
|
||||
self.vocabulary_state = "READY"
|
||||
self.last_modified_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
|
||||
class TranscribeBackend(BaseBackend):
|
||||
def __init__(self, region_name=None):
|
||||
@ -445,6 +436,22 @@ class TranscribeBackend(BaseBackend):
|
||||
self.vocabularies = {}
|
||||
self.region_name = region_name
|
||||
|
||||
state_manager.register_default_transition(
|
||||
"transcribe::vocabulary", transition={"progression": "manual", "times": 1}
|
||||
)
|
||||
state_manager.register_default_transition(
|
||||
"transcribe::medicalvocabulary",
|
||||
transition={"progression": "manual", "times": 1},
|
||||
)
|
||||
state_manager.register_default_transition(
|
||||
"transcribe::transcriptionjob",
|
||||
transition={"progression": "manual", "times": 1},
|
||||
)
|
||||
state_manager.register_default_transition(
|
||||
"transcribe::medicaltranscriptionjob",
|
||||
transition={"progression": "manual", "times": 1},
|
||||
)
|
||||
|
||||
def reset(self):
|
||||
region_name = self.region_name
|
||||
self.__dict__ = {}
|
||||
@ -534,7 +541,7 @@ class TranscribeBackend(BaseBackend):
|
||||
def get_transcription_job(self, transcription_job_name):
|
||||
try:
|
||||
job = self.transcriptions[transcription_job_name]
|
||||
job.advance_job_status() # Fakes advancement through statuses.
|
||||
job.advance() # Fakes advancement through statuses.
|
||||
return job.response_object("GET")
|
||||
except KeyError:
|
||||
raise BadRequestException(
|
||||
@ -545,7 +552,7 @@ class TranscribeBackend(BaseBackend):
|
||||
def get_medical_transcription_job(self, medical_transcription_job_name):
|
||||
try:
|
||||
job = self.medical_transcriptions[medical_transcription_job_name]
|
||||
job.advance_job_status() # Fakes advancement through statuses.
|
||||
job.advance() # Fakes advancement through statuses.
|
||||
return job.response_object("GET")
|
||||
except KeyError:
|
||||
raise BadRequestException(
|
||||
@ -577,7 +584,7 @@ class TranscribeBackend(BaseBackend):
|
||||
jobs = list(self.transcriptions.values())
|
||||
|
||||
if state_equals:
|
||||
jobs = [job for job in jobs if job.transcription_job_status == state_equals]
|
||||
jobs = [job for job in jobs if job.status == state_equals]
|
||||
|
||||
if job_name_contains:
|
||||
jobs = [
|
||||
@ -607,7 +614,7 @@ class TranscribeBackend(BaseBackend):
|
||||
jobs = list(self.medical_transcriptions.values())
|
||||
|
||||
if status:
|
||||
jobs = [job for job in jobs if job.transcription_job_status == status]
|
||||
jobs = [job for job in jobs if job.status == status]
|
||||
|
||||
if job_name_contains:
|
||||
jobs = [
|
||||
@ -698,7 +705,7 @@ class TranscribeBackend(BaseBackend):
|
||||
def get_vocabulary(self, vocabulary_name):
|
||||
try:
|
||||
job = self.vocabularies[vocabulary_name]
|
||||
job.advance_job_status() # Fakes advancement through statuses.
|
||||
job.advance() # Fakes advancement through statuses.
|
||||
return job.response_object("GET")
|
||||
except KeyError:
|
||||
raise BadRequestException(
|
||||
@ -709,7 +716,7 @@ class TranscribeBackend(BaseBackend):
|
||||
def get_medical_vocabulary(self, vocabulary_name):
|
||||
try:
|
||||
job = self.medical_vocabularies[vocabulary_name]
|
||||
job.advance_job_status() # Fakes advancement through statuses.
|
||||
job.advance() # Fakes advancement through statuses.
|
||||
return job.response_object("GET")
|
||||
except KeyError:
|
||||
raise BadRequestException(
|
||||
@ -740,7 +747,7 @@ class TranscribeBackend(BaseBackend):
|
||||
vocabularies = [
|
||||
vocabulary
|
||||
for vocabulary in vocabularies
|
||||
if vocabulary.vocabulary_state == state_equals
|
||||
if vocabulary.status == state_equals
|
||||
]
|
||||
|
||||
if name_contains:
|
||||
@ -777,7 +784,7 @@ class TranscribeBackend(BaseBackend):
|
||||
vocabularies = [
|
||||
vocabulary
|
||||
for vocabulary in vocabularies
|
||||
if vocabulary.vocabulary_state == state_equals
|
||||
if vocabulary.status == state_equals
|
||||
]
|
||||
|
||||
if name_contains:
|
||||
|
@ -160,8 +160,8 @@ def test_list_jobs():
|
||||
ec2_client, iam_client, _, _, batch_client = _get_clients()
|
||||
_, _, _, iam_arn = _setup(ec2_client, iam_client)
|
||||
|
||||
job_def_name = "sleep5"
|
||||
commands = ["sleep", "5"]
|
||||
job_def_name = "sleep2"
|
||||
commands = ["sleep", "2"]
|
||||
job_def_arn, queue_arn = prepare_job(batch_client, commands, iam_arn, job_def_name)
|
||||
|
||||
resp = batch_client.submit_job(
|
||||
@ -179,7 +179,10 @@ def test_list_jobs():
|
||||
job.should.have.key("createdAt")
|
||||
job.should.have.key("jobDefinition")
|
||||
job.should.have.key("jobName")
|
||||
job.should.have.key("status").which.should.be.within(["STARTING", "RUNNABLE"])
|
||||
# This is async, so we can't be sure where we are in the process
|
||||
job.should.have.key("status").within(
|
||||
["SUBMITTED", "PENDING", "STARTING", "RUNNABLE", "RUNNING"]
|
||||
)
|
||||
|
||||
batch_client.list_jobs(jobQueue=queue_arn, jobStatus="SUCCEEDED")[
|
||||
"jobSummaryList"
|
||||
@ -299,30 +302,36 @@ def test_cancel_running_job():
|
||||
jobName="test_job_name", jobQueue=queue_arn, jobDefinition=job_def_arn
|
||||
)
|
||||
job_id = resp["jobId"]
|
||||
_wait_for_job_status(batch_client, job_id, "STARTING")
|
||||
_wait_for_job_statuses(
|
||||
batch_client, job_id, statuses=["RUNNABLE", "STARTING", "RUNNING"]
|
||||
)
|
||||
|
||||
batch_client.cancel_job(jobId=job_id, reason="test_cancel")
|
||||
# We cancelled too late, the job was already running. Now we just wait for it to succeed
|
||||
_wait_for_job_status(batch_client, job_id, "SUCCEEDED")
|
||||
_wait_for_job_status(batch_client, job_id, "SUCCEEDED", seconds_to_wait=30)
|
||||
|
||||
resp = batch_client.describe_jobs(jobs=[job_id])
|
||||
resp["jobs"][0]["jobName"].should.equal("test_job_name")
|
||||
resp["jobs"][0].shouldnt.have.key("statusReason")
|
||||
|
||||
|
||||
def _wait_for_job_status(client, job_id, status, seconds_to_wait=60):
|
||||
def _wait_for_job_status(client, job_id, status, seconds_to_wait=30):
|
||||
_wait_for_job_statuses(client, job_id, [status], seconds_to_wait)
|
||||
|
||||
|
||||
def _wait_for_job_statuses(client, job_id, statuses, seconds_to_wait=30):
|
||||
wait_time = datetime.datetime.now() + datetime.timedelta(seconds=seconds_to_wait)
|
||||
last_job_status = None
|
||||
while datetime.datetime.now() < wait_time:
|
||||
resp = client.describe_jobs(jobs=[job_id])
|
||||
last_job_status = resp["jobs"][0]["status"]
|
||||
if last_job_status == status:
|
||||
if last_job_status in statuses:
|
||||
break
|
||||
time.sleep(0.1)
|
||||
else:
|
||||
raise RuntimeError(
|
||||
"Time out waiting for job status {status}!\n Last status: {last_status}".format(
|
||||
status=status, last_status=last_job_status
|
||||
status=statuses, last_status=last_job_status
|
||||
)
|
||||
)
|
||||
|
||||
|
0
tests/test_moto_api/__init__.py
Normal file
0
tests/test_moto_api/__init__.py
Normal file
0
tests/test_moto_api/state_manager/__init__.py
Normal file
0
tests/test_moto_api/state_manager/__init__.py
Normal file
@ -0,0 +1,64 @@
|
||||
import json
|
||||
import sure # noqa # pylint: disable=unused-import
|
||||
|
||||
|
||||
from moto import server
|
||||
|
||||
|
||||
def test_set_transition():
|
||||
backend = server.create_backend_app("moto_api")
|
||||
test_client = backend.test_client()
|
||||
|
||||
post_body = dict(
|
||||
model_name="server::test1",
|
||||
transition={"progression": "waiter", "wait_times": 3},
|
||||
)
|
||||
resp = test_client.post(
|
||||
"http://localhost:5000/moto-api/state-manager/set-transition",
|
||||
data=json.dumps(post_body),
|
||||
)
|
||||
resp.status_code.should.equal(201)
|
||||
|
||||
resp = test_client.get(
|
||||
"http://localhost:5000/moto-api/state-manager/get-transition?model_name=server::test1"
|
||||
)
|
||||
resp.status_code.should.equal(200)
|
||||
json.loads(resp.data).should.equal({"progression": "waiter", "wait_times": 3})
|
||||
|
||||
|
||||
def test_unset_transition():
|
||||
backend = server.create_backend_app("moto_api")
|
||||
test_client = backend.test_client()
|
||||
|
||||
post_body = dict(
|
||||
model_name="server::test2",
|
||||
transition={"progression": "waiter", "wait_times": 3},
|
||||
)
|
||||
test_client.post(
|
||||
"http://localhost:5000/moto-api/state-manager/set-transition",
|
||||
data=json.dumps(post_body),
|
||||
)
|
||||
|
||||
post_body = dict(model_name="server::test2")
|
||||
resp = test_client.post(
|
||||
"http://localhost:5000/moto-api/state-manager/unset-transition",
|
||||
data=json.dumps(post_body),
|
||||
)
|
||||
resp.status_code.should.equal(201)
|
||||
|
||||
resp = test_client.get(
|
||||
"http://localhost:5000/moto-api/state-manager/get-transition?model_name=server::test2"
|
||||
)
|
||||
resp.status_code.should.equal(200)
|
||||
json.loads(resp.data).should.equal({"progression": "immediate"})
|
||||
|
||||
|
||||
def test_get_default_transition():
|
||||
backend = server.create_backend_app("moto_api")
|
||||
test_client = backend.test_client()
|
||||
|
||||
resp = test_client.get(
|
||||
"http://localhost:5000/moto-api/state-manager/get-transition?model_name=unknown"
|
||||
)
|
||||
resp.status_code.should.equal(200)
|
||||
json.loads(resp.data).should.equal({"progression": "immediate"})
|
@ -0,0 +1,63 @@
|
||||
import json
|
||||
import requests
|
||||
import sure # noqa # pylint: disable=unused-import
|
||||
|
||||
|
||||
from moto import settings
|
||||
from unittest import SkipTest
|
||||
|
||||
|
||||
def test_set_transition():
|
||||
if not settings.TEST_SERVER_MODE:
|
||||
raise SkipTest("We only want to test ServerMode here")
|
||||
|
||||
post_body = dict(
|
||||
model_name="test_model0", transition={"progression": "waiter", "wait_times": 3}
|
||||
)
|
||||
resp = requests.post(
|
||||
"http://localhost:5000/moto-api/state-manager/set-transition",
|
||||
data=json.dumps(post_body),
|
||||
)
|
||||
resp.status_code.should.equal(201)
|
||||
|
||||
resp = requests.get(
|
||||
"http://localhost:5000/moto-api/state-manager/get-transition?model_name=test_model0"
|
||||
)
|
||||
resp.status_code.should.equal(200)
|
||||
json.loads(resp.content).should.equal({"progression": "waiter", "wait_times": 3})
|
||||
|
||||
|
||||
def test_unset_transition():
|
||||
if not settings.TEST_SERVER_MODE:
|
||||
raise SkipTest("We only want to test ServerMode here")
|
||||
|
||||
post_body = dict(
|
||||
model_name="test::model1", transition={"progression": "waiter", "wait_times": 3}
|
||||
)
|
||||
requests.post(
|
||||
"http://localhost:5000/moto-api/state-manager/set-transition",
|
||||
data=json.dumps(post_body),
|
||||
)
|
||||
|
||||
post_body = dict(model_name="test::model1")
|
||||
resp = requests.post(
|
||||
"http://localhost:5000/moto-api/state-manager/unset-transition",
|
||||
data=json.dumps(post_body),
|
||||
)
|
||||
|
||||
resp = requests.get(
|
||||
"http://localhost:5000/moto-api/state-manager/get-transition?model_name=test::model1"
|
||||
)
|
||||
resp.status_code.should.equal(200)
|
||||
json.loads(resp.content).should.equal({"progression": "immediate"})
|
||||
|
||||
|
||||
def test_get_default_transition():
|
||||
if not settings.TEST_SERVER_MODE:
|
||||
raise SkipTest("We only want to test ServerMode here")
|
||||
|
||||
resp = requests.get(
|
||||
"http://localhost:5000/moto-api/state-manager/get-transition?model_name=unknown"
|
||||
)
|
||||
resp.status_code.should.equal(200)
|
||||
json.loads(resp.content).should.equal({"progression": "immediate"})
|
51
tests/test_moto_api/state_manager/test_batch_integration.py
Normal file
51
tests/test_moto_api/state_manager/test_batch_integration.py
Normal file
@ -0,0 +1,51 @@
|
||||
from tests.test_batch import _get_clients, _setup
|
||||
from tests.test_batch.test_batch_jobs import prepare_job, _wait_for_job_status
|
||||
|
||||
import sure # noqa # pylint: disable=unused-import
|
||||
|
||||
from moto import mock_batch, mock_iam, mock_ec2, mock_ecs, mock_logs, settings
|
||||
from moto.moto_api import state_manager
|
||||
from unittest import SkipTest
|
||||
|
||||
|
||||
@mock_logs
|
||||
@mock_ec2
|
||||
@mock_ecs
|
||||
@mock_iam
|
||||
@mock_batch
|
||||
def test_cancel_pending_job():
|
||||
if settings.TEST_SERVER_MODE:
|
||||
raise SkipTest("Can't use state_manager in ServerMode directly")
|
||||
|
||||
ec2_client, iam_client, _, _, batch_client = _get_clients()
|
||||
_, _, _, iam_arn = _setup(ec2_client, iam_client)
|
||||
|
||||
# We need to be able to cancel a job that has not been started yet
|
||||
# Locally, our jobs start so fast that we can't cancel them in time
|
||||
# So artificially delay the status progression
|
||||
|
||||
state_manager.set_transition(
|
||||
"batch::job", transition={"progression": "time", "seconds": 2}
|
||||
)
|
||||
|
||||
commands = ["echo", "hello"]
|
||||
job_def_arn, queue_arn = prepare_job(batch_client, commands, iam_arn, "test")
|
||||
|
||||
resp = batch_client.submit_job(
|
||||
jobName="test_job_name",
|
||||
jobQueue=queue_arn,
|
||||
jobDefinition=job_def_arn,
|
||||
)
|
||||
job_id = resp["jobId"]
|
||||
|
||||
batch_client.cancel_job(jobId=job_id, reason="test_cancel")
|
||||
_wait_for_job_status(batch_client, job_id, "FAILED", seconds_to_wait=20)
|
||||
|
||||
resp = batch_client.describe_jobs(jobs=[job_id])
|
||||
resp["jobs"][0]["jobName"].should.equal("test_job_name")
|
||||
resp["jobs"][0]["statusReason"].should.equal("test_cancel")
|
||||
|
||||
|
||||
@mock_batch
|
||||
def test_state_manager_should_return_registered_model():
|
||||
state_manager.get_registered_models().should.contain("batch::job")
|
148
tests/test_moto_api/state_manager/test_managed_state_model.py
Normal file
148
tests/test_moto_api/state_manager/test_managed_state_model.py
Normal file
@ -0,0 +1,148 @@
|
||||
import sure # noqa # pylint: disable=unused-import
|
||||
|
||||
from moto.moto_api._internal.managed_state_model import ManagedState
|
||||
from moto.moto_api import state_manager
|
||||
|
||||
|
||||
class ExampleModel(ManagedState):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
model_name="example::model", transitions=[("frist_status", "second_status")]
|
||||
)
|
||||
|
||||
state_manager.register_default_transition(
|
||||
model_name="example::model", transition={"progression": "manual", "times": 999}
|
||||
)
|
||||
|
||||
|
||||
def test_initial_state():
|
||||
ExampleModel().status.should.equal("frist_status")
|
||||
|
||||
|
||||
def test_advancing_without_specifying_configuration_does_nothing():
|
||||
model = ExampleModel()
|
||||
for _ in range(5):
|
||||
model.status.should.equal("frist_status")
|
||||
model.advance()
|
||||
|
||||
|
||||
def test_advance_immediately():
|
||||
model = ExampleModel()
|
||||
model._transitions = [
|
||||
("frist_status", "second"),
|
||||
("second", "third"),
|
||||
("third", "fourth"),
|
||||
("fourth", "fifth"),
|
||||
]
|
||||
state_manager.set_transition(
|
||||
model_name="example::model", transition={"progression": "immediate"}
|
||||
)
|
||||
|
||||
model.status.should.equal("fifth")
|
||||
|
||||
model.advance()
|
||||
|
||||
model.status.should.equal("fifth")
|
||||
|
||||
|
||||
def test_advance_x_times():
|
||||
model = ExampleModel()
|
||||
state_manager.set_transition(
|
||||
model_name="example::model", transition={"progression": "manual", "times": 3}
|
||||
)
|
||||
for _ in range(2):
|
||||
model.advance()
|
||||
model.status.should.equal("frist_status")
|
||||
|
||||
# 3rd time is a charm
|
||||
model.advance()
|
||||
model.status.should.equal("second_status")
|
||||
|
||||
# Status is still the same if we keep asking for it
|
||||
model.status.should.equal("second_status")
|
||||
|
||||
# Advancing more does not make a difference - there's nothing to advance to
|
||||
model.advance()
|
||||
model.status.should.equal("second_status")
|
||||
|
||||
|
||||
def test_advance_multiple_stages():
|
||||
model = ExampleModel()
|
||||
model._transitions = [
|
||||
("frist_status", "second"),
|
||||
("second", "third"),
|
||||
("third", "fourth"),
|
||||
("fourth", "fifth"),
|
||||
]
|
||||
state_manager.set_transition(
|
||||
model_name="example::model", transition={"progression": "manual", "times": 1}
|
||||
)
|
||||
|
||||
model.status.should.equal("frist_status")
|
||||
model.status.should.equal("frist_status")
|
||||
model.advance()
|
||||
model.status.should.equal("second")
|
||||
model.status.should.equal("second")
|
||||
model.advance()
|
||||
model.status.should.equal("third")
|
||||
model.status.should.equal("third")
|
||||
model.advance()
|
||||
model.status.should.equal("fourth")
|
||||
model.status.should.equal("fourth")
|
||||
model.advance()
|
||||
model.status.should.equal("fifth")
|
||||
model.status.should.equal("fifth")
|
||||
|
||||
|
||||
def test_override_status():
|
||||
model = ExampleModel()
|
||||
model.status = "creating"
|
||||
model._transitions = [("creating", "ready"), ("updating", "ready")]
|
||||
state_manager.set_transition(
|
||||
model_name="example::model", transition={"progression": "manual", "times": 1}
|
||||
)
|
||||
|
||||
model.status.should.equal("creating")
|
||||
model.advance()
|
||||
model.status.should.equal("ready")
|
||||
model.advance()
|
||||
# We're still ready
|
||||
model.status.should.equal("ready")
|
||||
|
||||
# Override status manually
|
||||
model.status = "updating"
|
||||
|
||||
model.status.should.equal("updating")
|
||||
model.advance()
|
||||
model.status.should.equal("ready")
|
||||
model.status.should.equal("ready")
|
||||
model.advance()
|
||||
model.status.should.equal("ready")
|
||||
|
||||
|
||||
class SlowModel(ManagedState):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
model_name="example::slowmodel", transitions=[("first", "second")]
|
||||
)
|
||||
|
||||
|
||||
def test_realworld_delay():
|
||||
model = SlowModel()
|
||||
state_manager.set_transition(
|
||||
model_name="example::slowmodel",
|
||||
transition={"progression": "time", "seconds": 2},
|
||||
)
|
||||
|
||||
model.status.should.equal("first")
|
||||
# The status will stick to 'first' for a long time
|
||||
# Advancing the model doesn't do anything, really
|
||||
for _ in range(10):
|
||||
model.advance()
|
||||
model.status.should.equal("first")
|
||||
|
||||
import time
|
||||
|
||||
time.sleep(2)
|
||||
# Status has only progressed after 2 seconds have passed
|
||||
model.status.should.equal("second")
|
76
tests/test_moto_api/state_manager/test_state_manager.py
Normal file
76
tests/test_moto_api/state_manager/test_state_manager.py
Normal file
@ -0,0 +1,76 @@
|
||||
import sure # noqa # pylint: disable=unused-import
|
||||
|
||||
from moto.moto_api._internal.state_manager import StateManager
|
||||
|
||||
|
||||
def test_public_api():
|
||||
from moto.moto_api import state_manager
|
||||
|
||||
state_manager.should.be.a(StateManager)
|
||||
|
||||
|
||||
def test_default_transition():
|
||||
manager = StateManager()
|
||||
|
||||
manager.register_default_transition(
|
||||
model_name="dax::cluster", transition={"progression": "manual"}
|
||||
)
|
||||
|
||||
actual = manager.get_transition(model_name="dax::cluster")
|
||||
actual.should.equal({"progression": "manual"})
|
||||
|
||||
|
||||
def test_set_transition():
|
||||
manager = StateManager()
|
||||
|
||||
manager.set_transition(
|
||||
model_name="dax::cluster", transition={"progression": "waiter", "wait_times": 3}
|
||||
)
|
||||
|
||||
actual = manager.get_transition(model_name="dax::cluster")
|
||||
actual.should.equal({"progression": "waiter", "wait_times": 3})
|
||||
|
||||
|
||||
def test_set_transition_overrides_default():
|
||||
manager = StateManager()
|
||||
|
||||
manager.register_default_transition(
|
||||
model_name="dax::cluster", transition={"progression": "manual"}
|
||||
)
|
||||
|
||||
manager.set_transition(
|
||||
model_name="dax::cluster", transition={"progression": "waiter", "wait_times": 3}
|
||||
)
|
||||
|
||||
actual = manager.get_transition(model_name="dax::cluster")
|
||||
actual.should.equal({"progression": "waiter", "wait_times": 3})
|
||||
|
||||
|
||||
def test_unset_transition():
|
||||
manager = StateManager()
|
||||
|
||||
manager.register_default_transition(
|
||||
model_name="dax::cluster", transition={"progression": "manual"}
|
||||
)
|
||||
|
||||
manager.set_transition(
|
||||
model_name="dax::cluster", transition={"progression": "waiter", "wait_times": 3}
|
||||
)
|
||||
|
||||
manager.unset_transition(model_name="dax::cluster")
|
||||
|
||||
actual = manager.get_transition(model_name="dax::cluster")
|
||||
actual.should.equal({"progression": "manual"})
|
||||
|
||||
|
||||
def test_get_default_transition():
|
||||
manager = StateManager()
|
||||
|
||||
actual = manager.get_transition(model_name="unknown")
|
||||
actual.should.equal({"progression": "immediate"})
|
||||
|
||||
|
||||
def test_get_registered_models():
|
||||
manager = StateManager()
|
||||
|
||||
manager.get_registered_models().should.equal([])
|
Loading…
Reference in New Issue
Block a user