1877 lines
69 KiB
Python
1877 lines
69 KiB
Python
import hashlib
|
|
import re
|
|
import time
|
|
from collections import OrderedDict
|
|
from cryptography import x509
|
|
from cryptography.hazmat._oid import NameOID
|
|
from cryptography.hazmat.backends import default_backend
|
|
from cryptography.hazmat.primitives.asymmetric import rsa
|
|
from cryptography.hazmat.primitives import serialization, hashes
|
|
from datetime import datetime, timedelta
|
|
from typing import Any, Dict, List, Tuple, Optional, Pattern, Iterable, TYPE_CHECKING
|
|
|
|
from .utils import PAGINATION_MODEL
|
|
|
|
from moto.core import BaseBackend, BackendDict, BaseModel
|
|
from moto.core.utils import utcnow
|
|
from moto.moto_api._internal import mock_random as random
|
|
from moto.utilities.paginator import paginate
|
|
from .exceptions import (
|
|
CertificateStateException,
|
|
DeleteConflictException,
|
|
ResourceNotFoundException,
|
|
InvalidRequestException,
|
|
InvalidStateTransitionException,
|
|
VersionConflictException,
|
|
ResourceAlreadyExistsException,
|
|
VersionsLimitExceededException,
|
|
ThingStillAttached,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from moto.iotdata.models import FakeShadow
|
|
|
|
|
|
class FakeThing(BaseModel):
|
|
def __init__(
|
|
self,
|
|
thing_name: str,
|
|
thing_type: Optional["FakeThingType"],
|
|
attributes: Dict[str, Any],
|
|
account_id: str,
|
|
region_name: str,
|
|
):
|
|
self.region_name = region_name
|
|
self.thing_name = thing_name
|
|
self.thing_type = thing_type
|
|
self.attributes = attributes
|
|
self.arn = f"arn:aws:iot:{region_name}:{account_id}:thing/{thing_name}"
|
|
self.version = 1
|
|
# TODO: we need to handle "version"?
|
|
|
|
# for iot-data
|
|
self.thing_shadows: Dict[Optional[str], FakeShadow] = {}
|
|
|
|
def matches(self, query_string: str) -> bool:
|
|
if query_string == "*":
|
|
return True
|
|
if query_string.startswith("thingName:"):
|
|
qs = query_string[10:].replace("*", ".*").replace("?", ".")
|
|
return re.search(f"^{qs}$", self.thing_name) is not None
|
|
if query_string.startswith("attributes."):
|
|
k, v = query_string[11:].split(":")
|
|
return self.attributes.get(k) == v
|
|
return query_string in self.thing_name
|
|
|
|
def to_dict(
|
|
self,
|
|
include_default_client_id: bool = False,
|
|
include_connectivity: bool = False,
|
|
) -> Dict[str, Any]:
|
|
obj = {
|
|
"thingName": self.thing_name,
|
|
"thingArn": self.arn,
|
|
"attributes": self.attributes,
|
|
"version": self.version,
|
|
}
|
|
if self.thing_type:
|
|
obj["thingTypeName"] = self.thing_type.thing_type_name
|
|
if include_default_client_id:
|
|
obj["defaultClientId"] = self.thing_name
|
|
if include_connectivity:
|
|
obj["connectivity"] = {
|
|
"connected": True,
|
|
"timestamp": time.mktime(utcnow().timetuple()),
|
|
}
|
|
return obj
|
|
|
|
|
|
class FakeThingType(BaseModel):
|
|
def __init__(
|
|
self,
|
|
thing_type_name: str,
|
|
thing_type_properties: Optional[Dict[str, Any]],
|
|
region_name: str,
|
|
):
|
|
self.region_name = region_name
|
|
self.thing_type_name = thing_type_name
|
|
self.thing_type_properties = thing_type_properties
|
|
self.thing_type_id = str(random.uuid4()) # I don't know the rule of id
|
|
t = time.time()
|
|
self.metadata = {"deprecated": False, "creationDate": int(t * 1000) / 1000.0}
|
|
self.arn = f"arn:aws:iot:{self.region_name}:1:thingtype/{thing_type_name}"
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"thingTypeName": self.thing_type_name,
|
|
"thingTypeId": self.thing_type_id,
|
|
"thingTypeProperties": self.thing_type_properties,
|
|
"thingTypeMetadata": self.metadata,
|
|
"thingTypeArn": self.arn,
|
|
}
|
|
|
|
|
|
class FakeThingGroup(BaseModel):
|
|
def __init__(
|
|
self,
|
|
thing_group_name: str,
|
|
parent_group_name: str,
|
|
thing_group_properties: Dict[str, str],
|
|
region_name: str,
|
|
thing_groups: Dict[str, "FakeThingGroup"],
|
|
):
|
|
self.region_name = region_name
|
|
self.thing_group_name = thing_group_name
|
|
self.thing_group_id = str(random.uuid4()) # I don't know the rule of id
|
|
self.version = 1 # TODO: tmp
|
|
self.parent_group_name = parent_group_name
|
|
self.thing_group_properties = thing_group_properties or {}
|
|
t = time.time()
|
|
self.metadata: Dict[str, Any] = {"creationDate": int(t * 1000) / 1000.0}
|
|
if parent_group_name:
|
|
self.metadata["parentGroupName"] = parent_group_name
|
|
# initilize rootToParentThingGroups
|
|
if "rootToParentThingGroups" not in self.metadata:
|
|
self.metadata["rootToParentThingGroups"] = []
|
|
# search for parent arn
|
|
for thing_group in thing_groups.values():
|
|
if thing_group.thing_group_name == parent_group_name:
|
|
parent_thing_group_structure = thing_group
|
|
break
|
|
# if parent arn found (should always be found)
|
|
if parent_thing_group_structure:
|
|
# copy parent's rootToParentThingGroups
|
|
if "rootToParentThingGroups" in parent_thing_group_structure.metadata:
|
|
self.metadata["rootToParentThingGroups"].extend(
|
|
parent_thing_group_structure.metadata["rootToParentThingGroups"]
|
|
)
|
|
self.metadata["rootToParentThingGroups"].extend(
|
|
[
|
|
{
|
|
"groupName": parent_group_name,
|
|
"groupArn": parent_thing_group_structure.arn, # type: ignore
|
|
}
|
|
]
|
|
)
|
|
self.arn = f"arn:aws:iot:{self.region_name}:1:thinggroup/{thing_group_name}"
|
|
self.things: Dict[str, FakeThing] = OrderedDict()
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"thingGroupName": self.thing_group_name,
|
|
"thingGroupId": self.thing_group_id,
|
|
"version": self.version,
|
|
"thingGroupProperties": self.thing_group_properties,
|
|
"thingGroupMetadata": self.metadata,
|
|
"thingGroupArn": self.arn,
|
|
}
|
|
|
|
|
|
class FakeCertificate(BaseModel):
|
|
def __init__(
|
|
self,
|
|
certificate_pem: str,
|
|
status: str,
|
|
account_id: str,
|
|
region_name: str,
|
|
ca_certificate_id: Optional[str] = None,
|
|
):
|
|
m = hashlib.sha256()
|
|
m.update(certificate_pem.encode("utf-8"))
|
|
self.certificate_id = m.hexdigest()
|
|
self.arn = f"arn:aws:iot:{region_name}:{account_id}:cert/{self.certificate_id}"
|
|
self.certificate_pem = certificate_pem
|
|
self.status = status
|
|
|
|
self.owner = account_id
|
|
self.transfer_data: Dict[str, str] = {}
|
|
self.creation_date = time.time()
|
|
self.last_modified_date = self.creation_date
|
|
self.validity_not_before = time.time() - 86400
|
|
self.validity_not_after = time.time() + 86400
|
|
self.ca_certificate_id = ca_certificate_id
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"certificateArn": self.arn,
|
|
"certificateId": self.certificate_id,
|
|
"caCertificateId": self.ca_certificate_id,
|
|
"status": self.status,
|
|
"creationDate": self.creation_date,
|
|
}
|
|
|
|
def to_description_dict(self) -> Dict[str, Any]:
|
|
"""
|
|
You might need keys below in some situation
|
|
- caCertificateId
|
|
- previousOwnedBy
|
|
"""
|
|
return {
|
|
"certificateArn": self.arn,
|
|
"certificateId": self.certificate_id,
|
|
"status": self.status,
|
|
"certificatePem": self.certificate_pem,
|
|
"ownedBy": self.owner,
|
|
"creationDate": self.creation_date,
|
|
"lastModifiedDate": self.last_modified_date,
|
|
"validity": {
|
|
"notBefore": self.validity_not_before,
|
|
"notAfter": self.validity_not_after,
|
|
},
|
|
"transferData": self.transfer_data,
|
|
}
|
|
|
|
|
|
class FakeCaCertificate(FakeCertificate):
|
|
def __init__(
|
|
self,
|
|
ca_certificate: str,
|
|
status: str,
|
|
account_id: str,
|
|
region_name: str,
|
|
registration_config: Dict[str, str],
|
|
):
|
|
super().__init__(
|
|
certificate_pem=ca_certificate,
|
|
status=status,
|
|
account_id=account_id,
|
|
region_name=region_name,
|
|
ca_certificate_id=None,
|
|
)
|
|
self.registration_config = registration_config
|
|
|
|
|
|
class FakePolicy(BaseModel):
|
|
def __init__(
|
|
self,
|
|
name: str,
|
|
document: Dict[str, Any],
|
|
account_id: str,
|
|
region_name: str,
|
|
default_version_id: str = "1",
|
|
):
|
|
self.name = name
|
|
self.document = document
|
|
self.arn = f"arn:aws:iot:{region_name}:{account_id}:policy/{name}"
|
|
self.default_version_id = default_version_id
|
|
self.versions = [
|
|
FakePolicyVersion(self.name, document, True, account_id, region_name)
|
|
]
|
|
self._max_version_id = self.versions[0]._version_id
|
|
|
|
def to_get_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"policyName": self.name,
|
|
"policyArn": self.arn,
|
|
"policyDocument": self.document,
|
|
"defaultVersionId": self.default_version_id,
|
|
}
|
|
|
|
def to_dict_at_creation(self) -> Dict[str, Any]:
|
|
return {
|
|
"policyName": self.name,
|
|
"policyArn": self.arn,
|
|
"policyDocument": self.document,
|
|
"policyVersionId": self.default_version_id,
|
|
}
|
|
|
|
def to_dict(self) -> Dict[str, str]:
|
|
return {"policyName": self.name, "policyArn": self.arn}
|
|
|
|
|
|
class FakePolicyVersion:
|
|
def __init__(
|
|
self,
|
|
policy_name: str,
|
|
document: Dict[str, Any],
|
|
is_default: bool,
|
|
account_id: str,
|
|
region_name: str,
|
|
version_id: int = 1,
|
|
):
|
|
self.name = policy_name
|
|
self.arn = f"arn:aws:iot:{region_name}:{account_id}:policy/{policy_name}"
|
|
self.document = document or {}
|
|
self.is_default = is_default
|
|
self._version_id = version_id
|
|
|
|
self.create_datetime = time.mktime(datetime(2015, 1, 1).timetuple())
|
|
self.last_modified_datetime = time.mktime(datetime(2015, 1, 2).timetuple())
|
|
|
|
@property
|
|
def version_id(self) -> str:
|
|
return str(self._version_id)
|
|
|
|
def to_get_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"policyName": self.name,
|
|
"policyArn": self.arn,
|
|
"policyDocument": self.document,
|
|
"policyVersionId": self.version_id,
|
|
"isDefaultVersion": self.is_default,
|
|
"creationDate": self.create_datetime,
|
|
"lastModifiedDate": self.last_modified_datetime,
|
|
"generationId": self.version_id,
|
|
}
|
|
|
|
def to_dict_at_creation(self) -> Dict[str, Any]:
|
|
return {
|
|
"policyArn": self.arn,
|
|
"policyDocument": self.document,
|
|
"policyVersionId": self.version_id,
|
|
"isDefaultVersion": self.is_default,
|
|
}
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"versionId": self.version_id,
|
|
"isDefaultVersion": self.is_default,
|
|
"createDate": self.create_datetime,
|
|
}
|
|
|
|
|
|
class FakeJob(BaseModel):
|
|
JOB_ID_REGEX_PATTERN = "[a-zA-Z0-9_-]"
|
|
JOB_ID_REGEX = re.compile(JOB_ID_REGEX_PATTERN)
|
|
|
|
def __init__(
|
|
self,
|
|
job_id: str,
|
|
targets: List[str],
|
|
document_source: str,
|
|
document: str,
|
|
description: str,
|
|
presigned_url_config: Dict[str, Any],
|
|
target_selection: str,
|
|
job_executions_rollout_config: Dict[str, Any],
|
|
document_parameters: Dict[str, str],
|
|
region_name: str,
|
|
):
|
|
if not self._job_id_matcher(self.JOB_ID_REGEX, job_id):
|
|
raise InvalidRequestException()
|
|
|
|
self.region_name = region_name
|
|
self.job_id = job_id
|
|
self.job_arn = f"arn:aws:iot:{self.region_name}:1:job/{job_id}"
|
|
self.targets = targets
|
|
self.document_source = document_source
|
|
self.document = document
|
|
self.force = False
|
|
self.description = description
|
|
self.presigned_url_config = presigned_url_config
|
|
self.target_selection = target_selection
|
|
self.job_executions_rollout_config = job_executions_rollout_config
|
|
self.status = "QUEUED" # IN_PROGRESS | CANCELED | COMPLETED
|
|
self.comment: Optional[str] = None
|
|
self.reason_code: Optional[str] = None
|
|
self.created_at = time.mktime(datetime(2015, 1, 1).timetuple())
|
|
self.last_updated_at = time.mktime(datetime(2015, 1, 1).timetuple())
|
|
self.completed_at = None
|
|
self.job_process_details = {
|
|
"processingTargets": targets,
|
|
"numberOfQueuedThings": 1,
|
|
"numberOfCanceledThings": 0,
|
|
"numberOfSucceededThings": 0,
|
|
"numberOfFailedThings": 0,
|
|
"numberOfRejectedThings": 0,
|
|
"numberOfInProgressThings": 0,
|
|
"numberOfRemovedThings": 0,
|
|
}
|
|
self.document_parameters = document_parameters
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
obj = {
|
|
"jobArn": self.job_arn,
|
|
"jobId": self.job_id,
|
|
"targets": self.targets,
|
|
"description": self.description,
|
|
"presignedUrlConfig": self.presigned_url_config,
|
|
"targetSelection": self.target_selection,
|
|
"jobExecutionsRolloutConfig": self.job_executions_rollout_config,
|
|
"status": self.status,
|
|
"comment": self.comment,
|
|
"forceCanceled": self.force,
|
|
"reasonCode": self.reason_code,
|
|
"createdAt": self.created_at,
|
|
"lastUpdatedAt": self.last_updated_at,
|
|
"completedAt": self.completed_at,
|
|
"jobProcessDetails": self.job_process_details,
|
|
"documentParameters": self.document_parameters,
|
|
"document": self.document,
|
|
"documentSource": self.document_source,
|
|
}
|
|
|
|
return obj
|
|
|
|
def _job_id_matcher(self, regex: Pattern[str], argument: str) -> bool:
|
|
regex_match = regex.match(argument) is not None
|
|
length_match = len(argument) <= 64
|
|
return regex_match and length_match
|
|
|
|
|
|
class FakeJobExecution(BaseModel):
|
|
def __init__(
|
|
self,
|
|
job_id: str,
|
|
thing_arn: str,
|
|
status: str = "QUEUED",
|
|
force_canceled: bool = False,
|
|
status_details_map: Optional[Dict[str, Any]] = None,
|
|
):
|
|
self.job_id = job_id
|
|
self.status = status # IN_PROGRESS | CANCELED | COMPLETED
|
|
self.force_canceled = force_canceled
|
|
self.status_details_map = status_details_map or {}
|
|
self.thing_arn = thing_arn
|
|
self.queued_at = time.mktime(datetime(2015, 1, 1).timetuple())
|
|
self.started_at = time.mktime(datetime(2015, 1, 1).timetuple())
|
|
self.last_updated_at = time.mktime(datetime(2015, 1, 1).timetuple())
|
|
self.execution_number = 123
|
|
self.version_number = 123
|
|
self.approximate_seconds_before_time_out = 123
|
|
|
|
def to_get_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"jobId": self.job_id,
|
|
"status": self.status,
|
|
"forceCanceled": self.force_canceled,
|
|
"statusDetails": {"detailsMap": self.status_details_map},
|
|
"thingArn": self.thing_arn,
|
|
"queuedAt": self.queued_at,
|
|
"startedAt": self.started_at,
|
|
"lastUpdatedAt": self.last_updated_at,
|
|
"executionNumber": self.execution_number,
|
|
"versionNumber": self.version_number,
|
|
"approximateSecondsBeforeTimedOut": self.approximate_seconds_before_time_out,
|
|
}
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"jobId": self.job_id,
|
|
"thingArn": self.thing_arn,
|
|
"jobExecutionSummary": {
|
|
"status": self.status,
|
|
"queuedAt": self.queued_at,
|
|
"startedAt": self.started_at,
|
|
"lastUpdatedAt": self.last_updated_at,
|
|
"executionNumber": self.execution_number,
|
|
},
|
|
}
|
|
|
|
|
|
class FakeEndpoint(BaseModel):
|
|
def __init__(self, endpoint_type: str, region_name: str):
|
|
if endpoint_type not in [
|
|
"iot:Data",
|
|
"iot:Data-ATS",
|
|
"iot:CredentialProvider",
|
|
"iot:Jobs",
|
|
]:
|
|
raise InvalidRequestException(
|
|
" An error occurred (InvalidRequestException) when calling the DescribeEndpoint "
|
|
f"operation: Endpoint type {endpoint_type} not recognized."
|
|
)
|
|
self.region_name = region_name
|
|
identifier = random.get_random_string(length=14, lower_case=True)
|
|
if endpoint_type == "iot:Data":
|
|
self.endpoint = f"{identifier}.iot.{self.region_name}.amazonaws.com"
|
|
elif "iot:Data-ATS" in endpoint_type:
|
|
self.endpoint = f"{identifier}-ats.iot.{self.region_name}.amazonaws.com"
|
|
elif "iot:CredentialProvider" in endpoint_type:
|
|
self.endpoint = (
|
|
f"{identifier}.credentials.iot.{self.region_name}.amazonaws.com"
|
|
)
|
|
elif "iot:Jobs" in endpoint_type:
|
|
self.endpoint = f"{identifier}.jobs.iot.{self.region_name}.amazonaws.com"
|
|
self.endpoint_type = endpoint_type
|
|
|
|
def to_get_dict(self) -> Dict[str, str]:
|
|
return {
|
|
"endpointAddress": self.endpoint,
|
|
}
|
|
|
|
def to_dict(self) -> Dict[str, str]:
|
|
return {
|
|
"endpointAddress": self.endpoint,
|
|
}
|
|
|
|
|
|
class FakeRule(BaseModel):
|
|
def __init__(
|
|
self,
|
|
rule_name: str,
|
|
description: str,
|
|
created_at: int,
|
|
rule_disabled: bool,
|
|
topic_pattern: Optional[str],
|
|
actions: List[Dict[str, Any]],
|
|
error_action: Dict[str, Any],
|
|
sql: str,
|
|
aws_iot_sql_version: str,
|
|
region_name: str,
|
|
):
|
|
self.region_name = region_name
|
|
self.rule_name = rule_name
|
|
self.description = description or ""
|
|
self.created_at = created_at
|
|
self.rule_disabled = bool(rule_disabled)
|
|
self.topic_pattern = topic_pattern
|
|
self.actions = actions or []
|
|
self.error_action = error_action or {}
|
|
self.sql = sql
|
|
self.aws_iot_sql_version = aws_iot_sql_version or "2016-03-23"
|
|
self.arn = f"arn:aws:iot:{self.region_name}:1:rule/{rule_name}"
|
|
|
|
def to_get_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"rule": {
|
|
"actions": self.actions,
|
|
"awsIotSqlVersion": self.aws_iot_sql_version,
|
|
"createdAt": self.created_at,
|
|
"description": self.description,
|
|
"errorAction": self.error_action,
|
|
"ruleDisabled": self.rule_disabled,
|
|
"ruleName": self.rule_name,
|
|
"sql": self.sql,
|
|
},
|
|
"ruleArn": self.arn,
|
|
}
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"ruleName": self.rule_name,
|
|
"createdAt": self.created_at,
|
|
"ruleArn": self.arn,
|
|
"ruleDisabled": self.rule_disabled,
|
|
"topicPattern": self.topic_pattern,
|
|
}
|
|
|
|
|
|
class FakeDomainConfiguration(BaseModel):
|
|
def __init__(
|
|
self,
|
|
region_name: str,
|
|
domain_configuration_name: str,
|
|
domain_name: str,
|
|
server_certificate_arns: List[str],
|
|
domain_configuration_status: str,
|
|
service_type: str,
|
|
authorizer_config: Optional[Dict[str, Any]],
|
|
domain_type: str,
|
|
):
|
|
if service_type and service_type not in ["DATA", "CREDENTIAL_PROVIDER", "JOBS"]:
|
|
raise InvalidRequestException(
|
|
"An error occurred (InvalidRequestException) when calling the DescribeDomainConfiguration "
|
|
f"operation: Service type {service_type} not recognized."
|
|
)
|
|
self.domain_configuration_name = domain_configuration_name
|
|
self.domain_configuration_arn = f"arn:aws:iot:{region_name}:1:domainconfiguration/{domain_configuration_name}/{random.get_random_string(length=5)}"
|
|
self.domain_name = domain_name
|
|
self.server_certificates = []
|
|
if server_certificate_arns:
|
|
for sc in server_certificate_arns:
|
|
self.server_certificates.append(
|
|
{"serverCertificateArn": sc, "serverCertificateStatus": "VALID"}
|
|
)
|
|
self.domain_configuration_status = domain_configuration_status
|
|
self.service_type = service_type
|
|
self.authorizer_config = authorizer_config
|
|
self.domain_type = domain_type
|
|
self.last_status_change_date = time.time()
|
|
|
|
def to_description_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"domainConfigurationName": self.domain_configuration_name,
|
|
"domainConfigurationArn": self.domain_configuration_arn,
|
|
"domainName": self.domain_name,
|
|
"serverCertificates": self.server_certificates,
|
|
"authorizerConfig": self.authorizer_config,
|
|
"domainConfigurationStatus": self.domain_configuration_status,
|
|
"serviceType": self.service_type,
|
|
"domainType": self.domain_type,
|
|
"lastStatusChangeDate": self.last_status_change_date,
|
|
}
|
|
|
|
def to_dict(self) -> Dict[str, Any]:
|
|
return {
|
|
"domainConfigurationName": self.domain_configuration_name,
|
|
"domainConfigurationArn": self.domain_configuration_arn,
|
|
}
|
|
|
|
|
|
class IoTBackend(BaseBackend):
|
|
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.job_executions: Dict[Tuple[str, str], FakeJobExecution] = OrderedDict()
|
|
self.thing_types: Dict[str, FakeThingType] = OrderedDict()
|
|
self.thing_groups: Dict[str, FakeThingGroup] = OrderedDict()
|
|
self.ca_certificates: Dict[str, FakeCaCertificate] = OrderedDict()
|
|
self.certificates: Dict[str, FakeCertificate] = OrderedDict()
|
|
self.policies: Dict[str, FakePolicy] = OrderedDict()
|
|
self.principal_policies: Dict[
|
|
Tuple[str, str], Tuple[str, FakePolicy]
|
|
] = OrderedDict()
|
|
self.principal_things: Dict[
|
|
Tuple[str, str], Tuple[str, FakeThing]
|
|
] = OrderedDict()
|
|
self.rules: Dict[str, FakeRule] = OrderedDict()
|
|
self.endpoint: Optional[FakeEndpoint] = None
|
|
self.domain_configurations: Dict[str, FakeDomainConfiguration] = OrderedDict()
|
|
|
|
@staticmethod
|
|
def default_vpc_endpoint_service(
|
|
service_region: str, zones: List[str]
|
|
) -> List[Dict[str, str]]:
|
|
"""Default VPC endpoint service."""
|
|
return BaseBackend.default_vpc_endpoint_service_factory(
|
|
service_region, zones, "iot"
|
|
) + BaseBackend.default_vpc_endpoint_service_factory(
|
|
service_region,
|
|
zones,
|
|
"data.iot",
|
|
private_dns_names=False,
|
|
special_service_name="iot.data",
|
|
policy_supported=False,
|
|
)
|
|
|
|
def create_certificate_from_csr(
|
|
self, csr: str, set_as_active: bool
|
|
) -> FakeCertificate:
|
|
cert = x509.load_pem_x509_csr(csr.encode("utf-8"), default_backend())
|
|
pem = self._generate_certificate_pem(
|
|
domain_name="example.com", subject=cert.subject
|
|
)
|
|
return self.register_certificate(
|
|
pem, ca_certificate_pem=None, set_as_active=set_as_active, status="INACTIVE"
|
|
)
|
|
|
|
def _generate_certificate_pem(
|
|
self,
|
|
domain_name: str,
|
|
subject: x509.Name,
|
|
key: Optional[rsa.RSAPrivateKey] = None,
|
|
) -> str:
|
|
sans = [x509.DNSName(domain_name)]
|
|
|
|
key = key or rsa.generate_private_key(
|
|
public_exponent=65537, key_size=2048, backend=default_backend()
|
|
)
|
|
issuer = x509.Name(
|
|
[ # C = US, O = Moto, OU = Server CA 1B, CN = Moto
|
|
x509.NameAttribute(x509.NameOID.COUNTRY_NAME, "US"),
|
|
x509.NameAttribute(x509.NameOID.ORGANIZATION_NAME, "Moto"),
|
|
x509.NameAttribute(
|
|
x509.NameOID.ORGANIZATIONAL_UNIT_NAME, "Server CA 1B"
|
|
),
|
|
x509.NameAttribute(x509.NameOID.COMMON_NAME, "Moto"),
|
|
]
|
|
)
|
|
cert = (
|
|
x509.CertificateBuilder()
|
|
.subject_name(subject)
|
|
.issuer_name(issuer)
|
|
.public_key(key.public_key())
|
|
.serial_number(x509.random_serial_number())
|
|
.not_valid_before(utcnow())
|
|
.not_valid_after(utcnow() + timedelta(days=365))
|
|
.add_extension(x509.SubjectAlternativeName(sans), critical=False)
|
|
.sign(key, hashes.SHA512(), default_backend())
|
|
)
|
|
|
|
return cert.public_bytes(serialization.Encoding.PEM).decode("utf-8")
|
|
|
|
def create_thing(
|
|
self,
|
|
thing_name: str,
|
|
thing_type_name: str,
|
|
attribute_payload: Optional[Dict[str, Any]],
|
|
) -> Tuple[str, str]:
|
|
thing_types = self.list_thing_types()
|
|
thing_type = None
|
|
if thing_type_name:
|
|
filtered_thing_types = [
|
|
_ for _ in thing_types if _.thing_type_name == thing_type_name
|
|
]
|
|
if len(filtered_thing_types) == 0:
|
|
raise ResourceNotFoundException()
|
|
thing_type = filtered_thing_types[0]
|
|
|
|
if thing_type.metadata["deprecated"]:
|
|
# note - typo (depreated) exists also in the original exception.
|
|
raise InvalidRequestException(
|
|
msg=f"Can not create new thing with depreated thing type:{thing_type_name}"
|
|
)
|
|
if attribute_payload is None:
|
|
attributes: Dict[str, Any] = {}
|
|
elif "attributes" not in attribute_payload:
|
|
attributes = {}
|
|
else:
|
|
attributes = attribute_payload["attributes"]
|
|
thing = FakeThing(
|
|
thing_name, thing_type, attributes, self.account_id, self.region_name
|
|
)
|
|
self.things[thing.arn] = thing
|
|
return thing.thing_name, thing.arn
|
|
|
|
def create_thing_type(
|
|
self, thing_type_name: str, thing_type_properties: Dict[str, Any]
|
|
) -> Tuple[str, str]:
|
|
if thing_type_properties is None:
|
|
thing_type_properties = {}
|
|
thing_type = FakeThingType(
|
|
thing_type_name, thing_type_properties, self.region_name
|
|
)
|
|
self.thing_types[thing_type.arn] = thing_type
|
|
return thing_type.thing_type_name, thing_type.arn
|
|
|
|
def list_thing_types(
|
|
self, thing_type_name: Optional[str] = None
|
|
) -> Iterable[FakeThingType]:
|
|
if thing_type_name:
|
|
# It's weird but thing_type_name is filtered by forward match, not complete match
|
|
return [
|
|
_
|
|
for _ in self.thing_types.values()
|
|
if _.thing_type_name.startswith(thing_type_name)
|
|
]
|
|
return self.thing_types.values()
|
|
|
|
def list_things(
|
|
self,
|
|
attribute_name: str,
|
|
attribute_value: str,
|
|
thing_type_name: str,
|
|
max_results: int,
|
|
token: Optional[str],
|
|
) -> Tuple[Iterable[FakeThing], Optional[str]]:
|
|
all_things = [_.to_dict() for _ in self.things.values()]
|
|
if attribute_name is not None and thing_type_name is not None:
|
|
filtered_things = list(
|
|
filter(
|
|
lambda elem: attribute_name in elem["attributes"]
|
|
and elem["attributes"][attribute_name] == attribute_value
|
|
and "thingTypeName" in elem
|
|
and elem["thingTypeName"] == thing_type_name,
|
|
all_things,
|
|
)
|
|
)
|
|
elif attribute_name is not None and thing_type_name is None:
|
|
filtered_things = list(
|
|
filter(
|
|
lambda elem: attribute_name in elem["attributes"]
|
|
and elem["attributes"][attribute_name] == attribute_value,
|
|
all_things,
|
|
)
|
|
)
|
|
elif attribute_name is None and thing_type_name is not None:
|
|
filtered_things = list(
|
|
filter(
|
|
lambda elem: "thingTypeName" in elem
|
|
and elem["thingTypeName"] == thing_type_name,
|
|
all_things,
|
|
)
|
|
)
|
|
else:
|
|
filtered_things = all_things
|
|
|
|
if token is None:
|
|
things = filtered_things[0:max_results]
|
|
next_token = (
|
|
str(max_results) if len(filtered_things) > max_results else None
|
|
)
|
|
else:
|
|
int_token = int(token)
|
|
things = filtered_things[int_token : int_token + max_results]
|
|
next_token = (
|
|
str(int_token + max_results)
|
|
if len(filtered_things) > int_token + max_results
|
|
else None
|
|
)
|
|
|
|
return things, next_token
|
|
|
|
def describe_thing(self, thing_name: str) -> FakeThing:
|
|
things = [_ for _ in self.things.values() if _.thing_name == thing_name]
|
|
if len(things) == 0:
|
|
raise ResourceNotFoundException()
|
|
return things[0]
|
|
|
|
def describe_thing_type(self, thing_type_name: str) -> FakeThingType:
|
|
thing_types = [
|
|
_ for _ in self.thing_types.values() if _.thing_type_name == thing_type_name
|
|
]
|
|
if len(thing_types) == 0:
|
|
raise ResourceNotFoundException()
|
|
return thing_types[0]
|
|
|
|
def describe_endpoint(self, endpoint_type: str) -> FakeEndpoint:
|
|
self.endpoint = FakeEndpoint(endpoint_type, self.region_name)
|
|
return self.endpoint
|
|
|
|
def delete_thing(self, thing_name: str) -> None:
|
|
"""
|
|
The ExpectedVersion-parameter is not yet implemented
|
|
"""
|
|
|
|
# can raise ResourceNotFoundError
|
|
thing = self.describe_thing(thing_name)
|
|
|
|
for k in list(self.principal_things.keys()):
|
|
if k[1] == thing_name:
|
|
raise ThingStillAttached(thing_name)
|
|
|
|
del self.things[thing.arn]
|
|
|
|
def delete_thing_type(self, thing_type_name: str) -> None:
|
|
# can raise ResourceNotFoundError
|
|
thing_type = self.describe_thing_type(thing_type_name)
|
|
del self.thing_types[thing_type.arn]
|
|
|
|
def deprecate_thing_type(
|
|
self, thing_type_name: str, undo_deprecate: bool
|
|
) -> FakeThingType:
|
|
thing_types = [
|
|
_ for _ in self.thing_types.values() if _.thing_type_name == thing_type_name
|
|
]
|
|
if len(thing_types) == 0:
|
|
raise ResourceNotFoundException()
|
|
thing_types[0].metadata["deprecated"] = not undo_deprecate
|
|
return thing_types[0]
|
|
|
|
def update_thing(
|
|
self,
|
|
thing_name: str,
|
|
thing_type_name: str,
|
|
attribute_payload: Optional[Dict[str, Any]],
|
|
remove_thing_type: bool,
|
|
) -> None:
|
|
"""
|
|
The ExpectedVersion-parameter is not yet implemented
|
|
"""
|
|
# if attributes payload = {}, nothing
|
|
thing = self.describe_thing(thing_name)
|
|
|
|
if remove_thing_type and thing_type_name:
|
|
raise InvalidRequestException()
|
|
|
|
# thing_type
|
|
if thing_type_name:
|
|
thing_types = self.list_thing_types()
|
|
filtered_thing_types = [
|
|
_ for _ in thing_types if _.thing_type_name == thing_type_name
|
|
]
|
|
if len(filtered_thing_types) == 0:
|
|
raise ResourceNotFoundException()
|
|
thing_type = filtered_thing_types[0]
|
|
|
|
if thing_type.metadata["deprecated"]:
|
|
raise InvalidRequestException(
|
|
msg=f"Can not update a thing to use deprecated thing type: {thing_type_name}"
|
|
)
|
|
|
|
thing.thing_type = thing_type
|
|
|
|
if remove_thing_type:
|
|
thing.thing_type = None
|
|
|
|
# attribute
|
|
if attribute_payload is not None and "attributes" in attribute_payload:
|
|
do_merge = attribute_payload.get("merge", False)
|
|
attributes = attribute_payload["attributes"]
|
|
if not do_merge:
|
|
thing.attributes = attributes
|
|
else:
|
|
thing.attributes.update(attributes)
|
|
thing.attributes = {k: v for k, v in thing.attributes.items() if v}
|
|
|
|
def create_keys_and_certificate(
|
|
self, set_as_active: bool
|
|
) -> Tuple[FakeCertificate, Dict[str, str]]:
|
|
# implement here
|
|
# caCertificate can be blank
|
|
private_key = rsa.generate_private_key(
|
|
public_exponent=65537, key_size=2048, backend=default_backend()
|
|
)
|
|
key_pair = {
|
|
"PublicKey": private_key.public_key()
|
|
.public_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PublicFormat.SubjectPublicKeyInfo,
|
|
)
|
|
.decode("utf-8"),
|
|
"PrivateKey": private_key.private_bytes(
|
|
encoding=serialization.Encoding.PEM,
|
|
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
|
encryption_algorithm=serialization.NoEncryption(),
|
|
).decode("utf-8"),
|
|
}
|
|
subject = x509.Name(
|
|
[x509.NameAttribute(NameOID.COMMON_NAME, "AWS IoT Certificate")]
|
|
)
|
|
certificate_pem = self._generate_certificate_pem(
|
|
"getmoto.org", subject, key=private_key
|
|
)
|
|
status = "ACTIVE" if set_as_active else "INACTIVE"
|
|
certificate = FakeCertificate(
|
|
certificate_pem, status, self.account_id, self.region_name
|
|
)
|
|
self.certificates[certificate.certificate_id] = certificate
|
|
return certificate, key_pair
|
|
|
|
def delete_ca_certificate(self, certificate_id: str) -> None:
|
|
cert = self.describe_ca_certificate(certificate_id)
|
|
self._validation_delete(cert)
|
|
del self.ca_certificates[certificate_id]
|
|
|
|
def delete_certificate(self, certificate_id: str, force_delete: bool) -> None:
|
|
cert = self.describe_certificate(certificate_id)
|
|
self._validation_delete(cert, force_delete)
|
|
del self.certificates[certificate_id]
|
|
|
|
def _validation_delete(
|
|
self, cert: FakeCertificate, force_delete: bool = False
|
|
) -> None:
|
|
if cert.status == "ACTIVE":
|
|
raise CertificateStateException(
|
|
"Certificate must be deactivated (not ACTIVE) before deletion.",
|
|
cert.certificate_id,
|
|
)
|
|
|
|
certs = [
|
|
k[0]
|
|
for k, v in self.principal_things.items()
|
|
if self._get_principal(k[0]).certificate_id == cert.certificate_id
|
|
]
|
|
if len(certs) > 0:
|
|
raise DeleteConflictException(
|
|
f"Things must be detached before deletion (arn: {certs[0]})"
|
|
)
|
|
|
|
certs = [
|
|
k[0]
|
|
for k, v in self.principal_policies.items()
|
|
if self._get_principal(k[0]).certificate_id == cert.certificate_id
|
|
]
|
|
if len(certs) > 0 and not force_delete:
|
|
raise DeleteConflictException(
|
|
"Certificate policies must be detached before deletion (arn: %s)"
|
|
% certs[0]
|
|
)
|
|
|
|
def describe_ca_certificate(self, certificate_id: str) -> FakeCaCertificate:
|
|
if certificate_id not in self.ca_certificates:
|
|
raise ResourceNotFoundException()
|
|
return self.ca_certificates[certificate_id]
|
|
|
|
def describe_certificate(self, certificate_id: str) -> FakeCertificate:
|
|
certs = [
|
|
_ for _ in self.certificates.values() if _.certificate_id == certificate_id
|
|
]
|
|
if len(certs) == 0:
|
|
raise ResourceNotFoundException()
|
|
return certs[0]
|
|
|
|
def get_registration_code(self) -> str:
|
|
return str(random.uuid4())
|
|
|
|
def list_certificates(self) -> Iterable[FakeCertificate]:
|
|
"""
|
|
Pagination is not yet implemented
|
|
"""
|
|
return self.certificates.values()
|
|
|
|
def list_certificates_by_ca(self, ca_certificate_id: str) -> List[FakeCertificate]:
|
|
"""
|
|
Pagination is not yet implemented
|
|
"""
|
|
return [
|
|
cert
|
|
for cert in self.certificates.values()
|
|
if cert.ca_certificate_id == ca_certificate_id
|
|
]
|
|
|
|
def __raise_if_certificate_already_exists(
|
|
self, certificate_id: str, certificate_arn: str
|
|
) -> None:
|
|
if certificate_id in self.certificates:
|
|
raise ResourceAlreadyExistsException(
|
|
"The certificate is already provisioned or registered",
|
|
certificate_id,
|
|
certificate_arn,
|
|
)
|
|
|
|
def register_ca_certificate(
|
|
self,
|
|
ca_certificate: str,
|
|
set_as_active: bool,
|
|
registration_config: Dict[str, str],
|
|
) -> FakeCaCertificate:
|
|
"""
|
|
The VerificationCertificate-parameter is not yet implemented
|
|
"""
|
|
certificate = FakeCaCertificate(
|
|
ca_certificate=ca_certificate,
|
|
status="ACTIVE" if set_as_active else "INACTIVE",
|
|
account_id=self.account_id,
|
|
region_name=self.region_name,
|
|
registration_config=registration_config,
|
|
)
|
|
|
|
self.ca_certificates[certificate.certificate_id] = certificate
|
|
return certificate
|
|
|
|
def _find_ca_certificate(self, ca_certificate_pem: Optional[str]) -> Optional[str]:
|
|
for ca_cert in self.ca_certificates.values():
|
|
if ca_cert.certificate_pem == ca_certificate_pem:
|
|
return ca_cert.certificate_id
|
|
return None
|
|
|
|
def register_certificate(
|
|
self,
|
|
certificate_pem: str,
|
|
ca_certificate_pem: Optional[str],
|
|
set_as_active: bool,
|
|
status: str,
|
|
) -> FakeCertificate:
|
|
ca_certificate_id = self._find_ca_certificate(ca_certificate_pem)
|
|
certificate = FakeCertificate(
|
|
certificate_pem,
|
|
"ACTIVE" if set_as_active else status,
|
|
self.account_id,
|
|
self.region_name,
|
|
ca_certificate_id,
|
|
)
|
|
self.__raise_if_certificate_already_exists(
|
|
certificate.certificate_id, certificate_arn=certificate.arn
|
|
)
|
|
|
|
self.certificates[certificate.certificate_id] = certificate
|
|
return certificate
|
|
|
|
def register_certificate_without_ca(
|
|
self, certificate_pem: str, status: str
|
|
) -> FakeCertificate:
|
|
certificate = FakeCertificate(
|
|
certificate_pem, status, self.account_id, self.region_name
|
|
)
|
|
self.__raise_if_certificate_already_exists(
|
|
certificate.certificate_id, certificate_arn=certificate.arn
|
|
)
|
|
|
|
self.certificates[certificate.certificate_id] = certificate
|
|
return certificate
|
|
|
|
def update_ca_certificate(
|
|
self,
|
|
certificate_id: str,
|
|
new_status: Optional[str],
|
|
config: Optional[Dict[str, str]],
|
|
) -> None:
|
|
"""
|
|
The newAutoRegistrationStatus and removeAutoRegistration-parameters are not yet implemented
|
|
"""
|
|
cert = self.describe_ca_certificate(certificate_id)
|
|
if new_status is not None:
|
|
cert.status = new_status
|
|
if config is not None:
|
|
cert.registration_config = config
|
|
|
|
def update_certificate(self, certificate_id: str, new_status: str) -> None:
|
|
cert = self.describe_certificate(certificate_id)
|
|
# TODO: validate new_status
|
|
cert.status = new_status
|
|
|
|
def create_policy(
|
|
self, policy_name: str, policy_document: Dict[str, Any]
|
|
) -> FakePolicy:
|
|
if policy_name in self.policies:
|
|
current_policy = self.policies[policy_name]
|
|
raise ResourceAlreadyExistsException(
|
|
f"Policy cannot be created - name already exists (name={policy_name})",
|
|
current_policy.name,
|
|
current_policy.arn,
|
|
)
|
|
policy = FakePolicy(
|
|
policy_name, policy_document, self.account_id, self.region_name
|
|
)
|
|
self.policies[policy.name] = policy
|
|
return policy
|
|
|
|
def attach_policy(self, policy_name: str, target: str) -> None:
|
|
principal = self._get_principal(target)
|
|
policy = self.get_policy(policy_name)
|
|
k = (target, policy_name)
|
|
if k in self.principal_policies:
|
|
return
|
|
self.principal_policies[k] = (principal, policy)
|
|
|
|
def detach_policy(self, policy_name: str, target: str) -> None:
|
|
# this may raise ResourceNotFoundException
|
|
self._get_principal(target)
|
|
self.get_policy(policy_name)
|
|
|
|
k = (target, policy_name)
|
|
if k not in self.principal_policies:
|
|
raise ResourceNotFoundException()
|
|
del self.principal_policies[k]
|
|
|
|
def list_attached_policies(self, target: str) -> List[FakePolicy]:
|
|
return [v[1] for k, v in self.principal_policies.items() if k[0] == target]
|
|
|
|
def list_policies(self) -> Iterable[FakePolicy]:
|
|
return self.policies.values()
|
|
|
|
def get_policy(self, policy_name: str) -> FakePolicy:
|
|
policies = [_ for _ in self.policies.values() if _.name == policy_name]
|
|
if len(policies) == 0:
|
|
raise ResourceNotFoundException()
|
|
return policies[0]
|
|
|
|
def delete_policy(self, policy_name: str) -> None:
|
|
policies = [
|
|
k[1] for k, v in self.principal_policies.items() if k[1] == policy_name
|
|
]
|
|
if len(policies) > 0:
|
|
raise DeleteConflictException(
|
|
"The policy cannot be deleted as the policy is attached to one or more principals (name=%s)"
|
|
% policy_name
|
|
)
|
|
|
|
policy = self.get_policy(policy_name)
|
|
if len(policy.versions) > 1:
|
|
raise DeleteConflictException(
|
|
"Cannot delete the policy because it has one or more policy versions attached to it (name=%s)"
|
|
% policy_name
|
|
)
|
|
del self.policies[policy.name]
|
|
|
|
def create_policy_version(
|
|
self, policy_name: str, policy_document: Dict[str, Any], set_as_default: bool
|
|
) -> FakePolicyVersion:
|
|
policy = self.get_policy(policy_name)
|
|
if not policy:
|
|
raise ResourceNotFoundException()
|
|
if len(policy.versions) >= 5:
|
|
raise VersionsLimitExceededException(policy_name)
|
|
|
|
policy._max_version_id += 1
|
|
version = FakePolicyVersion(
|
|
policy_name,
|
|
policy_document,
|
|
set_as_default,
|
|
self.account_id,
|
|
self.region_name,
|
|
version_id=policy._max_version_id,
|
|
)
|
|
policy.versions.append(version)
|
|
if set_as_default:
|
|
self.set_default_policy_version(policy_name, version.version_id)
|
|
return version
|
|
|
|
def set_default_policy_version(self, policy_name: str, version_id: str) -> None:
|
|
policy = self.get_policy(policy_name)
|
|
if not policy:
|
|
raise ResourceNotFoundException()
|
|
for version in policy.versions:
|
|
if version.version_id == version_id:
|
|
version.is_default = True
|
|
policy.default_version_id = version.version_id
|
|
policy.document = version.document
|
|
else:
|
|
version.is_default = False
|
|
|
|
def get_policy_version(
|
|
self, policy_name: str, version_id: str
|
|
) -> FakePolicyVersion:
|
|
policy = self.get_policy(policy_name)
|
|
if not policy:
|
|
raise ResourceNotFoundException()
|
|
for version in policy.versions:
|
|
if version.version_id == version_id:
|
|
return version
|
|
raise ResourceNotFoundException()
|
|
|
|
def list_policy_versions(self, policy_name: str) -> Iterable[FakePolicyVersion]:
|
|
policy = self.get_policy(policy_name)
|
|
if not policy:
|
|
raise ResourceNotFoundException()
|
|
return policy.versions
|
|
|
|
def delete_policy_version(self, policy_name: str, version_id: str) -> None:
|
|
policy = self.get_policy(policy_name)
|
|
if not policy:
|
|
raise ResourceNotFoundException()
|
|
if version_id == policy.default_version_id:
|
|
raise InvalidRequestException(
|
|
"Cannot delete the default version of a policy"
|
|
)
|
|
for i, v in enumerate(policy.versions):
|
|
if v.version_id == version_id:
|
|
del policy.versions[i]
|
|
return
|
|
raise ResourceNotFoundException()
|
|
|
|
def _get_principal(self, principal_arn: str) -> Any:
|
|
"""
|
|
raise ResourceNotFoundException
|
|
"""
|
|
if ":cert/" in principal_arn:
|
|
certs = [_ for _ in self.certificates.values() if _.arn == principal_arn]
|
|
if len(certs) == 0:
|
|
raise ResourceNotFoundException()
|
|
principal = certs[0]
|
|
return principal
|
|
if ":thinggroup/" in principal_arn:
|
|
try:
|
|
return self.thing_groups[principal_arn]
|
|
except KeyError:
|
|
raise ResourceNotFoundException(
|
|
f"No thing group with ARN {principal_arn} exists"
|
|
)
|
|
from moto.cognitoidentity import cognitoidentity_backends
|
|
|
|
cognito = cognitoidentity_backends[self.account_id][self.region_name]
|
|
identities = []
|
|
for identity_pool in cognito.identity_pools:
|
|
pool_identities = cognito.pools_identities.get(identity_pool, None)
|
|
identities.extend(
|
|
[pi["IdentityId"] for pi in pool_identities.get("Identities", [])]
|
|
)
|
|
if principal_arn in identities:
|
|
return {"IdentityId": principal_arn}
|
|
|
|
raise ResourceNotFoundException()
|
|
|
|
def attach_principal_policy(self, policy_name: str, principal_arn: str) -> None:
|
|
principal = self._get_principal(principal_arn)
|
|
policy = self.get_policy(policy_name)
|
|
k = (principal_arn, policy_name)
|
|
if k in self.principal_policies:
|
|
return
|
|
self.principal_policies[k] = (principal, policy)
|
|
|
|
def detach_principal_policy(self, policy_name: str, principal_arn: str) -> None:
|
|
# this may raises ResourceNotFoundException
|
|
self._get_principal(principal_arn)
|
|
self.get_policy(policy_name)
|
|
|
|
k = (principal_arn, policy_name)
|
|
if k not in self.principal_policies:
|
|
raise ResourceNotFoundException()
|
|
del self.principal_policies[k]
|
|
|
|
def list_principal_policies(self, principal_arn: str) -> List[FakePolicy]:
|
|
policies = [
|
|
v[1] for k, v in self.principal_policies.items() if k[0] == principal_arn
|
|
]
|
|
return policies
|
|
|
|
def list_policy_principals(self, policy_name: str) -> List[str]:
|
|
# this action is deprecated
|
|
# https://docs.aws.amazon.com/iot/latest/apireference/API_ListTargetsForPolicy.html
|
|
# should use ListTargetsForPolicy instead
|
|
principals = [
|
|
k[0] for k, v in self.principal_policies.items() if k[1] == policy_name
|
|
]
|
|
return principals
|
|
|
|
def list_targets_for_policy(self, policy_name: str) -> List[str]:
|
|
# This behaviour is different to list_policy_principals which will just return an empty list
|
|
if policy_name not in self.policies:
|
|
raise ResourceNotFoundException("Policy not found")
|
|
return self.list_policy_principals(policy_name=policy_name)
|
|
|
|
def attach_thing_principal(self, thing_name: str, principal_arn: str) -> None:
|
|
principal = self._get_principal(principal_arn)
|
|
thing = self.describe_thing(thing_name)
|
|
k = (principal_arn, thing_name)
|
|
if k in self.principal_things:
|
|
return
|
|
self.principal_things[k] = (principal, thing)
|
|
|
|
def detach_thing_principal(self, thing_name: str, principal_arn: str) -> None:
|
|
# this may raises ResourceNotFoundException
|
|
self._get_principal(principal_arn)
|
|
self.describe_thing(thing_name)
|
|
|
|
k = (principal_arn, thing_name)
|
|
if k not in self.principal_things:
|
|
raise ResourceNotFoundException()
|
|
del self.principal_things[k]
|
|
|
|
def list_principal_things(self, principal_arn: str) -> List[str]:
|
|
thing_names = [
|
|
k[1] for k, v in self.principal_things.items() if k[0] == principal_arn
|
|
]
|
|
return thing_names
|
|
|
|
def list_thing_principals(self, thing_name: str) -> List[str]:
|
|
things = [_ for _ in self.things.values() if _.thing_name == thing_name]
|
|
if len(things) == 0:
|
|
raise ResourceNotFoundException(
|
|
"Failed to list principals for thing %s because the thing does not exist in your account"
|
|
% thing_name
|
|
)
|
|
|
|
principals = [
|
|
k[0] for k, v in self.principal_things.items() if k[1] == thing_name
|
|
]
|
|
return principals
|
|
|
|
def describe_thing_group(self, thing_group_name: str) -> FakeThingGroup:
|
|
thing_groups = [
|
|
_
|
|
for _ in self.thing_groups.values()
|
|
if _.thing_group_name == thing_group_name
|
|
]
|
|
if len(thing_groups) == 0:
|
|
raise ResourceNotFoundException()
|
|
return thing_groups[0]
|
|
|
|
def create_thing_group(
|
|
self,
|
|
thing_group_name: str,
|
|
parent_group_name: str,
|
|
thing_group_properties: Dict[str, Any],
|
|
) -> Tuple[str, str, str]:
|
|
thing_group = FakeThingGroup(
|
|
thing_group_name,
|
|
parent_group_name,
|
|
thing_group_properties,
|
|
self.region_name,
|
|
self.thing_groups,
|
|
)
|
|
# this behavior is not documented, but AWS does it like that
|
|
# if a thing group with the same name exists, it's properties are compared
|
|
# if they differ, an error is returned.
|
|
# Otherwise, the old thing group is returned
|
|
if thing_group.arn in self.thing_groups:
|
|
current_thing_group = self.thing_groups[thing_group.arn]
|
|
if current_thing_group.thing_group_properties != thing_group_properties:
|
|
raise ResourceAlreadyExistsException(
|
|
msg=f"Thing Group {thing_group_name} already exists in current account with different properties",
|
|
resource_arn=thing_group.arn,
|
|
resource_id=current_thing_group.thing_group_id,
|
|
)
|
|
thing_group = current_thing_group
|
|
else:
|
|
self.thing_groups[thing_group.arn] = thing_group
|
|
return thing_group.thing_group_name, thing_group.arn, thing_group.thing_group_id
|
|
|
|
def delete_thing_group(self, thing_group_name: str) -> None:
|
|
"""
|
|
The ExpectedVersion-parameter is not yet implemented
|
|
"""
|
|
child_groups = [
|
|
thing_group
|
|
for _, thing_group in self.thing_groups.items()
|
|
if thing_group.parent_group_name == thing_group_name
|
|
]
|
|
if len(child_groups) > 0:
|
|
raise InvalidRequestException(
|
|
" Cannot delete thing group : "
|
|
+ thing_group_name
|
|
+ " when there are still child groups attached to it"
|
|
)
|
|
try:
|
|
thing_group = self.describe_thing_group(thing_group_name)
|
|
del self.thing_groups[thing_group.arn]
|
|
except ResourceNotFoundException:
|
|
# AWS returns success even if the thing group does not exist.
|
|
pass
|
|
|
|
def list_thing_groups(
|
|
self,
|
|
parent_group: Optional[str],
|
|
name_prefix_filter: Optional[str],
|
|
recursive: Optional[bool],
|
|
) -> List[FakeThingGroup]:
|
|
if recursive is None:
|
|
recursive = True
|
|
if name_prefix_filter is None:
|
|
name_prefix_filter = ""
|
|
if parent_group and parent_group not in [
|
|
_.thing_group_name for _ in self.thing_groups.values()
|
|
]:
|
|
raise ResourceNotFoundException()
|
|
thing_groups = [
|
|
_ for _ in self.thing_groups.values() if _.parent_group_name == parent_group
|
|
]
|
|
if recursive:
|
|
for g in thing_groups:
|
|
thing_groups.extend(
|
|
self.list_thing_groups(
|
|
parent_group=g.thing_group_name,
|
|
name_prefix_filter=None,
|
|
recursive=False,
|
|
)
|
|
)
|
|
# thing_groups = groups_to_process.values()
|
|
return [
|
|
_ for _ in thing_groups if _.thing_group_name.startswith(name_prefix_filter)
|
|
]
|
|
|
|
def update_thing_group(
|
|
self,
|
|
thing_group_name: str,
|
|
thing_group_properties: Dict[str, Any],
|
|
expected_version: int,
|
|
) -> int:
|
|
thing_group = self.describe_thing_group(thing_group_name)
|
|
if expected_version and expected_version != thing_group.version:
|
|
raise VersionConflictException(thing_group_name)
|
|
attribute_payload = thing_group_properties.get("attributePayload", None)
|
|
if attribute_payload is not None and "attributes" in attribute_payload:
|
|
do_merge = attribute_payload.get("merge", False)
|
|
attributes = attribute_payload["attributes"]
|
|
if attributes:
|
|
# might not exist yet, for example when the thing group was created without attributes
|
|
current_attribute_payload = thing_group.thing_group_properties.setdefault(
|
|
"attributePayload", {"attributes": {}} # type: ignore
|
|
)
|
|
if not do_merge:
|
|
current_attribute_payload["attributes"] = attributes # type: ignore
|
|
else:
|
|
current_attribute_payload["attributes"].update(attributes) # type: ignore
|
|
elif attribute_payload is not None and "attributes" not in attribute_payload:
|
|
thing_group.attributes = {} # type: ignore
|
|
if "thingGroupDescription" in thing_group_properties:
|
|
thing_group.thing_group_properties[
|
|
"thingGroupDescription"
|
|
] = thing_group_properties["thingGroupDescription"]
|
|
thing_group.version = thing_group.version + 1
|
|
return thing_group.version
|
|
|
|
def _identify_thing_group(
|
|
self, thing_group_name: Optional[str], thing_group_arn: Optional[str]
|
|
) -> FakeThingGroup:
|
|
# identify thing group
|
|
if thing_group_name is None and thing_group_arn is None:
|
|
raise InvalidRequestException(
|
|
" Both thingGroupArn and thingGroupName are empty. Need to specify at least one of them"
|
|
)
|
|
if thing_group_name is not None:
|
|
thing_group = self.describe_thing_group(thing_group_name)
|
|
if thing_group_arn and thing_group.arn != thing_group_arn:
|
|
raise InvalidRequestException(
|
|
"ThingGroupName thingGroupArn does not match specified thingGroupName in request"
|
|
)
|
|
elif thing_group_arn is not None:
|
|
if thing_group_arn not in self.thing_groups:
|
|
raise InvalidRequestException()
|
|
thing_group = self.thing_groups[thing_group_arn]
|
|
return thing_group
|
|
|
|
def _identify_thing(
|
|
self, thing_name: Optional[str], thing_arn: Optional[str]
|
|
) -> FakeThing:
|
|
# identify thing
|
|
if thing_name is None and thing_arn is None:
|
|
raise InvalidRequestException(
|
|
"Both thingArn and thingName are empty. Need to specify at least one of them"
|
|
)
|
|
if thing_name is not None:
|
|
thing = self.describe_thing(thing_name)
|
|
if thing_arn and thing.arn != thing_arn:
|
|
raise InvalidRequestException(
|
|
"ThingName thingArn does not match specified thingName in request"
|
|
)
|
|
elif thing_arn is not None:
|
|
if thing_arn not in self.things:
|
|
raise InvalidRequestException()
|
|
thing = self.things[thing_arn]
|
|
return thing
|
|
|
|
def add_thing_to_thing_group(
|
|
self,
|
|
thing_group_name: str,
|
|
thing_group_arn: Optional[str],
|
|
thing_name: str,
|
|
thing_arn: Optional[str],
|
|
) -> None:
|
|
thing_group = self._identify_thing_group(thing_group_name, thing_group_arn)
|
|
thing = self._identify_thing(thing_name, thing_arn)
|
|
if thing.arn in thing_group.things:
|
|
# aws ignores duplicate registration
|
|
return
|
|
thing_group.things[thing.arn] = thing
|
|
|
|
def remove_thing_from_thing_group(
|
|
self,
|
|
thing_group_name: str,
|
|
thing_group_arn: Optional[str],
|
|
thing_name: str,
|
|
thing_arn: Optional[str],
|
|
) -> None:
|
|
thing_group = self._identify_thing_group(thing_group_name, thing_group_arn)
|
|
thing = self._identify_thing(thing_name, thing_arn)
|
|
if thing.arn not in thing_group.things:
|
|
# aws ignores non-registered thing
|
|
return
|
|
del thing_group.things[thing.arn]
|
|
|
|
def list_things_in_thing_group(self, thing_group_name: str) -> Iterable[FakeThing]:
|
|
"""
|
|
Pagination and the recursive-parameter is not yet implemented
|
|
"""
|
|
thing_group = self.describe_thing_group(thing_group_name)
|
|
return thing_group.things.values()
|
|
|
|
def list_thing_groups_for_thing(self, thing_name: str) -> List[Dict[str, str]]:
|
|
"""
|
|
Pagination is not yet implemented
|
|
"""
|
|
thing = self.describe_thing(thing_name)
|
|
all_thing_groups = self.list_thing_groups(None, None, None)
|
|
ret = []
|
|
for thing_group in all_thing_groups:
|
|
if thing.arn in thing_group.things:
|
|
ret.append(
|
|
{
|
|
"groupName": thing_group.thing_group_name,
|
|
"groupArn": thing_group.arn,
|
|
}
|
|
)
|
|
return ret
|
|
|
|
def update_thing_groups_for_thing(
|
|
self,
|
|
thing_name: str,
|
|
thing_groups_to_add: List[str],
|
|
thing_groups_to_remove: List[str],
|
|
) -> None:
|
|
thing = self.describe_thing(thing_name)
|
|
for thing_group_name in thing_groups_to_add:
|
|
thing_group = self.describe_thing_group(thing_group_name)
|
|
self.add_thing_to_thing_group(
|
|
thing_group.thing_group_name, None, thing.thing_name, None
|
|
)
|
|
for thing_group_name in thing_groups_to_remove:
|
|
thing_group = self.describe_thing_group(thing_group_name)
|
|
self.remove_thing_from_thing_group(
|
|
thing_group.thing_group_name, None, thing.thing_name, None
|
|
)
|
|
|
|
def create_job(
|
|
self,
|
|
job_id: str,
|
|
targets: List[str],
|
|
document_source: str,
|
|
document: str,
|
|
description: str,
|
|
presigned_url_config: Dict[str, Any],
|
|
target_selection: str,
|
|
job_executions_rollout_config: Dict[str, Any],
|
|
document_parameters: Dict[str, str],
|
|
) -> 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.region_name,
|
|
)
|
|
self.jobs[job_id] = job
|
|
|
|
for thing_arn in targets:
|
|
thing_name = thing_arn.split(":")[-1].split("/")[-1]
|
|
job_execution = FakeJobExecution(job_id, thing_arn)
|
|
self.job_executions[(job_id, thing_name)] = job_execution
|
|
return job.job_arn, job_id, description
|
|
|
|
def describe_job(self, job_id: str) -> FakeJob:
|
|
jobs = [_ for _ in self.jobs.values() if _.job_id == job_id]
|
|
if len(jobs) == 0:
|
|
raise ResourceNotFoundException()
|
|
return jobs[0]
|
|
|
|
def delete_job(self, job_id: str, force: bool) -> None:
|
|
job = self.jobs[job_id]
|
|
|
|
if job.status == "IN_PROGRESS" and force:
|
|
del self.jobs[job_id]
|
|
elif job.status != "IN_PROGRESS":
|
|
del self.jobs[job_id]
|
|
else:
|
|
raise InvalidStateTransitionException()
|
|
|
|
def cancel_job(
|
|
self, job_id: str, reason_code: str, comment: str, force: bool
|
|
) -> FakeJob:
|
|
job = self.jobs[job_id]
|
|
|
|
job.reason_code = reason_code if reason_code is not None else job.reason_code
|
|
job.comment = comment if comment is not None else job.comment
|
|
job.force = force if force is not None and force != job.force else job.force
|
|
job.status = "CANCELED"
|
|
|
|
if job.status == "IN_PROGRESS" and force:
|
|
self.jobs[job_id] = job
|
|
elif job.status != "IN_PROGRESS":
|
|
self.jobs[job_id] = job
|
|
else:
|
|
raise InvalidStateTransitionException()
|
|
|
|
return 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]]:
|
|
"""
|
|
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
|
|
|
|
def describe_job_execution(
|
|
self, job_id: str, thing_name: str, execution_number: int
|
|
) -> FakeJobExecution:
|
|
try:
|
|
job_execution = self.job_executions[(job_id, thing_name)]
|
|
except KeyError:
|
|
raise ResourceNotFoundException()
|
|
|
|
if job_execution is None or (
|
|
execution_number is not None
|
|
and job_execution.execution_number != execution_number
|
|
):
|
|
raise ResourceNotFoundException()
|
|
|
|
return job_execution
|
|
|
|
def cancel_job_execution(self, job_id: str, thing_name: str, force: bool) -> None:
|
|
"""
|
|
The parameters ExpectedVersion and StatusDetails are not yet implemented
|
|
"""
|
|
job_execution = self.job_executions[(job_id, thing_name)]
|
|
|
|
if job_execution is None:
|
|
raise ResourceNotFoundException()
|
|
|
|
job_execution.force_canceled = (
|
|
force if force is not None else job_execution.force_canceled
|
|
)
|
|
# TODO: implement expected_version and status_details (at most 10 can be specified)
|
|
|
|
if job_execution.status == "IN_PROGRESS" and force:
|
|
job_execution.status = "CANCELED"
|
|
self.job_executions[(job_id, thing_name)] = job_execution
|
|
elif job_execution.status != "IN_PROGRESS":
|
|
job_execution.status = "CANCELED"
|
|
self.job_executions[(job_id, thing_name)] = job_execution
|
|
else:
|
|
raise InvalidStateTransitionException()
|
|
|
|
def delete_job_execution(
|
|
self, job_id: str, thing_name: str, execution_number: int, force: bool
|
|
) -> None:
|
|
job_execution = self.job_executions[(job_id, thing_name)]
|
|
|
|
if job_execution.execution_number != execution_number:
|
|
raise ResourceNotFoundException()
|
|
|
|
if job_execution.status == "IN_PROGRESS" and force:
|
|
del self.job_executions[(job_id, thing_name)]
|
|
elif job_execution.status != "IN_PROGRESS":
|
|
del self.job_executions[(job_id, thing_name)]
|
|
else:
|
|
raise InvalidStateTransitionException()
|
|
|
|
def list_job_executions_for_job(
|
|
self, job_id: str, status: str, max_results: int, token: Optional[str]
|
|
) -> Tuple[List[Dict[str, Any]], Optional[str]]:
|
|
job_executions = [
|
|
self.job_executions[je].to_dict()
|
|
for je in self.job_executions
|
|
if je[0] == job_id
|
|
]
|
|
|
|
if status is not None:
|
|
job_executions = list(
|
|
filter(
|
|
lambda elem: elem["jobExecutionSummary"].get("status") == status,
|
|
job_executions,
|
|
)
|
|
)
|
|
|
|
if token is None:
|
|
job_executions = job_executions[0:max_results]
|
|
next_token = str(max_results) if len(job_executions) > max_results else None
|
|
else:
|
|
int_token = int(token)
|
|
job_executions = job_executions[int_token : int_token + max_results]
|
|
next_token = (
|
|
str(int_token + max_results)
|
|
if len(job_executions) > int_token + max_results
|
|
else None
|
|
)
|
|
|
|
return job_executions, next_token
|
|
|
|
@paginate(PAGINATION_MODEL) # type: ignore[misc]
|
|
def list_job_executions_for_thing(
|
|
self, thing_name: str, status: Optional[str]
|
|
) -> List[Dict[str, Any]]:
|
|
job_executions = [
|
|
self.job_executions[je].to_dict()
|
|
for je in self.job_executions
|
|
if je[1] == thing_name
|
|
]
|
|
|
|
if status is not None:
|
|
job_executions = list(
|
|
filter(
|
|
lambda elem: elem["jobExecutionSummary"].get("status") == status,
|
|
job_executions,
|
|
)
|
|
)
|
|
|
|
return job_executions
|
|
|
|
def list_topic_rules(self) -> List[Dict[str, Any]]:
|
|
return [r.to_dict() for r in self.rules.values()]
|
|
|
|
def get_topic_rule(self, rule_name: str) -> Dict[str, Any]:
|
|
if rule_name not in self.rules:
|
|
raise ResourceNotFoundException()
|
|
return self.rules[rule_name].to_get_dict()
|
|
|
|
def create_topic_rule(self, rule_name: str, sql: str, **kwargs: Any) -> None:
|
|
if rule_name in self.rules:
|
|
raise ResourceAlreadyExistsException(
|
|
"Rule with given name already exists", "", self.rules[rule_name].arn
|
|
)
|
|
result = re.search(r"FROM\s+([^\s]*)", sql)
|
|
topic = result.group(1).strip("'") if result else None
|
|
self.rules[rule_name] = FakeRule(
|
|
rule_name=rule_name,
|
|
created_at=int(time.time()),
|
|
topic_pattern=topic,
|
|
sql=sql,
|
|
region_name=self.region_name,
|
|
**kwargs,
|
|
)
|
|
|
|
def replace_topic_rule(self, rule_name: str, **kwargs: Any) -> None:
|
|
self.delete_topic_rule(rule_name)
|
|
self.create_topic_rule(rule_name, **kwargs)
|
|
|
|
def delete_topic_rule(self, rule_name: str) -> None:
|
|
if rule_name not in self.rules:
|
|
raise ResourceNotFoundException()
|
|
del self.rules[rule_name]
|
|
|
|
def enable_topic_rule(self, rule_name: str) -> None:
|
|
if rule_name not in self.rules:
|
|
raise ResourceNotFoundException()
|
|
self.rules[rule_name].rule_disabled = False
|
|
|
|
def disable_topic_rule(self, rule_name: str) -> None:
|
|
if rule_name not in self.rules:
|
|
raise ResourceNotFoundException()
|
|
self.rules[rule_name].rule_disabled = True
|
|
|
|
def create_domain_configuration(
|
|
self,
|
|
domain_configuration_name: str,
|
|
domain_name: str,
|
|
server_certificate_arns: List[str],
|
|
authorizer_config: Dict[str, Any],
|
|
service_type: str,
|
|
) -> FakeDomainConfiguration:
|
|
"""
|
|
The ValidationCertificateArn-parameter is not yet implemented
|
|
"""
|
|
if domain_configuration_name in self.domain_configurations:
|
|
raise ResourceAlreadyExistsException(
|
|
"Domain configuration with given name already exists.",
|
|
self.domain_configurations[
|
|
domain_configuration_name
|
|
].domain_configuration_name,
|
|
self.domain_configurations[
|
|
domain_configuration_name
|
|
].domain_configuration_arn,
|
|
)
|
|
self.domain_configurations[domain_configuration_name] = FakeDomainConfiguration(
|
|
self.region_name,
|
|
domain_configuration_name,
|
|
domain_name,
|
|
server_certificate_arns,
|
|
"ENABLED",
|
|
service_type,
|
|
authorizer_config,
|
|
"CUSTOMER_MANAGED",
|
|
)
|
|
return self.domain_configurations[domain_configuration_name]
|
|
|
|
def delete_domain_configuration(self, domain_configuration_name: str) -> None:
|
|
if domain_configuration_name not in self.domain_configurations:
|
|
raise ResourceNotFoundException("The specified resource does not exist.")
|
|
del self.domain_configurations[domain_configuration_name]
|
|
|
|
def describe_domain_configuration(
|
|
self, domain_configuration_name: str
|
|
) -> FakeDomainConfiguration:
|
|
if domain_configuration_name not in self.domain_configurations:
|
|
raise ResourceNotFoundException("The specified resource does not exist.")
|
|
return self.domain_configurations[domain_configuration_name]
|
|
|
|
def list_domain_configurations(self) -> List[Dict[str, Any]]:
|
|
return [_.to_dict() for _ in self.domain_configurations.values()]
|
|
|
|
def update_domain_configuration(
|
|
self,
|
|
domain_configuration_name: str,
|
|
authorizer_config: Dict[str, Any],
|
|
domain_configuration_status: str,
|
|
remove_authorizer_config: Optional[bool],
|
|
) -> FakeDomainConfiguration:
|
|
if domain_configuration_name not in self.domain_configurations:
|
|
raise ResourceNotFoundException("The specified resource does not exist.")
|
|
domain_configuration = self.domain_configurations[domain_configuration_name]
|
|
if authorizer_config is not None:
|
|
domain_configuration.authorizer_config = authorizer_config
|
|
if domain_configuration_status is not None:
|
|
domain_configuration.domain_configuration_status = (
|
|
domain_configuration_status
|
|
)
|
|
if remove_authorizer_config is not None and remove_authorizer_config is True:
|
|
domain_configuration.authorizer_config = None
|
|
return domain_configuration
|
|
|
|
def search_index(self, query_string: str) -> List[Dict[str, Any]]:
|
|
"""
|
|
Pagination is not yet implemented. Only basic search queries are supported for now.
|
|
"""
|
|
things = [
|
|
thing for thing in self.things.values() if thing.matches(query_string)
|
|
]
|
|
return [t.to_dict(include_connectivity=True) for t in things]
|
|
|
|
|
|
iot_backends = BackendDict(IoTBackend, "iot")
|