From 01829b2a668387f0e884046d4b3627819480bb02 Mon Sep 17 00:00:00 2001 From: "Jacek S." Date: Sun, 17 Nov 2024 13:17:24 +0100 Subject: [PATCH] IOT - Improve Job and JobTemplate (#8322) --- CLOUDFORMATION_COVERAGE.md | 5 + IMPLEMENTATION_COVERAGE.md | 8 +- moto/iot/exceptions.py | 9 + moto/iot/models.py | 245 +++++++++++++++++++--- moto/iot/responses.py | 66 +++++- moto/iot/utils.py | 30 ++- tests/test_iot/test_iot_cloudformation.py | 168 +++++++++++++++ tests/test_iot/test_iot_job_templates.py | 213 +++++++++++++++++++ tests/test_iot/test_iot_jobs.py | 41 ++++ 9 files changed, 747 insertions(+), 38 deletions(-) create mode 100644 tests/test_iot/test_iot_job_templates.py diff --git a/CLOUDFORMATION_COVERAGE.md b/CLOUDFORMATION_COVERAGE.md index 34bcedc0a11a..9d3cd37dd7a3 100644 --- a/CLOUDFORMATION_COVERAGE.md +++ b/CLOUDFORMATION_COVERAGE.md @@ -282,6 +282,11 @@ Please let us know if you'd like support for a resource not yet listed here. - [x] update implemented - [x] delete implemented - [x] Fn::GetAtt implemented +- AWS::IoT::JobTemplate: + - [x] create implemented + - [x] update implemented + - [x] delete implemented + - [x] Fn::GetAtt implemented - AWS::KMS::Key: - [x] create implemented - [ ] update implemented diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index 2349a0b5fc0a..58af0d7b8ba9 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -4528,7 +4528,7 @@ - [ ] create_dynamic_thing_group - [ ] create_fleet_metric - [X] create_job -- [ ] create_job_template +- [X] create_job_template - [X] create_keys_and_certificate - [ ] create_mitigation_action - [ ] create_ota_update @@ -4562,7 +4562,7 @@ - [ ] delete_fleet_metric - [X] delete_job - [X] delete_job_execution -- [ ] delete_job_template +- [X] delete_job_template - [ ] delete_mitigation_action - [ ] delete_ota_update - [ ] delete_package @@ -4604,7 +4604,7 @@ - [ ] describe_index - [X] describe_job - [X] describe_job_execution -- [ ] describe_job_template +- [X] describe_job_template - [ ] describe_managed_job_template - [ ] describe_mitigation_action - [ ] describe_provisioning_template @@ -4665,7 +4665,7 @@ - [ ] list_indices - [X] list_job_executions_for_job - [X] list_job_executions_for_thing -- [ ] list_job_templates +- [X] list_job_templates - [X] list_jobs - [ ] list_managed_job_templates - [ ] list_metric_values diff --git a/moto/iot/exceptions.py b/moto/iot/exceptions.py index b12c481e44ff..97e35cc5e488 100644 --- a/moto/iot/exceptions.py +++ b/moto/iot/exceptions.py @@ -83,3 +83,12 @@ def __init__(self, name: str): "InvalidRequestException", f"Cannot delete. Thing {name} is still attached to one or more principals", ) + + +class ConflictException(IoTClientError): + def __init__(self, name: str): + self.code = 409 + super().__init__( + "ConflictException", + f"A resource {name} already exists.", + ) diff --git a/moto/iot/models.py b/moto/iot/models.py index d3efc19d6afc..d5d0f77c0e5f 100644 --- a/moto/iot/models.py +++ b/moto/iot/models.py @@ -24,6 +24,7 @@ from .exceptions import ( CertificateStateException, + ConflictException, DeleteConflictException, InvalidRequestException, InvalidStateTransitionException, @@ -33,7 +34,7 @@ VersionConflictException, VersionsLimitExceededException, ) -from .utils import PAGINATION_MODEL +from .utils import PAGINATION_MODEL, decapitalize_dict if TYPE_CHECKING: from moto.iotdata.models import FakeShadow @@ -667,6 +668,10 @@ def __init__( target_selection: str, job_executions_rollout_config: Dict[str, Any], document_parameters: Dict[str, str], + abort_config: Dict[str, List[Dict[str, Any]]], + job_execution_retry_config: Dict[str, Any], + scheduling_config: Dict[str, Any], + timeout_config: Dict[str, Any], account_id: str, region_name: str, ): @@ -685,6 +690,10 @@ def __init__( self.presigned_url_config = presigned_url_config self.target_selection = target_selection self.job_executions_rollout_config = job_executions_rollout_config + self.abort_config = abort_config + self.job_execution_retry_config = job_execution_retry_config + self.scheduling_config = scheduling_config + self.timeout_config = timeout_config self.status = "QUEUED" # IN_PROGRESS | CANCELED | COMPLETED self.comment: Optional[str] = None self.reason_code: Optional[str] = None @@ -711,7 +720,7 @@ def to_dict(self) -> Dict[str, Any]: "description": self.description, "presignedUrlConfig": self.presigned_url_config, "targetSelection": self.target_selection, - "jobExecutionsRolloutConfig": self.job_executions_rollout_config, + "timeoutConfig": self.timeout_config, "status": self.status, "comment": self.comment, "forceCanceled": self.force, @@ -783,6 +792,139 @@ def to_dict(self) -> Dict[str, Any]: } +class FakeJobTemplate(CloudFormationModel): + JOB_TEMPLATE_ID_REGEX_PATTERN = "[a-zA-Z0-9_-]+" + JOB_TEMPLATE_ID_REGEX = re.compile(JOB_TEMPLATE_ID_REGEX_PATTERN) + + def __init__( + self, + job_template_id: str, + document_source: str, + document: str, + description: str, + presigned_url_config: Dict[str, Any], + job_executions_rollout_config: Dict[str, Any], + abort_config: Dict[str, List[Dict[str, Any]]], + job_execution_retry_config: Dict[str, Any], + timeout_config: Dict[str, Any], + account_id: str, + region_name: str, + ): + if not self._job_template_id_matcher(job_template_id): + raise InvalidRequestException() + + self.account_id = account_id + self.region_name = region_name + self.job_template_id = job_template_id + self.job_template_arn = f"arn:{get_partition(self.region_name)}:iot:{self.region_name}:{self.account_id}:jobtemplate/{job_template_id}" + self.document_source = document_source + self.document = document + self.description = description + self.presigned_url_config = presigned_url_config + self.job_executions_rollout_config = job_executions_rollout_config + self.abort_config = abort_config + self.job_execution_retry_config = job_execution_retry_config + self.timeout_config = timeout_config + self.created_at = time.mktime(datetime(2015, 1, 1).timetuple()) + + def to_dict(self) -> Dict[str, Any]: + return { + "jobTemplateArn": self.job_template_arn, + "jobTemplateId": self.job_template_id, + "description": self.description, + "createdAt": self.created_at, + } + + def _job_template_id_matcher(self, argument: str) -> bool: + return ( + self.JOB_TEMPLATE_ID_REGEX.fullmatch(argument) is not None + and len(argument) <= 64 + ) + + @staticmethod + def cloudformation_name_type() -> str: + return "JobTemplate" + + @staticmethod + def cloudformation_type() -> str: + return "AWS::IoT::JobTemplate" + + @classmethod + def has_cfn_attr(cls, attr: str) -> bool: + return attr in [ + "Arn", + ] + + def get_cfn_attribute(self, attribute_name: str) -> Any: + from moto.cloudformation.exceptions import UnformattedGetAttTemplateException + + if attribute_name == "Arn": + return self.job_template_arn + raise UnformattedGetAttTemplateException() + + @classmethod + def create_from_cloudformation_json( # type: ignore[misc] + cls, + resource_name: str, + cloudformation_json: Any, + account_id: str, + region_name: str, + **kwargs: Any, + ) -> "FakeJobTemplate": + iot_backend = iot_backends[account_id][region_name] + properties = cloudformation_json["Properties"] + + return iot_backend.create_job_template( + job_template_id=properties.get("JobTemplateId", resource_name), + document_source=properties.get("DocumentSource", ""), + document=properties.get("Document"), + description=properties.get("Description"), + presigned_url_config=decapitalize_dict( + properties.get("PresignedUrlConfig", {}) + ), + job_executions_rollout_config=decapitalize_dict( + properties.get("JobExecutionsRolloutConfig", {}) + ), + abort_config=decapitalize_dict(properties.get("AbortConfig", {})), + job_execution_retry_config=decapitalize_dict( + properties.get("JobExecutionsRetryConfig", {}) + ), + timeout_config=decapitalize_dict(properties.get("TimeoutConfig", {})), + ) + + @classmethod + def update_from_cloudformation_json( # type: ignore[misc] + cls, + original_resource: "FakeJobTemplate", + new_resource_name: str, + cloudformation_json: Any, + account_id: str, + region_name: str, + ) -> "FakeJobTemplate": + iot_backend = iot_backends[account_id][region_name] + iot_backend.delete_job_template( + job_template_id=original_resource.job_template_id + ) + + return cls.create_from_cloudformation_json( + new_resource_name, cloudformation_json, account_id, region_name + ) + + @classmethod + def delete_from_cloudformation_json( # type: ignore[misc] + cls, + resource_name: str, + cloudformation_json: Any, + account_id: str, + region_name: str, + ) -> None: + properties = cloudformation_json["Properties"] + job_template_id = properties.get("JobTemplateId", resource_name) + + iot_backend = iot_backends[account_id][region_name] + iot_backend.delete_job_template(job_template_id=job_template_id) + + class FakeEndpoint(BaseModel): def __init__(self, endpoint_type: str, region_name: str): if endpoint_type not in [ @@ -1110,6 +1252,7 @@ def __init__(self, region_name: str, account_id: str): super().__init__(region_name, account_id) self.things: Dict[str, FakeThing] = OrderedDict() self.jobs: Dict[str, FakeJob] = OrderedDict() + self.jobs_templates: Dict[str, FakeJobTemplate] = OrderedDict() self.job_executions: Dict[Tuple[str, str], FakeJobExecution] = OrderedDict() self.thing_types: Dict[str, FakeThingType] = OrderedDict() self.thing_groups: Dict[str, FakeThingGroup] = OrderedDict() @@ -2092,19 +2235,27 @@ def create_job( target_selection: str, job_executions_rollout_config: Dict[str, Any], document_parameters: Dict[str, str], + abort_config: Dict[str, List[Dict[str, Any]]], + job_execution_retry_config: Dict[str, Any], + scheduling_config: Dict[str, Any], + timeout_config: Dict[str, Any], ) -> Tuple[str, str, str]: job = FakeJob( - job_id, - targets, - document_source, - document, - description, - presigned_url_config, - target_selection, - job_executions_rollout_config, - document_parameters, - self.account_id, - self.region_name, + job_id=job_id, + targets=targets, + document_source=document_source, + document=document, + description=description, + presigned_url_config=presigned_url_config, + target_selection=target_selection, + job_executions_rollout_config=job_executions_rollout_config, + abort_config=abort_config, + job_execution_retry_config=job_execution_retry_config, + scheduling_config=scheduling_config, + timeout_config=timeout_config, + document_parameters=document_parameters, + account_id=self.account_id, + region_name=self.region_name, ) self.jobs[job_id] = job @@ -2152,28 +2303,12 @@ def cancel_job( def get_job_document(self, job_id: str) -> FakeJob: return self.jobs[job_id] - def list_jobs( - self, max_results: int, token: Optional[str] - ) -> Tuple[List[Dict[str, Any]], Optional[str]]: + @paginate(PAGINATION_MODEL) # type: ignore[misc] + def list_jobs(self) -> List[Dict[str, Any]]: """ The following parameter are not yet implemented: Status, TargetSelection, ThingGroupName, ThingGroupId """ - all_jobs = [_.to_dict() for _ in self.jobs.values()] - filtered_jobs = all_jobs - - if token is None: - jobs = filtered_jobs[0:max_results] - next_token = str(max_results) if len(filtered_jobs) > max_results else None - else: - int_token = int(token) - jobs = filtered_jobs[int_token : int_token + max_results] - next_token = ( - str(int_token + max_results) - if len(filtered_jobs) > int_token + max_results - else None - ) - - return jobs, next_token + return [_.to_dict() for _ in self.jobs.values()] def describe_job_execution( self, job_id: str, thing_name: str, execution_number: int @@ -2475,5 +2610,51 @@ def update_indexing_configuration( thingIndexingConfiguration, thingGroupIndexingConfiguration ) + def create_job_template( + self, + job_template_id: str, + document_source: str, + document: str, + description: str, + presigned_url_config: Dict[str, Any], + job_executions_rollout_config: Dict[str, Any], + abort_config: Dict[str, List[Dict[str, Any]]], + job_execution_retry_config: Dict[str, Any], + timeout_config: Dict[str, Any], + ) -> "FakeJobTemplate": + if job_template_id in self.jobs_templates: + raise ConflictException(job_template_id) + + job_template = FakeJobTemplate( + job_template_id=job_template_id, + document_source=document_source, + document=document, + description=description, + presigned_url_config=presigned_url_config, + job_executions_rollout_config=job_executions_rollout_config, + abort_config=abort_config, + job_execution_retry_config=job_execution_retry_config, + timeout_config=timeout_config, + account_id=self.account_id, + region_name=self.region_name, + ) + + self.jobs_templates[job_template_id] = job_template + return job_template + + @paginate(PAGINATION_MODEL) # type: ignore[misc] + def list_job_templates(self) -> List[Dict[str, Any]]: + return [_.to_dict() for _ in self.jobs_templates.values()] + + def delete_job_template(self, job_template_id: str) -> None: + if job_template_id not in self.jobs_templates: + raise ResourceNotFoundException(f"Job template {job_template_id} not found") + del self.jobs_templates[job_template_id] + + def describe_job_template(self, job_template_id: str) -> FakeJobTemplate: + if job_template_id not in self.jobs_templates: + raise ResourceNotFoundException(f"Job template {job_template_id} not found") + return self.jobs_templates[job_template_id] + iot_backends = BackendDict(IoTBackend, "iot") diff --git a/moto/iot/responses.py b/moto/iot/responses.py index 55842b818989..69885e2ca8e5 100644 --- a/moto/iot/responses.py +++ b/moto/iot/responses.py @@ -155,6 +155,10 @@ def create_job(self) -> str: target_selection=self._get_param("targetSelection"), job_executions_rollout_config=self._get_param("jobExecutionsRolloutConfig"), document_parameters=self._get_param("documentParameters"), + abort_config=self._get_param("abortConfig"), + job_execution_retry_config=self._get_param("jobExecutionsRetryConfig"), + scheduling_config=self._get_param("schedulingConfig"), + timeout_config=self._get_param("timeoutConfig"), ) return json.dumps(dict(jobArn=job_arn, jobId=job_id, description=description)) @@ -174,6 +178,10 @@ def describe_job(self) -> str: reasonCode=job.reason_code, jobArn=job.job_arn, jobExecutionsRolloutConfig=job.job_executions_rollout_config, + jobExecutionsRetryConfig=job.job_execution_retry_config, + schedulingConfig=job.scheduling_config, + timeoutConfig=job.timeout_config, + abortConfig=job.abort_config, jobId=job.job_id, jobProcessDetails=job.job_process_details, lastUpdatedAt=job.last_updated_at, @@ -220,7 +228,7 @@ def list_jobs(self) -> str: max_results = self._get_int_param("maxResults", 50) previous_next_token = self._get_param("nextToken") jobs, next_token = self.iot_backend.list_jobs( - max_results=max_results, token=previous_next_token + max_results=max_results, next_token=previous_next_token ) return json.dumps(dict(jobs=jobs, nextToken=next_token)) @@ -836,3 +844,59 @@ def update_indexing_configuration(self) -> str: self._get_param("thingGroupIndexingConfiguration", {}), ) return json.dumps({}) + + def create_job_template(self) -> str: + job_template = self.iot_backend.create_job_template( + job_template_id=self._get_param("jobTemplateId"), + description=self._get_param("description"), + document_source=self._get_param("documentSource"), + document=self._get_param("document"), + presigned_url_config=self._get_param("presignedUrlConfig"), + job_executions_rollout_config=self._get_param("jobExecutionsRolloutConfig"), + abort_config=self._get_param("abortConfig"), + job_execution_retry_config=self._get_param("jobExecutionsRetryConfig"), + timeout_config=self._get_param("timeoutConfig"), + ) + + return json.dumps( + dict( + jobTemplateArn=job_template.job_template_arn, + jobTemplateId=job_template.job_template_id, + ) + ) + + def list_job_templates(self) -> str: + max_results = self._get_int_param("maxResults", 50) + current_next_token = self._get_param("nextToken") + job_templates, future_next_token = self.iot_backend.list_job_templates( + max_results=max_results, next_token=current_next_token + ) + + return json.dumps(dict(jobTemplates=job_templates, nextToken=future_next_token)) + + def delete_job_template(self) -> str: + job_template_id = self._get_param("jobTemplateId") + + self.iot_backend.delete_job_template(job_template_id=job_template_id) + + return json.dumps(dict()) + + def describe_job_template(self) -> str: + job_template_id = self._get_param("jobTemplateId") + job_template = self.iot_backend.describe_job_template(job_template_id) + + return json.dumps( + { + "jobTemplateArn": job_template.job_template_arn, + "jobTemplateId": job_template.job_template_id, + "description": job_template.description, + "documentSource": job_template.document_source, + "document": job_template.document, + "createdAt": job_template.created_at, + "presignedUrlConfig": job_template.presigned_url_config, + "jobExecutionsRolloutConfig": job_template.job_executions_rollout_config, + "abortConfig": job_template.abort_config, + "timeoutConfig": job_template.timeout_config, + "jobExecutionsRetryConfig": job_template.job_execution_retry_config, + } + ) diff --git a/moto/iot/utils.py b/moto/iot/utils.py index 1a08f56b0ee2..fb71f639a484 100644 --- a/moto/iot/utils.py +++ b/moto/iot/utils.py @@ -1,8 +1,36 @@ +from typing import Any + PAGINATION_MODEL = { "list_job_executions_for_thing": { "input_token": "next_token", "limit_key": "max_results", "limit_default": 100, "unique_attribute": "jobId", - } + }, + "list_job_templates": { + "input_token": "next_token", + "limit_key": "max_results", + "limit_default": 100, + "unique_attribute": "jobTemplateId", + }, + "list_jobs": { + "input_token": "next_token", + "limit_key": "max_results", + "limit_default": 100, + "unique_attribute": "jobId", + }, } + + +def decapitalize_str(obj: str) -> str: + return obj[0].lower() + obj[1:] + + +def decapitalize_dict(obj: Any) -> Any: + if isinstance(obj, dict): + return { + decapitalize_str(key): decapitalize_dict(value) + for key, value in obj.items() + } + else: + return obj diff --git a/tests/test_iot/test_iot_cloudformation.py b/tests/test_iot/test_iot_cloudformation.py index 27199d2cb888..8eb60fe469b5 100644 --- a/tests/test_iot/test_iot_cloudformation.py +++ b/tests/test_iot/test_iot_cloudformation.py @@ -1,6 +1,8 @@ import json +from datetime import datetime import boto3 +from dateutil.tz import tzlocal from moto import mock_aws @@ -685,3 +687,169 @@ def test_delete_role_alias_with_cloudformation(): # then check stack cfn_conn.delete_stack(StackName=stack_name) assert iot_conn.list_role_aliases()["roleAliases"] == [] + + +@mock_aws +def test_create_job_template_with_simple_cloudformation(): + # given + stack_name = "test_stack" + job_document = {"field": "value"} + + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "IOT JobTemplate CloudFormation", + "Resources": { + "testJobTemplate": { + "Type": "AWS::IoT::JobTemplate", + "Properties": { + "JobTemplateId": "JobTemplate", + "Description": "Job template Description", + "Document": json.dumps(job_document), + "DocumentSource": "a document source link", + "PresignedUrlConfig": { + "ExpiresInSec": 123, + "RoleArn": "arn:aws:iam::1:role/service-role/iot_job_role", + }, + "TimeoutConfig": {"InProgressTimeoutInMinutes": 30}, + }, + }, + }, + "Outputs": { + "JobTemplateArn": {"Value": {"Fn::GetAtt": ["testJobTemplate", "Arn"]}}, + }, + } + + # when + cfn_conn = boto3.client("cloudformation", region_name=TEST_REGION) + cfn_conn.create_stack(StackName=stack_name, TemplateBody=json.dumps(template)) + + # then check list of things + iot_conn = boto3.client("iot", region_name=TEST_REGION) + assert len(iot_conn.list_job_templates()["jobTemplates"]) == 1 + assert ( + iot_conn.list_job_templates()["jobTemplates"][0]["jobTemplateId"] + == "JobTemplate" + ) + + # then check stack + stack = cfn_conn.describe_stacks(StackName=stack_name)["Stacks"][0] + outputs = { + Output["OutputKey"]: Output["OutputValue"] for Output in stack["Outputs"] + } + assert ( + outputs["JobTemplateArn"] + == "arn:aws:iot:us-west-1:123456789012:jobtemplate/JobTemplate" + ) + + # and describe it + job_template = iot_conn.describe_job_template(jobTemplateId="JobTemplate") + assert job_template["jobTemplateId"] == "JobTemplate" + assert ( + job_template["jobTemplateArn"] + == "arn:aws:iot:us-west-1:123456789012:jobtemplate/JobTemplate" + ) + assert job_template["description"] == "Job template Description" + assert job_template["document"] == '{"field": "value"}' + assert job_template["documentSource"] == "a document source link" + assert job_template["createdAt"] == datetime(2015, 1, 1, 0, 0, tzinfo=tzlocal()) + assert job_template["presignedUrlConfig"] == { + "roleArn": "arn:aws:iam::1:role/service-role/iot_job_role", + "expiresInSec": 123, + } + assert job_template["timeoutConfig"] == { + "inProgressTimeoutInMinutes": 30, + } + + +@mock_aws +def test_update_job_template_with_simple_cloudformation(): + # given + stack_name = "test_stack" + + initial_template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "IOT JobTemplate CloudFormation", + "Resources": { + "testJobTemplate": { + "Type": "AWS::IoT::JobTemplate", + "Properties": { + "JobTemplateId": "JobTemplate", + "Description": "Job template Description", + "Document": json.dumps({"field1": "value1"}), + }, + }, + }, + } + updated_template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "IOT JobTemplate CloudFormation", + "Resources": { + "testJobTemplate": { + "Type": "AWS::IoT::JobTemplate", + "Properties": { + "JobTemplateId": "JobTemplate2", + "Description": "Job template Description", + "Document": json.dumps({"field2": "value2"}), + }, + }, + }, + } + + # when + cfn_conn = boto3.client("cloudformation", region_name=TEST_REGION) + cfn_conn.create_stack( + StackName=stack_name, TemplateBody=json.dumps(initial_template) + ) + + # then check list of things + iot_conn = boto3.client("iot", region_name=TEST_REGION) + assert len(iot_conn.list_job_templates()["jobTemplates"]) == 1 + assert ( + iot_conn.list_job_templates()["jobTemplates"][0]["jobTemplateId"] + == "JobTemplate" + ) + + # then update stack + cfn_conn.update_stack( + StackName=stack_name, TemplateBody=json.dumps(updated_template) + ) + assert len(iot_conn.list_job_templates()["jobTemplates"]) == 1 + assert ( + iot_conn.list_job_templates()["jobTemplates"][0]["jobTemplateId"] + == "JobTemplate2" + ) + + +@mock_aws +def test_delete_job_template_with_simple_cloudformation(): + # given + stack_name = "test_stack" + + template = { + "AWSTemplateFormatVersion": "2010-09-09", + "Description": "IOT JobTemplate CloudFormation", + "Resources": { + "testJobTemplate": { + "Type": "AWS::IoT::JobTemplate", + "Properties": { + "JobTemplateId": "JobTemplate", + "Description": "Job template Description", + "Document": json.dumps({"field": "value"}), + }, + }, + }, + } + + # when + cfn_conn = boto3.client("cloudformation", region_name=TEST_REGION) + cfn_conn.create_stack(StackName=stack_name, TemplateBody=json.dumps(template)) + + # then check list of things + iot_conn = boto3.client("iot", region_name=TEST_REGION) + assert len(iot_conn.list_job_templates()["jobTemplates"]) == 1 + + # then check stack + cfn_conn.delete_stack(StackName=stack_name) + + # and + assert len(iot_conn.list_job_templates()["jobTemplates"]) == 0 diff --git a/tests/test_iot/test_iot_job_templates.py b/tests/test_iot/test_iot_job_templates.py new file mode 100644 index 000000000000..d94bbc3aaf3e --- /dev/null +++ b/tests/test_iot/test_iot_job_templates.py @@ -0,0 +1,213 @@ +import json + +import boto3 +import pytest + +from moto import mock_aws + + +@mock_aws +def test_create_job_template(): + client = boto3.client("iot", region_name="eu-west-1") + job_template_id = "TestJobTemplate" + + job_document = {"field": "value"} + + job_template = client.create_job_template( + jobTemplateId=job_template_id, + document=json.dumps(job_document), + description="Description", + presignedUrlConfig={ + "roleArn": "arn:aws:iam::1:role/service-role/iot_job_role", + "expiresInSec": 123, + }, + jobExecutionsRolloutConfig={"maximumPerMinute": 10}, + jobExecutionsRetryConfig={ + "criteriaList": [{"failureType": "ALL", "numberOfRetries": 10}] + }, + abortConfig={ + "criteriaList": [ + { + "action": "CANCEL", + "failureType": "ALL", + "minNumberOfExecutedThings": 1, + "thresholdPercentage": 90, + } + ] + }, + ) + + assert job_template["jobTemplateId"] == job_template_id + assert ( + job_template["jobTemplateArn"] + == "arn:aws:iot:eu-west-1:123456789012:jobtemplate/TestJobTemplate" + ) + + +@mock_aws +def test_create_job_template_with_invalid_id(): + client = boto3.client("iot", region_name="eu-west-1") + job_template_id = "TestJobTemplate!@#4" + + with pytest.raises(client.exceptions.InvalidRequestException): + client.create_job_template( + jobTemplateId=job_template_id, + document=json.dumps({"field": "value"}), + description="Description", + ) + + +@mock_aws +def test_create_job_template_with_the_same_id_twice(): + client = boto3.client("iot", region_name="eu-west-1") + job_template_id = "TestJobTemplate" + + client.create_job_template( + jobTemplateId=job_template_id, + document=json.dumps({"field": "value"}), + description="Description", + ) + + with pytest.raises(client.exceptions.ConflictException): + client.create_job_template( + jobTemplateId=job_template_id, + document=json.dumps({"field": "value"}), + description="Description", + ) + + +@mock_aws +def test_describe_job_template(): + client = boto3.client("iot", region_name="eu-west-1") + job_template_id = "TestJobTemplate" + + job_document = {"field": "value"} + + client.create_job_template( + jobTemplateId=job_template_id, + document=json.dumps(job_document), + description="Description", + presignedUrlConfig={ + "roleArn": "arn:aws:iam::1:role/service-role/iot_job_role", + "expiresInSec": 123, + }, + jobExecutionsRolloutConfig={"maximumPerMinute": 10}, + jobExecutionsRetryConfig={ + "criteriaList": [{"failureType": "ALL", "numberOfRetries": 10}] + }, + abortConfig={ + "criteriaList": [ + { + "action": "CANCEL", + "failureType": "ALL", + "minNumberOfExecutedThings": 1, + "thresholdPercentage": 90, + } + ] + }, + ) + + job_template = client.describe_job_template(jobTemplateId=job_template_id) + + assert job_template["jobTemplateId"] == job_template_id + assert ( + job_template["jobTemplateArn"] + == "arn:aws:iot:eu-west-1:123456789012:jobtemplate/TestJobTemplate" + ) + assert job_template["description"] == "Description" + assert job_template["presignedUrlConfig"] == { + "roleArn": "arn:aws:iam::1:role/service-role/iot_job_role", + "expiresInSec": 123, + } + assert job_template["jobExecutionsRolloutConfig"] == {"maximumPerMinute": 10} + assert job_template["jobExecutionsRetryConfig"]["criteriaList"] == [ + {"failureType": "ALL", "numberOfRetries": 10} + ] + + +@mock_aws +def test_describe_nonexistent_job_template(): + client = boto3.client("iot", region_name="eu-west-1") + with pytest.raises(client.exceptions.ResourceNotFoundException): + client.describe_job_template(jobTemplateId="nonexistent") + + +@mock_aws +def test_delete_nonexistent_job_template(): + client = boto3.client("iot", region_name="eu-west-1") + with pytest.raises(client.exceptions.ResourceNotFoundException): + client.delete_job_template(jobTemplateId="nonexistent") + + +@mock_aws +def test_list_job_templates(): + client = boto3.client("iot", region_name="eu-west-1") + job_document = {"field": "value"} + job_template_ids = ["TestJobTemplate1", "AnotherJobTemplate"] + + for template_id in job_template_ids: + job_template = client.create_job_template( + jobTemplateId=template_id, + document=json.dumps(job_document), + description="Description", + ) + + assert job_template["jobTemplateId"] == template_id + + list_result = client.list_job_templates(maxResults=100) + assert "nextToken" not in list_result + assert len(list_result["jobTemplates"]) == 2 + + +@mock_aws +def test_list_job_templates_wht_pagination(): + client = boto3.client("iot", region_name="eu-west-1") + job_document = {"field": "value"} + job_template_ids = [ + "TestJobTemplate1", + "AnotherJobTemplate", + "YetAnotherJob", + "LasttestJob", + ] + + for template_id in job_template_ids: + job_template = client.create_job_template( + jobTemplateId=template_id, + document=json.dumps(job_document), + description="Description", + ) + + assert job_template["jobTemplateId"] == template_id + + first_list_result = client.list_job_templates(maxResults=2) + assert "nextToken" in first_list_result + assert len(first_list_result["jobTemplates"]) == 2 + + second_list_result = client.list_job_templates( + maxResults=2, nextToken=first_list_result["nextToken"] + ) + assert "nextToken" not in second_list_result + assert len(second_list_result["jobTemplates"]) == 2 + + +@mock_aws +def test_delete_job_template(): + # given + client = boto3.client("iot", region_name="eu-west-1") + job_template_id = "TestJobTemplate" + client.create_job_template( + jobTemplateId=job_template_id, + document=json.dumps({"field": "value"}), + description="Description", + ) + list_result = client.list_job_templates(maxResults=100) + assert len(list_result["jobTemplates"]) == 1 + + # when + client.delete_job_template( + jobTemplateId=job_template_id, + ) + + # then + list_result = client.list_job_templates(maxResults=100) + assert len(list_result["jobTemplates"]) == 0 diff --git a/tests/test_iot/test_iot_jobs.py b/tests/test_iot/test_iot_jobs.py index 4a4a2be16550..d48347779ec7 100644 --- a/tests/test_iot/test_iot_jobs.py +++ b/tests/test_iot/test_iot_jobs.py @@ -32,6 +32,19 @@ def test_create_job(): }, targetSelection="CONTINUOUS", jobExecutionsRolloutConfig={"maximumPerMinute": 10}, + jobExecutionsRetryConfig={ + "criteriaList": [{"failureType": "ALL", "numberOfRetries": 10}] + }, + abortConfig={ + "criteriaList": [ + { + "action": "CANCEL", + "failureType": "ALL", + "minNumberOfExecutedThings": 1, + "thresholdPercentage": 90, + } + ] + }, ) assert job["jobId"] == job_id @@ -116,6 +129,21 @@ def test_describe_job(): }, targetSelection="CONTINUOUS", jobExecutionsRolloutConfig={"maximumPerMinute": 10}, + jobExecutionsRetryConfig={ + "criteriaList": [{"failureType": "ALL", "numberOfRetries": 10}] + }, + abortConfig={ + "criteriaList": [ + { + "action": "CANCEL", + "failureType": "ALL", + "minNumberOfExecutedThings": 1, + "thresholdPercentage": 90, + } + ] + }, + schedulingConfig={"endBehavior": "FORCE_CANCEL"}, + timeoutConfig={"inProgressTimeoutInMinutes": 100}, ) assert job["jobId"] == job_id @@ -140,6 +168,19 @@ def test_describe_job(): ) assert job["presignedUrlConfig"]["expiresInSec"] == 123 assert job["jobExecutionsRolloutConfig"]["maximumPerMinute"] == 10 + assert job["jobExecutionsRetryConfig"] == { + "criteriaList": [{"failureType": "ALL", "numberOfRetries": 10}] + } + assert job["schedulingConfig"] == {"endBehavior": "FORCE_CANCEL"} + assert job["timeoutConfig"] == {"inProgressTimeoutInMinutes": 100} + assert job["abortConfig"]["criteriaList"] == [ + { + "action": "CANCEL", + "failureType": "ALL", + "minNumberOfExecutedThings": 1, + "thresholdPercentage": 90, + } + ] @mock_aws