From c0f3ffb63ce4bc25cab5fd2235e5418a99bba02b Mon Sep 17 00:00:00 2001 From: Paymaun Date: Mon, 17 May 2021 14:51:06 -0700 Subject: [PATCH 01/42] Introducing IoT Hub dataplane RBAC support + various improvements (#341) * Introducing IoT Hub dataplane RBAC support. * IoT Hub rbac support for most commands. * Removed deprecated nested IoT edge artifacts. * Removed deprecated show-connection-string artifacts. * --auth-type supports configurable defaults. * Improved IoT Hub test infrastructure. * Significant refactor of IoT Hub tests. * Various additional improvements. * All warnings from tests are now shown. * Uamqp integration with jwt auth. * Modify HISTORY.rst --- HISTORY.rst | 37 + azext_iot/_factory.py | 19 +- azext_iot/_help.py | 91 - azext_iot/_params.py | 118 +- azext_iot/commands.py | 52 +- azext_iot/common/shared.py | 9 + azext_iot/common/utility.py | 4 +- azext_iot/constants.py | 7 +- azext_iot/digitaltwins/providers/auth.py | 2 +- azext_iot/iothub/commands_job.py | 52 +- azext_iot/iothub/providers/aad_oauth.py | 62 + azext_iot/iothub/providers/base.py | 3 +- azext_iot/iothub/providers/discovery.py | 27 +- azext_iot/monitor/event.py | 60 +- azext_iot/operations/hub.py | 607 ++++-- azext_iot/tests/__init__.py | 97 +- azext_iot/tests/iothub/__init__.py | 19 + .../configurations/test_iot_config_int.py | 1226 +++++++------ azext_iot/tests/iothub/devices/__init__.py | 5 + .../devices/test_iothub_device_tracing.py | 68 + .../devices/test_iothub_device_twin_int.py | 207 +++ .../iothub/devices/test_iothub_devices_int.py | 427 +++++ .../devices/test_iothub_nested_edge_int.py | 265 +++ .../tests/iothub/jobs/test_iothub_jobs_int.py | 404 ++-- azext_iot/tests/iothub/messaging/__init__.py | 5 + .../messaging/test_iothub_c2d_messages_int.py | 79 + azext_iot/tests/iothub/modules/__init__.py | 5 + .../modules/test_iothub_module_twin_int.py | 229 +++ .../iothub/modules/test_iothub_modules_int.py | 335 ++++ azext_iot/tests/iothub/test_iot_ext_int.py | 1633 +---------------- azext_iot/tests/iothub/test_iot_ext_unit.py | 184 +- .../tests/iothub/test_iot_messaging_int.py | 4 - .../iothub/test_iothub_nested_edge_int.py | 243 --- .../tests/iothub/test_iothub_utilities_int.py | 153 ++ dev_requirements | 4 +- linter_exclusions.yml | 35 + 36 files changed, 3489 insertions(+), 3288 deletions(-) create mode 100644 azext_iot/iothub/providers/aad_oauth.py create mode 100644 azext_iot/tests/iothub/devices/__init__.py create mode 100644 azext_iot/tests/iothub/devices/test_iothub_device_tracing.py create mode 100644 azext_iot/tests/iothub/devices/test_iothub_device_twin_int.py create mode 100644 azext_iot/tests/iothub/devices/test_iothub_devices_int.py create mode 100644 azext_iot/tests/iothub/devices/test_iothub_nested_edge_int.py create mode 100644 azext_iot/tests/iothub/messaging/__init__.py create mode 100644 azext_iot/tests/iothub/messaging/test_iothub_c2d_messages_int.py create mode 100644 azext_iot/tests/iothub/modules/__init__.py create mode 100644 azext_iot/tests/iothub/modules/test_iothub_module_twin_int.py create mode 100644 azext_iot/tests/iothub/modules/test_iothub_modules_int.py delete mode 100644 azext_iot/tests/iothub/test_iothub_nested_edge_int.py create mode 100644 azext_iot/tests/iothub/test_iothub_utilities_int.py diff --git a/HISTORY.rst b/HISTORY.rst index 2dd511e21..967fbc110 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,43 @@ Release History =============== +0.10.12 ++++++++++++++++ + +**IoT Hub updates** + +* Removed deprecated edge offline commands and artifacts. +* Removed deprecated device-identity | module-identity show-connection-string commands. + +* Most commands against IoT Hub support Azure AD based access. The type of auth + used to execute commands can be controlled with the "--auth-type" parameter + which accepts the values "key" or "login". The value of "key" is set by default. + + * When "--auth-type" has the value of "key", like before the CLI will auto-discover + a suitable policy when interacting with iothub. + * When "--auth-type" has the value "login", an access token from the Azure CLI logged in principal + will be used for the operation. + + * The following commands currently remain with key based access only. + + * az iot hub monitor-events + * az iot device c2d-message receive + * az iot device c2d-message complete + * az iot device c2d-message abandon + * az iot device c2d-message reject + * az iot device c2d-message purge + * az iot device send-d2c-message + * az iot device simulate + +For more information about IoT Hub support for AAD visit: https://docs.microsoft.com/en-us/azure/iot-hub/iot-hub-dev-guide-azure-ad-rbac + +**Azure Digital Twins updates** + +* Addition of the following commands + + * az dt model delete-all - Deletes all models associated with the Digital Twins instance. + + 0.10.11 +++++++++++++++ diff --git a/azext_iot/_factory.py b/azext_iot/_factory.py index 98fcbbf44..d7bd2d6f6 100644 --- a/azext_iot/_factory.py +++ b/azext_iot/_factory.py @@ -9,8 +9,9 @@ """ from azext_iot.common.sas_token_auth import SasTokenAuthentication +from azext_iot.iothub.providers.aad_oauth import IoTHubOAuth from azext_iot.common.shared import SdkType -from azext_iot.constants import USER_AGENT +from azext_iot.constants import USER_AGENT, IOTHUB_RESOURCE_ID from msrestazure.azure_exceptions import CloudError __all__ = [ @@ -97,15 +98,21 @@ def _get_iothub_device_sdk(self): def _get_iothub_service_sdk(self): from azext_iot.sdk.iothub.service import IotHubGatewayServiceAPIs - credentials = ( - self.auth_override - if self.auth_override - else SasTokenAuthentication( + credentials = None + + if self.auth_override: + credentials = self.auth_override + elif self.target["policy"] == "login": + credentials = IoTHubOAuth( + cmd=self.target["cmd"], + resource_id=IOTHUB_RESOURCE_ID + ) + else: + credentials = SasTokenAuthentication( uri=self.sas_uri, shared_access_policy_name=self.target["policy"], shared_access_key=self.target["primarykey"], ) - ) return IotHubGatewayServiceAPIs(credentials=credentials, base_url=self.endpoint) diff --git a/azext_iot/_help.py b/azext_iot/_help.py index 8667e4cda..0729b4d07 100644 --- a/azext_iot/_help.py +++ b/azext_iot/_help.py @@ -145,14 +145,6 @@ - name: Create an edge enabled IoT device with default authorization (shared private key). text: > az iot hub device-identity create -n {iothub_name} -d {device_id} --ee - - name: Create an edge enabled IoT device with default authorization (shared private key) and - add child devices as well. - text: > - az iot hub device-identity create -n {iothub_name} -d {device_id} --ee --cl {child_device_id} - - name: Create an IoT device with default authorization (shared private key) and - set parent device as well. - text: > - az iot hub device-identity create -n {iothub_name} -d {device_id} --pd {edge_device_id} - name: Create an IoT device with self-signed certificate authorization, generate a cert valid for 10 days then use its thumbprint. text: > @@ -234,13 +226,6 @@ short-summary: Delete an IoT Hub device. """ -helps[ - "iot hub device-identity show-connection-string" -] = """ - type: command - short-summary: Show a given IoT Hub device connection string. -""" - helps[ "iot hub device-identity connection-string" ] = """ @@ -290,32 +275,6 @@ az iot hub device-identity import -n {iothub_name} --ibcu {input_sas_uri_filepath} --obcu {output_sas_uri_filepath} """ -helps[ - "iot hub device-identity get-parent" -] = """ - type: command - short-summary: Get the parent device of the specified device. - examples: - - name: Get the parent device of the specified device. - text: > - az iot hub device-identity get-parent -d {device_id} -n {iothub_name} -""" - -helps[ - "iot hub device-identity set-parent" -] = """ - type: command - short-summary: Set the parent device of the specified device. - examples: - - name: Set the parent device of the specified device. - text: > - az iot hub device-identity set-parent -d {device_id} --pd {edge_device_id} -n {iothub_name} - - name: Set the parent device of the specified device irrespectively the device is - already a child of other edge device. - text: > - az iot hub device-identity set-parent -d {device_id} --pd {edge_device_id} --force -n {iothub_name} -""" - helps[ "iot hub device-identity parent" ] = """ @@ -348,49 +307,6 @@ az iot hub device-identity parent set -d {device_id} --pd {edge_device_id} --force -n {iothub_name} """ -helps[ - "iot hub device-identity add-children" -] = """ - type: command - short-summary: Add specified comma-separated list of device ids as children of specified edge device. - examples: - - name: Add devices as a children to the edge device. - text: > - az iot hub device-identity add-children -d {edge_device_id} --child-list {comma_separated_device_id} - -n {iothub_name} - - name: Add devices as a children to the edge device irrespectively the device is - already a child of other edge device. - text: > - az iot hub device-identity add-children -d {edge_device_id} --child-list {comma_separated_device_id} - -n {iothub_name} -f -""" - -helps[ - "iot hub device-identity list-children" -] = """ - type: command - short-summary: Outputs comma-separated list of assigned child devices. - examples: - - name: Show all assigned devices as comma-separated list. - text: > - az iot hub device-identity list-children -d {edge_device_id} -n {iothub_name} -""" - -helps[ - "iot hub device-identity remove-children" -] = """ - type: command - short-summary: Remove devices as children from specified edge device. - examples: - - name: Remove all mentioned devices as children of specified device. - text: > - az iot hub device-identity remove-children -d {edge_device_id} --child-list {comma_separated_device_id} - -n {iothub_name} - - name: Remove all devices as children specified edge device. - text: > - az iot hub device-identity remove-children -d {edge_device_id} --remove-all -""" - helps[ "iot hub device-identity children" ] = """ @@ -500,13 +416,6 @@ short-summary: Manage IoT device modules. """ -helps[ - "iot hub module-identity show-connection-string" -] = """ - type: command - short-summary: Show a target IoT device module connection string. -""" - helps[ "iot hub module-identity create" ] = """ diff --git a/azext_iot/_params.py b/azext_iot/_params.py index 91cfb4314..99f3cfcca 100644 --- a/azext_iot/_params.py +++ b/azext_iot/_params.py @@ -31,10 +31,29 @@ JobCreateType, JobStatusType, AuthenticationType, + AuthenticationTypeDataplane, RenewKeyType, ) from azext_iot._validators import mode2_iot_login_handler from azext_iot.assets.user_messages import info_param_properties_device +from azure.cli.core.local_context import LocalContextAttribute, LocalContextAction + + +auth_type_dataplane_param_type = CLIArgumentType( + options_list=["--auth-type"], + arg_type=get_enum_type( + AuthenticationTypeDataplane, AuthenticationTypeDataplane.key.value + ), + arg_group="Access Control", + help="Indicates whether the operation should auto-derive a policy key or use the current Azure AD session. " + "You can configure the default using `az configure --defaults iothub-data-auth-type=`", + configured_default="iothub-data-auth-type", + local_context_attribute=LocalContextAttribute( + name="iothub-data-auth-type", + actions=[LocalContextAction.SET, LocalContextAction.GET], + scopes=["iot"], + ), +) hub_name_type = CLIArgumentType( completer=get_resource_name_completion_list("Microsoft.Devices/IotHubs"), @@ -117,7 +136,7 @@ def load_arguments(self, _): "etag", options_list=["--etag", "-e"], help="Etag or entity tag corresponding to the last state of the resource. " - "If no etag is provided the value '*' is used." + "If no etag is provided the value '*' is used.", ) context.argument( "top", @@ -261,6 +280,11 @@ def load_arguments(self, _): options_list=["--desired"], help="Twin desired properties.", ) + context.argument( + "auth_type_dataplane", + options_list=["--auth-type"], + arg_type=auth_type_dataplane_param_type, + ) with self.argument_context("iot hub connection-string") as context: context.argument( @@ -370,7 +394,7 @@ def load_arguments(self, _): help="Description for device status.", ) - with self.argument_context('iot hub device-identity update') as context: + with self.argument_context("iot hub device-identity update") as context: context.argument( "primary_key", options_list=["--primary-key", "--pk"], @@ -382,39 +406,12 @@ def load_arguments(self, _): help="The secondary symmetric shared access key stored in base64 format.", ) - with self.argument_context("iot hub device-identity create") as context: - context.argument( - "force", - options_list=["--force", "-f"], - arg_type=get_three_state_flag(), - help="Overwrites the device's parent device. " - "This command parameter has been deprecated and will be removed " - "in a future release. Use 'az iot hub device-identity parent set' instead.", - deprecate_info=context.deprecate() - ) - context.argument( - "set_parent_id", - options_list=["--set-parent", "--pd"], - help="Id of edge device. " - "This command parameter has been deprecated and will be removed " - "in a future release. Use 'az iot hub device-identity parent set' instead.", - deprecate_info=context.deprecate() - ) - context.argument( - "add_children", - options_list=["--add-children", "--cl"], - help="Child device list (comma separated). " - "This command parameter has been deprecated and will be removed " - "in a future release. Use 'az iot hub device-identity children add' instead.", - deprecate_info=context.deprecate() - ) - - with self.argument_context('iot hub device-identity renew-key') as context: + with self.argument_context("iot hub device-identity renew-key") as context: context.argument( "renew_key_type", options_list=["--key-type", "--kt"], arg_type=get_enum_type(RenewKeyType), - help="Target key type to regenerate." + help="Target key type to regenerate.", ) with self.argument_context("iot hub device-identity export") as context: @@ -466,51 +463,6 @@ def load_arguments(self, _): help="Authentication type for communicating with the storage container.", ) - with self.argument_context("iot hub device-identity get-parent") as context: - context.argument("device_id", help="Id of device.") - - with self.argument_context("iot hub device-identity set-parent") as context: - context.argument("device_id", help="Id of device.") - context.argument( - "parent_id", - options_list=["--parent-device-id", "--pd"], - help="Id of edge device.", - ) - context.argument( - "force", - options_list=["--force", "-f"], - help="Overwrites the device's parent device.", - ) - - with self.argument_context("iot hub device-identity add-children") as context: - context.argument("device_id", help="Id of edge device.") - context.argument( - "child_list", - options_list=["--child-list", "--cl"], - help="Child device list (comma separated).", - ) - context.argument( - "force", - options_list=["--force", "-f"], - help="Overwrites the child device's parent device.", - ) - - with self.argument_context("iot hub device-identity remove-children") as context: - context.argument("device_id", help="Id of edge device.") - context.argument( - "child_list", - options_list=["--child-list", "--cl"], - help="Child device list (comma separated).", - ) - context.argument( - "remove_all", - options_list=["--remove-all", "-a"], - help="To remove all children.", - ) - - with self.argument_context("iot hub device-identity list-children") as context: - context.argument("device_id", help="Id of edge device.") - with self.argument_context("iot hub device-identity parent set") as context: context.argument( "parent_id", @@ -569,6 +521,11 @@ def load_arguments(self, _): ) with self.argument_context("iot device") as context: + context.argument( + "auth_type_dataplane", + options_list=["--auth-type"], + arg_type=auth_type_dataplane_param_type, + ) context.argument("data", options_list=["--data", "--da"], help="Message body.") context.argument( "properties", @@ -800,6 +757,11 @@ def load_arguments(self, _): arg_type=get_three_state_flag(), help="Disables client side schema validation for edge deployment creation.", ) + context.argument( + "auth_type_dataplane", + options_list=["--auth-type"], + arg_type=auth_type_dataplane_param_type, + ) with self.argument_context("iot dps") as context: context.argument( @@ -932,7 +894,7 @@ def load_arguments(self, _): "show_keys", options_list=["--show-keys", "--keys"], arg_type=get_three_state_flag(), - help="Include attestation keys and information in enrollment results" + help="Include attestation keys and information in enrollment results", ) with self.argument_context("iot dps enrollment update") as context: @@ -984,7 +946,7 @@ def load_arguments(self, _): "show_keys", options_list=["--show-keys", "--keys"], arg_type=get_three_state_flag(), - help="Include attestation keys and information in enrollment group results" + help="Include attestation keys and information in enrollment group results", ) with self.argument_context("iot dps registration") as context: diff --git a/azext_iot/commands.py b/azext_iot/commands.py index 68a1e021b..13203e0ce 100644 --- a/azext_iot/commands.py +++ b/azext_iot/commands.py @@ -40,51 +40,9 @@ def load_command_table(self, _): setter_name="iot_device_update", custom_func_name="update_iot_device_custom" ) - cmd_group.command("renew-key", 'iot_device_key_regenerate') - cmd_group.command( - "show-connection-string", - "iot_get_device_connection_string", - deprecate_info=self.deprecate( - redirect="az iot hub device-identity connection-string show" - ), - ) + cmd_group.command("renew-key", "iot_device_key_regenerate") cmd_group.command("import", "iot_device_import") cmd_group.command("export", "iot_device_export") - cmd_group.command( - "add-children", - "iot_device_children_add", - deprecate_info=self.deprecate( - redirect="az iot hub device-identity children add" - ), - ) - cmd_group.command( - "remove-children", - "iot_device_children_remove", - deprecate_info=self.deprecate( - redirect="az iot hub device-identity children remove" - ), - ) - cmd_group.command( - "list-children", - "iot_device_children_list_comma_separated", - deprecate_info=self.deprecate( - redirect="az iot hub device-identity children list" - ), - ) - cmd_group.command( - "get-parent", - "iot_device_get_parent", - deprecate_info=self.deprecate( - redirect="az iot hub device-identity parent show" - ), - ) - cmd_group.command( - "set-parent", - "iot_device_set_parent", - deprecate_info=self.deprecate( - redirect="az iot hub device-identity parent set" - ), - ) with self.command_group( "iot hub device-identity children", command_type=iothub_ops @@ -117,14 +75,6 @@ def load_command_table(self, _): setter_name="iot_device_module_update", ) - cmd_group.show_command( - "show-connection-string", - "iot_get_module_connection_string", - deprecate_info=self.deprecate( - redirect="az iot hub module-identity connection-string show" - ), - ) - with self.command_group( "iot hub module-identity connection-string", command_type=iothub_ops ) as cmd_group: diff --git a/azext_iot/common/shared.py b/azext_iot/common/shared.py index 2fd07e353..1513cc0f2 100644 --- a/azext_iot/common/shared.py +++ b/azext_iot/common/shared.py @@ -215,6 +215,15 @@ class AuthenticationType(Enum): identityBased = "identity" +class AuthenticationTypeDataplane(Enum): + """ + Use a policy key or Oauth token from Azure AD. + """ + + key = "key" + login = "login" + + class RenewKeyType(Enum): """ Target key type for regeneration. diff --git a/azext_iot/common/utility.py b/azext_iot/common/utility.py index e116f1d6d..fb632553a 100644 --- a/azext_iot/common/utility.py +++ b/azext_iot/common/utility.py @@ -349,10 +349,10 @@ def dict_transform_lower_case_key(d): return {k.lower(): v for k, v in d.items()} -def calculate_millisec_since_unix_epoch_utc(): +def calculate_millisec_since_unix_epoch_utc(offset_seconds: int = 0): now = datetime.utcnow() epoch = datetime.utcfromtimestamp(0) - return int(1000 * (now - epoch).total_seconds()) + return int(1000 * ((now - epoch).total_seconds() + offset_seconds)) def init_monitoring(cmd, timeout, properties, enqueued_time, repair, yes): diff --git a/azext_iot/constants.py b/azext_iot/constants.py index d4c932367..4954b7c51 100644 --- a/azext_iot/constants.py +++ b/azext_iot/constants.py @@ -7,7 +7,7 @@ import os -VERSION = "0.10.11" +VERSION = "0.10.12" EXTENSION_NAME = "azure-iot" EXTENSION_ROOT = os.path.dirname(os.path.abspath(__file__)) EXTENSION_CONFIG_ROOT_KEY = "iotext" @@ -36,9 +36,10 @@ CENTRAL_ENDPOINT = "azureiotcentral.com" DEVICE_DEVICESCOPE_PREFIX = "ms-azure-iot-edge://" TRACING_PROPERTY = "azureiot*com^dtracing^1" -TRACING_ALLOWED_FOR_LOCATION = ("northeurope", "westus2", "west us 2", "southeastasia") +TRACING_ALLOWED_FOR_LOCATION = ("northeurope", "westus2", "southeastasia") TRACING_ALLOWED_FOR_SKU = "standard" USER_AGENT = "IoTPlatformCliExtension/{}".format(VERSION) +IOTHUB_RESOURCE_ID = "https://iothubs.azure.net" DIGITALTWINS_RESOURCE_ID = "https://digitaltwins.azure.net" DEVICETWIN_POLLING_INTERVAL_SEC = 10 DEVICETWIN_MONITOR_TIME_SEC = 15 @@ -50,4 +51,4 @@ CONFIG_KEY_UAMQP_EXT_VERSION = "uamqp_ext_version" # Initial Track 2 SDK version -IOTHUB_TRACK_2_SDK_MIN_VERSION = '1.0.0' +IOTHUB_TRACK_2_SDK_MIN_VERSION = "1.0.0" diff --git a/azext_iot/digitaltwins/providers/auth.py b/azext_iot/digitaltwins/providers/auth.py index bb4cc528d..a6353e52c 100644 --- a/azext_iot/digitaltwins/providers/auth.py +++ b/azext_iot/digitaltwins/providers/auth.py @@ -9,7 +9,7 @@ class DigitalTwinAuthentication(Authentication): """ - Shared Access Signature authorization for Azure IoT Hub. + Azure AD OAuth for Azure Digital Twins. """ diff --git a/azext_iot/iothub/commands_job.py b/azext_iot/iothub/commands_job.py index 221f92508..520d49a4f 100644 --- a/azext_iot/iothub/commands_job.py +++ b/azext_iot/iothub/commands_job.py @@ -29,8 +29,15 @@ def job_create( hub_name=None, resource_group_name=None, login=None, + auth_type_dataplane=None, ): - jobs = JobProvider(cmd=cmd, hub_name=hub_name, rg=resource_group_name, login=login) + jobs = JobProvider( + cmd=cmd, + hub_name=hub_name, + rg=resource_group_name, + login=login, + auth_type_dataplane=auth_type_dataplane, + ) return jobs.create( job_id=job_id, job_type=job_type, @@ -48,8 +55,21 @@ def job_create( ) -def job_show(cmd, job_id, hub_name=None, resource_group_name=None, login=None): - jobs = JobProvider(cmd=cmd, hub_name=hub_name, rg=resource_group_name, login=login) +def job_show( + cmd, + job_id, + hub_name=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, +): + jobs = JobProvider( + cmd=cmd, + hub_name=hub_name, + rg=resource_group_name, + login=login, + auth_type_dataplane=auth_type_dataplane, + ) return jobs.get(job_id) @@ -61,11 +81,31 @@ def job_list( hub_name=None, resource_group_name=None, login=None, + auth_type_dataplane=None, ): - jobs = JobProvider(cmd=cmd, hub_name=hub_name, rg=resource_group_name, login=login) + jobs = JobProvider( + cmd=cmd, + hub_name=hub_name, + rg=resource_group_name, + login=login, + auth_type_dataplane=auth_type_dataplane, + ) return jobs.list(job_type=job_type, job_status=job_status, top=top) -def job_cancel(cmd, job_id, hub_name=None, resource_group_name=None, login=None): - jobs = JobProvider(cmd=cmd, hub_name=hub_name, rg=resource_group_name, login=login) +def job_cancel( + cmd, + job_id, + hub_name=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, +): + jobs = JobProvider( + cmd=cmd, + hub_name=hub_name, + rg=resource_group_name, + login=login, + auth_type_dataplane=auth_type_dataplane, + ) return jobs.cancel(job_id) diff --git a/azext_iot/iothub/providers/aad_oauth.py b/azext_iot/iothub/providers/aad_oauth.py new file mode 100644 index 000000000..174f2c571 --- /dev/null +++ b/azext_iot/iothub/providers/aad_oauth.py @@ -0,0 +1,62 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from msrest.authentication import Authentication + + +class IoTHubOAuth(Authentication): + """ + Azure AD OAuth for Azure IoT Hub. + + """ + + def __init__(self, cmd, resource_id): + self.resource_id = resource_id + self.cmd = cmd + + def signed_session(self, session=None): + """ + Create requests session with SAS auth headers. + + If a session object is provided, configure it directly. Otherwise, + create a new session and return it. + + Returns: + session (): requests.Session. + """ + + return self.refresh_session(session) + + def refresh_session( + self, session=None, + ): + """ + Refresh requests session with SAS auth headers. + + If a session object is provided, configure it directly. Otherwise, + create a new session and return it. + + Returns: + session (): requests.Session. + """ + + session = session or super(IoTHubOAuth, self).signed_session() + session.headers["Authorization"] = self.generate_token() + return session + + def generate_token(self): + from azure.cli.core._profile import Profile + + profile = Profile(cli_ctx=self.cmd.cli_ctx) + creds, subscription, tenant = profile.get_raw_token(resource=self.resource_id) + parsed_token = { + "tokenType": creds[0], + "accessToken": creds[1], + "expiresOn": creds[2].get("expiresOn", "N/A"), + "subscription": subscription, + "tenant": tenant, + } + return "{} {}".format(parsed_token["tokenType"], parsed_token["accessToken"]) diff --git a/azext_iot/iothub/providers/base.py b/azext_iot/iothub/providers/base.py index e668264dc..4d1ac7fe9 100644 --- a/azext_iot/iothub/providers/base.py +++ b/azext_iot/iothub/providers/base.py @@ -14,7 +14,7 @@ class IoTHubProvider(object): - def __init__(self, cmd, hub_name, rg, login=None): + def __init__(self, cmd, hub_name, rg, login=None, auth_type_dataplane=None): self.cmd = cmd self.hub_name = hub_name self.rg = rg @@ -23,6 +23,7 @@ def __init__(self, cmd, hub_name, rg, login=None): hub_name=self.hub_name, resource_group_name=self.rg, login=login, + auth_type=auth_type_dataplane, ) self.resolver = SdkResolver(self.target) diff --git a/azext_iot/iothub/providers/discovery.py b/azext_iot/iothub/providers/discovery.py index 4d9043d4a..93ecdcfa1 100644 --- a/azext_iot/iothub/providers/discovery.py +++ b/azext_iot/iothub/providers/discovery.py @@ -8,10 +8,12 @@ from knack.log import get_logger from azure.cli.core.commands.client_factory import get_subscription_id from azext_iot.common.utility import trim_from_start, ensure_iothub_sdk_min_version +from azext_iot.common.shared import AuthenticationTypeDataplane from azext_iot.iothub.models.iothub_target import IotHubTarget from azext_iot._factory import iot_hub_service_factory from azext_iot.constants import IOTHUB_TRACK_2_SDK_MIN_VERSION from typing import Dict, List +from types import SimpleNamespace from enum import Enum, EnumMeta PRIVILEDGED_ACCESS_RIGHTS_SET = set( @@ -142,8 +144,29 @@ def get_target(self, hub_name: str, resource_group_name: str = None, **kwargs) - if cstring: return self.get_target_by_cstring(connection_string=cstring) + resource_group_name = resource_group_name or kwargs.get("rg") + target_iothub = self.find_iothub(hub_name=hub_name, rg=resource_group_name) + key_type = kwargs.get("key_type", "primary") + include_events = kwargs.get("include_events", False) + + # Azure AD auth path + auth_type = kwargs.get("auth_type", AuthenticationTypeDataplane.key.value) + if auth_type == AuthenticationTypeDataplane.login.value: + logger.info("Using AAD access token for IoT Hub interaction.") + policy = SimpleNamespace() + policy.key_name = AuthenticationTypeDataplane.login.value + policy.primary_key = AuthenticationTypeDataplane.login.value + policy.secondary_key = AuthenticationTypeDataplane.login.value + + return self._build_target( + iothub=target_iothub, + policy=policy, + key_type="primary", + include_events=include_events + ) + policy_name = kwargs.get("policy_name", "auto") rg = target_iothub.additional_properties.get("resourcegroup") @@ -151,8 +174,6 @@ def get_target(self, hub_name: str, resource_group_name: str = None, **kwargs) - hub_name=target_iothub.name, rg=rg, policy_name=policy_name, ) - key_type = kwargs.get("key_type", "primary") - include_events = kwargs.get("include_events", False) return self._build_target( iothub=target_iothub, policy=target_policy, @@ -211,4 +232,6 @@ def _build_target( ].partition_ids target["events"] = events + target["cmd"] = self.cmd + return target diff --git a/azext_iot/monitor/event.py b/azext_iot/monitor/event.py index faa7afa85..7e250478f 100644 --- a/azext_iot/monitor/event.py +++ b/azext_iot/monitor/event.py @@ -8,11 +8,14 @@ import uamqp import yaml +from typing import Tuple, Union from uuid import uuid4 from knack.log import get_logger from azext_iot.constants import USER_AGENT +from azext_iot.common.shared import AuthenticationTypeDataplane from azext_iot.common.utility import process_json_arg from azext_iot.monitor.builders.hub_target_builder import AmqpBuilder +from uamqp.authentication import JWTTokenAuth # To provide amqp frame trace DEBUG = False @@ -72,10 +75,13 @@ def send_c2d_message( ) operation = "/messages/devicebound" - endpoint = AmqpBuilder.build_iothub_amqp_endpoint_from_target(target) - endpoint_with_op = endpoint + operation + endpoint_target, token_auth = _get_endpoint_and_token_auth( + target=target, operation=operation + ) + client = uamqp.SendClient( - target="amqps://" + endpoint_with_op, + target=endpoint_target, + auth=token_auth, client_name=_get_container_id(), debug=DEBUG, ) @@ -107,24 +113,23 @@ def handle_msg(msg): return None operation = "/messages/servicebound/feedback" - endpoint = AmqpBuilder.build_iothub_amqp_endpoint_from_target( - target, duration=token_duration + endpoint_target, token_auth = _get_endpoint_and_token_auth( + target=target, operation=operation ) - endpoint = endpoint + operation - device_filter_txt = None if device_id: device_filter_txt = " filtering on device: {},".format(device_id) print( - "Starting C2D feedback monitor,{} use ctrl-c to stop...".format( - device_filter_txt if device_filter_txt else "" - ) + f"Starting C2D feedback monitor,{device_filter_txt if device_filter_txt else ''} use ctrl-c to stop..." ) try: client = uamqp.ReceiveClient( - "amqps://" + endpoint, client_name=_get_container_id(), debug=DEBUG + source=endpoint_target, + auth=token_auth, + client_name=_get_container_id(), + debug=DEBUG, ) message_generator = client.receive_messages_iter() for msg in message_generator: @@ -141,3 +146,36 @@ def handle_msg(msg): def _get_container_id(): return "{}/{}".format(USER_AGENT, str(uuid4())) + + +def _get_endpoint_and_token_auth( + target: dict, operation: str +) -> Tuple[str, Union[JWTTokenAuth, None]]: + from azext_iot.constants import IOTHUB_RESOURCE_ID + from time import time + from collections import namedtuple + + AccessToken = namedtuple("AccessToken", ["token", "expires_on"]) + + def token_provider(): + from azure.cli.core._profile import Profile + profile = Profile(cli_ctx=target["cmd"].cli_ctx) + creds, _, _ = profile.get_raw_token(resource=IOTHUB_RESOURCE_ID) + access_token = AccessToken(f"{creds[0]} {creds[1]}", time() + 3599) + return access_token + + endpoint_with_op = None + jwt_token_auth = None + if target["policy"] == AuthenticationTypeDataplane.login.value: + endpoint_with_op = f"amqps://{target['entity']}{operation}" + jwt_token_auth = JWTTokenAuth( + audience=IOTHUB_RESOURCE_ID, + uri=endpoint_with_op, + get_token=token_provider, + token_type=b"Bearer", + ) + jwt_token_auth.update_token() # Work-around for uamqp error. + else: + endpoint_with_op = f"amqps://{AmqpBuilder.build_iothub_amqp_endpoint_from_target(target)}{operation}" + + return endpoint_with_op, jwt_token_auth diff --git a/azext_iot/operations/hub.py b/azext_iot/operations/hub.py index 2a234aa53..541b7284c 100644 --- a/azext_iot/operations/hub.py +++ b/azext_iot/operations/hub.py @@ -26,7 +26,7 @@ KeyType, SettleType, RenewKeyType, - IoTHubStateType + IoTHubStateType, ) from azext_iot.iothub.providers.discovery import IotHubDiscovery from azext_iot.common.utility import ( @@ -38,7 +38,7 @@ init_monitoring, process_json_arg, ensure_iothub_sdk_min_version, - generate_key + generate_key, ) from azext_iot._factory import SdkResolver, CloudError from azext_iot.operations.generic import _execute_query, _process_top @@ -51,12 +51,21 @@ def iot_query( - cmd, query_command, hub_name=None, top=None, resource_group_name=None, login=None + cmd, + query_command, + hub_name=None, + top=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, ): top = _process_top(top) discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -74,11 +83,19 @@ def iot_query( def iot_device_show( - cmd, device_id, hub_name=None, resource_group_name=None, login=None + cmd, + device_id, + hub_name=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) return _iot_device_show(target, device_id) @@ -104,13 +121,23 @@ def iot_device_list( edge_enabled=False, resource_group_name=None, login=None, + auth_type_dataplane=None, ): query = ( "select * from devices where capabilities.iotEdge = true" if edge_enabled else "select * from devices" ) - result = iot_query(cmd, query, hub_name, top, resource_group_name, login=login) + result = iot_query( + cmd=cmd, + query_command=query, + hub_name=hub_name, + top=top, + resource_group_name=resource_group_name, + login=login, + auth_type_dataplane=auth_type_dataplane, + ) + if not result: logger.info('No registered devices found on hub "%s".', hub_name) return result @@ -128,36 +155,20 @@ def iot_device_create( status_reason=None, valid_days=None, output_dir=None, - set_parent_id=None, - add_children=None, - force=False, resource_group_name=None, login=None, + auth_type_dataplane=None, ): - discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) - if add_children: - if not edge_enabled: - raise CLIError( - 'The device "{}" should be edge device in order to add children.'.format(device_id) - ) - - for child_device_id in add_children.split(","): - child_device = _iot_device_show(target, child_device_id.strip()) - _validate_parent_child_relation(child_device, force) - - deviceScope = None - if set_parent_id: - edge_device = _iot_device_show(target, set_parent_id) - _validate_edge_device(edge_device) - deviceScope = edge_device["deviceScope"] - if any([valid_days, output_dir]): valid_days = 365 if not valid_days else int(valid_days) if output_dir and not exists(output_dir): @@ -179,7 +190,6 @@ def iot_device_create( secondary_thumbprint, status, status_reason, - deviceScope ) output = service_sdk.devices.create_or_update_identity( id=device_id, device=device @@ -187,11 +197,6 @@ def iot_device_create( except CloudError as e: raise CLIError(unpack_msrest_error(e)) - if add_children: - for child_device_id in add_children.split(","): - child_device = _iot_device_show(target, child_device_id.strip()) - _update_device_parent(target, child_device, child_device["capabilities"]["iotEdge"], output.device_scope) - return output @@ -297,7 +302,7 @@ def update_iot_device_custom( if status_reason is not None: instance["statusReason"] = status_reason - auth_type = instance['authentication']['type'] + auth_type = instance["authentication"]["type"] if auth_method is not None: if auth_method == DeviceAuthType.shared_private_key.name: auth = "sas" @@ -359,24 +364,34 @@ def update_iot_device_custom( def iot_device_update( - cmd, device_id, parameters, hub_name=None, resource_group_name=None, login=None, etag=None + cmd, + device_id, + parameters, + hub_name=None, + resource_group_name=None, + login=None, + etag=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) auth, pk, sk = _parse_auth(parameters) updated_device = _assemble_device( True, - parameters['deviceId'], + parameters["deviceId"], auth, - parameters['capabilities']['iotEdge'], + parameters["capabilities"]["iotEdge"], pk, sk, - parameters['status'].lower(), - parameters.get('statusReason'), - parameters.get('deviceScope') + parameters["status"].lower(), + parameters.get("statusReason"), + parameters.get("deviceScope"), ) updated_device.etag = etag if etag else "*" return _iot_device_update(target, device_id, updated_device) @@ -390,20 +405,27 @@ def _iot_device_update(target, device_id, device): headers = {} headers["If-Match"] = '"{}"'.format(device.etag) return service_sdk.devices.create_or_update_identity( - id=device_id, - device=device, - custom_headers=headers + id=device_id, device=device, custom_headers=headers ) except CloudError as e: raise CLIError(unpack_msrest_error(e)) def iot_device_delete( - cmd, device_id, hub_name=None, resource_group_name=None, login=None, etag=None + cmd, + device_id, + hub_name=None, + resource_group_name=None, + login=None, + etag=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -411,9 +433,7 @@ def iot_device_delete( try: headers = {} headers["If-Match"] = '"{}"'.format(etag if etag else "*") - service_sdk.devices.delete_identity( - id=device_id, custom_headers=headers - ) + service_sdk.devices.delete_identity(id=device_id, custom_headers=headers) return except CloudError as e: raise CLIError(unpack_msrest_error(e)) @@ -437,13 +457,25 @@ def _update_device_key(target, device, auth_method, pk, sk, etag=None): raise CLIError(unpack_msrest_error(e)) -def iot_device_key_regenerate(cmd, hub_name, device_id, renew_key_type, resource_group_name=None, login=None, etag=None): +def iot_device_key_regenerate( + cmd, + hub_name, + device_id, + renew_key_type, + resource_group_name=None, + login=None, + etag=None, + auth_type_dataplane=None, +): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) device = _iot_device_show(target, device_id) - if (device["authentication"]["type"] != "sas"): + if device["authentication"]["type"] != "sas": raise CLIError("Device authentication should be of type sas") pk = device["authentication"]["symmetricKey"]["primaryKey"] @@ -458,15 +490,25 @@ def iot_device_key_regenerate(cmd, hub_name, device_id, renew_key_type, resource pk = sk sk = temp - return _update_device_key(target, device, device["authentication"]["type"], pk, sk, etag) + return _update_device_key( + target, device, device["authentication"]["type"], pk, sk, etag + ) def iot_device_get_parent( - cmd, device_id, hub_name=None, resource_group_name=None, login=None + cmd, + device_id, + hub_name=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) child_device = _iot_device_show(target, device_id) _validate_child_device(child_device) @@ -485,17 +527,26 @@ def iot_device_set_parent( hub_name=None, resource_group_name=None, login=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) parent_device = _iot_device_show(target, parent_id) _validate_edge_device(parent_device) child_device = _iot_device_show(target, device_id) _validate_parent_child_relation(child_device, force) - _update_device_parent(target, child_device, child_device["capabilities"]["iotEdge"], parent_device["deviceScope"]) + _update_device_parent( + target, + child_device, + child_device["capabilities"]["iotEdge"], + parent_device["deviceScope"], + ) def iot_device_children_add( @@ -506,26 +557,31 @@ def iot_device_children_add( hub_name=None, resource_group_name=None, login=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) devices = [] edge_device = _iot_device_show(target, device_id) _validate_edge_device(edge_device) converted_child_list = child_list - if isinstance(child_list, str): # this check would be removed once add-children command is deprecated - converted_child_list = child_list.split(",") for child_device_id in converted_child_list: child_device = _iot_device_show(target, child_device_id.strip()) - _validate_parent_child_relation( - child_device, force - ) + _validate_parent_child_relation(child_device, force) devices.append(child_device) for device in devices: - _update_device_parent(target, device, device["capabilities"]["iotEdge"], edge_device["deviceScope"]) + _update_device_parent( + target, + device, + device["capabilities"]["iotEdge"], + edge_device["deviceScope"], + ) def iot_device_children_remove( @@ -536,10 +592,14 @@ def iot_device_children_remove( hub_name=None, resource_group_name=None, login=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) devices = [] if remove_all: @@ -559,8 +619,6 @@ def iot_device_children_remove( edge_device = _iot_device_show(target, device_id) _validate_edge_device(edge_device) converted_child_list = child_list - if isinstance(child_list, str): # this check would be removed once remove-children command is deprecated - converted_child_list = child_list.split(",") for child_device_id in converted_child_list: child_device = _iot_device_show(target, child_device_id.strip()) _validate_child_device(child_device) @@ -582,42 +640,58 @@ def iot_device_children_remove( def iot_device_children_list( - cmd, device_id, hub_name=None, resource_group_name=None, login=None + cmd, + device_id, + hub_name=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, ): result = _iot_device_children_list( - cmd, device_id, hub_name, resource_group_name, login + cmd=cmd, + device_id=device_id, + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type_dataplane=auth_type_dataplane, ) return [device["deviceId"] for device in result] -# this method would be removed once remove-children command is deprecated -def iot_device_children_list_comma_separated( - cmd, device_id, hub_name=None, resource_group_name=None, login=None -): - result = _iot_device_children_list( - cmd, device_id, hub_name, resource_group_name, login - ) - if not result: - raise CLIError( - 'No registered child devices found for "{}" edge device.'.format(device_id) - ) - return ", ".join([str(x["deviceId"]) for x in result]) - - def _iot_device_children_list( - cmd, device_id, hub_name=None, resource_group_name=None, login=None + cmd, + device_id, + hub_name=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) device = _iot_device_show(target, device_id) _validate_edge_device(device) - query = "select deviceId from devices where array_contains(parentScopes, '{}')".format( - device["deviceScope"] + query = ( + "select deviceId from devices where array_contains(parentScopes, '{}')".format( + device["deviceScope"] + ) + ) + + # TODO: Inefficient + return iot_query( + cmd=cmd, + query_command=query, + hub_name=hub_name, + top=None, + resource_group_name=resource_group_name, + login=login, + auth_type_dataplane=auth_type_dataplane, ) - return iot_query(cmd, query, hub_name, None, resource_group_name, login=login) def _update_device_parent(target, device, is_edge, device_scope=None): @@ -667,9 +741,7 @@ def _validate_child_device(device): ) if not device["parentScopes"]: raise CLIError( - 'Device "{}" doesn\'t have any parent device.'.format( - device["deviceId"] - ) + 'Device "{}" doesn\'t have any parent device.'.format(device["deviceId"]) ) @@ -700,6 +772,7 @@ def iot_device_module_create( output_dir=None, resource_group_name=None, login=None, + auth_type_dataplane=None, ): if any([valid_days, output_dir]): @@ -715,7 +788,10 @@ def iot_device_module_create( discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -752,10 +828,14 @@ def iot_device_module_update( resource_group_name=None, login=None, etag=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -805,11 +885,20 @@ def _parse_auth(parameters): def iot_device_module_list( - cmd, device_id, hub_name=None, top=1000, resource_group_name=None, login=None + cmd, + device_id, + hub_name=None, + top=1000, + resource_group_name=None, + login=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -821,11 +910,20 @@ def iot_device_module_list( def iot_device_module_show( - cmd, device_id, module_id, hub_name=None, resource_group_name=None, login=None + cmd, + device_id, + module_id, + hub_name=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) return _iot_device_module_show(target, device_id, module_id) @@ -845,11 +943,21 @@ def _iot_device_module_show(target, device_id, module_id): def iot_device_module_delete( - cmd, device_id, module_id, hub_name=None, resource_group_name=None, login=None, etag=None + cmd, + device_id, + module_id, + hub_name=None, + resource_group_name=None, + login=None, + etag=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -866,11 +974,20 @@ def iot_device_module_delete( def iot_device_module_twin_show( - cmd, device_id, module_id, hub_name=None, resource_group_name=None, login=None + cmd, + device_id, + module_id, + hub_name=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) return _iot_device_module_twin_show( target=target, device_id=device_id, module_id=module_id @@ -897,13 +1014,17 @@ def iot_device_module_twin_update( hub_name=None, resource_group_name=None, login=None, - etag=None + etag=None, + auth_type_dataplane=None, ): from azext_iot.common.utility import verify_transform discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -938,11 +1059,15 @@ def iot_device_module_twin_replace( hub_name=None, resource_group_name=None, login=None, - etag=None + etag=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -962,13 +1087,22 @@ def iot_device_module_twin_replace( def iot_edge_set_modules( - cmd, device_id, content, hub_name=None, resource_group_name=None, login=None + cmd, + device_id, + content, + hub_name=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, ): from azext_iot.sdk.iothub.service.models import ConfigurationContent discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -999,6 +1133,7 @@ def iot_edge_deployment_create( no_validation=False, resource_group_name=None, login=None, + auth_type_dataplane=None, ): # Short-term fix for --no-validation config_type = ConfigType.layered if layered or no_validation else ConfigType.edge @@ -1014,6 +1149,7 @@ def iot_edge_deployment_create( resource_group_name=resource_group_name, login=login, config_type=config_type, + auth_type_dataplane=auth_type_dataplane, ) @@ -1028,6 +1164,7 @@ def iot_hub_configuration_create( metrics=None, resource_group_name=None, login=None, + auth_type_dataplane=None, ): return _iot_hub_configuration_create( cmd=cmd, @@ -1041,6 +1178,7 @@ def iot_hub_configuration_create( resource_group_name=resource_group_name, login=login, config_type=ConfigType.adm, + auth_type_dataplane=auth_type_dataplane, ) @@ -1056,6 +1194,7 @@ def _iot_hub_configuration_create( metrics=None, resource_group_name=None, login=None, + auth_type_dataplane=None, ): from azext_iot.sdk.iothub.service.models import ( Configuration, @@ -1065,7 +1204,10 @@ def _iot_hub_configuration_create( discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -1206,14 +1348,24 @@ def _validate_payload_schema(content): def iot_hub_configuration_update( - cmd, config_id, parameters, hub_name=None, resource_group_name=None, login=None, etag=None + cmd, + config_id, + parameters, + hub_name=None, + resource_group_name=None, + login=None, + etag=None, + auth_type_dataplane=None, ): from azext_iot.sdk.iothub.service.models import Configuration from azext_iot.common.utility import verify_transform discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -1232,7 +1384,7 @@ def iot_hub_configuration_update( content=parameters["content"], metrics=parameters.get("metrics", None), target_condition=parameters["targetCondition"], - priority=parameters["priority"] + priority=parameters["priority"], ) return service_sdk.configuration.create_or_update( id=config_id, configuration=config, custom_headers=headers @@ -1244,11 +1396,19 @@ def iot_hub_configuration_update( def iot_hub_configuration_show( - cmd, config_id, hub_name=None, resource_group_name=None, login=None + cmd, + config_id, + hub_name=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) return _iot_hub_configuration_show(target=target, config_id=config_id) @@ -1264,13 +1424,19 @@ def _iot_hub_configuration_show(target, config_id): def iot_hub_configuration_list( - cmd, hub_name=None, top=None, resource_group_name=None, login=None + cmd, + hub_name=None, + top=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, ): result = _iot_hub_configuration_list( - cmd, + cmd=cmd, hub_name=hub_name, resource_group_name=resource_group_name, login=login, + auth_type_dataplane=auth_type_dataplane, ) filtered = [ c @@ -1284,13 +1450,19 @@ def iot_hub_configuration_list( def iot_edge_deployment_list( - cmd, hub_name=None, top=None, resource_group_name=None, login=None + cmd, + hub_name=None, + top=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, ): result = _iot_hub_configuration_list( cmd, hub_name=hub_name, resource_group_name=resource_group_name, login=login, + auth_type_dataplane=auth_type_dataplane, ) filtered = [c for c in result if c["content"].get("modulesContent") is not None] @@ -1298,11 +1470,14 @@ def iot_edge_deployment_list( def _iot_hub_configuration_list( - cmd, hub_name=None, resource_group_name=None, login=None + cmd, hub_name=None, resource_group_name=None, login=None, auth_type_dataplane=None ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -1317,11 +1492,20 @@ def _iot_hub_configuration_list( def iot_hub_configuration_delete( - cmd, config_id, hub_name=None, resource_group_name=None, login=None, etag=None + cmd, + config_id, + hub_name=None, + resource_group_name=None, + login=None, + etag=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -1342,6 +1526,7 @@ def iot_edge_deployment_metric_show( hub_name=None, resource_group_name=None, login=None, + auth_type_dataplane=None, ): return iot_hub_configuration_metric_show( cmd, @@ -1351,6 +1536,7 @@ def iot_edge_deployment_metric_show( hub_name=hub_name, resource_group_name=resource_group_name, login=login, + auth_type_dataplane=auth_type_dataplane, ) @@ -1362,10 +1548,14 @@ def iot_hub_configuration_metric_show( hub_name=None, resource_group_name=None, login=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -1407,11 +1597,19 @@ def iot_hub_configuration_metric_show( def iot_device_twin_show( - cmd, device_id, hub_name=None, resource_group_name=None, login=None + cmd, + device_id, + hub_name=None, + resource_group_name=None, + login=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) return _iot_device_twin_show(target=target, device_id=device_id) @@ -1441,13 +1639,23 @@ def iot_twin_update_custom(instance, desired=None, tags=None): def iot_device_twin_update( - cmd, device_id, parameters, hub_name=None, resource_group_name=None, login=None, etag=None + cmd, + device_id, + parameters, + hub_name=None, + resource_group_name=None, + login=None, + etag=None, + auth_type_dataplane=None, ): from azext_iot.common.utility import verify_transform discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -1472,11 +1680,21 @@ def iot_device_twin_update( def iot_device_twin_replace( - cmd, device_id, target_json, hub_name=None, resource_group_name=None, login=None, etag=None + cmd, + device_id, + target_json, + hub_name=None, + resource_group_name=None, + login=None, + etag=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -1501,6 +1719,7 @@ def iot_device_method( timeout=30, resource_group_name=None, login=None, + auth_type_dataplane=None, ): from azext_iot.constants import ( METHOD_INVOKE_MAX_TIMEOUT_SEC, @@ -1518,7 +1737,10 @@ def iot_device_method( discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -1539,7 +1761,9 @@ def iot_device_method( } return service_sdk.devices.invoke_method( - device_id=device_id, direct_method_request=request_body, timeout=timeout, + device_id=device_id, + direct_method_request=request_body, + timeout=timeout, ) except CloudError as e: raise CLIError(unpack_msrest_error(e)) @@ -1558,6 +1782,7 @@ def iot_device_module_method( timeout=30, resource_group_name=None, login=None, + auth_type_dataplane=None, ): from azext_iot.constants import ( METHOD_INVOKE_MAX_TIMEOUT_SEC, @@ -1575,7 +1800,10 @@ def iot_device_module_method( discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -1618,6 +1846,7 @@ def iot_get_sas_token( resource_group_name=None, login=None, module_id=None, + auth_type_dataplane=None, ): key_type = key_type.lower() policy_name = policy_name.lower() @@ -1646,6 +1875,7 @@ def iot_get_sas_token( duration, resource_group_name, login, + auth_type_dataplane, ).generate_sas_token() } @@ -1660,18 +1890,24 @@ def _iot_build_sas_token( duration=3600, resource_group_name=None, login=None, + auth_type_dataplane=None, ): from azext_iot.common._azure import ( parse_iot_device_connection_string, parse_iot_device_module_connection_string, ) + # There is no dataplane operation for a pure IoT Hub sas token + if all([device_id is None, module_id is None]): + auth_type_dataplane = "key" + discovery = IotHubDiscovery(cmd) target = discovery.get_target( hub_name=hub_name, resource_group_name=resource_group_name, policy_name=policy_name, login=login, + auth_type=auth_type_dataplane, ) uri = None policy = None @@ -1753,6 +1989,7 @@ def iot_get_device_connection_string( key_type="primary", resource_group_name=None, login=None, + auth_type_dataplane=None, ): result = {} device = iot_device_show( @@ -1761,6 +1998,7 @@ def iot_get_device_connection_string( hub_name=hub_name, resource_group_name=resource_group_name, login=login, + auth_type_dataplane=auth_type_dataplane, ) result["connectionString"] = _build_device_or_module_connection_string( device, key_type @@ -1776,6 +2014,7 @@ def iot_get_module_connection_string( key_type="primary", resource_group_name=None, login=None, + auth_type_dataplane=None, ): result = {} module = iot_device_module_show( @@ -1785,6 +2024,7 @@ def iot_get_module_connection_string( resource_group_name=resource_group_name, hub_name=hub_name, login=login, + auth_type_dataplane=auth_type_dataplane, ) result["connectionString"] = _build_device_or_module_connection_string( module, key_type @@ -2016,18 +2256,24 @@ def _iot_c2d_message_receive(target, device_id, lock_timeout=60, ack=None): ack_response = {} if ack == SettleType.abandon.value: logger.debug("__Abandoning message__") - ack_response = device_sdk.device.abandon_device_bound_notification( - id=device_id, etag=eTag, raw=True + ack_response = ( + device_sdk.device.abandon_device_bound_notification( + id=device_id, etag=eTag, raw=True + ) ) elif ack == SettleType.reject.value: logger.debug("__Rejecting message__") - ack_response = device_sdk.device.complete_device_bound_notification( - id=device_id, etag=eTag, reject="", raw=True + ack_response = ( + device_sdk.device.complete_device_bound_notification( + id=device_id, etag=eTag, reject="", raw=True + ) ) else: logger.debug("__Completing message__") - ack_response = device_sdk.device.complete_device_bound_notification( - id=device_id, etag=eTag, raw=True + ack_response = ( + device_sdk.device.complete_device_bound_notification( + id=device_id, etag=eTag, raw=True + ) ) payload["ack"] = ( @@ -2089,6 +2335,7 @@ def iot_c2d_message_send( repair=False, resource_group_name=None, login=None, + auth_type_dataplane=None, ): from azext_iot.common.deps import ensure_uamqp from azext_iot.common.utility import validate_min_python_version @@ -2105,7 +2352,10 @@ def iot_c2d_message_send( discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) if properties: @@ -2238,16 +2488,24 @@ def http_wrap(target, device_id, generator): def iot_c2d_message_purge( - cmd, device_id, hub_name=None, resource_group_name=None, login=None, + cmd, + device_id, + hub_name=None, + resource_group_name=None, + login=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, ) resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) - return service_sdk.cloud_to_device_messages.purge_cloud_to_device_message_queue(device_id) + return service_sdk.cloud_to_device_messages.purge_cloud_to_device_message_queue( + device_id + ) def _iot_simulate_get_default_properties(protocol): @@ -2313,7 +2571,9 @@ def iot_device_export( authentication_type=storage_authentication_type, ) return client.export_devices( - target["resourcegroup"], hub_name, export_devices_parameters=export_request, + target["resourcegroup"], + hub_name, + export_devices_parameters=export_request, ) if storage_authentication_type: raise CLIError( @@ -2366,7 +2626,9 @@ def iot_device_import( authentication_type=storage_authentication_type, ) return client.import_devices( - target["resourcegroup"], hub_name, import_devices_parameters=import_request, + target["resourcegroup"], + hub_name, + import_devices_parameters=import_request, ) if storage_authentication_type: raise CLIError( @@ -2481,6 +2743,7 @@ def iot_hub_monitor_feedback( repair=False, resource_group_name=None, login=None, + auth_type_dataplane=None, ): from azext_iot.common.deps import ensure_uamqp from azext_iot.common.utility import validate_min_python_version @@ -2492,7 +2755,10 @@ def iot_hub_monitor_feedback( discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, ) return _iot_hub_monitor_feedback( @@ -2501,11 +2767,17 @@ def iot_hub_monitor_feedback( def iot_hub_distributed_tracing_show( - cmd, hub_name, device_id, resource_group_name=None, login=None, + cmd, + hub_name, + device_id, + resource_group_name=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( - hub_name=hub_name, resource_group_name=resource_group_name, login=login + hub_name=hub_name, + resource_group_name=resource_group_name, + auth_type=auth_type_dataplane, ) device_twin = _iot_hub_distributed_tracing_show(target=target, device_id=device_id) @@ -2598,14 +2870,14 @@ def iot_hub_distributed_tracing_update( sampling_mode, sampling_rate, resource_group_name=None, - login=None, + auth_type_dataplane=None, ): discovery = IotHubDiscovery(cmd) target = discovery.get_target( hub_name=hub_name, resource_group_name=resource_group_name, include_events=True, - login=login, + auth_type=auth_type_dataplane, ) if int(sampling_rate) not in range(0, 101): @@ -2622,7 +2894,7 @@ def iot_hub_distributed_tracing_update( 1 if sampling_mode.lower() == "on" else 2 ) result = iot_device_twin_update( - cmd, device_id, device_twin, hub_name, resource_group_name, login + cmd, device_id, device_twin, hub_name, resource_group_name ) return _customize_device_tracing_output( result.device_id, result.properties.desired, result.properties.reported @@ -2654,20 +2926,26 @@ def conn_str_getter(hub): for hub in hubs: if hub.properties.state == IoTHubStateType.Active.value: try: - connection_strings.append({ - "name": hub.name, - "connectionString": conn_str_getter(hub) - if show_all - else conn_str_getter(hub)[0], - }) + connection_strings.append( + { + "name": hub.name, + "connectionString": conn_str_getter(hub) + if show_all + else conn_str_getter(hub)[0], + } + ) except: - logger.warning(f"Warning: The IoT Hub {hub.name} in resource group " + - f"{hub.additional_properties['resourcegroup']} does " + - f"not have the target policy {policy_name}.") + logger.warning( + f"Warning: The IoT Hub {hub.name} in resource group " + + f"{hub.additional_properties['resourcegroup']} does " + + f"not have the target policy {policy_name}." + ) else: - logger.warning(f"Warning: The IoT Hub {hub.name} in resource group " + - f"{hub.additional_properties['resourcegroup']} is skipped " + - "because the hub is not active.") + logger.warning( + f"Warning: The IoT Hub {hub.name} in resource group " + + f"{hub.additional_properties['resourcegroup']} is skipped " + + "because the hub is not active." + ) return connection_strings hub = discovery.find_iothub(hub_name, resource_group_name) @@ -2710,7 +2988,12 @@ def _get_hub_connection_string( entityPath, ) for p in policies - if "serviceconnect" in (p.rights.value.lower() if isinstance(p.rights, (Enum, EnumMeta)) else p.rights.lower()) + if "serviceconnect" + in ( + p.rights.value.lower() + if isinstance(p.rights, (Enum, EnumMeta)) + else p.rights.lower() + ) ] hostname = hub.properties.host_name diff --git a/azext_iot/tests/__init__.py b/azext_iot/tests/__init__.py index f7cf926d2..00e227551 100644 --- a/azext_iot/tests/__init__.py +++ b/azext_iot/tests/__init__.py @@ -8,8 +8,10 @@ import io import os +from azext_iot.tests.iothub import DATAPLANE_AUTH_TYPES from azure.cli.testsdk import LiveScenarioTest from contextlib import contextmanager +from typing import List PREFIX_DEVICE = "test-device-" PREFIX_EDGE_DEVICE = "test-edge-device-" @@ -76,15 +78,46 @@ def __init__(self, test_scenario, entity_name, entity_rg): self.entity_name = entity_name self.entity_rg = entity_rg - self.device_ids = [] - self.config_ids = [] - - os.environ["AZURE_CORE_COLLECT_TELEMETRY"] = "no" super(IoTLiveScenarioTest, self).__init__(test_scenario) self.region = self.get_region() self.connection_string = self.get_hub_cstring() + def clean_up(self, device_ids: List[str] = None, config_ids: List[str] = None): + if device_ids: + device = device_ids.pop() + self.cmd( + "iot hub device-identity delete -d {} --login {}".format( + device, self.connection_string + ), + checks=self.is_empty(), + ) + + for device in device_ids: + self.cmd( + "iot hub device-identity delete -d {} -n {} -g {}".format( + device, self.entity_name, self.entity_rg + ), + checks=self.is_empty(), + ) + + if config_ids: + config = config_ids.pop() + self.cmd( + "iot hub configuration delete -c {} --login {}".format( + config, self.connection_string + ), + checks=self.is_empty(), + ) + + for config in config_ids: + self.cmd( + "iot hub configuration delete -c {} -n {} -g {}".format( + config, self.entity_name, self.entity_rg + ), + checks=self.is_empty(), + ) + def generate_device_names(self, count=1, edge=False): names = [ self.create_random_name( @@ -92,7 +125,6 @@ def generate_device_names(self, count=1, edge=False): ) for i in range(count) ] - self.device_ids.extend(names) return names def generate_module_names(self, count=1): @@ -108,7 +140,6 @@ def generate_config_names(self, count=1, edge=False): ) for i in range(count) ] - self.config_ids.extend(names) return names def generate_job_names(self, count=1): @@ -117,39 +148,21 @@ def generate_job_names(self, count=1): ] def tearDown(self): - if self.device_ids: - device = self.device_ids.pop() - self.cmd( - "iot hub device-identity delete -d {} --login {}".format( - device, self.connection_string - ), - checks=self.is_empty(), - ) + device_list = [] + device_list.extend(d["deviceId"] for d in self.cmd( + f"iot hub device-identity list -n {self.entity_name} -g {self.entity_rg}" + ).get_output_in_json()) - for device in self.device_ids: - self.cmd( - "iot hub device-identity delete -d {} -n {} -g {}".format( - device, self.entity_name, self.entity_rg - ), - checks=self.is_empty(), - ) + config_list = [] + config_list.extend(c["id"] for c in self.cmd( + f"iot edge deployment list -n {self.entity_name} -g {self.entity_rg}" + ).get_output_in_json()) - if self.config_ids: - config = self.config_ids.pop() - self.cmd( - "iot hub configuration delete -c {} --login {}".format( - config, self.connection_string - ), - checks=self.is_empty(), - ) + config_list.extend(c["id"] for c in self.cmd( + f"iot hub configuration list -n {self.entity_name} -g {self.entity_rg}" + ).get_output_in_json()) - for config in self.config_ids: - self.cmd( - "iot hub configuration delete -c {} -n {} -g {}".format( - config, self.entity_name, self.entity_rg - ), - checks=self.is_empty(), - ) + self.clean_up(device_ids=device_list, config_ids=config_list) def get_region(self): result = self.cmd( @@ -162,11 +175,21 @@ def get_region(self): def get_hub_cstring(self, policy="iothubowner"): return self.cmd( - "iot hub show-connection-string -n {} -g {} --policy-name {}".format( + "iot hub connection-string show -n {} -g {} --policy-name {}".format( self.entity_name, self.entity_rg, policy ) ).get_output_in_json()["connectionString"] + def set_cmd_auth_type(self, command: str, auth_type: str) -> str: + if auth_type not in DATAPLANE_AUTH_TYPES: + raise RuntimeError(f"auth_type of: {auth_type} is unsupported.") + + # cstring takes precedence + if auth_type == "cstring": + return f"{command} --login {self.connection_string}" + + return f"{command} --auth-type {auth_type}" + def disable_telemetry(test_function): def wrapper(*args, **kwargs): diff --git a/azext_iot/tests/iothub/__init__.py b/azext_iot/tests/iothub/__init__.py index 55614acbf..fd73719f8 100644 --- a/azext_iot/tests/iothub/__init__.py +++ b/azext_iot/tests/iothub/__init__.py @@ -3,3 +3,22 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- + + +from azext_iot.common.certops import create_self_signed_certificate +from azext_iot.common.shared import AuthenticationTypeDataplane + +DATAPLANE_AUTH_TYPES = [ + AuthenticationTypeDataplane.key.value, + AuthenticationTypeDataplane.login.value, + "cstring", +] + +PRIMARY_THUMBPRINT = create_self_signed_certificate( + subject="aziotcli", valid_days=1, cert_output_dir=None +)["thumbprint"] +SECONDARY_THUMBPRINT = create_self_signed_certificate( + subject="aziotcli", valid_days=1, cert_output_dir=None +)["thumbprint"] + +DEVICE_TYPES = ["non-edge", "edge"] diff --git a/azext_iot/tests/iothub/configurations/test_iot_config_int.py b/azext_iot/tests/iothub/configurations/test_iot_config_int.py index 8c4997e96..8105acec6 100644 --- a/azext_iot/tests/iothub/configurations/test_iot_config_int.py +++ b/azext_iot/tests/iothub/configurations/test_iot_config_int.py @@ -9,6 +9,7 @@ from azext_iot.tests import IoTLiveScenarioTest from azext_iot.tests.conftest import get_context_path +from azext_iot.tests.iothub import DATAPLANE_AUTH_TYPES from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC from azext_iot.common.utility import read_file_content @@ -37,48 +38,53 @@ def __init__(self, test_case): ) def test_edge_set_modules(self): - edge_device_count = 1 - edge_device_ids = self.generate_device_names(edge_device_count, True) - - self.cmd( - "iot hub device-identity create -d {} -n {} -g {} --ee".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG + for auth_phase in DATAPLANE_AUTH_TYPES: + edge_device_count = 1 + edge_device_ids = self.generate_device_names(edge_device_count, True) + + self.cmd( + self.set_cmd_auth_type( + "iot hub device-identity create -d {} -n {} -g {} --ee".format( + edge_device_ids[0], LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase, + ) ) - ) - - self.kwargs["edge_content"] = read_file_content(edge_content_path) - # Content from file - self.cmd( - "iot edge set-modules -d {} -n {} -g {} -k '{}'".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, edge_content_path - ), - checks=[self.check("length([*])", 3)], - ) + self.kwargs["edge_content"] = read_file_content(edge_content_path) - # Content inline - self.cmd( - "iot edge set-modules -d {} -n {} -g {} --content '{}'".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, "{edge_content}" - ), - self.check("length([*])", 3), - ) + # Content inline + self.cmd( + self.set_cmd_auth_type( + "iot edge set-modules -d {} -n {} -g {} --content '{}'".format( + edge_device_ids[0], LIVE_HUB, LIVE_RG, "{edge_content}" + ), + auth_type=auth_phase, + ), + self.check("length([*])", 3), + ) - # Using connection string - content from file - self.cmd( - "iot edge set-modules -d {} --login {} -k '{}'".format( - edge_device_ids[0], self.connection_string, edge_content_v1_path - ), - checks=[self.check("length([*])", 4)], - ) + # Content from file + self.cmd( + self.set_cmd_auth_type( + "iot edge set-modules -d {} -n {} -g {} -k '{}'".format( + edge_device_ids[0], LIVE_HUB, LIVE_RG, edge_content_v1_path + ), + auth_type=auth_phase, + ), + checks=[self.check("length([*])", 4)], + ) - # Error schema validation - Malformed deployment - self.cmd( - "iot edge set-modules -d {} -n {} -g {} -k '{}'".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, edge_content_malformed_path - ), - expect_failure=True, - ) + # Error schema validation - Malformed deployment + self.cmd( + self.set_cmd_auth_type( + "iot edge set-modules -d {} -n {} -g {} -k '{}'".format( + edge_device_ids[0], LIVE_HUB, LIVE_RG, edge_content_malformed_path + ), + auth_type=auth_phase + ), + expect_failure=True, + ) class TestIoTEdgeDeployments(IoTLiveScenarioTest): @@ -88,341 +94,355 @@ def __init__(self, test_case): ) def test_edge_deployments(self): - config_count = 5 - config_ids = self.generate_config_names(config_count) - - self.kwargs["generic_metrics"] = read_file_content(generic_metrics_path) - self.kwargs["edge_content"] = read_file_content(edge_content_path) - self.kwargs["edge_content_layered"] = read_file_content( - edge_content_layered_path - ) - self.kwargs["edge_content_v1"] = read_file_content(edge_content_v1_path) - self.kwargs["edge_content_malformed"] = read_file_content( - edge_content_malformed_path - ) - self.kwargs["labels"] = '{"key0": "value0"}' - - priority = random.randint(1, 10) - condition = "tags.building=9 and tags.environment='test'" - - # Content inline - # Note: $schema is included as a nested property in the sample content. - self.cmd( - """iot edge deployment create --deployment-id {} --hub-name {} --resource-group {} --priority {} - --target-condition \"{}\" --labels '{}' --content '{}'""".format( - config_ids[0], - LIVE_HUB, - LIVE_RG, - priority, - condition, - "{labels}", - "{edge_content}", - ), - checks=[ - self.check("id", config_ids[0]), - self.check("priority", priority), - self.check("targetCondition", condition), - self.check("labels", json.loads(self.kwargs["labels"])), - self.check( - "content.modulesContent", - json.loads(self.kwargs["edge_content"])["content"][ - "modulesContent" - ], + for auth_phase in DATAPLANE_AUTH_TYPES: + config_count = 5 + config_ids = self.generate_config_names(config_count) + + self.kwargs["generic_metrics"] = read_file_content(generic_metrics_path) + self.kwargs["edge_content"] = read_file_content(edge_content_path) + self.kwargs["edge_content_layered"] = read_file_content( + edge_content_layered_path + ) + self.kwargs["edge_content_v1"] = read_file_content(edge_content_v1_path) + self.kwargs["edge_content_malformed"] = read_file_content( + edge_content_malformed_path + ) + self.kwargs["labels"] = '{"key0": "value0"}' + + priority = random.randint(1, 10) + condition = "tags.building=9 and tags.environment='test'" + + # Content inline + # Note: $schema is included as a nested property in the sample content. + self.cmd( + self.set_cmd_auth_type( + """iot edge deployment create --deployment-id {} --hub-name {} --resource-group {} --priority {} + --target-condition \"{}\" --labels '{}' --content '{}'""".format( + config_ids[0], + LIVE_HUB, + LIVE_RG, + priority, + condition, + "{labels}", + "{edge_content}", + ), + auth_type=auth_phase, ), - self.check("metrics.queries", {}), - ], - ) + checks=[ + self.check("id", config_ids[0]), + self.check("priority", priority), + self.check("targetCondition", condition), + self.check("labels", json.loads(self.kwargs["labels"])), + self.check( + "content.modulesContent", + json.loads(self.kwargs["edge_content"])["content"][ + "modulesContent" + ], + ), + self.check("metrics.queries", {}), + ], + ) - # Using connection string - content + metrics from file. Configurations must be lowercase and will be lower()'ed. - # Note: $schema is included as a nested property in the sample content. - self.cmd( - "iot edge deployment create -d {} --login {} --pri {} --tc \"{}\" --lab '{}' -k '{}' --metrics '{}'".format( - config_ids[1].upper(), - self.connection_string, - priority, - condition, - "{labels}", - edge_content_path, - edge_content_path, - ), - checks=[ - self.check("id", config_ids[1].lower()), - self.check("priority", priority), - self.check("targetCondition", condition), - self.check("labels", json.loads(self.kwargs["labels"])), - self.check( - "content.modulesContent", - json.loads(self.kwargs["edge_content"])["content"][ - "modulesContent" - ], + # Content + metrics from file. Configurations must be lowercase and will be lower()'ed. + # Note: $schema is included as a nested property in the sample content. + self.cmd( + self.set_cmd_auth_type( + "iot edge deployment create -d {} --pri {} --tc \"{}\" --lab '{}' -k '{}' --metrics '{}' -n {} -g {}".format( + config_ids[1].upper(), + priority, + condition, + "{labels}", + edge_content_path, + edge_content_path, + LIVE_HUB, + LIVE_RG + ), + auth_type=auth_phase ), - self.check( - "metrics.queries", - json.loads(self.kwargs["edge_content"])["metrics"]["queries"], - ), - ], - ) + checks=[ + self.check("id", config_ids[1].lower()), + self.check("priority", priority), + self.check("targetCondition", condition), + self.check("labels", json.loads(self.kwargs["labels"])), + self.check( + "content.modulesContent", + json.loads(self.kwargs["edge_content"])["content"][ + "modulesContent" + ], + ), + self.check( + "metrics.queries", + json.loads(self.kwargs["edge_content"])["metrics"]["queries"], + ), + ], + ) - # Using connection string - layered deployment with content + metrics from file. - # No labels, target-condition or priority - self.cmd( - "iot edge deployment create -d {} --login {} -k '{}' --metrics '{}' --layered".format( - config_ids[2].upper(), - self.connection_string, - edge_content_layered_path, - generic_metrics_path, - ), - checks=[ - self.check("id", config_ids[2].lower()), - self.check("priority", 0), - self.check("targetCondition", ""), - self.check("labels", None), - self.check( - "content.modulesContent", - json.loads(self.kwargs["edge_content_layered"])["content"][ - "modulesContent" - ], - ), - self.check( - "metrics.queries", - json.loads(self.kwargs["generic_metrics"])["metrics"]["queries"], + # Layered deployment with content + metrics from file. + # No labels, target-condition or priority + self.cmd( + self.set_cmd_auth_type( + "iot edge deployment create -d {} -k '{}' --metrics '{}' --layered -n {} -g {}".format( + config_ids[2].upper(), + edge_content_layered_path, + generic_metrics_path, + LIVE_HUB, + LIVE_RG, + ), + auth_type=auth_phase ), - ], - ) + checks=[ + self.check("id", config_ids[2].lower()), + self.check("priority", 0), + self.check("targetCondition", ""), + self.check("labels", None), + self.check( + "content.modulesContent", + json.loads(self.kwargs["edge_content_layered"])["content"][ + "modulesContent" + ], + ), + self.check( + "metrics.queries", + json.loads(self.kwargs["generic_metrics"])["metrics"]["queries"], + ), + ], + ) - # Content inline - Edge v1 format - self.cmd( - """iot edge deployment create --deployment-id {} --hub-name {} --resource-group {} --priority {} - --target-condition \"{}\" --labels '{}' --content '{}' --metrics '{}'""".format( - config_ids[3], - LIVE_HUB, - LIVE_RG, - priority, - condition, - "{labels}", - "{edge_content_v1}", - "{generic_metrics}", - ), - checks=[ - self.check("id", config_ids[3]), - self.check("priority", priority), - self.check("targetCondition", condition), - self.check("labels", json.loads(self.kwargs["labels"])), - self.check( - "content.modulesContent", - json.loads(self.kwargs["edge_content_v1"])["content"][ - "moduleContent" - ], - ), - self.check( - "metrics.queries", - json.loads(self.kwargs["generic_metrics"])["metrics"]["queries"], + # Content inline - Edge v1 format + self.cmd( + self.set_cmd_auth_type( + """iot edge deployment create --deployment-id {} --hub-name {} --resource-group {} --priority {} + --target-condition \"{}\" --labels '{}' --content '{}' --metrics '{}'""".format( + config_ids[3], + LIVE_HUB, + LIVE_RG, + priority, + condition, + "{labels}", + "{edge_content_v1}", + "{generic_metrics}", + ), + auth_type=auth_phase, ), - ], - ) - - # Error schema validation - Malformed deployment content causes validation error - self.cmd( - """iot edge deployment create --deployment-id {} --hub-name {} --resource-group {} --priority {} - --target-condition \"{}\" --labels '{}' --content '{}'""".format( - config_ids[1], - LIVE_HUB, - LIVE_RG, - priority, - condition, - "{labels}", - "{edge_content_malformed}", - ), - expect_failure=True, - ) - - # Error schema validation - Layered deployment without flag causes validation error - self.cmd( - """iot edge deployment create --deployment-id {} --hub-name {} --resource-group {} --priority {} - --target-condition \"{}\" --labels '{}' --content '{}'""".format( - config_ids[1], - LIVE_HUB, - LIVE_RG, - priority, - condition, - "{labels}", - "{edge_content_layered}", - ), - expect_failure=True, - ) + checks=[ + self.check("id", config_ids[3]), + self.check("priority", priority), + self.check("targetCondition", condition), + self.check("labels", json.loads(self.kwargs["labels"])), + self.check( + "content.modulesContent", + json.loads(self.kwargs["edge_content_v1"])["content"][ + "moduleContent" + ], + ), + self.check( + "metrics.queries", + json.loads(self.kwargs["generic_metrics"])["metrics"]["queries"], + ), + ], + ) - # Uses IoT Edge hub schema version 1.1 - self.cmd( - """iot edge deployment create --deployment-id {} --hub-name {} --resource-group {} --priority {} - --target-condition \"{}\" --labels '{}' --content '{}'""".format( - config_ids[4], - LIVE_HUB, - LIVE_RG, - priority, - condition, - "{labels}", - edge_content_v11_path, - ), - checks=[ - self.check("id", config_ids[4]), - self.check("priority", priority), - self.check("targetCondition", condition), - self.check("labels", json.loads(self.kwargs["labels"])), - self.check( - "content.modulesContent", - json.loads(read_file_content(edge_content_v11_path))["modulesContent"], + # Error schema validation - Malformed deployment content causes validation error + self.cmd( + self.set_cmd_auth_type( + """iot edge deployment create --deployment-id {} --hub-name {} --resource-group {} --priority {} + --target-condition \"{}\" --labels '{}' --content '{}'""".format( + config_ids[1], + LIVE_HUB, + LIVE_RG, + priority, + condition, + "{labels}", + "{edge_content_malformed}", + ), + auth_type=auth_phase ), - self.check("metrics.queries", {}), - ], - ) - - # Show deployment - self.cmd( - "iot edge deployment show --deployment-id {} --hub-name {} --resource-group {}".format( - config_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("id", config_ids[0]), - self.check("priority", priority), - self.check("targetCondition", condition), - self.check("labels", json.loads(self.kwargs["labels"])), - ], - ) + expect_failure=True, + ) - # Show deployment - using connection string - self.cmd( - "iot edge deployment show -d {} --login {}".format( - config_ids[1], self.connection_string - ), - checks=[ - self.check("id", config_ids[1]), - self.check("priority", priority), - self.check("targetCondition", condition), - self.check("labels", json.loads(self.kwargs["labels"])), - ], - ) + # Error schema validation - Layered deployment without flag causes validation error + self.cmd( + self.set_cmd_auth_type( + """iot edge deployment create --deployment-id {} --hub-name {} --resource-group {} --priority {} + --target-condition \"{}\" --labels '{}' --content '{}'""".format( + config_ids[1], + LIVE_HUB, + LIVE_RG, + priority, + condition, + "{labels}", + "{edge_content_layered}", + ), + auth_type=auth_phase + ), + expect_failure=True, + ) - # Update deployment - new_priority = random.randint(1, 10) - new_condition = "tags.building=43 and tags.environment='dev'" - self.kwargs["new_labels"] = '{"key": "super_value"}' - self.cmd( - "iot edge deployment update -d {} -n {} -g {} --set priority={} targetCondition=\"{}\" labels='{}'".format( - config_ids[0], - LIVE_HUB, - LIVE_RG, - new_priority, - new_condition, - "{new_labels}", - ), - checks=[ - self.check("id", config_ids[0]), - self.check("priority", new_priority), - self.check("targetCondition", new_condition), - self.check("labels", json.loads(self.kwargs["new_labels"])), - ], - ) + # Uses IoT Edge hub schema version 1.1 + self.cmd( + self.set_cmd_auth_type( + """iot edge deployment create --deployment-id {} --hub-name {} --resource-group {} --priority {} + --target-condition \"{}\" --labels '{}' --content '{}'""".format( + config_ids[4], + LIVE_HUB, + LIVE_RG, + priority, + condition, + "{labels}", + edge_content_v11_path, + ), + auth_type=auth_phase, + ), + checks=[ + self.check("id", config_ids[4]), + self.check("priority", priority), + self.check("targetCondition", condition), + self.check("labels", json.loads(self.kwargs["labels"])), + self.check( + "content.modulesContent", + json.loads(read_file_content(edge_content_v11_path))["modulesContent"], + ), + self.check("metrics.queries", {}), + ], + ) - # Update deployment - using connection string - new_priority = random.randint(1, 10) - new_condition = "tags.building=40 and tags.environment='kindaprod'" - self.kwargs["new_labels"] = '{"key": "legit_value"}' - self.cmd( - "iot edge deployment update -d {} -n {} -g {} --set priority={} targetCondition=\"{}\" labels='{}'".format( - config_ids[0], - LIVE_HUB, - LIVE_RG, - new_priority, - new_condition, - "{new_labels}", - ), - checks=[ - self.check("id", config_ids[0]), - self.check("priority", new_priority), - self.check("targetCondition", new_condition), - self.check("labels", json.loads(self.kwargs["new_labels"])), - ], - ) + # Show deployment + self.cmd( + self.set_cmd_auth_type( + "iot edge deployment show --deployment-id {} --hub-name {} --resource-group {}".format( + config_ids[0], LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase, + ), + checks=[ + self.check("id", config_ids[0]), + self.check("priority", priority), + self.check("targetCondition", condition), + self.check("labels", json.loads(self.kwargs["labels"])), + ], + ) - # Evaluate metrics of a deployment - user_metric_name = "mymetric" - system_metric_name = "appliedCount" - config_output = self.cmd( - "iot edge deployment show --login {} --deployment-id {}".format( - self.connection_string, config_ids[1] - ) - ).get_output_in_json() - - # Default metric type is user - self.cmd( - "iot edge deployment show-metric --metric-id {} --deployment-id {} --hub-name {}".format( - user_metric_name, config_ids[1], LIVE_HUB - ), - checks=[ - self.check("metric", user_metric_name), - self.check( - "query", config_output["metrics"]["queries"][user_metric_name] + # Update deployment + new_priority = random.randint(1, 10) + new_condition = "tags.building=43 and tags.environment='dev'" + self.kwargs["new_labels"] = '{"key": "super_value"}' + self.cmd( + self.set_cmd_auth_type( + "iot edge deployment update -d {} -n {} -g {} --set priority={} targetCondition=\"{}\" labels='{}'".format( + config_ids[0], + LIVE_HUB, + LIVE_RG, + new_priority, + new_condition, + "{new_labels}", + ), + auth_type=auth_phase ), - ], - ) + checks=[ + self.check("id", config_ids[0]), + self.check("priority", new_priority), + self.check("targetCondition", new_condition), + self.check("labels", json.loads(self.kwargs["new_labels"])), + ], + ) - # System metric - using connection string - self.cmd( - "iot edge deployment show-metric --metric-id {} --login '{}' --deployment-id {} --metric-type {}".format( - system_metric_name, self.connection_string, config_ids[1], "system" - ), - checks=[ - self.check("metric", system_metric_name), - self.check( - "query", - config_output["systemMetrics"]["queries"][system_metric_name], + # Evaluate metrics of a deployment + user_metric_name = "mymetric" + system_metric_name = "appliedCount" + config_output = self.cmd( + self.set_cmd_auth_type( + "iot edge deployment show --deployment-id {} -n {} -g {}".format( + config_ids[1], + LIVE_HUB, + LIVE_RG + ), + auth_type=auth_phase + ) + ).get_output_in_json() + + # Default metric type is user + self.cmd( + self.set_cmd_auth_type( + "iot edge deployment show-metric --metric-id {} --deployment-id {} --hub-name {}".format( + user_metric_name, config_ids[1], LIVE_HUB + ), + auth_type=auth_phase ), - ], - ) + checks=[ + self.check("metric", user_metric_name), + self.check( + "query", config_output["metrics"]["queries"][user_metric_name] + ), + ], + ) - # Error - metric does not exist, using connection string - self.cmd( - "iot edge deployment show-metric -m {} --login {} -d {}".format( - "doesnotexist", self.connection_string, config_ids[0] - ), - expect_failure=True, - ) + # System metric + self.cmd( + self.set_cmd_auth_type( + "iot edge deployment show-metric --metric-id {} --deployment-id {} --metric-type {} -n {} -g {}".format( + system_metric_name, config_ids[1], "system", LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase + ), + checks=[ + self.check("metric", system_metric_name), + self.check( + "query", + config_output["systemMetrics"]["queries"][system_metric_name], + ), + ], + ) - config_list_check = [ - self.check("length([*])", config_count), - self.exists("[?id=='{}']".format(config_ids[0])), - self.exists("[?id=='{}']".format(config_ids[1])), - self.exists("[?id=='{}']".format(config_ids[2])), - self.exists("[?id=='{}']".format(config_ids[3])) - ] - - # List all edge deployments - self.cmd( - "iot edge deployment list -n {} -g {}".format(LIVE_HUB, LIVE_RG), - checks=config_list_check, - ) + # Error - metric does not exist + self.cmd( + self.set_cmd_auth_type( + "iot edge deployment show-metric -m {} -d {} -n {} -g {}".format( + "doesnotexist", config_ids[0], LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase + ), + expect_failure=True, + ) - # List all edge deployments - using connection string - self.cmd( - "iot edge deployment list --login {}".format(self.connection_string), - checks=config_list_check, - ) + config_list_check = [ + self.check("length([*])", config_count), + self.exists("[?id=='{}']".format(config_ids[0])), + self.exists("[?id=='{}']".format(config_ids[1])), + self.exists("[?id=='{}']".format(config_ids[2])), + self.exists("[?id=='{}']".format(config_ids[3])) + ] + + # List all edge deployments + self.cmd( + self.set_cmd_auth_type( + "iot edge deployment list -n {} -g {}".format(LIVE_HUB, LIVE_RG), + auth_type=auth_phase + ), + checks=config_list_check, + ) - # Explicitly delete an edge deployment - self.cmd( - "iot edge deployment delete -d {} -n {} -g {}".format( - config_ids[0], LIVE_HUB, LIVE_RG + # Explicitly delete an edge deployment + self.cmd( + self.set_cmd_auth_type( + "iot edge deployment delete -d {} -n {} -g {}".format( + config_ids[0], LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase + ) ) - ) - del self.config_ids[0] - # Explicitly delete an edge deployment - using connection string - self.cmd( - "iot edge deployment delete -d {} --login {}".format( - config_ids[1], self.connection_string + # Validate deletion + self.cmd( + self.set_cmd_auth_type( + "iot edge deployment show -d {} -n {} -g {}".format( + config_ids[0], LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase + ), + expect_failure=True ) - ) - del self.config_ids[0] + + self.tearDown() class TestIoTHubConfigurations(IoTLiveScenarioTest): @@ -445,272 +465,284 @@ def test_device_configurations(self): priority = random.randint(1, 10) condition = "tags.building=9 and tags.environment='test'" - # Device content inline - # Note: $schema is included as a nested property in the sample content. - self.cmd( - """iot hub configuration create --config-id {} --hub-name {} --resource-group {} --priority {} - --target-condition \"{}\" --labels '{}' --content '{}'""".format( - config_ids[0], - LIVE_HUB, - LIVE_RG, - priority, - condition, - "{labels}", - "{adm_content_device}", - ), - checks=[ - self.check("id", config_ids[0]), - self.check("priority", priority), - self.check("targetCondition", condition), - self.check("labels", json.loads(self.kwargs["labels"])), - self.check( - "content.deviceContent", - json.loads(self.kwargs["adm_content_device"])["content"][ - "deviceContent" - ], + for auth_phase in DATAPLANE_AUTH_TYPES: + # Device content inline + # Note: $schema is included as a nested property in the sample content. + self.cmd( + self.set_cmd_auth_type( + """iot hub configuration create --config-id {} --hub-name {} --resource-group {} --priority {} + --target-condition \"{}\" --labels '{}' --content '{}'""".format( + config_ids[0], + LIVE_HUB, + LIVE_RG, + priority, + condition, + "{labels}", + "{adm_content_device}", + ), + auth_type=auth_phase ), - self.check("metrics.queries", {}), - ], - ) + checks=[ + self.check("id", config_ids[0]), + self.check("priority", priority), + self.check("targetCondition", condition), + self.check("labels", json.loads(self.kwargs["labels"])), + self.check( + "content.deviceContent", + json.loads(self.kwargs["adm_content_device"])["content"][ + "deviceContent" + ], + ), + self.check("metrics.queries", {}), + ], + ) - # Using connection string - module content + metrics from file. Configurations must be lowercase and will be lower()'ed. - # Note: $schema is included as a nested property in the sample content. - module_condition = "{} {}".format("FROM devices.modules WHERE", condition) - self.cmd( - "iot hub configuration create -c {} --login {} --pri {} --tc \"{}\" --lab '{}' -k '{}' --metrics '{}'".format( - config_ids[1].upper(), - self.connection_string, - priority, - module_condition, - "{labels}", - adm_content_module_path, - adm_content_module_path, - ), - checks=[ - self.check("id", config_ids[1].lower()), - self.check("priority", priority), - self.check("targetCondition", module_condition), - self.check("labels", json.loads(self.kwargs["labels"])), - self.check( - "content.moduleContent", - json.loads(self.kwargs["adm_content_module"])["content"][ - "moduleContent" - ], - ), - self.check( - "metrics.queries", - json.loads(self.kwargs["adm_content_module"])["metrics"]["queries"], + # Module content + metrics from file. + # Configurations must be lowercase and will be lower()'ed. + # Note: $schema is included as a nested property in the sample content. + module_condition = "{} {}".format("FROM devices.modules WHERE", condition) + self.cmd( + self.set_cmd_auth_type( + "iot hub configuration create -c {} --pri {} --tc \"{}\" --lab '{}' -k '{}' -m '{}' -n {} -g {}".format( + config_ids[1].upper(), + priority, + module_condition, + "{labels}", + adm_content_module_path, + adm_content_module_path, + LIVE_HUB, + LIVE_RG + ), + auth_type=auth_phase, ), - ], - ) + checks=[ + self.check("id", config_ids[1].lower()), + self.check("priority", priority), + self.check("targetCondition", module_condition), + self.check("labels", json.loads(self.kwargs["labels"])), + self.check( + "content.moduleContent", + json.loads(self.kwargs["adm_content_module"])["content"][ + "moduleContent" + ], + ), + self.check( + "metrics.queries", + json.loads(self.kwargs["adm_content_module"])["metrics"]["queries"], + ), + ], + ) - # Using connection string - device content + metrics from file. Configurations must be lowercase and will be lower()'ed. - # No labels, target-condition or priority - self.cmd( - "iot hub configuration create -c {} --login {} -k '{}' --metrics '{}'".format( - config_ids[2].upper(), - self.connection_string, - adm_content_device_path, - generic_metrics_path, - ), - checks=[ - self.check("id", config_ids[2].lower()), - self.check("priority", 0), - self.check("targetCondition", ""), - self.check("labels", None), - self.check( - "content.deviceContent", - json.loads(self.kwargs["adm_content_device"])["content"][ - "deviceContent" - ], - ), - self.check( - "metrics.queries", - json.loads(self.kwargs["generic_metrics"])["metrics"]["queries"], + # Device content + metrics from file. + # Configurations must be lowercase and will be lower()'ed. + # No labels, target-condition or priority + self.cmd( + self.set_cmd_auth_type( + "iot hub configuration create -c {} -k '{}' --metrics '{}' -n {} -g {}".format( + config_ids[2].upper(), + adm_content_device_path, + generic_metrics_path, + LIVE_HUB, + LIVE_RG + ), + auth_type=auth_phase ), - ], - ) - - # Error validation - Malformed configuration content causes validation error - # In this case we attempt to use an edge deployment ^_^ - self.cmd( - """iot hub configuration create --config-id {} --hub-name {} --resource-group {} --priority {} - --target-condition \"{}\" --labels '{}' --content '{}'""".format( - config_ids[1], - LIVE_HUB, - LIVE_RG, - priority, - condition, - "{labels}", - "{edge_content}", - ), - expect_failure=True, - ) - - # Error validation - Module configuration target condition must start with 'from devices.modules where' - module_condition = "{} {}".format("FROM devices.modules WHERE", condition) - self.cmd( - "iot hub configuration create -c {} --login {} -k '{}'".format( - config_ids[1].upper(), - self.connection_string, - adm_content_module_path, - ), - expect_failure=True, - ) - - # Show ADM configuration - self.cmd( - "iot hub configuration show --config-id {} --hub-name {} --resource-group {}".format( - config_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("id", config_ids[0]), - self.check("priority", priority), - self.check("targetCondition", condition), - self.check("labels", json.loads(self.kwargs["labels"])), - ], - ) + checks=[ + self.check("id", config_ids[2].lower()), + self.check("priority", 0), + self.check("targetCondition", ""), + self.check("labels", None), + self.check( + "content.deviceContent", + json.loads(self.kwargs["adm_content_device"])["content"][ + "deviceContent" + ], + ), + self.check( + "metrics.queries", + json.loads(self.kwargs["generic_metrics"])["metrics"]["queries"], + ), + ], + ) - # Show ADM configuration - using connection string - self.cmd( - "iot hub configuration show -c {} --login {}".format( - config_ids[1], self.connection_string - ), - checks=[ - self.check("id", config_ids[1]), - self.check("priority", priority), - self.check("targetCondition", module_condition), - self.check("labels", json.loads(self.kwargs["labels"])), - ], - ) + # Error validation - Malformed configuration content causes validation error + # In this case we attempt to use an edge deployment ^_^ + self.cmd( + self.set_cmd_auth_type( + """iot hub configuration create --config-id {} --hub-name {} --resource-group {} --priority {} + --target-condition \"{}\" --labels '{}' --content '{}'""".format( + config_ids[1], + LIVE_HUB, + LIVE_RG, + priority, + condition, + "{labels}", + "{edge_content}", + ), + auth_type=auth_phase + ), + expect_failure=True, + ) - # Update deployment - new_priority = random.randint(1, 10) - new_condition = "tags.building=43 and tags.environment='dev'" - self.kwargs["new_labels"] = '{"key": "super_value"}' - self.cmd( - "iot hub configuration update -c {} -n {} -g {} --set priority={} targetCondition=\"{}\" labels='{}'".format( - config_ids[0], - LIVE_HUB, - LIVE_RG, - new_priority, - new_condition, - "{new_labels}", - ), - checks=[ - self.check("id", config_ids[0]), - self.check("priority", new_priority), - self.check("targetCondition", new_condition), - self.check("labels", json.loads(self.kwargs["new_labels"])), - ], - ) + # Error validation - Module configuration target condition must start with 'from devices.modules where' + module_condition = "{} {}".format("FROM devices.modules WHERE", condition) + self.cmd( + self.set_cmd_auth_type( + "iot hub configuration create -c {} -k '{}' -n {} -g {}".format( + config_ids[1].upper(), + adm_content_module_path, + LIVE_HUB, + LIVE_RG + ), + auth_type=auth_phase + ), + expect_failure=True, + ) - # Update deployment - using connection string - new_priority = random.randint(1, 10) - new_condition = "tags.building=40 and tags.environment='kindaprod'" - self.kwargs["new_labels"] = '{"key": "legit_value"}' - self.cmd( - "iot hub configuration update -c {} -n {} -g {} --set priority={} targetCondition=\"{}\" labels='{}'".format( - config_ids[0], - LIVE_HUB, - LIVE_RG, - new_priority, - new_condition, - "{new_labels}", - ), - checks=[ - self.check("id", config_ids[0]), - self.check("priority", new_priority), - self.check("targetCondition", new_condition), - self.check("labels", json.loads(self.kwargs["new_labels"])), - ], - ) + # Show ADM configuration + self.cmd( + self.set_cmd_auth_type( + "iot hub configuration show --config-id {} --hub-name {} --resource-group {}".format( + config_ids[0], LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase + ), + checks=[ + self.check("id", config_ids[0]), + self.check("priority", priority), + self.check("targetCondition", condition), + self.check("labels", json.loads(self.kwargs["labels"])), + ], + ) - # Evaluate metrics of a deployment - user_metric_name = "mymetric" - system_metric_name = "appliedCount" - config_output = self.cmd( - "iot hub configuration show --login {} --config-id {}".format( - self.connection_string, config_ids[1] - ) - ).get_output_in_json() - - # Default metric type is user - self.cmd( - "iot hub configuration show-metric --metric-id {} --config-id {} --hub-name {}".format( - user_metric_name, config_ids[1], LIVE_HUB - ), - checks=[ - self.check("metric", user_metric_name), - self.check( - "query", config_output["metrics"]["queries"][user_metric_name] + # Update deployment + new_priority = random.randint(1, 10) + new_condition = "tags.building=43 and tags.environment='dev'" + self.kwargs["new_labels"] = '{"key": "super_value"}' + self.cmd( + self.set_cmd_auth_type( + "iot hub configuration update -c {} -n {} -g {} --set priority={} targetCondition=\"{}\" labels='{}'".format( + config_ids[0], + LIVE_HUB, + LIVE_RG, + new_priority, + new_condition, + "{new_labels}", + ), + auth_type=auth_phase, ), - ], - ) + checks=[ + self.check("id", config_ids[0]), + self.check("priority", new_priority), + self.check("targetCondition", new_condition), + self.check("labels", json.loads(self.kwargs["new_labels"])), + ], + ) - # System metric - using connection string - self.cmd( - "iot hub configuration show-metric --metric-id {} --login '{}' --config-id {} --metric-type {}".format( - system_metric_name, self.connection_string, config_ids[1], "system" - ), - checks=[ - self.check("metric", system_metric_name), - self.check( - "query", - config_output["systemMetrics"]["queries"][system_metric_name], + # Evaluate metrics of a deployment + user_metric_name = "mymetric" + system_metric_name = "appliedCount" + config_output = self.cmd( + self.set_cmd_auth_type( + "iot hub configuration show --config-id {} -n {} -g {}".format( + config_ids[1], LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase, + ) + ).get_output_in_json() + + # Default metric type is user + self.cmd( + self.set_cmd_auth_type( + "iot hub configuration show-metric --metric-id {} --config-id {} --hub-name {}".format( + user_metric_name, config_ids[1], LIVE_HUB + ), + auth_type=auth_phase ), - ], - ) + checks=[ + self.check("metric", user_metric_name), + self.check( + "query", config_output["metrics"]["queries"][user_metric_name] + ), + ], + ) - # Error - metric does not exist, using connection string - self.cmd( - "iot hub configuration show-metric -m {} --login {} -c {}".format( - "doesnotexist", self.connection_string, config_ids[0] - ), - expect_failure=True, - ) + # System metric + self.cmd( + self.set_cmd_auth_type( + "iot hub configuration show-metric --metric-id {} --config-id {} --metric-type {} -n {} -g {}".format( + system_metric_name, config_ids[1], "system", LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase, + ), + checks=[ + self.check("metric", system_metric_name), + self.check( + "query", + config_output["systemMetrics"]["queries"][system_metric_name], + ), + ], + ) - # Create Edge deployment to ensure it doesn't show up on ADM list - self.cmd( - """iot edge deployment create --deployment-id {} --hub-name {} --resource-group {} --content '{}'""".format( - edge_config_ids[0], - LIVE_HUB, - LIVE_RG, - "{edge_content}", + # Error - metric does not exist, using connection string + self.cmd( + self.set_cmd_auth_type( + "iot hub configuration show-metric -m {} -c {} -n {} -g {}".format( + "doesnotexist", config_ids[0], LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase, + ), + expect_failure=True, ) - ) - config_list_check = [ - self.check("length([*])", config_count), - self.exists("[?id=='{}']".format(config_ids[0])), - self.exists("[?id=='{}']".format(config_ids[1])), - self.exists("[?id=='{}']".format(config_ids[2])) - ] - - # List all ADM configurations - self.cmd( - "iot hub configuration list -n {} -g {}".format(LIVE_HUB, LIVE_RG), - checks=config_list_check, - ) + # Create Edge deployment to ensure it doesn't show up on ADM list + self.cmd( + self.set_cmd_auth_type( + """iot edge deployment create --deployment-id {} --hub-name {} --resource-group {} --content '{}'""".format( + edge_config_ids[0], + LIVE_HUB, + LIVE_RG, + "{edge_content}", + ), + auth_type=auth_phase + ) + ) - # List all ADM configurations - using connection string - self.cmd( - "iot hub configuration list --login {}".format(self.connection_string), - checks=config_list_check, - ) + config_list_check = [ + self.check("length([*])", config_count), + self.exists("[?id=='{}']".format(config_ids[0])), + self.exists("[?id=='{}']".format(config_ids[1])), + self.exists("[?id=='{}']".format(config_ids[2])) + ] + + # List all ADM configurations + self.cmd( + self.set_cmd_auth_type( + "iot hub configuration list -n {} -g {}".format(LIVE_HUB, LIVE_RG), + auth_type=auth_phase + ), + checks=config_list_check, + ) - # Explicitly delete an ADM configuration - self.cmd( - "iot hub configuration delete -c {} -n {} -g {}".format( - config_ids[0], LIVE_HUB, LIVE_RG + # Explicitly delete an ADM configuration + self.cmd( + self.set_cmd_auth_type( + "iot hub configuration delete -c {} -n {} -g {}".format( + config_ids[0], LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase + ) ) - ) - del self.config_ids[0] - # Explicitly delete an ADM configuration - using connection string - self.cmd( - "iot hub configuration delete -c {} --login {}".format( - config_ids[1], self.connection_string + # Validate deletion + self.cmd( + self.set_cmd_auth_type( + "iot hub configuration show -c {} -n {} -g {}".format( + config_ids[0], LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase, + ), + expect_failure=True ) - ) - del self.config_ids[0] + + self.tearDown() diff --git a/azext_iot/tests/iothub/devices/__init__.py b/azext_iot/tests/iothub/devices/__init__.py new file mode 100644 index 000000000..55614acbf --- /dev/null +++ b/azext_iot/tests/iothub/devices/__init__.py @@ -0,0 +1,5 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/azext_iot/tests/iothub/devices/test_iothub_device_tracing.py b/azext_iot/tests/iothub/devices/test_iothub_device_tracing.py new file mode 100644 index 000000000..932f96ce4 --- /dev/null +++ b/azext_iot/tests/iothub/devices/test_iothub_device_tracing.py @@ -0,0 +1,68 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import pytest + +from azext_iot.tests import IoTLiveScenarioTest +from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC +from azext_iot.common.shared import AuthenticationTypeDataplane + +settings = DynamoSettings(req_env_set=ENV_SET_TEST_IOTHUB_BASIC) + +LIVE_HUB = settings.env.azext_iot_testhub +LIVE_RG = settings.env.azext_iot_testrg + + +# The current implementation of preview distributed tracing commands do not work with a cstring. + +custom_auth_types = [ + AuthenticationTypeDataplane.key.value, + AuthenticationTypeDataplane.login.value, +] + + +class TestIoTHubDistributedTracing(IoTLiveScenarioTest): + def __init__(self, test_case): + super(TestIoTHubDistributedTracing, self).__init__(test_case, LIVE_HUB, LIVE_RG) + + def test_iothub_device_distributed_tracing(self): + # Region specific test + if self.region not in ["West US 2", "North Europe", "Southeast Asia"]: + pytest.skip( + msg="Skipping distributed-tracing tests. IoT Hub not in supported region!" + ) + return + + for auth_phase in custom_auth_types: + device_count = 1 + device_ids = self.generate_device_names(device_count) + + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ) + ) + + self.cmd( + self.set_cmd_auth_type( + f"iot hub distributed-tracing show -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=self.is_empty(), + ) + + result = self.cmd( + self.set_cmd_auth_type( + f"iot hub distributed-tracing update -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} --sm on --sr 50", + auth_type=auth_phase, + ) + ).get_output_in_json() + + assert result["deviceId"] == device_ids[0] + assert result["samplingMode"] == "enabled" + assert result["samplingRate"] == "50%" + assert not result["isSynced"] diff --git a/azext_iot/tests/iothub/devices/test_iothub_device_twin_int.py b/azext_iot/tests/iothub/devices/test_iothub_device_twin_int.py new file mode 100644 index 000000000..fe2692802 --- /dev/null +++ b/azext_iot/tests/iothub/devices/test_iothub_device_twin_int.py @@ -0,0 +1,207 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +import os +from pathlib import Path + +from azext_iot.common.utility import read_file_content +from azext_iot.tests import IoTLiveScenarioTest +from azext_iot.tests.generators import generate_generic_id +from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC +from azext_iot.tests.iothub import DATAPLANE_AUTH_TYPES + +settings = DynamoSettings(req_env_set=ENV_SET_TEST_IOTHUB_BASIC) + +LIVE_HUB = settings.env.azext_iot_testhub +LIVE_RG = settings.env.azext_iot_testrg +CWD = os.path.dirname(os.path.abspath(__file__)) + + +class TestIoTHubDeviceTwin(IoTLiveScenarioTest): + def __init__(self, test_case): + super(TestIoTHubDeviceTwin, self).__init__(test_case, LIVE_HUB, LIVE_RG) + + def test_iothub_device_twin(self): + for auth_phase in DATAPLANE_AUTH_TYPES: + device_count = 1 + device_ids = self.generate_device_names(device_count) + + patch_desired = { + generate_generic_id(): generate_generic_id(), + generate_generic_id(): generate_generic_id(), + } + patch_tags = { + generate_generic_id(): generate_generic_id(), + generate_generic_id(): generate_generic_id(), + } + + self.kwargs["patch_desired"] = json.dumps(patch_desired) + self.kwargs["patch_tags"] = json.dumps(patch_tags) + + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ) + ) + + # Initial twin state + d0_twin = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-twin show -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=[ + self.check("deviceId", device_ids[0]), + self.exists("properties.desired"), + self.exists("properties.reported"), + ], + ).get_output_in_json() + + assert d0_twin["properties"]["desired"]["$version"] == 1 + assert d0_twin["properties"]["reported"]["$version"] == 1 + + # Patch based twin update of desired props + d0_twin = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-twin update -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "--desired '{patch_desired}'", # Not f-string due to CLI TestFramework self.kwargs application :( + auth_type=auth_phase, + ) + ).get_output_in_json() + + assert d0_twin["properties"]["desired"]["$version"] == 2 + + for key in patch_desired: + assert d0_twin["properties"]["desired"][key] == patch_desired[key] + + # Patch based twin update of tag props + d0_twin = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-twin update -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "--tags '{patch_tags}'", # Not f-string due to CLI TestFramework self.kwargs application :( + auth_type=auth_phase, + ) + ).get_output_in_json() + + for key in patch_tags: + assert d0_twin["tags"][key] == patch_tags[key] + + for key in patch_desired: + assert d0_twin["properties"]["desired"][key] == patch_desired[key] + + assert d0_twin["properties"]["desired"]["$version"] == 2 + + # Patch based twin update of tag and desired props + d0_twin = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-twin update -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "--tags '{patch_tags}' --desired '{patch_desired}'", + auth_type=auth_phase, + ) + ).get_output_in_json() + + for key in patch_tags: + assert d0_twin["tags"][key] == patch_tags[key] + + for key in patch_desired: + assert d0_twin["properties"]["desired"][key] == patch_desired[key] + + assert d0_twin["properties"]["desired"]["$version"] == 3 + + # Prepare removal of all twin tag properties + for key in patch_tags: + patch_tags[key] = None + self.kwargs["patch_tags"] = json.dumps(patch_tags) + + # Remove all twin tag properties + d0_twin = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-twin update -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "--tags '{patch_tags}'", + auth_type=auth_phase, + ) + ).get_output_in_json() + assert d0_twin["tags"] is None + + # Prepare removal of single desired twin property + target_key = list(patch_desired.keys())[0] + patch_desired[target_key] = None + self.kwargs["patch_desired"] = json.dumps(patch_desired) + + # Remove single desired property + d0_twin = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-twin update -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "--desired '{patch_desired}'", + auth_type=auth_phase, + ) + ).get_output_in_json() + + assert d0_twin["properties"]["desired"].get(target_key) is None + assert d0_twin["properties"]["desired"]["$version"] == 4 + + # Validation error --desired is not an object + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-twin update -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "--desired 'badinput'", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + def test_iothub_device_twin_replace(self): + for auth_phase in DATAPLANE_AUTH_TYPES: + device_count = 1 + device_ids = self.generate_device_names(device_count) + + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ) + ) + + replace_twin_content_path = os.path.join( + Path(CWD).parent, "test_generic_replace.json" + ) + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-twin replace -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} -j '{replace_twin_content_path}'", + auth_type=auth_phase, + ), + checks=[ + self.check("deviceId", device_ids[0]), + self.check("properties.desired.awesome", 9001), + self.check("properties.desired.temperature.min", 10), + self.check("properties.desired.temperature.max", 100), + self.check("tags.location.region", "US"), + ], + ) + + # Inline json + replace_twin_content_path = os.path.join( + Path(CWD).parent, "test_generic_replace.json" + ) + self.kwargs["inline_replace_content"] = read_file_content( + replace_twin_content_path + ) + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-twin replace -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "-j '{inline_replace_content}'", + auth_type=auth_phase, + ), + checks=[ + self.check("deviceId", device_ids[0]), + self.check("properties.desired.awesome", 9001), + self.check("properties.desired.temperature.min", 10), + self.check("properties.desired.temperature.max", 100), + self.check("tags.location.region", "US"), + ], + ) diff --git a/azext_iot/tests/iothub/devices/test_iothub_devices_int.py b/azext_iot/tests/iothub/devices/test_iothub_devices_int.py new file mode 100644 index 000000000..dbced2f08 --- /dev/null +++ b/azext_iot/tests/iothub/devices/test_iothub_devices_int.py @@ -0,0 +1,427 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azext_iot.tests import IoTLiveScenarioTest +from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC +from azext_iot.tests.generators import generate_generic_id +from azext_iot.tests.iothub import ( + DATAPLANE_AUTH_TYPES, + PRIMARY_THUMBPRINT, + SECONDARY_THUMBPRINT, + DEVICE_TYPES, +) + +settings = DynamoSettings(req_env_set=ENV_SET_TEST_IOTHUB_BASIC) + +LIVE_HUB = settings.env.azext_iot_testhub +LIVE_RG = settings.env.azext_iot_testrg + + +class TestIoTHubDevices(IoTLiveScenarioTest): + def __init__(self, test_case): + super(TestIoTHubDevices, self).__init__(test_case, LIVE_HUB, LIVE_RG) + + def test_iothub_device_identity(self): + to_remove_device_ids = [] + for auth_phase in DATAPLANE_AUTH_TYPES: + for device_type in DEVICE_TYPES: + device_count = 4 + device_ids = self.generate_device_names( + device_count, edge=device_type == "edge" + ) + edge_enabled = "--edge-enabled" if device_type == "edge" else "" + + # Symmetric key device checks + d0_device_checks = [ + self.check("deviceId", device_ids[0]), + self.check("status", "enabled"), + self.check("statusReason", None), + self.check("connectionState", "Disconnected"), + self.check("capabilities.iotEdge", device_type == "edge"), + self.exists("authentication.symmetricKey.primaryKey"), + self.exists("authentication.symmetricKey.secondaryKey"), + ] + + # Symmetric key device creation + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} {edge_enabled}", + auth_type=auth_phase, + ), + checks=d0_device_checks, + ) + to_remove_device_ids.append(device_ids[0]) + + # x509 thumbprint device checks + d1_device_checks = [ + self.check("deviceId", device_ids[1]), + self.check("status", "enabled"), + self.check("statusReason", None), + self.check("capabilities.iotEdge", device_type == "edge"), + self.check("connectionState", "Disconnected"), + self.check("authentication.symmetricKey.primaryKey", None), + self.check("authentication.symmetricKey.secondaryKey", None), + self.check( + "authentication.x509Thumbprint.primaryThumbprint", + PRIMARY_THUMBPRINT, + ), + self.check( + "authentication.x509Thumbprint.secondaryThumbprint", + SECONDARY_THUMBPRINT, + ), + ] + + # Create x509 thumbprint device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity create --device-id {device_ids[1]} " + f"--hub-name {LIVE_HUB} --resource-group {LIVE_RG} --auth-method x509_thumbprint " + f"--primary-thumbprint {PRIMARY_THUMBPRINT} --secondary-thumbprint {SECONDARY_THUMBPRINT} " + f"{edge_enabled}", + auth_type=auth_phase, + ), + checks=d1_device_checks, + ) + to_remove_device_ids.append(device_ids[1]) + + # Create x509 thumbprint device using generated cert for primary thumbprint + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity create --device-id {device_ids[2]} --hub-name {LIVE_HUB} " + f"--resource-group {LIVE_RG} --auth-method x509_thumbprint --valid-days 1 {edge_enabled}", + auth_type=auth_phase, + ), + checks=[ + self.check("deviceId", device_ids[2]), + self.check("status", "enabled"), + self.check("statusReason", None), + self.check("capabilities.iotEdge", device_type == "edge"), + self.check("connectionState", "Disconnected"), + self.check("authentication.symmetricKey.primaryKey", None), + self.check("authentication.symmetricKey.secondaryKey", None), + self.exists("authentication.x509Thumbprint.primaryThumbprint"), + self.check( + "authentication.x509Thumbprint.secondaryThumbprint", + None, + ), + ], + ) + to_remove_device_ids.append(device_ids[2]) + + # Create x509 CA device, disabled status with reason, auth with connection string + status_reason = "Test Status Reason" + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity create --device-id {device_ids[3]} --hub-name {LIVE_HUB} " + f"--resource-group {LIVE_RG} --auth-method x509_ca --status disabled " + f"--status-reason '{status_reason}' {edge_enabled}", + auth_type=auth_phase, + ), + checks=[ + self.check("deviceId", device_ids[3]), + self.check("status", "disabled"), + self.check("statusReason", status_reason), + self.check("capabilities.iotEdge", device_type == "edge"), + self.check("connectionState", "Disconnected"), + self.check("authentication.symmetricKey.primaryKey", None), + self.check("authentication.symmetricKey.secondaryKey", None), + self.check( + "authentication.x509Thumbprint.primaryThumbprint", + None, + ), + self.check( + "authentication.x509Thumbprint.secondaryThumbprint", + None, + ), + ], + ) + + # Delete device identity + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity delete -d {device_ids[3]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=self.is_empty(), + ) + + # Validate deletion worked + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity show -d {device_ids[3]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Show symmetric key device identity + d0_show = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity show -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=d0_device_checks, + ).get_output_in_json() + + # Reset device symmetric key using device-identity generic update + d0_updated = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity update -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + '--set authentication.symmetricKey.primaryKey="" ' + 'authentication.symmetricKey.secondaryKey=""', + auth_type=auth_phase, + ) + ).get_output_in_json() + assert ( + d0_updated["authentication"]["symmetricKey"]["primaryKey"] + != d0_show["authentication"]["symmetricKey"]["primaryKey"] + ) + assert ( + d0_updated["authentication"]["symmetricKey"]["secondaryKey"] + != d0_show["authentication"]["symmetricKey"]["secondaryKey"] + ) + + # Update device identity with higher level update parms + random_status_reason = generate_generic_id() + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity update -d {device_ids[1]} --ee false " + f"--ptp {SECONDARY_THUMBPRINT} --stp {PRIMARY_THUMBPRINT} " + f"--status-reason '{random_status_reason}' --status disabled " + f"-n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=[ + self.check("deviceId", device_ids[1]), + self.check("status", "disabled"), + self.check("capabilities.iotEdge", False), + self.check("statusReason", random_status_reason), + self.check( + "authentication.x509Thumbprint.primaryThumbprint", + SECONDARY_THUMBPRINT, + ), + self.check( + "authentication.x509Thumbprint.secondaryThumbprint", + PRIMARY_THUMBPRINT, + ), + ], + ) + + query_checks = [self.check("length([*])", len(to_remove_device_ids))] + for d in to_remove_device_ids: + query_checks.append(self.exists(f"[?deviceId=='{d}']")) + + # By default query has no return cap + self.cmd( + self.set_cmd_auth_type( + f'iot hub query --hub-name {LIVE_HUB} -g {LIVE_RG} -q "select * from devices"', + auth_type=auth_phase, + ), + checks=query_checks, + ) + + # -1 Top is equivalent to unlimited + self.cmd( + self.set_cmd_auth_type( + f'iot hub query --top -1 --hub-name {LIVE_HUB} -g {LIVE_RG} -q "select * from devices"', + auth_type=auth_phase, + ), + checks=query_checks, + ) + + # Explicit top to constrain records and use connection string + self.cmd( + self.set_cmd_auth_type( + f'iot hub query --top 1 --hub-name {LIVE_HUB} -g {LIVE_RG} -q "select * from devices"', + auth_type=auth_phase, + ), + checks=[self.check("length([*])", 1)], + ) + # List devices + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity list -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=query_checks, + ) + + # List devices filtering for edge devices + edge_filtered_list = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity list -n {LIVE_HUB} -g {LIVE_RG} --ee", + auth_type=auth_phase, + ) + ).get_output_in_json() + assert all( + (d["capabilities"]["iotEdge"] is True for d in edge_filtered_list) + ) + + def test_iothub_device_renew_key(self): + device_count = 2 + device_ids = self.generate_device_names(device_count) + + original_device = self.cmd( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}" + ).get_output_in_json() + + self.cmd( + f"iot hub device-identity create -d {device_ids[1]} -n {LIVE_HUB} -g {LIVE_RG} " + f"--am x509_thumbprint --ptp {PRIMARY_THUMBPRINT} --stp {SECONDARY_THUMBPRINT}" + ) + + for auth_phase in DATAPLANE_AUTH_TYPES: + renew_primary_key_device = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity renew-key -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} --kt primary", + auth_type=auth_phase, + ) + ).get_output_in_json() + assert ( + renew_primary_key_device["authentication"]["symmetricKey"]["primaryKey"] + != original_device["authentication"]["symmetricKey"]["primaryKey"] + ) + assert ( + renew_primary_key_device["authentication"]["symmetricKey"][ + "secondaryKey" + ] + == original_device["authentication"]["symmetricKey"]["secondaryKey"] + ) + + swap_keys_device = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity renew-key -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} --kt swap", + auth_type=auth_phase, + ) + ).get_output_in_json() + assert ( + renew_primary_key_device["authentication"]["symmetricKey"]["primaryKey"] + == swap_keys_device["authentication"]["symmetricKey"]["secondaryKey"] + ) + assert ( + renew_primary_key_device["authentication"]["symmetricKey"]["secondaryKey"] + == swap_keys_device["authentication"]["symmetricKey"]["primaryKey"] + ) + + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity renew-key -d {device_ids[1]} -n {LIVE_HUB} -g {LIVE_RG} --kt secondary", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + def test_iothub_device_connection_string_show(self): + device_count = 2 + device_ids = self.generate_device_names(device_count) + + symmetric_key_device = self.cmd( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}" + ).get_output_in_json() + + self.cmd( + f"iot hub device-identity create -d {device_ids[1]} -n {LIVE_HUB} -g {LIVE_RG} --am x509_ca" + ) + + sym_cstring_pattern = f"HostName={LIVE_HUB}.azure-devices.net;DeviceId={device_ids[0]};SharedAccessKey=#" + cer_cstring_pattern = ( + f"HostName={LIVE_HUB}.azure-devices.net;DeviceId={device_ids[1]};x509=true" + ) + + for auth_phase in DATAPLANE_AUTH_TYPES: + primary_key_cstring = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity connection-string show -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ) + ).get_output_in_json() + + target_key = symmetric_key_device["authentication"]["symmetricKey"][ + "primaryKey" + ] + target_sym_cstring = sym_cstring_pattern.replace("#", target_key) + + assert target_sym_cstring == primary_key_cstring["connectionString"] + + secondary_key_cstring = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity connection-string show -d {device_ids[0]} " + f"-n {LIVE_HUB} -g {LIVE_RG} --kt secondary", + auth_type=auth_phase, + ) + ).get_output_in_json() + + target_key = symmetric_key_device["authentication"]["symmetricKey"][ + "secondaryKey" + ] + target_sym_cstring = sym_cstring_pattern.replace("#", target_key) + + assert target_sym_cstring == secondary_key_cstring["connectionString"] + + x509_cstring = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity connection-string show -d {device_ids[1]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ) + ).get_output_in_json() + + assert cer_cstring_pattern == x509_cstring["connectionString"] + + # TODO: Improve validation of tests via micro device client or other means. + def test_iothub_device_generate_sas_token(self): + device_count = 2 + device_ids = self.generate_device_names(device_count) + + # Create SAS-auth device + self.cmd( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}" + ) + + # Create non SAS-auth device + self.cmd( + f"iot hub device-identity create -d {device_ids[1]} -n {LIVE_HUB} -g {LIVE_RG} --auth-method X509_ca" + ) + + for auth_phase in DATAPLANE_AUTH_TYPES: + self.cmd( + self.set_cmd_auth_type( + f"iot hub generate-sas-token -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=[self.exists("sas")], + ) + + # Custom duration + self.cmd( + self.set_cmd_auth_type( + f"iot hub generate-sas-token -d {device_ids[0]} --du 1000 -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=[self.exists("sas")], + ) + + # Custom key type + self.cmd( + self.set_cmd_auth_type( + f'iot hub generate-sas-token -d {device_ids[0]} --kt "secondary" -n {LIVE_HUB} -g {LIVE_RG}', + auth_type=auth_phase, + ), + checks=[self.exists("sas")], + ) + + # Error - generate sas token against non SAS device + self.cmd( + f"iot hub generate-sas-token -d {device_ids[1]} -n {LIVE_HUB} -g {LIVE_RG}", + expect_failure=True, + ) + + # Mixed case connection string + cstring = self.connection_string + mixed_case_cstring = cstring.replace("HostName", "hostname", 1) + self.cmd( + f"iot hub generate-sas-token -d {device_ids[0]} --login {mixed_case_cstring}", + checks=[self.exists("sas")], + ) diff --git a/azext_iot/tests/iothub/devices/test_iothub_nested_edge_int.py b/azext_iot/tests/iothub/devices/test_iothub_nested_edge_int.py new file mode 100644 index 000000000..6d7da00ac --- /dev/null +++ b/azext_iot/tests/iothub/devices/test_iothub_nested_edge_int.py @@ -0,0 +1,265 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azext_iot.tests import IoTLiveScenarioTest +from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC +from azext_iot.tests.iothub import DATAPLANE_AUTH_TYPES + +settings = DynamoSettings(req_env_set=ENV_SET_TEST_IOTHUB_BASIC) + +LIVE_HUB = settings.env.azext_iot_testhub +LIVE_RG = settings.env.azext_iot_testrg + +# TODO: assert device scope format in device twin. +# from azext_iot.constants import DEVICE_DEVICESCOPE_PREFIX + + +class TestIoTHubNestedEdge(IoTLiveScenarioTest): + def __init__(self, test_case): + super(TestIoTHubNestedEdge, self).__init__(test_case, LIVE_HUB, LIVE_RG) + + def test_iothub_nested_edge(self): + for auth_phase in DATAPLANE_AUTH_TYPES: + device_count = 3 + device_ids = self.generate_device_names(device_count) + edge_device_count = 2 + edge_device_ids = self.generate_device_names(edge_device_count) + + for edge_device_id in edge_device_ids: + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity create -d {edge_device_id} -n {LIVE_HUB} -g {LIVE_RG} --ee", + auth_type=auth_phase, + ), + checks=[ + self.check("capabilities.iotEdge", True), + self.exists("deviceScope"), + ], + ) + + for device_id in device_ids: + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity create -d {device_id} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=[ + self.check("capabilities.iotEdge", False), + self.check("deviceScope", None), + ], + ) + + # Error - Get parent of edge device with no initial parent + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity parent show -d {edge_device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Error - Get parent of device which does not have any parent set + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity parent show -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Error - Set non-edge device as a parent of a non-edge device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity parent set -d {device_ids[0]} --pd {device_ids[1]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Set edge device as a parent of an edge device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity parent set -d {edge_device_ids[0]} --pd {edge_device_ids[1]} " + f"-n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=self.is_empty(), + ) + + # Error - Add device as a child of a non-edge device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children add -d {device_ids[0]} --child-list {device_ids[1]} " + f"-n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Add a space separated list of devices as children of an edge device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children add -d {edge_device_ids[0]} --child-list {' '.join(device_ids)} " + f"-n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=self.is_empty(), + ) + + # Error - setting edge device as a parent of non-edge device which already having different parent device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity parent set -d {device_ids[2]} --pd {edge_device_ids[1]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Setting edge device as a parent of non-edge device which already having different parent device by force + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity parent set -d {device_ids[2]} --pd {edge_device_ids[1]} " + f"-n {LIVE_HUB} -g {LIVE_RG} --force", + auth_type=auth_phase, + ), + checks=self.is_empty(), + ) + + # Get parent of device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity parent show -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=[ + self.check("deviceId", edge_device_ids[0]), + self.exists("deviceScope"), + ], + ) + + # Error - add same device as a child of same parent device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children add -d {edge_device_ids[0]} --child-list {device_ids[0]} " + f"-n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Error - add same device as a child of another edge device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children add -d {edge_device_ids[1]} --child-list {device_ids[0]} " + f"-n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Add same device as a child of another edge device by force + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children add -d {edge_device_ids[1]} --child-list {device_ids[0]} " + f"-n {LIVE_HUB} -g {LIVE_RG} --force", + auth_type=auth_phase, + ), + checks=self.is_empty(), + ) + + # List child devices of edge device + output = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children list -d {edge_device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ) + ) + assert output.get_output_in_json() == [device_ids[1]] + + # Error - Remove all child devices of non-edge device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children remove -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} --remove-all", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Remove all child devices from edge device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children remove -d {edge_device_ids[1]} -n {LIVE_HUB} -g {LIVE_RG} --remove-all", + auth_type=auth_phase, + ), + checks=self.is_empty(), + ) + + # Error - remove all child devices of edge device which does not have any child devices + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children remove -d {edge_device_ids[1]} -n {LIVE_HUB} -g {LIVE_RG} --remove-all", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Error - remove child device of edge device neither passing child devices list nor remove-all parameter + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children remove -d {edge_device_ids[1]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Error - remove edge device from edge device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children remove -d {edge_device_ids[1]} --child-list {edge_device_ids[0]} " + f"-n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Error - remove device from edge device but device is a child of another edge device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children remove -d {edge_device_ids[1]} --child-list {device_ids[1]} " + f"-n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Remove device + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children remove -d {edge_device_ids[0]} --child-list {device_ids[1]} " + f"-n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=self.is_empty(), + ) + + # Error - remove device which does not have any parent set + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children remove -d {edge_device_ids[0]} --child-list {device_ids[0]} " + f"-n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # List child devices of edge device which doesn't have any children + output = self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity children list -d {edge_device_ids[1]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ) + ) + assert output.get_output_in_json() == [] diff --git a/azext_iot/tests/iothub/jobs/test_iothub_jobs_int.py b/azext_iot/tests/iothub/jobs/test_iothub_jobs_int.py index 902adcfd9..fd44943f2 100644 --- a/azext_iot/tests/iothub/jobs/test_iothub_jobs_int.py +++ b/azext_iot/tests/iothub/jobs/test_iothub_jobs_int.py @@ -8,7 +8,7 @@ from datetime import datetime, timedelta from azext_iot.tests import IoTLiveScenarioTest from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC - +from azext_iot.tests.iothub import DATAPLANE_AUTH_TYPES settings = DynamoSettings(ENV_SET_TEST_IOTHUB_BASIC) LIVE_HUB = settings.env.azext_iot_testhub @@ -19,216 +19,228 @@ class TestIoTHubJobs(IoTLiveScenarioTest): def __init__(self, test_case): super(TestIoTHubJobs, self).__init__(test_case, LIVE_HUB, LIVE_RG) - job_count = 3 - self.job_ids = self.generate_job_names(job_count) - def test_jobs(self): - device_count = 2 - device_ids_twin_tags = self.generate_device_names(device_count) - device_ids_twin_props = self.generate_device_names(device_count) + for auth_phase in DATAPLANE_AUTH_TYPES: + device_count = 2 + device_ids_twin_tags = self.generate_device_names(device_count) + device_ids_twin_props = self.generate_device_names(device_count) + + job_count = 3 + self.job_ids = self.generate_job_names(job_count) + + for device_id in device_ids_twin_tags + device_ids_twin_props: + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity create -d {device_id} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase + ) + ) + + # Focus is on scheduleUpdateTwin jobs until we improve JIT device simulation + + # Update twin tags scenario + self.kwargs[ + "twin_patch_tags" + ] = '{"tags": {"deviceClass": "Class1, Class2, Class3"}}' + query_condition = "deviceId in ['{}']".format("','".join(device_ids_twin_tags)) - for device_id in device_ids_twin_tags + device_ids_twin_props: self.cmd( - "iot hub device-identity create -d {} -n {} -g {}".format( - device_id, LIVE_HUB, LIVE_RG + self.set_cmd_auth_type( + f"iot hub job create --job-id {self.job_ids[0]} --job-type scheduleUpdateTwin -q \"{query_condition}\" " + f"-n {LIVE_HUB} -g {LIVE_RG} " + "--twin-patch '{twin_patch_tags}' --ttl 300 --wait", + auth_type=auth_phase + ), + checks=[ + self.check("jobId", self.job_ids[0]), + self.check("queryCondition", query_condition), + self.check("status", "completed"), + self.check("updateTwin.etag", "*"), + self.check( + "updateTwin.tags", + json.loads(self.kwargs["twin_patch_tags"])["tags"], + ), + self.check("type", "scheduleUpdateTwin"), + ], + ) + + for device_id in device_ids_twin_tags: + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-twin show -d {device_id} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase + ), + checks=[ + self.check( + "tags", json.loads(self.kwargs["twin_patch_tags"])["tags"] + ) + ], ) + + # Update twin desired properties + self.kwargs[ + "twin_patch_props" + ] = '{"properties": {"desired": {"arbitrary": "value"}}}' + query_condition = "deviceId in ['{}']".format("','".join(device_ids_twin_props)) + + self.cmd( + self.set_cmd_auth_type( + f"iot hub job create --job-id {self.job_ids[1]} --job-type scheduleUpdateTwin -q \"{query_condition}\" " + f"-n {LIVE_HUB} -g {LIVE_RG} " + "--twin-patch '{twin_patch_props}' --ttl 300 --wait", + auth_type=auth_phase, + ), + checks=[ + self.check("jobId", self.job_ids[1]), + self.check("queryCondition", query_condition), + self.check("status", "completed"), + self.check("updateTwin.etag", "*"), + self.check( + "updateTwin.properties", + json.loads(self.kwargs["twin_patch_props"])["properties"], + ), + self.check("type", "scheduleUpdateTwin"), + ], ) - # Focus is on scheduleUpdateTwin jobs until we improve JIT device simulation - - # Update twin tags scenario - self.kwargs[ - "twin_patch_tags" - ] = '{"tags": {"deviceClass": "Class1, Class2, Class3"}}' - query_condition = "deviceId in ['{}']".format("','".join(device_ids_twin_tags)) - - self.cmd( - "iot hub job create --job-id {} --job-type {} -q \"{}\" --twin-patch '{}' -n {} -g {} --ttl 300 --wait".format( - self.job_ids[0], - "scheduleUpdateTwin", - query_condition, - "{twin_patch_tags}", - LIVE_HUB, - LIVE_RG, - ), - checks=[ - self.check("jobId", self.job_ids[0]), - self.check("queryCondition", query_condition), - self.check("status", "completed"), - self.check("updateTwin.etag", "*"), - self.check( - "updateTwin.tags", - json.loads(self.kwargs["twin_patch_tags"])["tags"], + # Error - omit queryCondition when scheduleUpdateTwin or scheduleDeviceMethod + self.cmd( + self.set_cmd_auth_type( + "iot hub job create --job-id {} --job-type {} --twin-patch '{}' -n {}".format( + self.job_ids[1], "scheduleUpdateTwin", "{twin_patch_props}", LIVE_HUB + ), + auth_type=auth_phase, ), - self.check("type", "scheduleUpdateTwin"), - ], - ) + expect_failure=True, + ) - for device_id in device_ids_twin_tags: self.cmd( - "iot hub device-twin show -d {} -n {} -g {}".format( - device_id, LIVE_HUB, LIVE_RG + self.set_cmd_auth_type( + "iot hub job create --job-id {} --job-type {} --twin-patch '{}' -n {}".format( + self.job_ids[1], "scheduleDeviceMethod", "{twin_patch_props}", LIVE_HUB + ), + auth_type=auth_phase + ), + expect_failure=True, + ) + + # Error - omit twin patch when scheduleUpdateTwin + self.cmd( + self.set_cmd_auth_type( + "iot hub job create --job-id {} --job-type {} -q '*' -n {}".format( + self.job_ids[1], "scheduleUpdateTwin", LIVE_HUB + ), + auth_type=auth_phase + ), + expect_failure=True, + ) + + # Error - omit method name when scheduleDeviceMethod + self.cmd( + self.set_cmd_auth_type( + "iot hub job create --job-id {} --job-type {} -q '*' -n {}".format( + self.job_ids[1], "scheduleDeviceMethod", LIVE_HUB + ), + auth_type=auth_phase + ), + expect_failure=True, + ) + + # Show Job tests + # Using --wait when creating effectively uses show + self.cmd( + self.set_cmd_auth_type( + "iot hub job show --job-id {} -n {} -g {}".format( + self.job_ids[0], LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase ), checks=[ - self.check( - "tags", json.loads(self.kwargs["twin_patch_tags"])["tags"] - ) + self.check("jobId", self.job_ids[0]), + self.check("type", "scheduleUpdateTwin"), + ], + ) + + # Error - Show non-existant job + self.cmd( + self.set_cmd_auth_type( + "iot hub job show --job-id notarealjobid -n {} -g {}".format( + LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase + ), + expect_failure=True, + ) + + # Cancel Job test + # Create job to be cancelled - scheduled +7 days from now. + scheduled_time_iso = (datetime.utcnow() + timedelta(days=6)).isoformat() + + self.cmd( + self.set_cmd_auth_type( + "iot hub job create --job-id {} --job-type {} -q \"{}\" --twin-patch '{}' --start '{}' -n {} -g {}".format( + self.job_ids[2], + "scheduleUpdateTwin", + query_condition, + "{twin_patch_tags}", + scheduled_time_iso, + LIVE_HUB, + LIVE_RG, + ), + auth_type=auth_phase + ), + checks=[self.check("jobId", self.job_ids[2])], + ) + + # Allow time for job to transfer to scheduled state (cannot cancel job in running state) + from time import sleep + sleep(5) + + self.cmd( + self.set_cmd_auth_type( + "iot hub job show --job-id {} -n {} -g {}".format( + self.job_ids[2], LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase + ), + checks=[ + self.check("jobId", self.job_ids[2]), + self.check("status", "scheduled"), ], ) - # Update twin desired properties, using connection string - self.kwargs[ - "twin_patch_props" - ] = '{"properties": {"desired": {"arbitrary": "value"}}}' - query_condition = "deviceId in ['{}']".format("','".join(device_ids_twin_props)) - - self.cmd( - "iot hub job create --job-id {} --job-type {} -q \"{}\" --twin-patch '{}' --login '{}' --ttl 300 --wait".format( - self.job_ids[1], - "scheduleUpdateTwin", - query_condition, - "{twin_patch_props}", - self.connection_string, - ), - checks=[ - self.check("jobId", self.job_ids[1]), - self.check("queryCondition", query_condition), - self.check("status", "completed"), - self.check("updateTwin.etag", "*"), - self.check( - "updateTwin.properties", - json.loads(self.kwargs["twin_patch_props"])["properties"], + # Cancel job + self.cmd( + self.set_cmd_auth_type( + "iot hub job cancel --job-id {} -n {} -g {}".format( + self.job_ids[2], LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase, ), - self.check("type", "scheduleUpdateTwin"), - ], - ) - - # Error - omit queryCondition when scheduleUpdateTwin or scheduleDeviceMethod - self.cmd( - "iot hub job create --job-id {} --job-type {} --twin-patch '{}' -n {}".format( - self.job_ids[1], "scheduleUpdateTwin", "{twin_patch_props}", LIVE_HUB - ), - expect_failure=True, - ) - - self.cmd( - "iot hub job create --job-id {} --job-type {} --twin-patch '{}' -n {}".format( - self.job_ids[1], "scheduleDeviceMethod", "{twin_patch_props}", LIVE_HUB - ), - expect_failure=True, - ) - - # Error - omit twin patch when scheduleUpdateTwin - self.cmd( - "iot hub job create --job-id {} --job-type {} -q '*' -n {}".format( - self.job_ids[1], "scheduleUpdateTwin", LIVE_HUB - ), - expect_failure=True, - ) - - # Error - omit method name when scheduleDeviceMethod - self.cmd( - "iot hub job create --job-id {} --job-type {} -q '*' -n {}".format( - self.job_ids[1], "scheduleDeviceMethod", LIVE_HUB - ), - expect_failure=True, - ) - - # Show Job tests - # Using --wait when creating effectively uses show - self.cmd( - "iot hub job show --job-id {} -n {} -g {}".format( - self.job_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("jobId", self.job_ids[0]), - self.check("type", "scheduleUpdateTwin"), - ], - ) - - # With connection string - self.cmd( - "iot hub job show --job-id {} --login {}".format( - self.job_ids[1], self.connection_string - ), - checks=[ - self.check("jobId", self.job_ids[1]), - self.check("type", "scheduleUpdateTwin"), - ], - ) - - # Error - Show non-existant job - self.cmd( - "iot hub job show --job-id notarealjobid -n {} -g {}".format( - LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # Cancel Job test - # Create job to be cancelled - scheduled +7 days from now. - scheduled_time_iso = (datetime.utcnow() + timedelta(days=6)).isoformat() - - self.cmd( - "iot hub job create --job-id {} --job-type {} -q \"{}\" --twin-patch '{}' --start '{}' -n {} -g {}".format( - self.job_ids[2], - "scheduleUpdateTwin", - query_condition, - "{twin_patch_tags}", - scheduled_time_iso, - LIVE_HUB, - LIVE_RG, - ), - checks=[self.check("jobId", self.job_ids[2])], - ) - - # Allow time for job to transfer to scheduled state (cannot cancel job in running state) - from time import sleep - sleep(5) - - self.cmd( - "iot hub job show --job-id {} -n {} -g {}".format( - self.job_ids[2], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("jobId", self.job_ids[2]), - self.check("status", "scheduled"), - ], - ) - - # Cancel job - self.cmd( - "iot hub job cancel --job-id {} -n {} -g {}".format( - self.job_ids[2], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("jobId", self.job_ids[2]), - self.check("status", "cancelled"), - ], - ) - - # Error - Cancel non-existant job - self.cmd( - "iot hub job cancel --job-id notarealjobid -n {} -g {}".format( - LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # List Job tests - # You can't explictly delete a job/job history so check for existance - job_result_set = self.cmd( - "iot hub job list -n {} -g {}".format(LIVE_HUB, LIVE_RG) - ).get_output_in_json() - - self.validate_job_list(jobs_set=job_result_set) - - # List Jobs - with connection string - job_result_set_cs = self.cmd( - "iot hub job list --login {}".format(self.connection_string) - ).get_output_in_json() - - self.validate_job_list(jobs_set=job_result_set_cs) + checks=[ + self.check("jobId", self.job_ids[2]), + self.check("status", "cancelled"), + ], + ) + + # Error - Cancel non-existant job + self.cmd( + self.set_cmd_auth_type( + "iot hub job cancel --job-id notarealjobid -n {} -g {}".format( + LIVE_HUB, LIVE_RG + ), + auth_type=auth_phase + ), + expect_failure=True, + ) + + # List Job tests + # You can't explictly delete a job/job history so check for existance + job_result_set = self.cmd( + "iot hub job list -n {} -g {}".format(LIVE_HUB, LIVE_RG) + ).get_output_in_json() + + self.validate_job_list(jobs_set=job_result_set) def validate_job_list(self, jobs_set): filtered_job_ids_result = {} diff --git a/azext_iot/tests/iothub/messaging/__init__.py b/azext_iot/tests/iothub/messaging/__init__.py new file mode 100644 index 000000000..55614acbf --- /dev/null +++ b/azext_iot/tests/iothub/messaging/__init__.py @@ -0,0 +1,5 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/azext_iot/tests/iothub/messaging/test_iothub_c2d_messages_int.py b/azext_iot/tests/iothub/messaging/test_iothub_c2d_messages_int.py new file mode 100644 index 000000000..5d34cdc38 --- /dev/null +++ b/azext_iot/tests/iothub/messaging/test_iothub_c2d_messages_int.py @@ -0,0 +1,79 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json + +from uuid import uuid4 +from azext_iot.tests import IoTLiveScenarioTest +from azext_iot.tests.iothub import DATAPLANE_AUTH_TYPES +from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC +from azext_iot.common.utility import ( + calculate_millisec_since_unix_epoch_utc, + validate_key_value_pairs +) + +settings = DynamoSettings(ENV_SET_TEST_IOTHUB_BASIC) +LIVE_HUB = settings.env.azext_iot_testhub +LIVE_RG = settings.env.azext_iot_testrg + + +class TestIoTHubC2DMessages(IoTLiveScenarioTest): + def __init__(self, test_case): + super(TestIoTHubC2DMessages, self).__init__( + test_case, LIVE_HUB, LIVE_RG + ) + + def test_iothub_c2d_messages(self): + device_count = 1 + device_ids = self.generate_device_names(device_count) + + self.cmd( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}" + ) + + for auth_phase in DATAPLANE_AUTH_TYPES: + test_body = str(uuid4()) + test_props = f"key0={str(uuid4())};key1={str(uuid4())}" + test_cid = str(uuid4()) + test_mid = str(uuid4()) + test_ct = "text/plain" + test_et = calculate_millisec_since_unix_epoch_utc(3600) # milliseconds since epoch + test_ce = "utf8" + + self.kwargs["c2d_json_send_data"] = json.dumps({"data": str(uuid4())}) + + # Send C2D message + self.cmd( + self.set_cmd_auth_type( + f"iot device c2d-message send -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} --data '{test_body}' " + f"--cid {test_cid} --mid {test_mid} --ct {test_ct} --expiry {test_et} --ce {test_ce} -p '{test_props}'", + auth_type=auth_phase + ), + checks=self.is_empty(), + ) + + c2d_receive_result = self.cmd( + f"iot device c2d-message receive -d {device_ids[0]} --hub-name {LIVE_HUB} -g {LIVE_RG} --complete", + ).get_output_in_json() + + assert c2d_receive_result["data"] == test_body + + # Assert system properties + received_system_props = c2d_receive_result["properties"]["system"] + assert received_system_props["ContentEncoding"] == test_ce + assert received_system_props["ContentType"] == test_ct + assert received_system_props["iothub-correlationid"] == test_cid + assert received_system_props["iothub-messageid"] == test_mid + assert received_system_props["iothub-expiry"] + assert received_system_props["iothub-to"] == f"/devices/{device_ids[0]}/messages/devicebound" + + # Ack is tested in message feedback tests + assert received_system_props["iothub-ack"] == "none" + + # Assert app properties + received_app_props = c2d_receive_result["properties"]["app"] + assert received_app_props == validate_key_value_pairs(test_props) + assert c2d_receive_result["etag"] diff --git a/azext_iot/tests/iothub/modules/__init__.py b/azext_iot/tests/iothub/modules/__init__.py new file mode 100644 index 000000000..55614acbf --- /dev/null +++ b/azext_iot/tests/iothub/modules/__init__.py @@ -0,0 +1,5 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- diff --git a/azext_iot/tests/iothub/modules/test_iothub_module_twin_int.py b/azext_iot/tests/iothub/modules/test_iothub_module_twin_int.py new file mode 100644 index 000000000..30e6aea71 --- /dev/null +++ b/azext_iot/tests/iothub/modules/test_iothub_module_twin_int.py @@ -0,0 +1,229 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import json +import os +from pathlib import Path + +from azext_iot.common.utility import read_file_content +from azext_iot.tests import IoTLiveScenarioTest +from azext_iot.tests.generators import generate_generic_id +from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC +from azext_iot.tests.iothub import DATAPLANE_AUTH_TYPES + +settings = DynamoSettings(req_env_set=ENV_SET_TEST_IOTHUB_BASIC) + +LIVE_HUB = settings.env.azext_iot_testhub +LIVE_RG = settings.env.azext_iot_testrg +CWD = os.path.dirname(os.path.abspath(__file__)) + + +class TestIoTHubModuleTwin(IoTLiveScenarioTest): + def __init__(self, test_case): + super(TestIoTHubModuleTwin, self).__init__(test_case, LIVE_HUB, LIVE_RG) + + def test_iothub_module_twin(self): + for auth_phase in DATAPLANE_AUTH_TYPES: + device_count = 1 + device_ids = self.generate_device_names(device_count) + module_count = 1 + module_ids = self.generate_device_names(module_count) + + patch_desired = { + generate_generic_id(): generate_generic_id(), + generate_generic_id(): generate_generic_id(), + } + patch_tags = { + generate_generic_id(): generate_generic_id(), + generate_generic_id(): generate_generic_id(), + } + + self.kwargs["patch_desired"] = json.dumps(patch_desired) + self.kwargs["patch_tags"] = json.dumps(patch_tags) + + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ) + ) + + self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity create -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ) + ) + + # Initial twin state + d0_twin = self.cmd( + self.set_cmd_auth_type( + f"iot hub module-twin show -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=[ + self.check("moduleId", module_ids[0]), + self.check("deviceId", device_ids[0]), + self.exists("properties.desired"), + self.exists("properties.reported"), + ], + ).get_output_in_json() + + assert d0_twin["properties"]["desired"]["$version"] == 1 + assert d0_twin["properties"]["reported"]["$version"] == 1 + + # Patch based twin update of desired props + d0_twin = self.cmd( + self.set_cmd_auth_type( + f"iot hub module-twin update -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "--desired '{patch_desired}'", # Not f-string due to CLI TestFramework self.kwargs application :( + auth_type=auth_phase, + ) + ).get_output_in_json() + + assert d0_twin["properties"]["desired"]["$version"] == 2 + + for key in patch_desired: + assert d0_twin["properties"]["desired"][key] == patch_desired[key] + + # Patch based twin update of tag props + d0_twin = self.cmd( + self.set_cmd_auth_type( + f"iot hub module-twin update -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "--tags '{patch_tags}'", # Not f-string due to CLI TestFramework self.kwargs application :( + auth_type=auth_phase, + ) + ).get_output_in_json() + + for key in patch_tags: + assert d0_twin["tags"][key] == patch_tags[key] + + for key in patch_desired: + assert d0_twin["properties"]["desired"][key] == patch_desired[key] + + assert d0_twin["properties"]["desired"]["$version"] == 2 + + # Patch based twin update of tag and desired props + d0_twin = self.cmd( + self.set_cmd_auth_type( + f"iot hub module-twin update -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "--tags '{patch_tags}' --desired '{patch_desired}'", + auth_type=auth_phase, + ) + ).get_output_in_json() + + for key in patch_tags: + assert d0_twin["tags"][key] == patch_tags[key] + + for key in patch_desired: + assert d0_twin["properties"]["desired"][key] == patch_desired[key] + + assert d0_twin["properties"]["desired"]["$version"] == 3 + + # Prepare removal of all twin tag properties + for key in patch_tags: + patch_tags[key] = None + self.kwargs["patch_tags"] = json.dumps(patch_tags) + + # Remove all twin tag properties + d0_twin = self.cmd( + self.set_cmd_auth_type( + f"iot hub module-twin update -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "--tags '{patch_tags}'", + auth_type=auth_phase, + ) + ).get_output_in_json() + assert d0_twin["tags"] is None + + # Prepare removal of single desired twin property + target_key = list(patch_desired.keys())[0] + patch_desired[target_key] = None + self.kwargs["patch_desired"] = json.dumps(patch_desired) + + # Remove single desired property + d0_twin = self.cmd( + self.set_cmd_auth_type( + f"iot hub module-twin update -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "--desired '{patch_desired}'", + auth_type=auth_phase, + ) + ).get_output_in_json() + + assert d0_twin["properties"]["desired"].get(target_key) is None + assert d0_twin["properties"]["desired"]["$version"] == 4 + + # Validation error --desired is not an object + self.cmd( + self.set_cmd_auth_type( + f"iot hub module-twin update -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "--desired 'badinput'", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + def test_iothub_module_twin_replace(self): + for auth_phase in DATAPLANE_AUTH_TYPES: + device_count = 1 + device_ids = self.generate_device_names(device_count) + module_count = 1 + module_ids = self.generate_device_names(module_count) + + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ) + ) + + self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity create -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ) + ) + + replace_twin_content_path = os.path.join( + Path(CWD).parent, "test_generic_replace.json" + ) + self.cmd( + self.set_cmd_auth_type( + f"iot hub module-twin replace -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} -j " + f"'{replace_twin_content_path}'", + auth_type=auth_phase, + ), + checks=[ + self.check("moduleId", module_ids[0]), + self.check("deviceId", device_ids[0]), + self.check("properties.desired.awesome", 9001), + self.check("properties.desired.temperature.min", 10), + self.check("properties.desired.temperature.max", 100), + self.check("tags.location.region", "US"), + ], + ) + + # Inline json + replace_twin_content_path = os.path.join( + Path(CWD).parent, "test_generic_replace.json" + ) + self.kwargs["inline_replace_content"] = read_file_content( + replace_twin_content_path + ) + self.cmd( + self.set_cmd_auth_type( + f"iot hub module-twin replace -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "-j '{inline_replace_content}'", + auth_type=auth_phase, + ), + checks=[ + self.check("moduleId", module_ids[0]), + self.check("deviceId", device_ids[0]), + self.check("properties.desired.awesome", 9001), + self.check("properties.desired.temperature.min", 10), + self.check("properties.desired.temperature.max", 100), + self.check("tags.location.region", "US"), + ], + ) diff --git a/azext_iot/tests/iothub/modules/test_iothub_modules_int.py b/azext_iot/tests/iothub/modules/test_iothub_modules_int.py new file mode 100644 index 000000000..f9c7b1caf --- /dev/null +++ b/azext_iot/tests/iothub/modules/test_iothub_modules_int.py @@ -0,0 +1,335 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azext_iot.tests import IoTLiveScenarioTest +from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC +from azext_iot.tests.iothub import ( + DATAPLANE_AUTH_TYPES, + PRIMARY_THUMBPRINT, + SECONDARY_THUMBPRINT, + DEVICE_TYPES, +) + +settings = DynamoSettings(req_env_set=ENV_SET_TEST_IOTHUB_BASIC) + +LIVE_HUB = settings.env.azext_iot_testhub +LIVE_RG = settings.env.azext_iot_testrg + + +class TestIoTHubModules(IoTLiveScenarioTest): + def __init__(self, test_case): + super(TestIoTHubModules, self).__init__(test_case, LIVE_HUB, LIVE_RG) + + def test_iothub_module_identity(self): + for auth_phase in DATAPLANE_AUTH_TYPES: + for device_type in DEVICE_TYPES: + device_count = 1 + module_count = 4 + device_ids = self.generate_device_names( + device_count, edge=device_type == "edge" + ) + module_ids = self.generate_module_names(module_count) + edge_enabled = "--edge-enabled" if device_type == "edge" else "" + + # Symmetric key device creation + self.cmd( + self.set_cmd_auth_type( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} {edge_enabled}", + auth_type=auth_phase, + ), + ) + + m0_d0_checks = [ + self.check("deviceId", device_ids[0]), + self.check("moduleId", module_ids[0]), + self.exists("authentication.symmetricKey.primaryKey"), + self.exists("authentication.symmetricKey.secondaryKey"), + ] + + # Create module identity with symmetric keys + self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity create --module-id {module_ids[0]} --device-id {device_ids[0]} " + f"--hub-name {LIVE_HUB} --resource-group {LIVE_RG}", + auth_type=auth_phase, + ), + checks=m0_d0_checks, + ) + + # Create module identity with x509 thumbprint + self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity create -m {module_ids[1]} -d {device_ids[0]} " + f"-n {LIVE_HUB} -g {LIVE_RG} --auth-method x509_thumbprint --primary-thumbprint {PRIMARY_THUMBPRINT} " + f"--secondary-thumbprint {SECONDARY_THUMBPRINT}", + auth_type=auth_phase, + ), + checks=[ + self.check("deviceId", device_ids[0]), + self.check("moduleId", module_ids[1]), + self.check("connectionState", "Disconnected"), + self.check("authentication.symmetricKey.primaryKey", None), + self.check("authentication.symmetricKey.secondaryKey", None), + self.check( + "authentication.x509Thumbprint.primaryThumbprint", + PRIMARY_THUMBPRINT, + ), + self.check( + "authentication.x509Thumbprint.secondaryThumbprint", + SECONDARY_THUMBPRINT, + ), + ], + ) + + # Create module identity with generated x509 thumbprint + self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity create -m {module_ids[2]} -d {device_ids[0]} " + f"-n {LIVE_HUB} -g {LIVE_RG} --am x509_thumbprint --valid-days 1", + auth_type=auth_phase, + ), + checks=[ + self.check("deviceId", device_ids[0]), + self.check("moduleId", module_ids[2]), + self.check("connectionState", "Disconnected"), + self.check("authentication.symmetricKey.primaryKey", None), + self.check("authentication.symmetricKey.secondaryKey", None), + self.exists("authentication.x509Thumbprint.primaryThumbprint"), + self.check( + "authentication.x509Thumbprint.secondaryThumbprint", None + ), + ], + ) + + # Create module identity with x509 ca + self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity create -m {module_ids[3]} -d {device_ids[0]} " + f"-n {LIVE_HUB} -g {LIVE_RG} --am x509_ca", + auth_type=auth_phase, + ), + checks=[ + self.check("deviceId", device_ids[0]), + self.check("moduleId", module_ids[3]), + self.check("connectionState", "Disconnected"), + self.check("authentication.symmetricKey.primaryKey", None), + self.check("authentication.symmetricKey.secondaryKey", None), + self.check( + "authentication.x509Thumbprint.primaryThumbprint", None + ), + self.check( + "authentication.x509Thumbprint.secondaryThumbprint", None + ), + ], + ) + + # Show symmetric key module identity + m0_d0_show = self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity show -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=m0_d0_checks, + ).get_output_in_json() + + # Reset module symmetric key using module-identity generic update + m0_d0_updated = self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity update -m {module_ids[0]} -d {device_ids[0]} " + f'-n {LIVE_HUB} -g {LIVE_RG} --set authentication.symmetricKey.primaryKey="" ' + 'authentication.symmetricKey.secondaryKey=""', + auth_type=auth_phase, + ) + ).get_output_in_json() + assert ( + m0_d0_updated["authentication"]["symmetricKey"]["primaryKey"] + != m0_d0_show["authentication"]["symmetricKey"]["primaryKey"] + ) + assert ( + m0_d0_updated["authentication"]["symmetricKey"]["secondaryKey"] + != m0_d0_show["authentication"]["symmetricKey"]["secondaryKey"] + ) + + query_checks = [] + for m in module_ids: + query_checks.append(self.exists(f"[?moduleId=='{m}']")) + if device_type == "edge": + query_checks.append(self.exists("[?moduleId=='$edgeAgent']")) + query_checks.append(self.exists("[?moduleId=='$edgeHub']")) + + # Query device modules. Edge devices include the $edgeAgent and $edgeHub system modules. + module_query_result = self.cmd( + self.set_cmd_auth_type( + f"iot hub query -n {LIVE_HUB} -g {LIVE_RG} " + f"-q \"select * from devices.modules where devices.deviceId='{device_ids[0]}'\"", + auth_type=auth_phase, + ), + checks=query_checks, + ).get_output_in_json() + + target_module_count = ( + 2 + module_count if device_type == "edge" else module_count + ) + assert len(module_query_result) == target_module_count + + self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity list -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=query_checks, + ) + + # Delete module identity. + self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity delete -m {module_ids[2]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=self.is_empty(), + ) + + # Validate deletion worked. + self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity show -m {module_ids[2]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + def test_iothub_module_connection_string_show(self): + device_count = 1 + device_ids = self.generate_device_names(device_count) + module_count = 2 + module_ids = self.generate_device_names(module_count) + + self.cmd( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}" + ).get_output_in_json() + + symmetric_key_module = self.cmd( + f"iot hub module-identity create -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}" + ).get_output_in_json() + + self.cmd( + f"iot hub module-identity create -m {module_ids[1]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} --am x509_ca" + ) + + sym_cstring_pattern = ( + f"HostName={LIVE_HUB}.azure-devices.net;DeviceId={device_ids[0]};" + f"ModuleId={module_ids[0]};SharedAccessKey=#" + ) + cer_cstring_pattern = f"HostName={LIVE_HUB}.azure-devices.net;DeviceId={device_ids[0]};ModuleId={module_ids[1]};x509=true" + + for auth_phase in DATAPLANE_AUTH_TYPES: + primary_key_cstring = self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity connection-string show -m {module_ids[0]} -d {device_ids[0]} " + f"-n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ) + ).get_output_in_json() + + target_key = symmetric_key_module["authentication"]["symmetricKey"][ + "primaryKey" + ] + target_sym_cstring = sym_cstring_pattern.replace("#", target_key) + + assert target_sym_cstring == primary_key_cstring["connectionString"] + + secondary_key_cstring = self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity connection-string show -m {module_ids[0]} -d {device_ids[0]} " + f"-n {LIVE_HUB} -g {LIVE_RG} --kt secondary", + auth_type=auth_phase, + ) + ).get_output_in_json() + + target_key = symmetric_key_module["authentication"]["symmetricKey"][ + "secondaryKey" + ] + target_sym_cstring = sym_cstring_pattern.replace("#", target_key) + + assert target_sym_cstring == secondary_key_cstring["connectionString"] + + x509_cstring = self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity connection-string show -m {module_ids[1]} -d {device_ids[0]} " + f"-n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ) + ).get_output_in_json() + + assert cer_cstring_pattern == x509_cstring["connectionString"] + + def test_iothub_module_generate_sas_token(self): + device_count = 1 + device_ids = self.generate_device_names(device_count) + + module_count = 2 + module_ids = self.generate_device_names(module_count) + + self.cmd( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}" + ) + + self.cmd( + f"iot hub module-identity create -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}" + ) + + self.cmd( + f"iot hub module-identity create -m {module_ids[1]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} " + "--auth-method x509_ca" + ) + + for auth_phase in DATAPLANE_AUTH_TYPES: + self.cmd( + self.set_cmd_auth_type( + f"iot hub generate-sas-token -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=[self.exists("sas")], + ) + + # Custom duration + self.cmd( + self.set_cmd_auth_type( + f"iot hub generate-sas-token -m {module_ids[0]} -d {device_ids[0]} --du 1000 -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=[self.exists("sas")], + ) + + # Custom key type + self.cmd( + self.set_cmd_auth_type( + f'iot hub generate-sas-token -m {module_ids[0]} -d {device_ids[0]} --kt "secondary" ' + f"-n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=[self.exists("sas")], + ) + + # Error - generate sas token against non SAS module + self.cmd( + f"iot hub generate-sas-token -m {module_ids[1]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + expect_failure=True, + ) + + # Error - generate sas token against module with no device + self.cmd( + f"iot hub generate-sas-token -m {module_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}", + expect_failure=True, + ) + + # Mixed case connection string + cstring = self.connection_string + mixed_case_cstring = cstring.replace("HostName", "hostname", 1) + self.cmd( + f"iot hub generate-sas-token -m {module_ids[0]} -d {device_ids[0]} --login {mixed_case_cstring}", + checks=[self.exists("sas")], + ) diff --git a/azext_iot/tests/iothub/test_iot_ext_int.py b/azext_iot/tests/iothub/test_iot_ext_int.py index 81b67f0f5..eb58cb06e 100644 --- a/azext_iot/tests/iothub/test_iot_ext_int.py +++ b/azext_iot/tests/iothub/test_iot_ext_int.py @@ -6,1404 +6,31 @@ import os import pytest -import warnings -from azext_iot.common.utility import read_file_content from azext_iot.tests import IoTLiveScenarioTest from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC -from azext_iot.constants import DEVICE_DEVICESCOPE_PREFIX -opt_env_set = ["azext_iot_teststorageuri", "azext_iot_identity_teststorageid"] - -settings = DynamoSettings( - req_env_set=ENV_SET_TEST_IOTHUB_BASIC, opt_env_set=opt_env_set -) - -LIVE_HUB = settings.env.azext_iot_testhub -LIVE_RG = settings.env.azext_iot_testrg - -# Set this environment variable to your empty blob container sas uri to test device export and enable file upload test. -# For file upload, you will need to have configured your IoT Hub before running. -LIVE_STORAGE = settings.env.azext_iot_teststorageuri - -# Set this environment variable to enable identity-based integration tests -# You will need permissions to add and remove role assignments for this storage account -LIVE_STORAGE_ID = settings.env.azext_iot_identity_teststorageid - -LIVE_CONSUMER_GROUPS = ["test1", "test2", "test3"] - -CWD = os.path.dirname(os.path.abspath(__file__)) - -PRIMARY_THUMBPRINT = "A361EA6A7119A8B0B7BBFFA2EAFDAD1F9D5BED8C" -SECONDARY_THUMBPRINT = "14963E8F3BA5B3984110B3C1CA8E8B8988599087" - - -class TestIoTHub(IoTLiveScenarioTest): - def __init__(self, test_case): - super(TestIoTHub, self).__init__(test_case, LIVE_HUB, LIVE_RG) - - def test_hub(self): - self.cmd( - "az iot hub generate-sas-token -n {} -g {}".format(LIVE_HUB, LIVE_RG), - checks=[self.exists("sas")], - ) - - self.cmd( - "az iot hub generate-sas-token -n {}".format(LIVE_HUB), - checks=[self.exists("sas")], - ) - - self.cmd( - "az iot hub generate-sas-token -n {} --du {}".format(LIVE_HUB, "1000"), - checks=[self.exists("sas")], - ) - - # With connection string - self.cmd( - "az iot hub generate-sas-token --login {}".format(self.connection_string), - checks=[self.exists("sas")], - ) - - self.cmd( - "az iot hub generate-sas-token --login {} --pn somepolicy".format( - self.connection_string - ), - expect_failure=True, - ) - - # Test 'az iot hub connection-string show' - conn_str_pattern = r'^HostName={0}.azure-devices.net;SharedAccessKeyName=iothubowner;SharedAccessKey='.format( - LIVE_HUB) - conn_str_eventhub_pattern = (r'^Endpoint=sb://(.+?)servicebus.windows.net/;SharedAccessKeyName=' - r'iothubowner;SharedAccessKey=(.+?);EntityPath=') - defaultpolicy = "iothubowner" - nonexistantpolicy = "badpolicy" - - hubs_in_sub = self.cmd('iot hub connection-string show').get_output_in_json() - hubs_in_rg = self.cmd('iot hub connection-string show -g {}'.format(LIVE_RG)).get_output_in_json() - assert len(hubs_in_sub) >= len(hubs_in_rg) - - self.cmd('iot hub connection-string show -n {0}'.format(LIVE_HUB), checks=[ - self.check_pattern('connectionString', conn_str_pattern) - ]) - - self.cmd('iot hub connection-string show -n {0} --pn {1}'.format(LIVE_HUB, defaultpolicy), checks=[ - self.check_pattern('connectionString', conn_str_pattern) - ]) - - self.cmd( - 'iot hub connection-string show -n {0} --pn {1}'.format(LIVE_HUB, nonexistantpolicy), - expect_failure=True, - ) - - self.cmd( - 'iot hub connection-string show --pn {0}'.format(nonexistantpolicy), - checks=[self.check('length(@)', 0)] - ) - - self.cmd('iot hub connection-string show -n {0} --eh'.format(LIVE_HUB), checks=[ - self.check_pattern('connectionString', conn_str_eventhub_pattern) - ]) - - self.cmd('iot hub connection-string show -n {0} -g {1}'.format(LIVE_HUB, LIVE_RG), checks=[ - self.check('length(@)', 1), - self.check_pattern('connectionString', conn_str_pattern) - ]) - - self.cmd('iot hub connection-string show -n {0} -g {1} --all'.format(LIVE_HUB, LIVE_RG), checks=[ - self.greater_than('length(connectionString[*])', 0), - self.check_pattern('connectionString[0]', conn_str_pattern) - ]) - - self.cmd('iot hub connection-string show -n {0} -g {1} --all --eh'.format(LIVE_HUB, LIVE_RG), checks=[ - self.greater_than('length(connectionString[*])', 0), - self.check_pattern('connectionString[0]', conn_str_eventhub_pattern) - ]) - - # With connection string - # Error can't change key for a sas token with conn string - self.cmd( - "az iot hub generate-sas-token --login {} --kt secondary".format( - self.connection_string - ), - expect_failure=True, - ) - - self.cmd( - 'iot hub query --hub-name {} -q "{}"'.format( - LIVE_HUB, "select * from devices" - ), - checks=[self.check("length([*])", 0)], - ) - - # With connection string - self.cmd( - 'iot hub query --query-command "{}" --login {}'.format( - "select * from devices", self.connection_string - ), - checks=[self.check("length([*])", 0)], - ) - - # Test mode 2 handler - self.cmd( - 'iot hub query -q "{}"'.format("select * from devices"), expect_failure=True - ) - - self.cmd( - 'iot hub query -q "{}" -l "{}"'.format( - "select * from devices", "Hostname=badlogin;key=1235" - ), - expect_failure=True, - ) - - -class TestIoTHubDevices(IoTLiveScenarioTest): - def __init__(self, test_case): - super(TestIoTHubDevices, self).__init__( - test_case, LIVE_HUB, LIVE_RG - ) - - def test_hub_devices(self): - device_count = 5 - edge_device_count = 2 - edge_x509_device_count = 2 - total_edge_device_count = edge_x509_device_count + edge_device_count - - device_ids = self.generate_device_names(device_count) - edge_device_ids = self.generate_device_names(edge_device_count, edge=True) - edge_x509_device_ids = self.generate_device_names(edge_x509_device_count, edge=True) - - total_devices = device_count + total_edge_device_count - - self.cmd( - "iot hub device-identity create -d {} -n {} -g {}".format( - device_ids[4], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("deviceId", device_ids[4]), - self.check("status", "enabled"), - self.check("statusReason", None), - self.check("connectionState", "Disconnected"), - self.check("capabilities.iotEdge", False), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) - - for edge_device in edge_device_ids: - self.cmd( - "iot hub device-identity create -d {} -n {} -g {} --ee --add-children {} --force".format( - edge_device, LIVE_HUB, LIVE_RG, device_ids[4] - ), - checks=[ - self.check("deviceId", edge_device), - self.check("status", "enabled"), - self.check("statusReason", None), - self.check("connectionState", "Disconnected"), - self.check("capabilities.iotEdge", True), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) - - device_scope_str_pattern = r"^{}{}-".format( - DEVICE_DEVICESCOPE_PREFIX, edge_device - ) - self.cmd( - "iot hub device-identity show -d {} -n {} -g {}".format( - device_ids[4], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("deviceId", device_ids[4]), - self.check_pattern("deviceScope", device_scope_str_pattern), - ], - ) - - # All edge devices + child device - query_checks = [self.check("length([*])", total_edge_device_count + 1)] - for i in edge_device_ids: - query_checks.append(self.exists("[?deviceId==`{}`]".format(i))) - query_checks.append(self.exists("[?deviceId==`{}`]".format(device_ids[4]))) - - # Edge x509_thumbprint - self.cmd( - "iot hub device-identity create -d {} -n {} -g {} --auth-method x509_thumbprint --ptp {} --stp {} --ee".format( - edge_x509_device_ids[0], LIVE_HUB, LIVE_RG, PRIMARY_THUMBPRINT, SECONDARY_THUMBPRINT - ), - checks=[ - self.check("deviceId", edge_x509_device_ids[0]), - self.check("status", "enabled"), - self.check("statusReason", None), - self.check("capabilities.iotEdge", True), - self.check("connectionState", "Disconnected"), - self.check("authentication.symmetricKey.primaryKey", None), - self.check("authentication.symmetricKey.secondaryKey", None), - self.check( - "authentication.x509Thumbprint.primaryThumbprint", - PRIMARY_THUMBPRINT, - ), - self.check( - "authentication.x509Thumbprint.secondaryThumbprint", - SECONDARY_THUMBPRINT, - ), - ] - ) - - # Edge x509_ca - self.cmd( - "iot hub device-identity create -d {} -n {} -g {} --auth-method x509_ca --ee".format( - edge_x509_device_ids[1], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("deviceId", edge_x509_device_ids[1]), - self.check("status", "enabled"), - self.check("capabilities.iotEdge", True), - self.check("connectionState", "Disconnected"), - self.check("authentication.symmetricKey.primaryKey", None), - self.check("authentication.symmetricKey.secondaryKey", None), - self.check("authentication.x509Thumbprint.primaryThumbprint", None), - self.check("authentication.x509Thumbprint.secondaryThumbprint", None), - self.check("authentication.type", "certificateAuthority") - ] - ) - - self.cmd( - 'iot hub query --hub-name {} -g {} -q "{}"'.format( - LIVE_HUB, LIVE_RG, "select * from devices" - ), - checks=query_checks, - ) - - # With connection string - self.cmd( - 'iot hub query -q "{}" --login {}'.format( - "select * from devices", self.connection_string - ), - checks=query_checks, - ) - - # -1 for no return limit - self.cmd( - 'iot hub query -q "{}" --login {} --top -1'.format( - "select * from devices", self.connection_string - ), - checks=query_checks, - ) - - self.cmd( - """iot hub device-identity create --device-id {} --hub-name {} --resource-group {} - --auth-method x509_thumbprint --primary-thumbprint {} --secondary-thumbprint {}""".format( - device_ids[0], - LIVE_HUB, - LIVE_RG, - PRIMARY_THUMBPRINT, - SECONDARY_THUMBPRINT, - ), - checks=[ - self.check("deviceId", device_ids[0]), - self.check("status", "enabled"), - self.check("statusReason", None), - self.check("capabilities.iotEdge", False), - self.check("connectionState", "Disconnected"), - self.check("authentication.symmetricKey.primaryKey", None), - self.check("authentication.symmetricKey.secondaryKey", None), - self.check( - "authentication.x509Thumbprint.primaryThumbprint", - PRIMARY_THUMBPRINT, - ), - self.check( - "authentication.x509Thumbprint.secondaryThumbprint", - SECONDARY_THUMBPRINT, - ), - ], - ) - - self.cmd( - """iot hub device-identity create --device-id {} --hub-name {} --resource-group {} - --auth-method x509_thumbprint --valid-days {}""".format( - device_ids[1], LIVE_HUB, LIVE_RG, 10 - ), - checks=[ - self.check("deviceId", device_ids[1]), - self.check("status", "enabled"), - self.check("statusReason", None), - self.check("capabilities.iotEdge", False), - self.check("connectionState", "Disconnected"), - self.check("authentication.symmetricKey.primaryKey", None), - self.check("authentication.symmetricKey.secondaryKey", None), - self.exists("authentication.x509Thumbprint.primaryThumbprint"), - self.check("authentication.x509Thumbprint.secondaryThumbprint", None), - ], - ) - - # With connection string - status_reason = "Test Status Reason" - self.cmd( - '''iot hub device-identity create --device-id {} --login {} - --auth-method x509_ca --status disabled --status-reason "{}"'''.format( - device_ids[2], self.connection_string, status_reason - ), - checks=[ - self.check("deviceId", device_ids[2]), - self.check("status", "disabled"), - self.check("statusReason", status_reason), - self.check("capabilities.iotEdge", False), - self.check("connectionState", "Disconnected"), - self.check("authentication.symmetricKey.primaryKey", None), - self.check("authentication.symmetricKey.secondaryKey", None), - self.check("authentication.x509Thumbprint.primaryThumbprint", None), - self.check("authentication.x509Thumbprint.secondaryThumbprint", None), - ], - ) - - child_device_scope_str_pattern = r"^{}{}-".format( - DEVICE_DEVICESCOPE_PREFIX, edge_device_ids[0] - ) - - # Create device with parent device - self.cmd( - """iot hub device-identity create --device-id {} --hub-name {} --resource-group {} - --auth-method x509_thumbprint --valid-days {} --set-parent {}""".format( - device_ids[3], LIVE_HUB, LIVE_RG, 10, edge_device_ids[0] - ), - checks=[ - self.check("deviceId", device_ids[3]), - self.check("status", "enabled"), - self.check("statusReason", None), - self.check("capabilities.iotEdge", False), - self.check("connectionState", "Disconnected"), - self.check("authentication.symmetricKey.primaryKey", None), - self.check("authentication.symmetricKey.secondaryKey", None), - self.exists("authentication.x509Thumbprint.primaryThumbprint"), - self.check("authentication.x509Thumbprint.secondaryThumbprint", None), - self.exists("deviceScope"), - self.exists("parentScopes"), - self.check_pattern("deviceScope", child_device_scope_str_pattern), - ], - ) - - self.cmd( - "iot hub device-identity show -d {} -n {} -g {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("status", "enabled"), - self.check("statusReason", None), - self.check("connectionState", "Disconnected"), - self.check("capabilities.iotEdge", True), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) - - # With connection string - self.cmd( - "iot hub device-identity show -d {} --login {}".format( - edge_device_ids[0], self.connection_string - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("status", "enabled"), - self.check("statusReason", None), - self.check("connectionState", "Disconnected"), - self.check("capabilities.iotEdge", True), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) - - # List all devices - self.cmd( - "iot hub device-identity list --hub-name {} --resource-group {}".format( - LIVE_HUB, LIVE_RG - ), - checks=[self.check("length([*])", total_devices)], - ) - - self.cmd( - "iot hub device-identity list --hub-name {} --resource-group {} --top -1".format( - LIVE_HUB, LIVE_RG - ), - checks=[self.check("length([*])", total_devices)], - ) - - # List only edge devices - self.cmd( - "iot hub device-identity list -n {} -g {} --ee".format(LIVE_HUB, LIVE_RG), - checks=[self.check("length([*])", total_edge_device_count)], - ) - - # With connection string - self.cmd( - "iot hub device-identity list --ee --login {}".format(self.connection_string), - checks=[self.check("length([*])", total_edge_device_count)], - ) - - self.cmd( - "iot hub device-identity update -d {} -n {} -g {} --set capabilities.iotEdge={}".format( - device_ids[0], LIVE_HUB, LIVE_RG, True - ), - checks=[ - self.check("deviceId", device_ids[0]), - self.check("status", "enabled"), - self.check("capabilities.iotEdge", True), - self.check("authentication.symmetricKey.primaryKey", None), - self.check("authentication.symmetricKey.secondaryKey", None), - self.check( - "authentication.x509Thumbprint.primaryThumbprint", - PRIMARY_THUMBPRINT, - ), - self.check( - "authentication.x509Thumbprint.secondaryThumbprint", - SECONDARY_THUMBPRINT, - ), - ], - ) - - self.cmd( - "iot hub device-identity update -d {} -n {} -g {} --ee {} --auth-method {}" - .format(device_ids[0], LIVE_HUB, LIVE_RG, False, 'x509_ca'), - checks=[ - self.check("deviceId", device_ids[0]), - self.check("status", "enabled"), - self.check("capabilities.iotEdge", False), - self.check("authentication.symmetricKey.primaryKey", None), - self.check("authentication.symmetricKey.secondaryKey", None), - self.check("authentication.x509Thumbprint.primaryThumbprint", None), - self.check("authentication.x509Thumbprint.secondaryThumbprint", None), - self.check("authentication.type", 'certificateAuthority') - ] - ) - - self.cmd( - "iot hub device-identity update -d {} -n {} -g {} --status-reason {}" - .format(device_ids[0], LIVE_HUB, LIVE_RG, 'TestStatusReason'), - checks=[ - self.check("deviceId", device_ids[0]), - self.check("statusReason", 'TestStatusReason'), - ] - ) - - self.cmd( - "iot hub device-identity update -d {} -n {} -g {} --ee {} --status {}" - " --status-reason {} --auth-method {} --ptp {} --stp {}" - .format(device_ids[0], LIVE_HUB, LIVE_RG, False, 'enabled', - 'StatusReasonUpdated', 'x509_thumbprint', PRIMARY_THUMBPRINT, SECONDARY_THUMBPRINT), - checks=[ - self.check("deviceId", device_ids[0]), - self.check("status", "enabled"), - self.check("capabilities.iotEdge", False), - self.check("statusReason", 'StatusReasonUpdated'), - self.check("authentication.x509Thumbprint.primaryThumbprint", PRIMARY_THUMBPRINT), - self.check("authentication.x509Thumbprint.secondaryThumbprint", SECONDARY_THUMBPRINT), - ] - ) - - self.cmd("iot hub device-identity update -d {} -n {} -g {} --auth-method {}" - .format(device_ids[0], LIVE_HUB, LIVE_RG, 'x509_thumbprint'), - expect_failure=True) - - self.cmd("iot hub device-identity update -d {} -n {} -g {} --auth-method {} --pk {}" - .format(device_ids[0], LIVE_HUB, LIVE_RG, 'shared_private_key', '123'), - expect_failure=True) - - self.cmd( - '''iot hub device-identity update -d {} -n {} -g {} --primary-key="" - --secondary-key=""'''.format( - device_ids[4], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("deviceId", device_ids[4]), - self.check("status", "enabled"), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) - - # With connection string - self.cmd( - '''iot hub device-identity update -d {} --login {} --set authentication.symmetricKey.primaryKey="" - authentication.symmetricKey.secondaryKey=""'''.format( - device_ids[4], self.connection_string - ), - checks=[ - self.check("deviceId", device_ids[4]), - self.check("status", "enabled"), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) - - # Test 'az iot hub device renew-key' - device = self.cmd( - '''iot hub device-identity renew-key -d {} -n {} -g {} --kt primary - '''.format( - edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("deviceId", edge_device_ids[1]) - ] - ).get_output_in_json() - - # Test swap keys 'az iot hub device renew-key' - self.cmd( - '''iot hub device-identity renew-key -d {} -n {} -g {} --kt swap - '''.format( - edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("authentication.symmetricKey.primaryKey", device['authentication']['symmetricKey']['secondaryKey']), - self.check("authentication.symmetricKey.secondaryKey", device['authentication']['symmetricKey']['primaryKey']) - ], - ) - - # Test 'az iot hub device renew-key' with non sas authentication - self.cmd("iot hub device-identity renew-key -d {} -n {} -g {} --kt secondary" - .format(device_ids[0], LIVE_HUB, LIVE_RG), - expect_failure=True) - - sym_conn_str_pattern = r"^HostName={}\.azure-devices\.net;DeviceId={};SharedAccessKey=".format( - LIVE_HUB, edge_device_ids[0] - ) - cer_conn_str_pattern = r"^HostName={}\.azure-devices\.net;DeviceId={};x509=true".format( - LIVE_HUB, device_ids[2] - ) - - self.cmd( - "iot hub device-identity show-connection-string -d {} -n {} -g {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[self.check_pattern("connectionString", sym_conn_str_pattern)], - ) - - self.cmd( - "iot hub device-identity show-connection-string -d {} -n {} -g {} --kt {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, "secondary" - ), - checks=[self.check_pattern("connectionString", sym_conn_str_pattern)], - ) - - self.cmd( - "iot hub device-identity show-connection-string -d {} -n {} -g {}".format( - device_ids[2], LIVE_HUB, LIVE_RG - ), - checks=[self.check_pattern("connectionString", cer_conn_str_pattern)], - ) - - self.cmd( - "iot hub device-identity connection-string show -d {} -n {} -g {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[self.check_pattern("connectionString", sym_conn_str_pattern)], - ) - - self.cmd( - "iot hub device-identity connection-string show -d {} -n {} -g {} --kt {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, "secondary" - ), - checks=[self.check_pattern("connectionString", sym_conn_str_pattern)], - ) - - self.cmd( - "iot hub device-identity connection-string show -d {} -n {} -g {}".format( - device_ids[2], LIVE_HUB, LIVE_RG - ), - checks=[self.check_pattern("connectionString", cer_conn_str_pattern)], - ) - - self.cmd( - "iot hub generate-sas-token -n {} -g {} -d {}".format( - LIVE_HUB, LIVE_RG, edge_device_ids[0] - ), - checks=[self.exists("sas")], - ) - - self.cmd( - "iot hub generate-sas-token -n {} -g {} -d {} --du {}".format( - LIVE_HUB, LIVE_RG, edge_device_ids[0], "1000" - ), - checks=[self.exists("sas")], - ) - - # None SAS device auth - self.cmd( - "iot hub generate-sas-token -n {} -g {} -d {}".format( - LIVE_HUB, LIVE_RG, device_ids[1] - ), - expect_failure=True, - ) - - self.cmd( - 'iot hub generate-sas-token -n {} -g {} -d {} --kt "secondary"'.format( - LIVE_HUB, LIVE_RG, edge_device_ids[1] - ), - checks=[self.exists("sas")], - ) - - # With connection string - self.cmd( - "iot hub generate-sas-token -d {} --login {}".format( - edge_device_ids[0], self.connection_string - ), - checks=[self.exists("sas")], - ) - - self.cmd( - 'iot hub generate-sas-token -d {} --login {} --kt "secondary"'.format( - edge_device_ids[1], self.connection_string - ), - checks=[self.exists("sas")], - ) - - self.cmd( - 'iot hub generate-sas-token -d {} --login {} --pn "mypolicy"'.format( - edge_device_ids[1], self.connection_string - ), - expect_failure=True, - ) - - -class TestIoTHubDeviceTwins(IoTLiveScenarioTest): - def __init__(self, test_case): - super(TestIoTHubDeviceTwins, self).__init__( - test_case, LIVE_HUB, LIVE_RG - ) - - def test_hub_device_twins(self): - self.kwargs["generic_dict"] = {"key": "value"} - self.kwargs["bad_format"] = "{'key: 'value'}" - self.kwargs["patch_desired"] = {"patchScenario": {"desiredKey": "desiredValue"}} - self.kwargs["patch_tags"] = {"patchScenario": {"tagkey": "tagValue"}} - - device_count = 3 - device_ids = self.generate_device_names(device_count) - - for device in device_ids: - self.cmd( - "iot hub device-identity create -d {} -n {} -g {}".format( - device, LIVE_HUB, LIVE_RG - ), - checks=[self.check("deviceId", device)], - ) - - self.cmd( - "iot hub device-twin show -d {} -n {} -g {}".format( - device_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("deviceId", device_ids[0]), - self.check("status", "enabled"), - self.exists("properties.desired"), - self.exists("properties.reported"), - ], - ) - - # With connection string - self.cmd( - "iot hub device-twin show -d {} --login {}".format( - device_ids[0], self.connection_string - ), - checks=[ - self.check("deviceId", device_ids[0]), - self.check("status", "enabled"), - self.exists("properties.desired"), - self.exists("properties.reported"), - ], - ) - - # Patch based twin update of desired props - self.cmd( - "iot hub device-twin update -d {} -n {} -g {} --desired {}".format( - device_ids[2], - LIVE_HUB, - LIVE_RG, - '"{patch_desired}"', - ), - checks=[ - self.check("deviceId", device_ids[2]), - self.check( - "properties.desired.patchScenario", - self.kwargs["patch_desired"]["patchScenario"], - ), - ], - ) - - # Patch based twin update of tags with connection string - self.cmd( - "iot hub device-twin update -d {} --login {} --tags {}".format( - device_ids[2], self.connection_string, '"{patch_tags}"' - ), - checks=[ - self.check("deviceId", device_ids[2]), - self.check( - "tags.patchScenario", self.kwargs["patch_tags"]["patchScenario"] - ), - ], - ) - - # Patch based twin update of desired + tags - self.cmd( - "iot hub device-twin update -d {} -n {} --desired {} --tags {}".format( - device_ids[2], - LIVE_HUB, - '"{patch_desired}"', - '"{patch_tags}"', - ), - checks=[ - self.check("deviceId", device_ids[2]), - self.check( - "properties.desired.patchScenario", - self.kwargs["patch_desired"]["patchScenario"], - ), - self.check( - "tags.patchScenario", - self.kwargs["patch_tags"]["patchScenario"] - ), - ], - ) - - # Deprecated generic update - result = self.cmd( - "iot hub device-twin update -d {} -n {} -g {} --set properties.desired.special={}".format( - device_ids[0], LIVE_HUB, LIVE_RG, '"{generic_dict}"' - ) - ).get_output_in_json() - assert result["deviceId"] == device_ids[0] - assert result["properties"]["desired"]["special"]["key"] == "value" - - # Removal of desired property from twin - result = self.cmd( - 'iot hub device-twin update -d {} -n {} -g {} --set properties.desired.special="null"'.format( - device_ids[0], LIVE_HUB, LIVE_RG - ) - ).get_output_in_json() - assert result["deviceId"] == device_ids[0] - assert result["properties"]["desired"].get("special") is None - - # With connection string - result = self.cmd( - "iot hub device-twin update -d {} --login {} --set properties.desired.special={}".format( - device_ids[0], self.connection_string, '"{generic_dict}"' - ) - ).get_output_in_json() - assert result["deviceId"] == device_ids[0] - assert result["properties"]["desired"]["special"]["key"] == "value" - - # Error case, test type enforcer - self.cmd( - "iot hub device-twin update -d {} -n {} -g {} --set tags={}".format( - device_ids[0], LIVE_HUB, LIVE_RG, '"{bad_format}"' - ), - expect_failure=True, - ) - - content_path = os.path.join(CWD, "test_generic_replace.json") - self.cmd( - "iot hub device-twin replace -d {} -n {} -g {} -j '{}'".format( - device_ids[0], LIVE_HUB, LIVE_RG, content_path - ), - checks=[ - self.check("deviceId", device_ids[0]), - self.check("properties.desired.awesome", 9001), - self.check("properties.desired.temperature.min", 10), - self.check("properties.desired.temperature.max", 100), - self.check("tags.location.region", "US"), - ], - ) - - self.kwargs["twin_payload"] = read_file_content(content_path) - self.cmd( - "iot hub device-twin replace -d {} -n {} -g {} -j '{}'".format( - device_ids[1], LIVE_HUB, LIVE_RG, "{twin_payload}" - ), - checks=[ - self.check("deviceId", device_ids[1]), - self.check("properties.desired.awesome", 9001), - self.check("properties.desired.temperature.min", 10), - self.check("properties.desired.temperature.max", 100), - self.check("tags.location.region", "US"), - ], - ) - - # With connection string - self.cmd( - "iot hub device-twin replace -d {} --login {} -j '{}'".format( - device_ids[1], self.connection_string, "{twin_payload}" - ), - checks=[ - self.check("deviceId", device_ids[1]), - self.check("properties.desired.awesome", 9001), - self.check("properties.desired.temperature.min", 10), - self.check("properties.desired.temperature.max", 100), - self.check("tags.location.region", "US"), - ], - ) - - # Region specific test - if self.region not in ["West US 2", "North Europe", "Southeast Asia"]: - warnings.warn(UserWarning("Skipping distributed-tracing tests. IoT Hub not in supported region!")) - else: - self.cmd( - "iot hub distributed-tracing show -d {} -n {} -g {}".format( - device_ids[2], LIVE_HUB, LIVE_RG - ), - checks=self.is_empty(), - ) - - result = self.cmd( - "iot hub distributed-tracing update -d {} -n {} -g {} --sm on --sr 50".format( - device_ids[2], LIVE_HUB, LIVE_RG - ) - ).get_output_in_json() - assert result["deviceId"] == device_ids[2] - assert result["samplingMode"] == "enabled" - assert result["samplingRate"] == "50%" - assert not result["isSynced"] - - -class TestIoTHubModules(IoTLiveScenarioTest): - def __init__(self, test_case): - super(TestIoTHubModules, self).__init__( - test_case, LIVE_HUB, LIVE_RG - ) - - def test_hub_modules(self): - edge_device_count = 2 - device_count = 1 - module_count = 2 - - edge_device_ids = self.generate_device_names(edge_device_count, edge=True) - device_ids = self.generate_device_names(device_count) - module_ids = self.generate_module_names(module_count) - - for edge_device in edge_device_ids: - self.cmd( - "iot hub device-identity create -d {} -n {} -g {} --ee".format( - edge_device, LIVE_HUB, LIVE_RG - ), - checks=[self.check("deviceId", edge_device)], - ) - - self.cmd( - "iot hub device-identity create -d {} -n {} -g {}".format( - device_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[self.check("deviceId", device_ids[0])], - ) - - # Symmetric Key - # With connection string - self.cmd( - "iot hub module-identity create --device-id {} --hub-name {} --resource-group {} --module-id {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, module_ids[1] - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[1]), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) - - self.cmd( - "iot hub module-identity create -d {} --login {} -m {}".format( - edge_device_ids[0], self.connection_string, module_ids[0] - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) - - # Error can't get a sas token for module without device - self.cmd( - "az iot hub generate-sas-token -n {} -g {} -m {}".format( - LIVE_HUB, LIVE_RG, module_ids[1] - ), - expect_failure=True, - ) - - # sas token for module - self.cmd( - "iot hub generate-sas-token -n {} -g {} -d {} -m {}".format( - LIVE_HUB, LIVE_RG, edge_device_ids[0], module_ids[1] - ), - checks=[self.exists("sas")], - ) - - # sas token for module with connection string - self.cmd( - "iot hub generate-sas-token -d {} -m {} --login {}".format( - edge_device_ids[0], module_ids[1], self.connection_string - ), - checks=[self.exists("sas")], - ) - - # sas token for module with mixed case connection string - mixed_case_cstring = self.connection_string.replace("HostName", "hostname", 1) - self.cmd( - "iot hub generate-sas-token -d {} -m {} --login {}".format( - edge_device_ids[0], module_ids[1], mixed_case_cstring - ), - checks=[self.exists("sas")], - ) - - # X509 Thumbprint - # With connection string - self.cmd( - """iot hub module-identity create --module-id {} --device-id {} --login {} - --auth-method x509_thumbprint --primary-thumbprint {} --secondary-thumbprint {}""".format( - module_ids[0], - device_ids[0], - self.connection_string, - PRIMARY_THUMBPRINT, - SECONDARY_THUMBPRINT, - ), - checks=[ - self.check("deviceId", device_ids[0]), - self.check("moduleId", module_ids[0]), - self.check("connectionState", "Disconnected"), - self.check("authentication.symmetricKey.primaryKey", None), - self.check("authentication.symmetricKey.secondaryKey", None), - self.check( - "authentication.x509Thumbprint.primaryThumbprint", - PRIMARY_THUMBPRINT, - ), - self.check( - "authentication.x509Thumbprint.secondaryThumbprint", - SECONDARY_THUMBPRINT, - ), - ], - ) - - self.cmd( - """iot hub module-identity create -m {} -d {} -n {} -g {} --am x509_thumbprint --vd {}""".format( - module_ids[1], device_ids[0], LIVE_HUB, LIVE_RG, 10 - ), - checks=[ - self.check("deviceId", device_ids[0]), - self.check("moduleId", module_ids[1]), - self.check("connectionState", "Disconnected"), - self.check("authentication.symmetricKey.primaryKey", None), - self.check("authentication.symmetricKey.secondaryKey", None), - self.exists("authentication.x509Thumbprint.primaryThumbprint"), - self.check("authentication.x509Thumbprint.secondaryThumbprint", None), - ], - ) - - # X509 CA - # With connection string - self.cmd( - """iot hub module-identity create --module-id {} --device-id {} --login {} --auth-method x509_ca""".format( - module_ids[0], edge_device_ids[1], self.connection_string - ), - checks=[ - self.check("deviceId", edge_device_ids[1]), - self.check("moduleId", module_ids[0]), - self.check("connectionState", "Disconnected"), - self.check("authentication.symmetricKey.primaryKey", None), - self.check("authentication.symmetricKey.secondaryKey", None), - self.check("authentication.x509Thumbprint.primaryThumbprint", None), - self.check("authentication.x509Thumbprint.secondaryThumbprint", None), - ], - ) - - # Includes $edgeAgent && $edgeHub system modules - result = self.cmd( - 'iot hub query --hub-name {} -g {} -q "{}"'.format( - LIVE_HUB, - LIVE_RG, - "select * from devices.modules where devices.deviceId='{}'".format( - edge_device_ids[0] - ), - ) - ).get_output_in_json() - assert len(result) == 4 - - self.cmd( - '''iot hub module-identity update -d {} -n {} -g {} -m {} - --set authentication.symmetricKey.primaryKey="" authentication.symmetricKey.secondaryKey=""'''.format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, module_ids[0] - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) - - # With connection string - self.cmd( - '''iot hub module-identity update -d {} --login {} -m {} - --set authentication.symmetricKey.primaryKey="" authentication.symmetricKey.secondaryKey=""'''.format( - edge_device_ids[0], self.connection_string, module_ids[0] - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) - - self.cmd( - "iot hub module-identity list -d {} -n {} -g {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("length([*])", 4), - self.exists("[?moduleId=='$edgeAgent']"), - self.exists("[?moduleId=='$edgeHub']"), - ], - ) - - self.cmd( - "iot hub module-identity list -d {} -n {} -g {} --top -1".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("length([*])", 3), - self.exists("[?moduleId=='$edgeAgent']"), - self.exists("[?moduleId=='$edgeHub']"), - ], - ) - - # With connection string - self.cmd( - "iot hub module-identity list -d {} --login {}".format( - edge_device_ids[0], self.connection_string - ), - checks=[ - self.check("length([*])", 4), - self.exists("[?moduleId=='$edgeAgent']"), - self.exists("[?moduleId=='$edgeHub']"), - ], - ) - - self.cmd( - "iot hub module-identity show -d {} -n {} -g {} -m {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, module_ids[0] - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) - - # With connection string - self.cmd( - "iot hub module-identity show -d {} --login {} -m {}".format( - edge_device_ids[0], self.connection_string, module_ids[0] - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) +# TODO: assert DEVICE_DEVICESCOPE_PREFIX format in parent device twin. +# from azext_iot.constants import DEVICE_DEVICESCOPE_PREFIX - mod_sym_conn_str_pattern = r"^HostName={}\.azure-devices\.net;DeviceId={};ModuleId={};SharedAccessKey=".format( - LIVE_HUB, edge_device_ids[0], module_ids[0] - ) - self.cmd( - "iot hub module-identity show-connection-string -d {} -n {} -g {} -m {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, module_ids[0] - ), - checks=[self.check_pattern("connectionString", mod_sym_conn_str_pattern)], - ) - - # With connection string - self.cmd( - "iot hub module-identity show-connection-string -d {} --login {} -m {}".format( - edge_device_ids[0], self.connection_string, module_ids[0] - ), - checks=[self.check_pattern("connectionString", mod_sym_conn_str_pattern)], - ) - - self.cmd( - "iot hub module-identity show-connection-string -d {} -n {} -g {} -m {} --kt {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, module_ids[0], "secondary" - ), - checks=[self.check_pattern("connectionString", mod_sym_conn_str_pattern)], - ) - - self.cmd( - "iot hub module-identity connection-string show -d {} -n {} -g {} -m {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, module_ids[0] - ), - checks=[self.check_pattern("connectionString", mod_sym_conn_str_pattern)], - ) - - # With connection string - self.cmd( - "iot hub module-identity connection-string show -d {} --login {} -m {}".format( - edge_device_ids[0], self.connection_string, module_ids[0] - ), - checks=[self.check_pattern("connectionString", mod_sym_conn_str_pattern)], - ) - - self.cmd( - "iot hub module-identity connection-string show -d {} -n {} -g {} -m {} --kt {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, module_ids[0], "secondary" - ), - checks=[self.check_pattern("connectionString", mod_sym_conn_str_pattern)], - ) - - for i in module_ids: - if module_ids.index(i) == (module_count - 1): - # With connection string - self.cmd( - "iot hub module-identity delete -d {} --login {} --module-id {}".format( - edge_device_ids[0], self.connection_string, i - ), - checks=self.is_empty(), - ) - else: - self.cmd( - "iot hub module-identity delete -d {} -n {} -g {} --module-id {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, i - ), - checks=self.is_empty(), - ) - - -class TestIoTHubModuleTwins(IoTLiveScenarioTest): - def __init__(self, test_case): - super(TestIoTHubModuleTwins, self).__init__( - test_case, LIVE_HUB, LIVE_RG - ) - - def test_hub_module_twins(self): - self.kwargs["generic_dict"] = {"key": "value"} - self.kwargs["bad_format"] = "{'key: 'value'}" - self.kwargs["patch_desired"] = {"patchScenario": {"desiredKey": "desiredValue"}} - self.kwargs["patch_tags"] = {"patchScenario": {"tagkey": "tagValue"}} - - edge_device_count = 1 - device_count = 1 - module_count = 1 - - edge_device_ids = self.generate_device_names(edge_device_count, True) - device_ids = self.generate_device_names(device_count) - module_ids = self.generate_module_names(module_count) - - self.cmd( - "iot hub device-identity create -d {} -n {} -g {} --ee".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[self.check("deviceId", edge_device_ids[0])], - ) - - self.cmd( - "iot hub device-identity create -d {} -n {} -g {}".format( - device_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[self.check("deviceId", device_ids[0])], - ) - - self.cmd( - "iot hub module-identity create -d {} -n {} -g {} -m {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, module_ids[0] - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) - - self.cmd( - "iot hub module-identity create -d {} -n {} -g {} -m {}".format( - device_ids[0], LIVE_HUB, LIVE_RG, module_ids[0] - ), - checks=[ - self.check("deviceId", device_ids[0]), - self.check("moduleId", module_ids[0]), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - ], - ) - - self.cmd( - "iot hub module-twin show -d {} -n {} -g {} -m {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, module_ids[0] - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.exists("properties.desired"), - self.exists("properties.reported"), - ], - ) - - # With connection string - self.cmd( - "iot hub module-twin show -d {} --login {} -m {}".format( - edge_device_ids[0], self.connection_string, module_ids[0] - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.exists("properties.desired"), - self.exists("properties.reported"), - ], - ) - - # Patch based twin update of desired props - self.cmd( - "iot hub module-twin update -d {} -n {} -g {} -m {} --desired {}".format( - edge_device_ids[0], - LIVE_HUB, - LIVE_RG, - module_ids[0], - '"{patch_desired}"', - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.check( - "properties.desired.patchScenario", - self.kwargs["patch_desired"]["patchScenario"], - ), - ], - ) - - # Patch based twin update of tags with connection string - self.cmd( - "iot hub module-twin update -d {} --login {} -m {} --tags {}".format( - edge_device_ids[0], self.connection_string, module_ids[0], '"{patch_tags}"' - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.check( - "tags.patchScenario", self.kwargs["patch_tags"]["patchScenario"] - ), - ], - ) - - # Patch based twin update of desired + tags - self.cmd( - "iot hub module-twin update -d {} -n {} -m {} --desired {} --tags {}".format( - device_ids[0], - LIVE_HUB, - module_ids[0], - '"{patch_desired}"', - '"{patch_tags}"', - ), - checks=[ - self.check("deviceId", device_ids[0]), - self.check("moduleId", module_ids[0]), - self.check( - "properties.desired.patchScenario", - self.kwargs["patch_desired"]["patchScenario"], - ), - self.check( - "tags.patchScenario", - self.kwargs["patch_tags"]["patchScenario"] - ), - ], - ) - - # Deprecated twin update style - self.cmd( - "iot hub module-twin update -d {} -n {} -g {} -m {} --set properties.desired.special={}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, module_ids[0], '"{generic_dict}"' - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.check("properties.desired.special.key", "value"), - ], - ) - - # With connection string - self.cmd( - "iot hub module-twin update -d {} --login {} -m {} --set properties.desired.special={}".format( - edge_device_ids[0], self.connection_string, module_ids[0], '"{generic_dict}"' - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.check("properties.desired.special.key", "value"), - ], - ) - - # Error case test type enforcer - self.cmd( - "iot hub module-twin update -d {} --login {} -m {} --set properties.desired={}".format( - edge_device_ids[0], self.connection_string, module_ids[0], '"{bad_format}"' - ), - expect_failure=True, - ) +opt_env_set = ["azext_iot_teststorageuri", "azext_iot_identity_teststorageid"] - self.cmd( - "iot hub module-twin update -d {} --login {} -m {} --set tags={}".format( - edge_device_ids[0], self.connection_string, module_ids[0], '"{bad_format}"' - ), - expect_failure=True, - ) +settings = DynamoSettings( + req_env_set=ENV_SET_TEST_IOTHUB_BASIC, opt_env_set=opt_env_set +) - content_path = os.path.join(CWD, "test_generic_replace.json") - self.cmd( - "iot hub module-twin replace -d {} -n {} -g {} -m {} -j '{}'".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, module_ids[0], content_path - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.check("properties.desired.awesome", 9001), - self.check("properties.desired.temperature.min", 10), - self.check("properties.desired.temperature.max", 100), - self.check("tags.location.region", "US"), - ], - ) +LIVE_HUB = settings.env.azext_iot_testhub +LIVE_RG = settings.env.azext_iot_testrg - # With connection string - self.cmd( - "iot hub module-twin replace -d {} --login {} -m {} -j '{}'".format( - edge_device_ids[0], self.connection_string, module_ids[0], content_path - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.check("properties.desired.awesome", 9001), - self.check("properties.desired.temperature.min", 10), - self.check("properties.desired.temperature.max", 100), - self.check("tags.location.region", "US"), - ], - ) +# Set this environment variable to your empty blob container sas uri to test device export and enable file upload test. +# For file upload, you will need to have configured your IoT Hub before running. +LIVE_STORAGE = settings.env.azext_iot_teststorageuri - self.kwargs["twin_payload"] = read_file_content(content_path) - self.cmd( - "iot hub module-twin replace -d {} -n {} -g {} -m {} -j '{}'".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, module_ids[0], "{twin_payload}" - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.check("moduleId", module_ids[0]), - self.check("properties.desired.awesome", 9001), - self.check("properties.desired.temperature.min", 10), - self.check("properties.desired.temperature.max", 100), - self.check("tags.location.region", "US"), - ], - ) +# Set this environment variable to enable identity-based integration tests +# You will need permissions to add and remove role assignments for this storage account +LIVE_STORAGE_ID = settings.env.azext_iot_identity_teststorageid - for i in module_ids: - self.cmd( - "iot hub module-identity delete -d {} -n {} -g {} --module-id {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG, i - ), - checks=self.is_empty(), - ) +CWD = os.path.dirname(os.path.abspath(__file__)) class TestIoTStorage(IoTLiveScenarioTest): @@ -1525,233 +152,3 @@ def test_identity_storage(self): LIVE_HUB, identity_type_disable ) ) - - -class TestIoTEdgeOffline(IoTLiveScenarioTest): - def __init__(self, test_case): - super(TestIoTEdgeOffline, self).__init__( - test_case, LIVE_HUB, LIVE_RG - ) - - def test_edge_offline(self): - device_count = 3 - edge_device_count = 2 - - device_ids = self.generate_device_names(device_count) - edge_device_ids = self.generate_device_names(edge_device_count, True) - - for edge_device in edge_device_ids: - self.cmd( - "iot hub device-identity create -d {} -n {} -g {} --ee".format( - edge_device, LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("deviceId", edge_device), - self.check("status", "enabled"), - self.check("statusReason", None), - self.check("connectionState", "Disconnected"), - self.check("capabilities.iotEdge", True), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - self.exists("deviceScope"), - ], - ) - - for device in device_ids: - self.cmd( - "iot hub device-identity create -d {} -n {} -g {}".format( - device, LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("deviceId", device), - self.check("status", "enabled"), - self.check("statusReason", None), - self.check("connectionState", "Disconnected"), - self.check("capabilities.iotEdge", False), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - self.check("deviceScope", None), - ], - ) - - # get-parent of edge device - self.cmd( - "iot hub device-identity get-parent -d {} -n {} -g {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # get-parent of device which doesn't have any parent set - self.cmd( - "iot hub device-identity get-parent -d {} -n {} -g {}".format( - device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # setting non-edge device as a parent of non-edge device - self.cmd( - "iot hub device-identity set-parent -d {} --pd {} -n {} -g {}".format( - device_ids[0], device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # setting edge device as a parent of edge device - self.cmd( - "iot hub device-identity set-parent -d {} --pd {} -n {} -g {}".format( - edge_device_ids[0], edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - checks=self.is_empty(), - ) - - # add device as a child of non-edge device - self.cmd( - "iot hub device-identity add-children -d {} --child-list {} -n {} -g {}".format( - device_ids[0], device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # add device list as children of edge device - self.cmd( - "iot hub device-identity add-children -d {} --child-list '{}' -n {} -g {}".format( - edge_device_ids[0], ", ".join(device_ids), LIVE_HUB, LIVE_RG - ), - checks=self.is_empty(), - ) - - # setting edge device as a parent of non-edge device which already having different parent device - self.cmd( - "iot hub device-identity set-parent -d {} --pd {} -n {} -g {}".format( - device_ids[2], edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # setting edge device as a parent of non-edge device which already having different parent device by force - self.cmd( - "iot hub device-identity set-parent -d {} --pd {} -n {} -g {} --force".format( - device_ids[2], edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - checks=self.is_empty(), - ) - - # get-parent of device - self.cmd( - "iot hub device-identity get-parent -d {} -n {} -g {}".format( - device_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.exists("deviceScope"), - ], - ) - - # add same device as a child of same parent device - self.cmd( - "iot hub device-identity add-children -d {} --child-list {} -n {} -g {}".format( - edge_device_ids[0], device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # add same device as a child of another edge device - self.cmd( - "iot hub device-identity add-children -d {} --child-list {} -n {} -g {}".format( - edge_device_ids[1], device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # add same device as a child of another edge device by force - self.cmd( - "iot hub device-identity add-children -d {} --child-list {} -n {} -g {} --force".format( - edge_device_ids[1], device_ids[0], LIVE_HUB, LIVE_RG - ), - checks=self.is_empty(), - ) - - # list child devices of edge device - output = self.cmd( - "iot hub device-identity list-children -d {} -n {} -g {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=False, - ) - - expected_output = "{}".format(device_ids[1]) - assert output.get_output_in_json() == expected_output - - # removing all child devices of non-edge device - self.cmd( - "iot hub device-identity remove-children -d {} -n {} -g {} --remove-all".format( - device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # remove all child devices from edge device - self.cmd( - "iot hub device-identity remove-children -d {} -n {} -g {} --remove-all".format( - edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - checks=self.is_empty(), - ) - - # removing all child devices of edge device which doesn't have any child devices - self.cmd( - "iot hub device-identity remove-children -d {} -n {} -g {} --remove-all".format( - edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # removing child devices of edge device neither passing child devices list nor remove-all parameter - self.cmd( - "iot hub device-identity remove-children -d {} -n {} -g {}".format( - edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # remove edge device from edge device - self.cmd( - "iot hub device-identity remove-children -d {} --child-list {} -n {} -g {}".format( - edge_device_ids[1], edge_device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # remove device from edge device but device is a child of another edge device - self.cmd( - "iot hub device-identity remove-children -d {} --child-list {} -n {} -g {}".format( - edge_device_ids[1], device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # remove device - self.cmd( - "iot hub device-identity remove-children -d {} --child-list {} -n {} -g {}".format( - edge_device_ids[0], device_ids[1], LIVE_HUB, LIVE_RG - ), - checks=self.is_empty(), - ) - - # remove device which doesn't have any parent set - self.cmd( - "iot hub device-identity remove-children -d {} --child-list {} -n {} -g {}".format( - edge_device_ids[0], device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # list child devices of edge device which doesn't have any children - self.cmd( - "iot hub device-identity list-children -d {} -n {} -g {}".format( - edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) diff --git a/azext_iot/tests/iothub/test_iot_ext_unit.py b/azext_iot/tests/iothub/test_iot_ext_unit.py index 51fb2ada9..3ae27c1cd 100644 --- a/azext_iot/tests/iothub/test_iot_ext_unit.py +++ b/azext_iot/tests/iothub/test_iot_ext_unit.py @@ -61,9 +61,6 @@ def generate_device_create_req( status_reason=None, valid_days=None, output_dir=None, - set_parent_id=None, - add_children=None, - force=False, ): return { "client": None, @@ -76,10 +73,7 @@ def generate_device_create_req( "status": status, "status_reason": status_reason, "valid_days": valid_days, - "output_dir": output_dir, - "set_parent_id": set_parent_id, - "add_children": add_children, - "force": force, + "output_dir": output_dir } @@ -151,168 +145,6 @@ def test_device_create(self, serviceclient, req): else: assert x509tp["secondaryThumbprint"] == req["stp"] - @pytest.fixture - def sc_device_create_setparent(self, mocker, fixture_ghcs, fixture_sas, request): - service_client = mocker.patch(path_service_client) - test_side_effect = [ - build_mock_response(mocker, 200, generate_parent_device()), - build_mock_response(mocker, 200, {}), - ] - service_client.side_effect = test_side_effect - return service_client - - @pytest.mark.parametrize( - "req", [(generate_device_create_req(set_parent_id=device_id))] - ) - def test_device_create_setparent(self, sc_device_create_setparent, req): - subject.iot_device_create( - fixture_cmd, - child_device_id, - req["hub_name"], - req["ee"], - req["auth"], - req["ptp"], - req["stp"], - req["status"], - req["status_reason"], - req["valid_days"], - req["output_dir"], - req["set_parent_id"], - ) - - args = sc_device_create_setparent.call_args - url = args[0][0].url - body = json.loads(args[0][0].body) - - assert "{}/devices/{}?".format(mock_target["entity"], child_device_id) in url - assert args[0][0].method == "PUT" - - assert body["deviceId"] == child_device_id - assert body["deviceScope"] == generate_parent_device().get("deviceScope") - - @pytest.fixture(params=[(200, 0)]) - def sc_invalid_args_device_create_setparent( - self, mocker, fixture_ghcs, fixture_sas, request - ): - service_client = mocker.patch(path_service_client) - parent_kvp = {} - if request.param[1] == 0: - parent_kvp.setdefault("capabilities", {"iotEdge": False}) - test_side_effect = [ - build_mock_response( - mocker, request.param[0], generate_parent_device(**parent_kvp) - ) - ] - service_client.side_effect = test_side_effect - return service_client - - @pytest.mark.parametrize("req, exp", [(generate_device_create_req(), CLIError)]) - def test_device_create_setparent_invalid_args( - self, sc_invalid_args_device_create_setparent, req, exp - ): - with pytest.raises(exp): - subject.iot_device_create( - fixture_cmd, - child_device_id, - req["hub_name"], - req["ee"], - req["auth"], - req["ptp"], - req["stp"], - req["status"], - req["status_reason"], - req["valid_days"], - req["output_dir"], - device_id, - ) - - @pytest.fixture(params=[(200, 0), (200, 1), (200, 1)]) - def sc_device_create_addchildren(self, mocker, fixture_ghcs, fixture_sas, request): - service_client = mocker.patch(path_service_client) - child_kvp = {} - if request.param[1] == 1: - child_kvp.setdefault("parentScopes", ["abcd"]) - if request.param[1] == 1: - child_kvp.setdefault("capabilities", {"iotEdge": True}) - test_side_effect = [ - build_mock_response( - mocker, request.param[0], generate_child_device(**child_kvp) - ), - build_mock_response(mocker, request.param[0], generate_parent_device()), - build_mock_response( - mocker, request.param[0], generate_child_device(**child_kvp) - ), - build_mock_response(mocker, request.param[0], {}), - ] - service_client.side_effect = test_side_effect - return service_client - - @pytest.mark.parametrize("req", [(generate_device_create_req())]) - def test_device_create_addchildren(self, sc_device_create_addchildren, req): - subject.iot_device_create( - fixture_cmd, - req["device_id"], - req["hub_name"], - True, - req["auth"], - req["ptp"], - req["stp"], - req["status"], - req["status_reason"], - req["valid_days"], - req["output_dir"], - None, - child_device_id, - True, - ) - - args = sc_device_create_addchildren.call_args - url = args[0][0].url - body = json.loads(args[0][0].body) - assert "{}/devices/{}?".format(mock_target["entity"], child_device_id) in url - assert args[0][0].method == "PUT" - assert body["deviceId"] == child_device_id - assert body["deviceScope"] == generate_parent_device().get( - "deviceScope" - ) or body["parentScopes"] == [generate_parent_device().get("deviceScope")] - - @pytest.fixture(params=[(200, 0)]) - def sc_invalid_args_device_create_addchildren( - self, mocker, fixture_ghcs, fixture_sas, request - ): - service_client = mocker.patch(path_service_client) - child_kvp = {} - child_kvp.setdefault("parentScopes", ["abcd"]) - test_side_effect = [ - build_mock_response( - mocker, request.param[0], generate_child_device(**child_kvp) - ) - ] - service_client.side_effect = test_side_effect - return service_client - - @pytest.mark.parametrize("req, exp", [(generate_device_create_req(), CLIError)]) - def test_device_create_addchildren_invalid_args( - self, sc_invalid_args_device_create_addchildren, req, exp - ): - with pytest.raises(exp): - subject.iot_device_create( - fixture_cmd, - req["device_id"], - req["hub_name"], - True, - req["auth"], - req["ptp"], - req["stp"], - req["status"], - req["status_reason"], - req["valid_days"], - req["output_dir"], - None, - child_device_id, - False, - ) - @pytest.mark.parametrize( "req, exp", [ @@ -2578,7 +2410,7 @@ def sc_addchildren(self, mocker, fixture_ghcs, fixture_sas, request): def test_device_children_add(self, sc_addchildren): subject.iot_device_children_add( - None, device_id, child_device_id, True, mock_target["entity"] + None, device_id, [child_device_id], True, mock_target["entity"] ) args = sc_addchildren.call_args url = args[0][0].url @@ -2614,7 +2446,7 @@ def sc_invalid_args_addchildren(self, mocker, fixture_ghcs, fixture_sas, request def test_device_addchildren_invalid_args(self, sc_invalid_args_addchildren, exp): with pytest.raises(exp): subject.iot_device_children_add( - fixture_cmd, device_id, child_device_id, False, mock_target["entity"] + fixture_cmd, device_id, [child_device_id], False, mock_target["entity"] ) @pytest.fixture(params=[(200, 400), (200, 401), (200, 500)]) @@ -2631,7 +2463,7 @@ def sc_addchildren_error(self, mocker, fixture_ghcs, fixture_sas, request): def test_device_addchildren_error(self, sc_addchildren_error): with pytest.raises(CLIError): subject.iot_device_children_add( - fixture_cmd, device_id, child_device_id, True, mock_target["entity"] + fixture_cmd, device_id, [child_device_id], True, mock_target["entity"] ) @pytest.fixture(params=[(200, 200)]) @@ -2652,7 +2484,7 @@ def sc_invalid_etag_addchildren(self, mocker, fixture_ghcs, fixture_sas, request def test_device_addchildren_invalid_etag(self, sc_invalid_etag_setparent, exp): with pytest.raises(exp): subject.iot_device_children_add( - fixture_cmd, device_id, child_device_id, True, mock_target["entity"] + fixture_cmd, device_id, [child_device_id], True, mock_target["entity"] ) # list-children @@ -2725,7 +2557,7 @@ def sc_removechildrenlist(self, mocker, fixture_ghcs, fixture_sas, request): def test_device_children_remove_list(self, sc_removechildrenlist): subject.iot_device_children_remove( - fixture_cmd, device_id, child_device_id, False, mock_target["entity"] + fixture_cmd, device_id, [child_device_id], False, mock_target["entity"] ) args = sc_removechildrenlist.call_args url = args[0][0].url @@ -2764,7 +2596,7 @@ def test_device_removechildrenlist_invalid_args( ): with pytest.raises(exp): subject.iot_device_children_remove( - fixture_cmd, device_id, child_device_id, False, mock_target["entity"] + fixture_cmd, device_id, [child_device_id], False, mock_target["entity"] ) @pytest.fixture(params=[(200, 200)]) @@ -2793,7 +2625,7 @@ def test_device_removechildrenlist_invalid_etag( ): with pytest.raises(exp): subject.iot_device_children_remove( - fixture_cmd, device_id, child_device_id, False, mock_target["entity"] + fixture_cmd, device_id, [child_device_id], False, mock_target["entity"] ) @pytest.fixture(params=[(200, 400), (200, 401), (200, 500)]) diff --git a/azext_iot/tests/iothub/test_iot_messaging_int.py b/azext_iot/tests/iothub/test_iot_messaging_int.py index 6b767631d..4b38be1ad 100644 --- a/azext_iot/tests/iothub/test_iot_messaging_int.py +++ b/azext_iot/tests/iothub/test_iot_messaging_int.py @@ -31,10 +31,6 @@ def __init__(self, test_case): test_case, LIVE_HUB, LIVE_RG ) - @pytest.mark.skipif( - not validate_min_python_version(3, 4, exit_on_fail=False), - reason="minimum python version not satisfied", - ) def test_uamqp_device_messaging(self): device_count = 1 device_ids = self.generate_device_names(device_count) diff --git a/azext_iot/tests/iothub/test_iothub_nested_edge_int.py b/azext_iot/tests/iothub/test_iothub_nested_edge_int.py deleted file mode 100644 index dee8cb20e..000000000 --- a/azext_iot/tests/iothub/test_iothub_nested_edge_int.py +++ /dev/null @@ -1,243 +0,0 @@ -# coding=utf-8 -# -------------------------------------------------------------------------------------------- -# Copyright (c) Microsoft Corporation. All rights reserved. -# Licensed under the MIT License. See License.txt in the project root for license information. -# -------------------------------------------------------------------------------------------- - -from azext_iot.tests import IoTLiveScenarioTest -from ..settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC - -settings = DynamoSettings(ENV_SET_TEST_IOTHUB_BASIC) -LIVE_HUB = settings.env.azext_iot_testhub -LIVE_RG = settings.env.azext_iot_testrg - - -class TestIoTNestedEdge(IoTLiveScenarioTest): - def __init__(self, test_case): - super(TestIoTNestedEdge, self).__init__( - test_case, LIVE_HUB, LIVE_RG - ) - - def test_nested_edge(self): - device_count = 3 - edge_device_count = 2 - - device_ids = self.generate_device_names(device_count) - edge_device_ids = self.generate_device_names(edge_device_count, True) - - for edge_device in edge_device_ids: - self.cmd( - "iot hub device-identity create -d {} -n {} -g {} --ee".format( - edge_device, LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("deviceId", edge_device), - self.check("status", "enabled"), - self.check("statusReason", None), - self.check("connectionState", "Disconnected"), - self.check("capabilities.iotEdge", True), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - self.exists("deviceScope"), - ], - ) - - for device in device_ids: - self.cmd( - "iot hub device-identity create -d {} -n {} -g {}".format( - device, LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("deviceId", device), - self.check("status", "enabled"), - self.check("statusReason", None), - self.check("connectionState", "Disconnected"), - self.check("capabilities.iotEdge", False), - self.exists("authentication.symmetricKey.primaryKey"), - self.exists("authentication.symmetricKey.secondaryKey"), - self.check("deviceScope", None), - ], - ) - - # get parent of edge device - self.cmd( - "iot hub device-identity parent show -d {} -n {} -g {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # get parent of device which doesn't have any parent set - self.cmd( - "iot hub device-identity parent show -d {} -n {} -g {}".format( - device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # setting non-edge device as a parent of non-edge device - self.cmd( - "iot hub device-identity parent set -d {} --pd {} -n {} -g {}".format( - device_ids[0], device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # setting edge device as a parent of edge device - self.cmd( - "iot hub device-identity parent set -d {} --pd {} -n {} -g {}".format( - edge_device_ids[0], edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - checks=self.is_empty(), - ) - - # add device as a child of non-edge device - self.cmd( - "iot hub device-identity children add -d {} --child-list {} -n {} -g {}".format( - device_ids[0], device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # add device list as children of edge device - self.cmd( - "iot hub device-identity children add -d {} --child-list {} -n {} -g {}".format( - edge_device_ids[0], " ".join(device_ids), LIVE_HUB, LIVE_RG - ), - checks=self.is_empty(), - ) - - # setting edge device as a parent of non-edge device which already having different parent device - self.cmd( - "iot hub device-identity parent set -d {} --pd {} -n {} -g {}".format( - device_ids[2], edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # setting edge device as a parent of non-edge device which already having different parent device by force - self.cmd( - "iot hub device-identity parent set -d {} --pd {} -n {} -g {} --force".format( - device_ids[2], edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - checks=self.is_empty(), - ) - - # get parent of device - self.cmd( - "iot hub device-identity parent show -d {} -n {} -g {}".format( - device_ids[0], LIVE_HUB, LIVE_RG - ), - checks=[ - self.check("deviceId", edge_device_ids[0]), - self.exists("deviceScope"), - ], - ) - - # add same device as a child of same parent device - self.cmd( - "iot hub device-identity children add -d {} --child-list {} -n {} -g {}".format( - edge_device_ids[0], device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # add same device as a child of another edge device - self.cmd( - "iot hub device-identity children add -d {} --child-list {} -n {} -g {}".format( - edge_device_ids[1], device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # add same device as a child of another edge device by force - self.cmd( - "iot hub device-identity children add -d {} --child-list {} -n {} -g {} --force".format( - edge_device_ids[1], device_ids[0], LIVE_HUB, LIVE_RG - ), - checks=self.is_empty(), - ) - - # list child devices of edge device - output = self.cmd( - "iot hub device-identity children list -d {} -n {} -g {}".format( - edge_device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=False, - ) - - assert output.get_output_in_json() == [device_ids[1]] - - # removing all child devices of non-edge device - self.cmd( - "iot hub device-identity children remove -d {} -n {} -g {} --remove-all".format( - device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # remove all child devices from edge device - self.cmd( - "iot hub device-identity children remove -d {} -n {} -g {} --remove-all".format( - edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - checks=self.is_empty(), - ) - - # removing all child devices of edge device which doesn't have any child devices - self.cmd( - "iot hub device-identity children remove -d {} -n {} -g {} --remove-all".format( - edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # removing child devices of edge device neither passing child devices list nor remove-all parameter - self.cmd( - "iot hub device-identity children remove -d {} -n {} -g {}".format( - edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # remove edge device from edge device - self.cmd( - "iot hub device-identity children remove -d {} --child-list {} -n {} -g {}".format( - edge_device_ids[1], edge_device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # remove device from edge device but device is a child of another edge device - self.cmd( - "iot hub device-identity children remove -d {} --child-list {} -n {} -g {}".format( - edge_device_ids[1], device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # remove device - self.cmd( - "iot hub device-identity children remove -d {} --child-list {} -n {} -g {}".format( - edge_device_ids[0], device_ids[1], LIVE_HUB, LIVE_RG - ), - checks=self.is_empty(), - ) - - # remove device which doesn't have any parent set - self.cmd( - "iot hub device-identity children remove -d {} --child-list {} -n {} -g {}".format( - edge_device_ids[0], device_ids[0], LIVE_HUB, LIVE_RG - ), - expect_failure=True, - ) - - # list child devices of edge device which doesn't have any children - output = self.cmd( - "iot hub device-identity children list -d {} -n {} -g {}".format( - edge_device_ids[1], LIVE_HUB, LIVE_RG - ), - expect_failure=False, - ) - - assert output.get_output_in_json() == [] diff --git a/azext_iot/tests/iothub/test_iothub_utilities_int.py b/azext_iot/tests/iothub/test_iothub_utilities_int.py new file mode 100644 index 000000000..992bd0c60 --- /dev/null +++ b/azext_iot/tests/iothub/test_iothub_utilities_int.py @@ -0,0 +1,153 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azext_iot.tests import IoTLiveScenarioTest +from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC +from azext_iot.tests.iothub import DATAPLANE_AUTH_TYPES + +settings = DynamoSettings(req_env_set=ENV_SET_TEST_IOTHUB_BASIC) + +LIVE_HUB = settings.env.azext_iot_testhub +LIVE_RG = settings.env.azext_iot_testrg + + +class TestIoTHubUtilities(IoTLiveScenarioTest): + def __init__(self, test_case): + super(TestIoTHubUtilities, self).__init__(test_case, LIVE_HUB, LIVE_RG) + + def test_iothub_generate_sas_token(self): + for auth_phase in DATAPLANE_AUTH_TYPES: + self.cmd( + self.set_cmd_auth_type( + f"iot hub generate-sas-token -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + checks=[self.exists("sas")], + ) + + # Custom duration + self.cmd( + self.set_cmd_auth_type( + f"iot hub generate-sas-token -n {LIVE_HUB} --du 1000", + auth_type=auth_phase, + ), + checks=[self.exists("sas")], + ) + + if auth_phase != "cstring": + # Custom policy + self.cmd( + self.set_cmd_auth_type( + f"iot hub generate-sas-token -n {LIVE_HUB} -g {LIVE_RG} --pn service", + auth_type=auth_phase, + ), + checks=[self.exists("sas")], + ) + + # Error - non-existent custom policy + self.cmd( + self.set_cmd_auth_type( + f"iot hub generate-sas-token --pn somepolicy -n {LIVE_HUB} -g {LIVE_RG}", + auth_type=auth_phase, + ), + expect_failure=True, + ) + + # Error - Unable to change key type when using cstring + self.cmd( + f"iot hub generate-sas-token --login {self.connection_string} --kt secondary", + expect_failure=True, + ) + + def test_iothub_connection_string_show(self): + conn_str_pattern = r"^HostName={0}.azure-devices.net;SharedAccessKeyName=iothubowner;SharedAccessKey=".format( + LIVE_HUB + ) + conn_str_eventhub_pattern = ( + r"^Endpoint=sb://(.+?)servicebus.windows.net/;SharedAccessKeyName=" + r"iothubowner;SharedAccessKey=(.+?);EntityPath=" + ) + + default_policy = "iothubowner" + nonexistent_policy = "badpolicy" + + hubs_in_sub = self.cmd("iot hub connection-string show").get_output_in_json() + + hubs_in_rg = self.cmd(f"iot hub connection-string show -g {LIVE_RG}").get_output_in_json() + assert len(hubs_in_sub) >= len(hubs_in_rg) + + self.cmd( + f"iot hub connection-string show -n {LIVE_HUB}", + checks=[self.check_pattern("connectionString", conn_str_pattern)], + ) + + self.cmd( + f"iot hub connection-string show -n {LIVE_HUB} --pn {default_policy}", + checks=[self.check_pattern("connectionString", conn_str_pattern)], + ) + + self.cmd( + f"iot hub connection-string show -n {LIVE_HUB} -g {LIVE_RG} --pn {nonexistent_policy}", + expect_failure=True, + ) + + self.cmd( + f"iot hub connection-string show --pn {nonexistent_policy}", + checks=[self.check("length(@)", 0)], + ) + + self.cmd( + f"iot hub connection-string show -n {LIVE_HUB} --eh", + checks=[self.check_pattern("connectionString", conn_str_eventhub_pattern)], + ) + + self.cmd( + f"iot hub connection-string show -n {LIVE_HUB} -g {LIVE_RG}", + checks=[ + self.check("length(@)", 1), + self.check_pattern("connectionString", conn_str_pattern), + ], + ) + + self.cmd( + f"iot hub connection-string show -n {LIVE_HUB} -g {LIVE_RG} --all", + checks=[ + self.greater_than("length(connectionString[*])", 0), + self.check_pattern("connectionString[0]", conn_str_pattern), + ], + ) + + self.cmd( + f"iot hub connection-string show -n {LIVE_HUB} -g {LIVE_RG} --all --eh", + checks=[ + self.greater_than("length(connectionString[*])", 0), + self.check_pattern( + "connectionString[0]", conn_str_eventhub_pattern + ), + ], + ) + + def test_iothub_init(self): + for auth_phase in DATAPLANE_AUTH_TYPES: + self.cmd( + self.set_cmd_auth_type( + f'iot hub query --hub-name {LIVE_HUB} -q "select * from devices"', + auth_type=auth_phase, + ), + checks=[self.check("length([*])", 0)], + ) + + # Test mode 2 handler + self.cmd( + 'iot hub query -q "select * from devices"', + expect_failure=True, + ) + + # Error - invalid cstring + self.cmd( + 'iot hub query -q "select * from devices" -l "Hostname=badlogin;key=1235"', + expect_failure=True, + ) diff --git a/dev_requirements b/dev_requirements index 25ac53c05..39eb2e502 100644 --- a/dev_requirements +++ b/dev_requirements @@ -3,13 +3,11 @@ pytest-mock pytest-cov pytest-env uamqp~=1.2 -mock;python_version<'3.3' responses urllib3[secure]>=1.21.1,<=1.25 -black;python_version>='3.6' +black wheel==0.30.0 pre-commit -six>=1.12 pylint flake8 azure-iot-device diff --git a/linter_exclusions.yml b/linter_exclusions.yml index 83b77abf8..684bd71b9 100644 --- a/linter_exclusions.yml +++ b/linter_exclusions.yml @@ -13,3 +13,38 @@ dt endpoint create eventhub: eventhub_resource_group: rule_exclusions: - parameter_should_not_end_in_resource_group +iot hub configuration update: + parameters: + auth_type_dataplane: + rule_exclusions: + - no_parameter_defaults_for_update_commands +iot hub device-identity update: + parameters: + auth_type_dataplane: + rule_exclusions: + - no_parameter_defaults_for_update_commands +iot hub device-twin update: + parameters: + auth_type_dataplane: + rule_exclusions: + - no_parameter_defaults_for_update_commands +iot hub distributed-tracing update: + parameters: + auth_type_dataplane: + rule_exclusions: + - no_parameter_defaults_for_update_commands +iot hub module-identity update: + parameters: + auth_type_dataplane: + rule_exclusions: + - no_parameter_defaults_for_update_commands +iot hub module-twin update: + parameters: + auth_type_dataplane: + rule_exclusions: + - no_parameter_defaults_for_update_commands +iot edge deployment update: + parameters: + auth_type_dataplane: + rule_exclusions: + - no_parameter_defaults_for_update_commands From 624d849e1a44a0039f8e8d33f22a11e4baa30411 Mon Sep 17 00:00:00 2001 From: valluriraj Date: Mon, 17 May 2021 16:52:16 -0700 Subject: [PATCH 02/42] Iotc command versioning (#340) * add support for v1 and preview routes * update history file * address review comments * lint fixes --- HISTORY.rst | 4 + azext_iot/central/_help.py | 2 +- azext_iot/central/commands_api_token.py | 55 +- azext_iot/central/commands_device.py | 105 ++-- azext_iot/central/commands_device_template.py | 63 ++- azext_iot/central/commands_user.py | 50 +- azext_iot/central/models/__init__.py | 14 + .../models/{device.py => devicePreview.py} | 5 +- azext_iot/central/models/devicev1.py | 42 ++ azext_iot/central/models/enum.py | 23 +- .../{template.py => templatepreview.py} | 11 +- azext_iot/central/models/templatev1.py | 115 +++++ azext_iot/central/params.py | 24 +- azext_iot/central/providers/__init__.py | 10 - .../central/providers/devicetwin_provider.py | 8 +- .../central/providers/monitor_provider.py | 14 +- .../central/providers/preview/__init__.py | 26 + .../preview/api_token_provider_preview.py | 81 +++ .../preview/device_provider_preview.py | 182 +++++++ .../device_template_provider_preview.py | 124 +++++ .../preview/user_provider_preview.py | 109 ++++ azext_iot/central/providers/v1/__init__.py | 22 + .../api_token_provider_v1.py} | 22 +- .../device_provider_v1.py} | 34 +- .../device_template_provider_v1.py} | 25 +- .../user_provider_v1.py} | 25 +- azext_iot/central/services/api_token.py | 57 +- azext_iot/central/services/device.py | 190 +++++-- azext_iot/central/services/device_template.py | 62 ++- azext_iot/central/services/user.py | 80 ++- azext_iot/monitor/handlers/central_handler.py | 10 +- azext_iot/monitor/parsers/central_parser.py | 20 +- azext_iot/monitor/property.py | 15 +- .../central/json/deeply_nested_template.json | Bin 35148 -> 27710 bytes .../tests/central/json/device_template.json | 463 +++++++---------- .../json/device_template_int_test.json | 487 +++++++----------- .../json/property_validation_template.json | 214 ++++---- .../tests/central/test_iot_central_int.py | 95 ++-- .../tests/central/test_iot_central_unit.py | 20 +- .../test_iot_central_validator_unit.py | 30 +- .../utility/test_monitor_parsers_unit.py | 32 +- 41 files changed, 1930 insertions(+), 1040 deletions(-) rename azext_iot/central/models/{device.py => devicePreview.py} (93%) create mode 100644 azext_iot/central/models/devicev1.py rename azext_iot/central/models/{template.py => templatepreview.py} (93%) create mode 100644 azext_iot/central/models/templatev1.py create mode 100644 azext_iot/central/providers/preview/__init__.py create mode 100644 azext_iot/central/providers/preview/api_token_provider_preview.py create mode 100644 azext_iot/central/providers/preview/device_provider_preview.py create mode 100644 azext_iot/central/providers/preview/device_template_provider_preview.py create mode 100644 azext_iot/central/providers/preview/user_provider_preview.py create mode 100644 azext_iot/central/providers/v1/__init__.py rename azext_iot/central/providers/{api_token_provider.py => v1/api_token_provider_v1.py} (80%) rename azext_iot/central/providers/{device_provider.py => v1/device_provider_v1.py} (92%) rename azext_iot/central/providers/{device_template_provider.py => v1/device_template_provider_v1.py} (82%) rename azext_iot/central/providers/{user_provider.py => v1/user_provider_v1.py} (84%) diff --git a/HISTORY.rst b/HISTORY.rst index 967fbc110..1b218b2d8 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -6,6 +6,10 @@ Release History 0.10.12 +++++++++++++++ +**IoT Central updates** + +* Public API GA update - add support for preview and 1.0 routes +* Addition of the optional '--av' argument to specify the version of API for the requested operation. **IoT Hub updates** * Removed deprecated edge offline commands and artifacts. diff --git a/azext_iot/central/_help.py b/azext_iot/central/_help.py index bdde96408..35c6d18e1 100644 --- a/azext_iot/central/_help.py +++ b/azext_iot/central/_help.py @@ -67,7 +67,7 @@ def _load_central_devices_help(): az iot central device create --app-id {appid} --device-id {deviceid} - --instance-of {devicetemplateid} + --template {devicetemplateid} --simulated """ diff --git a/azext_iot/central/commands_api_token.py b/azext_iot/central/commands_api_token.py index e8111f1a6..6a7f3cd30 100644 --- a/azext_iot/central/commands_api_token.py +++ b/azext_iot/central/commands_api_token.py @@ -7,8 +7,9 @@ from azext_iot.constants import CENTRAL_ENDPOINT -from azext_iot.central.providers import CentralApiTokenProvider -from azext_iot.central.models.enum import Role +from azext_iot.central.providers.preview import CentralApiTokenProviderPreview +from azext_iot.central.providers.v1 import CentralApiTokenProviderV1 +from azext_iot.central.models.enum import Role, ApiVersion def add_api_token( @@ -18,36 +19,66 @@ def add_api_token( role: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralApiTokenProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralApiTokenProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralApiTokenProviderV1(cmd=cmd, app_id=app_id, token=token) + return provider.add_api_token( token_id=token_id, role=Role[role], central_dns_suffix=central_dns_suffix, ) def list_api_tokens( - cmd, app_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + cmd, + app_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralApiTokenProvider(cmd=cmd, app_id=app_id, token=token) - return provider.get_api_token_list(central_dns_suffix=central_dns_suffix,) + if api_version == ApiVersion.preview.value: + provider = CentralApiTokenProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralApiTokenProviderV1(cmd=cmd, app_id=app_id, token=token) + + return provider.get_api_token_list(central_dns_suffix=central_dns_suffix) def get_api_token( - cmd, app_id: str, token_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + cmd, + app_id: str, + token_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralApiTokenProvider(cmd=cmd, app_id=app_id, token=token) + + if api_version == ApiVersion.preview.value: + provider = CentralApiTokenProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralApiTokenProviderV1(cmd=cmd, app_id=app_id, token=token) return provider.get_api_token( - token_id=token_id, central_dns_suffix=central_dns_suffix + token_id=token_id, central_dns_suffix=central_dns_suffix, ) def delete_api_token( - cmd, app_id: str, token_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + cmd, + app_id: str, + token_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralApiTokenProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralApiTokenProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralApiTokenProviderV1(cmd=cmd, app_id=app_id, token=token) return provider.delete_api_token( - token_id=token_id, central_dns_suffix=central_dns_suffix + token_id=token_id, central_dns_suffix=central_dns_suffix, ) diff --git a/azext_iot/central/commands_device.py b/azext_iot/central/commands_device.py index 38b80e8d8..df254e2a6 100644 --- a/azext_iot/central/commands_device.py +++ b/azext_iot/central/commands_device.py @@ -9,18 +9,39 @@ from azext_iot.common import utility from azext_iot.constants import CENTRAL_ENDPOINT -from azext_iot.central.providers import CentralDeviceProvider +from azext_iot.central.providers.preview import CentralDeviceProviderPreview +from azext_iot.central.providers.v1 import CentralDeviceProviderV1 +from azext_iot.central.models.enum import ApiVersion -def list_devices(cmd, app_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT): - provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token) +def list_devices( + cmd, + app_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +): + if api_version == ApiVersion.preview.value: + provider = CentralDeviceProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralDeviceProviderV1(cmd=cmd, app_id=app_id, token=token) + return provider.list_devices(central_dns_suffix=central_dns_suffix) def get_device( - cmd, app_id: str, device_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + cmd, + app_id: str, + device_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralDeviceProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralDeviceProviderV1(cmd=cmd, app_id=app_id, token=token) + return provider.get_device(device_id, central_dns_suffix=central_dns_suffix) @@ -29,38 +50,51 @@ def create_device( app_id: str, device_id: str, device_name=None, - instance_of=None, + template=None, simulated=False, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - if simulated and not instance_of: + if simulated and not template: raise CLIError( - "Error: if you supply --simulated you must also specify --instance-of" + "Error: if you supply --simulated you must also specify --template" ) - provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token) + + if api_version == ApiVersion.preview.value: + provider = CentralDeviceProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralDeviceProviderV1(cmd=cmd, app_id=app_id, token=token) + return provider.create_device( device_id=device_id, device_name=device_name, - instance_of=instance_of, + template=template, simulated=simulated, central_dns_suffix=central_dns_suffix, ) def delete_device( - cmd, app_id: str, device_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + cmd, + app_id: str, + device_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token) - return provider.delete_device( - device_id=device_id, - central_dns_suffix=central_dns_suffix) + if api_version == ApiVersion.preview.value: + provider = CentralDeviceProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralDeviceProviderV1(cmd=cmd, app_id=app_id, token=token) + + return provider.delete_device(device_id, central_dns_suffix=central_dns_suffix) def registration_info( cmd, app_id: str, device_id, token=None, central_dns_suffix=CENTRAL_ENDPOINT, ): - provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token,) + provider = CentralDeviceProviderV1(cmd=cmd, app_id=app_id, token=token) return provider.get_device_registration_info( device_id=device_id, central_dns_suffix=central_dns_suffix, device_status=None, @@ -76,18 +110,24 @@ def run_command( content: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): if not isinstance(content, str): raise CLIError("content must be a string: {}".format(content)) payload = utility.process_json_arg(content, argument_name="content") - provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralDeviceProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralDeviceProviderV1(cmd=cmd, app_id=app_id, token=token) + return provider.run_component_command( device_id=device_id, interface_id=interface_id, command_name=command_name, payload=payload, + central_dns_suffix=central_dns_suffix, ) @@ -102,25 +142,20 @@ def run_manual_failover( if ttl_minutes and ttl_minutes < 1: raise CLIError("TTL value should be a positive integer: {}".format(ttl_minutes)) - provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token) + provider = CentralDeviceProviderV1(cmd=cmd, app_id=app_id, token=token) return provider.run_manual_failover( device_id=device_id, ttl_minutes=ttl_minutes, - central_dns_suffix=central_dns_suffix + central_dns_suffix=central_dns_suffix, ) def run_manual_failback( - cmd, - app_id: str, - device_id: str, - token=None, - central_dns_suffix=CENTRAL_ENDPOINT, + cmd, app_id: str, device_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, ): - provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token) + provider = CentralDeviceProviderV1(cmd=cmd, app_id=app_id, token=token) return provider.run_manual_failback( - device_id=device_id, - central_dns_suffix=central_dns_suffix + device_id=device_id, central_dns_suffix=central_dns_suffix ) @@ -132,17 +167,25 @@ def get_command_history( command_name: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralDeviceProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralDeviceProviderV1(cmd=cmd, app_id=app_id, token=token) + return provider.get_component_command_history( - device_id=device_id, interface_id=interface_id, command_name=command_name, + device_id=device_id, + interface_id=interface_id, + command_name=command_name, + central_dns_suffix=central_dns_suffix, ) def registration_summary( cmd, app_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, ): - provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token,) + provider = CentralDeviceProviderV1(cmd=cmd, app_id=app_id, token=token,) return provider.get_device_registration_summary( central_dns_suffix=central_dns_suffix, ) @@ -151,7 +194,7 @@ def registration_summary( def get_credentials( cmd, app_id: str, device_id, token=None, central_dns_suffix=CENTRAL_ENDPOINT, ): - provider = CentralDeviceProvider(cmd=cmd, app_id=app_id, token=token,) + provider = CentralDeviceProviderV1(cmd=cmd, app_id=app_id, token=token,) return provider.get_device_credentials( device_id=device_id, central_dns_suffix=central_dns_suffix, ) diff --git a/azext_iot/central/commands_device_template.py b/azext_iot/central/commands_device_template.py index 9cbcdfab9..fd6d90e63 100644 --- a/azext_iot/central/commands_device_template.py +++ b/azext_iot/central/commands_device_template.py @@ -9,7 +9,9 @@ from azext_iot.constants import CENTRAL_ENDPOINT from azext_iot.common import utility -from azext_iot.central.providers import CentralDeviceTemplateProvider +from azext_iot.central.providers.preview import CentralDeviceTemplateProviderPreview +from azext_iot.central.providers.v1 import CentralDeviceTemplateProviderV1 +from azext_iot.central.models.enum import ApiVersion def get_device_template( @@ -18,26 +20,53 @@ def get_device_template( device_template_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralDeviceTemplateProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralDeviceTemplateProviderPreview( + cmd=cmd, app_id=app_id, token=token + ) + else: + provider = CentralDeviceTemplateProviderV1(cmd=cmd, app_id=app_id, token=token) + template = provider.get_device_template( - device_template_id=device_template_id, central_dns_suffix=central_dns_suffix + device_template_id=device_template_id, central_dns_suffix=central_dns_suffix, ) return template.raw_template def list_device_templates( - cmd, app_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT + cmd, + app_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralDeviceTemplateProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralDeviceTemplateProviderPreview( + cmd=cmd, app_id=app_id, token=token + ) + else: + provider = CentralDeviceTemplateProviderV1(cmd=cmd, app_id=app_id, token=token) + templates = provider.list_device_templates(central_dns_suffix=central_dns_suffix) return {template.id: template.raw_template for template in templates.values()} def map_device_templates( - cmd, app_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT + cmd, + app_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralDeviceTemplateProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralDeviceTemplateProviderPreview( + cmd=cmd, app_id=app_id, token=token + ) + else: + provider = CentralDeviceTemplateProviderV1(cmd=cmd, app_id=app_id, token=token) + return provider.map_device_templates(central_dns_suffix=central_dns_suffix) @@ -48,13 +77,20 @@ def create_device_template( content: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): if not isinstance(content, str): raise CLIError("content must be a string: {}".format(content)) payload = utility.process_json_arg(content, argument_name="content") - provider = CentralDeviceTemplateProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralDeviceTemplateProviderPreview( + cmd=cmd, app_id=app_id, token=token + ) + else: + provider = CentralDeviceTemplateProviderV1(cmd=cmd, app_id=app_id, token=token) + template = provider.create_device_template( device_template_id=device_template_id, payload=payload, @@ -69,8 +105,15 @@ def delete_device_template( device_template_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralDeviceTemplateProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralDeviceTemplateProviderPreview( + cmd=cmd, app_id=app_id, token=token + ) + else: + provider = CentralDeviceTemplateProviderV1(cmd=cmd, app_id=app_id, token=token) + return provider.delete_device_template( - device_template_id=device_template_id, central_dns_suffix=central_dns_suffix + device_template_id=device_template_id, central_dns_suffix=central_dns_suffix, ) diff --git a/azext_iot/central/commands_user.py b/azext_iot/central/commands_user.py index 63841f35a..5f79db8ac 100644 --- a/azext_iot/central/commands_user.py +++ b/azext_iot/central/commands_user.py @@ -7,8 +7,9 @@ from azext_iot.constants import CENTRAL_ENDPOINT -from azext_iot.central.providers import CentralUserProvider -from azext_iot.central.models.enum import Role +from azext_iot.central.providers.preview import CentralUserProviderPreview +from azext_iot.central.providers.v1 import CentralUserProviderV1 +from azext_iot.central.models.enum import Role, ApiVersion def add_user( @@ -21,8 +22,12 @@ def add_user( object_id=None, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralUserProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralUserProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralUserProviderV1(cmd=cmd, app_id=app_id, token=token) if email: return provider.add_email( @@ -42,26 +47,49 @@ def add_user( def list_users( - cmd, app_id: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + cmd, + app_id: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralUserProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralUserProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralUserProviderV1(cmd=cmd, app_id=app_id, token=token) return provider.get_user_list(central_dns_suffix=central_dns_suffix,) def get_user( - cmd, app_id: str, assignee: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + cmd, + app_id: str, + assignee: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralUserProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralUserProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralUserProviderV1(cmd=cmd, app_id=app_id, token=token) - return provider.get_user(assignee=assignee, central_dns_suffix=central_dns_suffix) + return provider.get_user(assignee=assignee, central_dns_suffix=central_dns_suffix,) def delete_user( - cmd, app_id: str, assignee: str, token=None, central_dns_suffix=CENTRAL_ENDPOINT, + cmd, + app_id: str, + assignee: str, + token=None, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): - provider = CentralUserProvider(cmd=cmd, app_id=app_id, token=token) + if api_version == ApiVersion.preview.value: + provider = CentralUserProviderPreview(cmd=cmd, app_id=app_id, token=token) + else: + provider = CentralUserProviderV1(cmd=cmd, app_id=app_id, token=token) return provider.delete_user( - assignee=assignee, central_dns_suffix=central_dns_suffix + assignee=assignee, central_dns_suffix=central_dns_suffix, ) diff --git a/azext_iot/central/models/__init__.py b/azext_iot/central/models/__init__.py index 55614acbf..cc1abd64c 100644 --- a/azext_iot/central/models/__init__.py +++ b/azext_iot/central/models/__init__.py @@ -3,3 +3,17 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +from azext_iot.central.models.devicePreview import DevicePreview +from azext_iot.central.models.devicev1 import DeviceV1 +from azext_iot.central.models.devicetwin import DeviceTwin +from azext_iot.central.models.templatepreview import TemplatePreview +from azext_iot.central.models.templatev1 import TemplateV1 + + +__all__ = [ + "DevicePreview", + "DeviceV1", + "DeviceTwin", + "TemplatePreview", + "TemplateV1", +] diff --git a/azext_iot/central/models/device.py b/azext_iot/central/models/devicePreview.py similarity index 93% rename from azext_iot/central/models/device.py rename to azext_iot/central/models/devicePreview.py index 7bc02bd78..b3f033903 100644 --- a/azext_iot/central/models/device.py +++ b/azext_iot/central/models/devicePreview.py @@ -7,10 +7,9 @@ from azext_iot.central.models.enum import DeviceStatus -class Device: +class DevicePreview: def __init__(self, device: dict): self.approved = device.get("approved") - self.description = device.get("description") self.display_name = device.get("displayName") self.etag = device.get("etag") self.id = device.get("id") @@ -32,7 +31,7 @@ def _parse_device_status(self) -> DeviceStatus: return DeviceStatus.provisioned - def get_registration_info(self): + def get_registration_info(self) -> dict: registration_info = { "device_status": self.device_status.value, "display_name": self.display_name, diff --git a/azext_iot/central/models/devicev1.py b/azext_iot/central/models/devicev1.py new file mode 100644 index 000000000..28f6bf4c1 --- /dev/null +++ b/azext_iot/central/models/devicev1.py @@ -0,0 +1,42 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azext_iot.central.models.enum import DeviceStatus + + +class DeviceV1: + def __init__(self, device: dict): + self.enabled = device.get("enabled") + self.display_name = device.get("displayName") + self.etag = device.get("etag") + self.id = device.get("id") + self.template = device.get("template") + self.provisioned = device.get("provisioned") + self.simulated = device.get("simulated") + self.device_status = self._parse_device_status() + + def _parse_device_status(self) -> DeviceStatus: + if not self.enabled: + return DeviceStatus.blocked + + if not self.template: + return DeviceStatus.unassociated + + if not self.provisioned: + return DeviceStatus.registered + + return DeviceStatus.provisioned + + def get_registration_info(self) -> dict: + registration_info = { + "device_status": self.device_status.value, + "display_name": self.display_name, + "id": self.id, + "simulated": self.simulated, + "template": self.template, + } + + return registration_info diff --git a/azext_iot/central/models/enum.py b/azext_iot/central/models/enum.py index 1e4e976c5..16eea4ac2 100644 --- a/azext_iot/central/models/enum.py +++ b/azext_iot/central/models/enum.py @@ -33,11 +33,30 @@ class Role(Enum): operator = "ae2c9854-393b-4f97-8c42-479d70ce626e" -class UserType(Enum): +class UserTypePreview(Enum): """ - Types of users that can be added to use/manage a Central app + Types of users , supported under the preview route, that can be added to use/manage a Central app (service principal, email, etc) """ service_principal = "ServicePrincipalUser" email = "EmailUser" + + +class UserTypeV1(Enum): + """ + Types of users, supported under V1/1.0 route, that can be added to use/manage a Central app + (service principal, email, etc) + """ + + service_principal = "servicePrincipal" + email = "email" + + +class ApiVersion(Enum): + """ + API version's supported + """ + + preview = "preview" + v1 = "1.0" diff --git a/azext_iot/central/models/template.py b/azext_iot/central/models/templatepreview.py similarity index 93% rename from azext_iot/central/models/template.py rename to azext_iot/central/models/templatepreview.py index cfb767a84..48f6e23d1 100644 --- a/azext_iot/central/models/template.py +++ b/azext_iot/central/models/templatepreview.py @@ -7,7 +7,7 @@ from knack.util import CLIError -class Template: +class TemplatePreview: def __init__(self, template: dict): self.raw_template = template try: @@ -81,10 +81,8 @@ def _extract_interfaces(self, template: dict) -> dict: if dcm.get("contents"): interfaces.append(self._extract_root_interface_contents(dcm)) - if dcm.get("@type") == "CapabilityModel": + if dcm.get("implements"): interfaces.extend(dcm.get("implements")) - else: - interfaces.extend(dcm.get("extends")) return { interface["@id"]: self._extract_schemas(interface) @@ -97,7 +95,8 @@ def _extract_interfaces(self, template: dict) -> dict: raise CLIError(details) def _extract_schemas(self, entity: dict) -> dict: - return {schema["name"]: schema for schema in entity["schema"]["contents"]} + if entity.get("schema"): + return {schema["name"]: schema for schema in entity["schema"]["contents"]} def _extract_schema_names(self, entity: dict) -> dict: return { @@ -105,7 +104,7 @@ def _extract_schema_names(self, entity: dict) -> dict: for entity_name, entity_schemas in entity.items() } - def _get_interface_list_property(self, property_name): + def _get_interface_list_property(self, property_name) -> list: # returns the list of interfaces where property with property_name is defined return [ interface diff --git a/azext_iot/central/models/templatev1.py b/azext_iot/central/models/templatev1.py new file mode 100644 index 000000000..20d439a3b --- /dev/null +++ b/azext_iot/central/models/templatev1.py @@ -0,0 +1,115 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +from knack.util import CLIError + + +class TemplateV1: + def __init__(self, template: dict): + self.raw_template = template + try: + self.id = template.get("id") + self.name = template.get("displayName") + self.interfaces = self._extract_interfaces(template) + self.schema_names = self._extract_schema_names(self.interfaces) + self.components = self._extract_components(template) + if self.components: + self.component_schema_names = self._extract_schema_names( + self.components + ) + + except: + raise CLIError("Could not parse iot central device template.") + + def get_schema(self, name, is_component=False, identifier="") -> dict: + entities = self.components if is_component else self.interfaces + if identifier: + # identifier specified, do a pointed lookup + entry = entities.get(identifier, {}) + return entry.get(name) + + # find first matching name in any component + for entry in entities.values(): + schema = entry.get(name) + if schema: + return schema + + # not found + return None + + def _extract_components(self, template: dict) -> dict: + try: + dcm = template.get("capabilityModel", {}) + if dcm.get("contents"): + rootContents = dcm.get("contents", {}) + components = [ + entity + for entity in rootContents + if entity.get("@type") == "Component" + ] + + if components: + return { + component["name"]: self._extract_schemas(component) + for component in components + } + return {} + return {} + except Exception: + details = "Unable to extract schema for component from template '{}'.".format( + self.id + ) + raise CLIError(details) + + def _extract_root_interface_contents(self, dcm: dict) -> dict: + rootContents = dcm.get("contents", {}) + contents = [ + entity for entity in rootContents if entity.get("@type") != "Component" + ] + + return {"@id": dcm.get("@id", {}), "schema": {"contents": contents}} + + def _extract_interfaces(self, template: dict) -> dict: + try: + interfaces = [] + dcm = template.get("capabilityModel", {}) + + if dcm.get("contents"): + interfaces.append(self._extract_root_interface_contents(dcm)) + + if dcm.get("extends"): + interfaces.extend(dcm.get("extends")) + + return { + interface["@id"]: self._extract_schemas(interface) + for interface in interfaces + } + except Exception: + details = "Unable to extract device schema from template '{}'.".format( + self.id + ) + raise CLIError(details) + + def _extract_schemas(self, entity: dict) -> dict: + if entity.get("schema"): + return {schema["name"]: schema for schema in entity["schema"]["contents"]} + else: + return {schema["name"]: schema for schema in entity["contents"]} + + def _extract_schema_names(self, entity: dict) -> dict: + return { + entity_name: list(entity_schemas.keys()) + for entity_name, entity_schemas in entity.items() + } + + def _get_interface_list_property(self, property_name) -> list: + # returns the list of interfaces where property with property_name is defined + return [ + interface + for interface, schema in self.schema_names.items() + if property_name in schema + ] diff --git a/azext_iot/central/params.py b/azext_iot/central/params.py index b543ecc53..fe1a2e4c6 100644 --- a/azext_iot/central/params.py +++ b/azext_iot/central/params.py @@ -11,7 +11,7 @@ from knack.arguments import CLIArgumentType, CaseInsensitiveList from azure.cli.core.commands.parameters import get_three_state_flag from azext_iot.monitor.models.enum import Severity -from azext_iot.central.models.enum import Role +from azext_iot.central.models.enum import Role, ApiVersion from azext_iot._params import event_msg_prop_type, event_timeout_type severity_type = CLIArgumentType( @@ -35,6 +35,13 @@ "scroll = deliver errors as they arrive, json = summarize results as json, csv = summarize results as csv", ) +api_version = CLIArgumentType( + options_list=["--api-version", "--av"], + choices=CaseInsensitiveList([version.value for version in ApiVersion]), + default=ApiVersion.v1.value, + help="The API version for the requested operation.", +) + def load_central_arguments(self, _): """ @@ -45,8 +52,9 @@ def load_central_arguments(self, _): "app_id", options_list=["--app-id", "-n"], help="The App ID of the IoT Central app you want to manage." - " You can find the App ID in the \"About\" page for your application under the help menu." + ' You can find the App ID in the "About" page for your application under the help menu.', ) + context.argument("api_version", arg_type=api_version) context.argument( "token", options_list=["--token"], @@ -105,16 +113,16 @@ def load_central_arguments(self, _): with self.argument_context("iot central device") as context: context.argument( - "instance_of", - options_list=["--instance-of"], - help="Central template id. Example: urn:ojpkindbz:modelDefinition:iild3tm_uo", + "template", + options_list=["--template"], + help="Central template id. Example: dtmi:ojpkindbz:modelDefinition:iild3tm_uo.", ) context.argument( "simulated", options_list=["--simulated"], arg_type=get_three_state_flag(), help="Add this flag if you would like IoT Central to set this up as a simulated device. " - "--instance-of is required if this is true", + "--template is required if this is true", ) context.argument( "device_name", @@ -203,5 +211,7 @@ def load_central_arguments(self, _): "Use 0 for infinity.", ) context.argument( - "module_id", options_list=["--module-id", "-m"], help="Provide IoT Edge Module ID if the device type is IoT Edge.", + "module_id", + options_list=["--module-id", "-m"], + help="Provide IoT Edge Module ID if the device type is IoT Edge.", ) diff --git a/azext_iot/central/providers/__init__.py b/azext_iot/central/providers/__init__.py index 451120fe6..64ee059b6 100644 --- a/azext_iot/central/providers/__init__.py +++ b/azext_iot/central/providers/__init__.py @@ -4,18 +4,8 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- -from azext_iot.central.providers.device_provider import CentralDeviceProvider -from azext_iot.central.providers.device_template_provider import ( - CentralDeviceTemplateProvider, -) from azext_iot.central.providers.devicetwin_provider import CentralDeviceTwinProvider -from azext_iot.central.providers.user_provider import CentralUserProvider -from azext_iot.central.providers.api_token_provider import CentralApiTokenProvider __all__ = [ - "CentralDeviceProvider", - "CentralDeviceTemplateProvider", "CentralDeviceTwinProvider", - "CentralUserProvider", - "CentralApiTokenProvider", ] diff --git a/azext_iot/central/providers/devicetwin_provider.py b/azext_iot/central/providers/devicetwin_provider.py index 836676742..4a4542620 100644 --- a/azext_iot/central/providers/devicetwin_provider.py +++ b/azext_iot/central/providers/devicetwin_provider.py @@ -50,9 +50,13 @@ def get_device_twin(self, central_dns_suffix): endpoint = find_between(sas_token, "SharedAccessSignature sr=", "&sig=") target = {"entity": endpoint} auth = BasicSasTokenAuthentication(sas_token=sas_token) - service_sdk = SdkResolver(target=target, auth_override=auth).get_sdk(SdkType.service_sdk) + service_sdk = SdkResolver(target=target, auth_override=auth).get_sdk( + SdkType.service_sdk + ) try: - return service_sdk.devices.get_twin(id=self._device_id, raw=True).response.json() + return service_sdk.devices.get_twin( + id=self._device_id, raw=True + ).response.json() except CloudError as e: if exception is None: exception = CLIError(unpack_msrest_error(e)) diff --git a/azext_iot/central/providers/monitor_provider.py b/azext_iot/central/providers/monitor_provider.py index 555db570c..76b877e27 100644 --- a/azext_iot/central/providers/monitor_provider.py +++ b/azext_iot/central/providers/monitor_provider.py @@ -6,9 +6,9 @@ from azure.cli.core.commands import AzCliCommand -from azext_iot.central.providers import ( - CentralDeviceProvider, - CentralDeviceTemplateProvider, +from azext_iot.central.providers.v1 import ( + CentralDeviceProviderV1, + CentralDeviceTemplateProviderV1, ) from azext_iot.monitor.models.arguments import ( @@ -31,10 +31,10 @@ def __init__( central_handler_args: CentralHandlerArguments, central_dns_suffix: str, ): - central_device_provider = CentralDeviceProvider( + central_device_provider = CentralDeviceProviderV1( cmd=cmd, app_id=app_id, token=token ) - central_template_provider = CentralDeviceTemplateProvider( + central_template_provider = CentralDeviceTemplateProviderV1( cmd=cmd, app_id=app_id, token=token ) self._targets = self._build_targets( @@ -91,8 +91,8 @@ def _build_targets( def _build_handler( self, - central_device_provider: CentralDeviceProvider, - central_template_provider: CentralDeviceTemplateProvider, + central_device_provider: CentralDeviceProviderV1, + central_template_provider: CentralDeviceTemplateProviderV1, central_handler_args: CentralHandlerArguments, ): from azext_iot.monitor.handlers import CentralHandler diff --git a/azext_iot/central/providers/preview/__init__.py b/azext_iot/central/providers/preview/__init__.py new file mode 100644 index 000000000..f47cdd416 --- /dev/null +++ b/azext_iot/central/providers/preview/__init__.py @@ -0,0 +1,26 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azext_iot.central.providers.preview.device_provider_preview import ( + CentralDeviceProviderPreview, +) +from azext_iot.central.providers.preview.device_template_provider_preview import ( + CentralDeviceTemplateProviderPreview, +) + +from azext_iot.central.providers.preview.user_provider_preview import ( + CentralUserProviderPreview, +) +from azext_iot.central.providers.preview.api_token_provider_preview import ( + CentralApiTokenProviderPreview, +) + +__all__ = [ + "CentralDeviceProviderPreview", + "CentralDeviceTemplateProviderPreview", + "CentralUserProviderPreview", + "CentralApiTokenProviderPreview", +] diff --git a/azext_iot/central/providers/preview/api_token_provider_preview.py b/azext_iot/central/providers/preview/api_token_provider_preview.py new file mode 100644 index 000000000..60601c178 --- /dev/null +++ b/azext_iot/central/providers/preview/api_token_provider_preview.py @@ -0,0 +1,81 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.log import get_logger + +from azext_iot.constants import CENTRAL_ENDPOINT +from azext_iot.central import services as central_services +from azext_iot.central.models.enum import Role, ApiVersion + + +logger = get_logger(__name__) + + +class CentralApiTokenProviderPreview: + def __init__(self, cmd, app_id: str, token=None): + """ + Provider for API token APIs + + Args: + cmd: command passed into az + app_id: name of app (used for forming request URL) + token: (OPTIONAL) authorization token to fetch API token details from IoTC. + MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') + Useful in scenarios where user doesn't own the app + therefore AAD token won't work, but a SAS token generated by owner will + """ + self._cmd = cmd + self._app_id = app_id + self._token = token + + def add_api_token( + self, token_id: str, role: Role, central_dns_suffix=CENTRAL_ENDPOINT, + ) -> dict: + + return central_services.api_token.add_api_token( + cmd=self._cmd, + app_id=self._app_id, + token_id=token_id, + role=role, + token=self._token, + api_version=ApiVersion.preview.value, + central_dns_suffix=central_dns_suffix, + ) + + def get_api_token_list(self, central_dns_suffix=CENTRAL_ENDPOINT) -> dict: + + return central_services.api_token.get_api_token_list( + cmd=self._cmd, + app_id=self._app_id, + token=self._token, + api_version=ApiVersion.preview.value, + central_dns_suffix=central_dns_suffix, + ) + + def get_api_token(self, token_id, central_dns_suffix=CENTRAL_ENDPOINT,) -> dict: + return central_services.api_token.get_api_token( + cmd=self._cmd, + app_id=self._app_id, + token_id=token_id, + token=self._token, + api_version=ApiVersion.preview.value, + central_dns_suffix=central_dns_suffix, + ) + + def delete_api_token( + self, + token_id, + api_version=ApiVersion.v1.value, + central_dns_suffix=CENTRAL_ENDPOINT, + ) -> dict: + return central_services.api_token.delete_api_token( + cmd=self._cmd, + app_id=self._app_id, + token_id=token_id, + token=self._token, + api_version=ApiVersion.preview.value, + central_dns_suffix=central_dns_suffix, + ) diff --git a/azext_iot/central/providers/preview/device_provider_preview.py b/azext_iot/central/providers/preview/device_provider_preview.py new file mode 100644 index 000000000..49368258e --- /dev/null +++ b/azext_iot/central/providers/preview/device_provider_preview.py @@ -0,0 +1,182 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + + +from typing import List +from azext_iot.central.models.devicePreview import DevicePreview +from knack.util import CLIError +from knack.log import get_logger +from azext_iot.constants import CENTRAL_ENDPOINT +from azext_iot.central import services as central_services +from azext_iot.central.models.enum import DeviceStatus, ApiVersion +from azext_iot.central import models as central_models + +logger = get_logger(__name__) + + +class CentralDeviceProviderPreview: + def __init__(self, cmd, app_id: str, token=None): + """ + Provider for device APIs + + Args: + cmd: command passed into az + app_id: name of app (used for forming request URL) + token: (OPTIONAL) authorization token to fetch device details from IoTC. + MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') + Useful in scenarios where user doesn't own the app + therefore AAD token won't work, but a SAS token generated by owner will + """ + self._cmd = cmd + self._app_id = app_id + self._token = token + self._devices = {} + self._device_templates = {} + self._device_credentials = {} + self._device_registration_info = {} + + def get_device( + self, device_id, central_dns_suffix=CENTRAL_ENDPOINT, + ) -> central_models.DevicePreview: + # get or add to cache + device = self._devices.get(device_id) + if not device: + device = central_services.device.get_device( + cmd=self._cmd, + app_id=self._app_id, + device_id=device_id, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + self._devices[device_id] = device + + if not device: + raise CLIError("No device found with id: '{}'.".format(device_id)) + + return device + + def list_devices(self, central_dns_suffix=CENTRAL_ENDPOINT) -> List[DevicePreview]: + devices = central_services.device.list_devices( + cmd=self._cmd, + app_id=self._app_id, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + + # add to cache + self._devices.update({device.id: device for device in devices}) + + return self._devices + + def create_device( + self, + device_id, + device_name=None, + template=None, + simulated=False, + central_dns_suffix=CENTRAL_ENDPOINT, + ) -> central_models.DevicePreview: + + if device_id in self._devices: + raise CLIError("Device already exists.") + + device = central_services.device.create_device( + cmd=self._cmd, + app_id=self._app_id, + device_id=device_id, + device_name=device_name, + template=template, + simulated=simulated, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + + if not device: + raise CLIError("No device found with id: '{}'.".format(device_id)) + + # add to cache + self._devices[device.id] = device + + return device + + def delete_device(self, device_id, central_dns_suffix=CENTRAL_ENDPOINT,) -> dict: + if not device_id: + raise CLIError("Device id must be specified.") + + # get or add to cache + result = central_services.device.delete_device( + cmd=self._cmd, + app_id=self._app_id, + device_id=device_id, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + + # remove from cache + # pop "miss" raises a KeyError if None is not provided + self._devices.pop(device_id, None) + self._device_credentials.pop(device_id, None) + + return result + + def run_component_command( + self, + device_id: str, + interface_id: str, + command_name: str, + payload: dict, + central_dns_suffix=CENTRAL_ENDPOINT, + ) -> dict: + return central_services.device.run_component_command( + cmd=self._cmd, + app_id=self._app_id, + token=self._token, + device_id=device_id, + interface_id=interface_id, + command_name=command_name, + payload=payload, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + + def get_component_command_history( + self, + device_id: str, + interface_id: str, + command_name: str, + central_dns_suffix=CENTRAL_ENDPOINT, + ) -> dict: + return central_services.device.get_component_command_history( + cmd=self._cmd, + app_id=self._app_id, + token=self._token, + device_id=device_id, + interface_id=interface_id, + command_name=command_name, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + + def _dps_populate_essential_info( + self, dps_info, device_status: DeviceStatus + ) -> dict: + error = { + DeviceStatus.provisioned: "None.", + DeviceStatus.registered: "Device is not yet provisioned.", + DeviceStatus.blocked: "Device is blocked from connecting to IoT Central application." + " Unblock the device in IoT Central and retry. Learn more: https://aka.ms/iotcentral-docs-dps-SAS", + DeviceStatus.unassociated: "Device does not have a valid template associated with it.", + } + + filtered_dps_info = { + "status": dps_info.get("status"), + "error": error.get(device_status), + } + return filtered_dps_info diff --git a/azext_iot/central/providers/preview/device_template_provider_preview.py b/azext_iot/central/providers/preview/device_template_provider_preview.py new file mode 100644 index 000000000..63ad41406 --- /dev/null +++ b/azext_iot/central/providers/preview/device_template_provider_preview.py @@ -0,0 +1,124 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from typing import List +from knack.util import CLIError +from azext_iot.constants import CENTRAL_ENDPOINT +from azext_iot.central import services as central_services +from azext_iot.central.models.enum import ApiVersion +from azext_iot.central import models as central_models + + +class CentralDeviceTemplateProviderPreview: + def __init__(self, cmd, app_id, token=None): + """ + Provider for device_template APIs + + Args: + cmd: command passed into az + app_id: name of app (used for forming request URL) + token: (OPTIONAL) authorization token to fetch device details from IoTC. + MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') + Useful in scenarios where user doesn't own the app + therefore AAD token won't work, but a SAS token generated by owner will + """ + self._cmd = cmd + self._app_id = app_id + self._token = token + self._device_templates = {} + + def get_device_template( + self, device_template_id, central_dns_suffix=CENTRAL_ENDPOINT, + ) -> central_models.TemplatePreview: + # get or add to cache + device_template = self._device_templates.get(device_template_id) + if not device_template: + device_template = central_services.device_template.get_device_template( + cmd=self._cmd, + app_id=self._app_id, + device_template_id=device_template_id, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + self._device_templates[device_template_id] = device_template + + if not device_template: + raise CLIError( + "No device template for device template with id: '{}'.".format( + device_template_id + ) + ) + + return device_template + + def list_device_templates( + self, central_dns_suffix=CENTRAL_ENDPOINT, + ) -> List[central_models.TemplatePreview]: + templates = central_services.device_template.list_device_templates( + cmd=self._cmd, + app_id=self._app_id, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + + self._device_templates.update({template.id: template for template in templates}) + + return self._device_templates + + def map_device_templates(self, central_dns_suffix=CENTRAL_ENDPOINT,) -> dict: + """ + Maps each template name to the corresponding template id + """ + templates = central_services.device_template.list_device_templates( + cmd=self._cmd, + app_id=self._app_id, + token=self._token, + api_version=ApiVersion.preview.value, + ) + return {template.name: template.id for template in templates} + + def create_device_template( + self, + device_template_id: str, + payload: str, + central_dns_suffix=CENTRAL_ENDPOINT, + ) -> central_models.TemplatePreview: + template = central_services.device_template.create_device_template( + cmd=self._cmd, + app_id=self._app_id, + device_template_id=device_template_id, + payload=payload, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + + self._device_templates[template.id] = template + + return template + + def delete_device_template( + self, device_template_id, central_dns_suffix=CENTRAL_ENDPOINT, + ) -> dict: + if not device_template_id: + raise CLIError("Device template id must be specified.") + + result = central_services.device_template.delete_device_template( + cmd=self._cmd, + token=self._token, + app_id=self._app_id, + device_template_id=device_template_id, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + + # remove from cache + # pop "miss" raises a KeyError if None is not provided + self._device_templates.pop(device_template_id, None) + + return result diff --git a/azext_iot/central/providers/preview/user_provider_preview.py b/azext_iot/central/providers/preview/user_provider_preview.py new file mode 100644 index 000000000..b1aa5517f --- /dev/null +++ b/azext_iot/central/providers/preview/user_provider_preview.py @@ -0,0 +1,109 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from knack.log import get_logger +from knack.util import CLIError + +from azext_iot.constants import CENTRAL_ENDPOINT +from azext_iot.central import services as central_services +from azext_iot.central.models.enum import Role, ApiVersion + + +logger = get_logger(__name__) + + +class CentralUserProviderPreview: + def __init__(self, cmd, app_id: str, token=None): + """ + Provider for device APIs + + Args: + cmd: command passed into az + app_id: name of app (used for forming request URL) + token: (OPTIONAL) authorization token to fetch device details from IoTC. + MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') + Useful in scenarios where user doesn't own the app + therefore AAD token won't work, but a SAS token generated by owner will + """ + self._cmd = cmd + self._app_id = app_id + self._token = token + + def add_service_principal( + self, + assignee: str, + tenant_id: str, + object_id: str, + role: Role, + central_dns_suffix=CENTRAL_ENDPOINT, + ) -> dict: + if not tenant_id: + raise CLIError("Must specify --tenant-id when adding a service principal") + + if not object_id: + raise CLIError("Must specify --object-id when adding a service principal") + + return central_services.user.add_service_principal( + cmd=self._cmd, + app_id=self._app_id, + assignee=assignee, + tenant_id=tenant_id, + object_id=object_id, + role=role, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + + def get_user_list(self, central_dns_suffix=CENTRAL_ENDPOINT,) -> dict: + return central_services.user.get_user_list( + cmd=self._cmd, + app_id=self._app_id, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + + def get_user(self, assignee, central_dns_suffix=CENTRAL_ENDPOINT,) -> dict: + return central_services.user.get_user( + cmd=self._cmd, + app_id=self._app_id, + assignee=assignee, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + + def delete_user(self, assignee, central_dns_suffix=CENTRAL_ENDPOINT,) -> dict: + return central_services.user.delete_user( + cmd=self._cmd, + app_id=self._app_id, + assignee=assignee, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) + + def add_email( + self, + assignee: str, + email: str, + role: Role, + central_dns_suffix=CENTRAL_ENDPOINT, + ): + if not email: + raise CLIError("Must specify --email when adding a user by email") + + return central_services.user.add_email( + cmd=self._cmd, + app_id=self._app_id, + assignee=assignee, + email=email, + role=role, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.preview.value, + ) diff --git a/azext_iot/central/providers/v1/__init__.py b/azext_iot/central/providers/v1/__init__.py new file mode 100644 index 000000000..1befc5ee3 --- /dev/null +++ b/azext_iot/central/providers/v1/__init__.py @@ -0,0 +1,22 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +from azext_iot.central.providers.v1.device_provider_v1 import CentralDeviceProviderV1 +from azext_iot.central.providers.v1.device_template_provider_v1 import ( + CentralDeviceTemplateProviderV1, +) + +from azext_iot.central.providers.v1.user_provider_v1 import CentralUserProviderV1 +from azext_iot.central.providers.v1.api_token_provider_v1 import ( + CentralApiTokenProviderV1, +) + +__all__ = [ + "CentralDeviceProviderV1", + "CentralDeviceTemplateProviderV1", + "CentralUserProviderV1", + "CentralApiTokenProviderV1", +] diff --git a/azext_iot/central/providers/api_token_provider.py b/azext_iot/central/providers/v1/api_token_provider_v1.py similarity index 80% rename from azext_iot/central/providers/api_token_provider.py rename to azext_iot/central/providers/v1/api_token_provider_v1.py index b76574513..51b03b176 100644 --- a/azext_iot/central/providers/api_token_provider.py +++ b/azext_iot/central/providers/v1/api_token_provider_v1.py @@ -8,13 +8,13 @@ from azext_iot.constants import CENTRAL_ENDPOINT from azext_iot.central import services as central_services -from azext_iot.central.models.enum import Role +from azext_iot.central.models.enum import Role, ApiVersion logger = get_logger(__name__) -class CentralApiTokenProvider: +class CentralApiTokenProviderV1: def __init__(self, cmd, app_id: str, token=None): """ Provider for API token APIs @@ -33,7 +33,7 @@ def __init__(self, cmd, app_id: str, token=None): def add_api_token( self, token_id: str, role: Role, central_dns_suffix=CENTRAL_ENDPOINT, - ): + ) -> dict: return central_services.api_token.add_api_token( cmd=self._cmd, @@ -41,38 +41,36 @@ def add_api_token( token_id=token_id, role=role, token=self._token, + api_version=ApiVersion.v1.value, central_dns_suffix=central_dns_suffix, ) - def get_api_token_list( - self, central_dns_suffix=CENTRAL_ENDPOINT, - ): + def get_api_token_list(self, central_dns_suffix=CENTRAL_ENDPOINT) -> dict: return central_services.api_token.get_api_token_list( cmd=self._cmd, app_id=self._app_id, token=self._token, + api_version=ApiVersion.v1.value, central_dns_suffix=central_dns_suffix, ) - def get_api_token( - self, token_id, central_dns_suffix=CENTRAL_ENDPOINT, - ): + def get_api_token(self, token_id, central_dns_suffix=CENTRAL_ENDPOINT,) -> dict: return central_services.api_token.get_api_token( cmd=self._cmd, app_id=self._app_id, token_id=token_id, token=self._token, + api_version=ApiVersion.v1.value, central_dns_suffix=central_dns_suffix, ) - def delete_api_token( - self, token_id, central_dns_suffix=CENTRAL_ENDPOINT, - ): + def delete_api_token(self, token_id, central_dns_suffix=CENTRAL_ENDPOINT,) -> dict: return central_services.api_token.delete_api_token( cmd=self._cmd, app_id=self._app_id, token_id=token_id, token=self._token, + api_version=ApiVersion.v1.value, central_dns_suffix=central_dns_suffix, ) diff --git a/azext_iot/central/providers/device_provider.py b/azext_iot/central/providers/v1/device_provider_v1.py similarity index 92% rename from azext_iot/central/providers/device_provider.py rename to azext_iot/central/providers/v1/device_provider_v1.py index 43cb5ed12..ceed21644 100644 --- a/azext_iot/central/providers/device_provider.py +++ b/azext_iot/central/providers/v1/device_provider_v1.py @@ -4,20 +4,20 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +from typing import List +from azext_iot.central.models.devicev1 import DeviceV1 from knack.util import CLIError from knack.log import get_logger -from typing import List from azext_iot.constants import CENTRAL_ENDPOINT from azext_iot.central import services as central_services -from azext_iot.central.models.enum import DeviceStatus -from azext_iot.central.models.device import Device +from azext_iot.central.models.enum import DeviceStatus, ApiVersion from azext_iot.dps.services import global_service as dps_global_service logger = get_logger(__name__) -class CentralDeviceProvider: +class CentralDeviceProviderV1: def __init__(self, cmd, app_id: str, token=None): """ Provider for device APIs @@ -38,9 +38,8 @@ def __init__(self, cmd, app_id: str, token=None): self._device_credentials = {} self._device_registration_info = {} - def get_device(self, device_id, central_dns_suffix=CENTRAL_ENDPOINT) -> Device: - if not device_id: - raise CLIError("Device id must be specified.") + def get_device(self, device_id, central_dns_suffix=CENTRAL_ENDPOINT,) -> DeviceV1: + # get or add to cache device = self._devices.get(device_id) if not device: @@ -50,6 +49,7 @@ def get_device(self, device_id, central_dns_suffix=CENTRAL_ENDPOINT) -> Device: device_id=device_id, token=self._token, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) self._devices[device_id] = device @@ -58,12 +58,13 @@ def get_device(self, device_id, central_dns_suffix=CENTRAL_ENDPOINT) -> Device: return device - def list_devices(self, central_dns_suffix=CENTRAL_ENDPOINT) -> List[Device]: + def list_devices(self, central_dns_suffix=CENTRAL_ENDPOINT,) -> List[DeviceV1]: devices = central_services.device.list_devices( cmd=self._cmd, app_id=self._app_id, token=self._token, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) # add to cache @@ -75,10 +76,10 @@ def create_device( self, device_id, device_name=None, - instance_of=None, + template=None, simulated=False, central_dns_suffix=CENTRAL_ENDPOINT, - ) -> Device: + ) -> DeviceV1: if not device_id: raise CLIError("Device id must be specified.") @@ -90,10 +91,11 @@ def create_device( app_id=self._app_id, device_id=device_id, device_name=device_name, - instance_of=instance_of, + template=template, simulated=simulated, token=self._token, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) if not device: @@ -115,6 +117,7 @@ def delete_device(self, device_id, central_dns_suffix=CENTRAL_ENDPOINT,) -> dict device_id=device_id, token=self._token, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) # remove from cache @@ -136,6 +139,7 @@ def get_device_credentials( device_id=device_id, token=self._token, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) if not credentials: @@ -163,7 +167,7 @@ def get_device_registration_info( device = self.get_device(device_id, central_dns_suffix) if device.device_status == DeviceStatus.provisioned: credentials = self.get_device_credentials( - device_id=device_id, central_dns_suffix=central_dns_suffix + device_id=device_id, central_dns_suffix=central_dns_suffix, ) id_scope = credentials["idScope"] key = credentials["symmetricKey"]["primaryKey"] @@ -207,6 +211,7 @@ def run_component_command( command_name=command_name, payload=payload, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) def get_component_command_history( @@ -224,6 +229,7 @@ def get_component_command_history( interface_id=interface_id, command_name=command_name, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) def run_manual_failover( @@ -242,9 +248,7 @@ def run_manual_failover( ) def run_manual_failback( - self, - device_id: str, - central_dns_suffix=CENTRAL_ENDPOINT, + self, device_id: str, central_dns_suffix=CENTRAL_ENDPOINT, ): return central_services.device.run_manual_failback( cmd=self._cmd, diff --git a/azext_iot/central/providers/device_template_provider.py b/azext_iot/central/providers/v1/device_template_provider_v1.py similarity index 82% rename from azext_iot/central/providers/device_template_provider.py rename to azext_iot/central/providers/v1/device_template_provider_v1.py index 2a8f72597..5495883bc 100644 --- a/azext_iot/central/providers/device_template_provider.py +++ b/azext_iot/central/providers/v1/device_template_provider_v1.py @@ -4,12 +4,15 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +from typing import List from knack.util import CLIError from azext_iot.constants import CENTRAL_ENDPOINT from azext_iot.central import services as central_services +from azext_iot.central.models.enum import ApiVersion +from azext_iot.central import models as central_models -class CentralDeviceTemplateProvider: +class CentralDeviceTemplateProviderV1: def __init__(self, cmd, app_id, token=None): """ Provider for device_template APIs @@ -29,7 +32,7 @@ def __init__(self, cmd, app_id, token=None): def get_device_template( self, device_template_id, central_dns_suffix=CENTRAL_ENDPOINT, - ): + ) -> central_models.TemplateV1: # get or add to cache device_template = self._device_templates.get(device_template_id) if not device_template: @@ -39,6 +42,7 @@ def get_device_template( device_template_id=device_template_id, token=self._token, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) self._device_templates[device_template_id] = device_template @@ -51,11 +55,13 @@ def get_device_template( return device_template - def list_device_templates( - self, central_dns_suffix=CENTRAL_ENDPOINT, - ): + def list_device_templates(self, central_dns_suffix=CENTRAL_ENDPOINT,) -> List[central_models.TemplateV1] : templates = central_services.device_template.list_device_templates( - cmd=self._cmd, app_id=self._app_id, token=self._token + cmd=self._cmd, + app_id=self._app_id, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) self._device_templates.update({template.id: template for template in templates}) @@ -69,7 +75,10 @@ def map_device_templates( Maps each template name to the corresponding template id """ templates = central_services.device_template.list_device_templates( - cmd=self._cmd, app_id=self._app_id, token=self._token + cmd=self._cmd, + app_id=self._app_id, + token=self._token, + api_version=ApiVersion.v1.value, ) return {template.name: template.id for template in templates} @@ -86,6 +95,7 @@ def create_device_template( payload=payload, token=self._token, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) self._device_templates[template.id] = template @@ -104,6 +114,7 @@ def delete_device_template( app_id=self._app_id, device_template_id=device_template_id, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) # remove from cache diff --git a/azext_iot/central/providers/user_provider.py b/azext_iot/central/providers/v1/user_provider_v1.py similarity index 84% rename from azext_iot/central/providers/user_provider.py rename to azext_iot/central/providers/v1/user_provider_v1.py index f69ef1903..2f50153a7 100644 --- a/azext_iot/central/providers/user_provider.py +++ b/azext_iot/central/providers/v1/user_provider_v1.py @@ -9,13 +9,13 @@ from azext_iot.constants import CENTRAL_ENDPOINT from azext_iot.central import services as central_services -from azext_iot.central.models.enum import Role +from azext_iot.central.models.enum import Role, ApiVersion logger = get_logger(__name__) -class CentralUserProvider: +class CentralUserProviderV1: def __init__(self, cmd, app_id: str, token=None): """ Provider for device APIs @@ -39,7 +39,7 @@ def add_service_principal( object_id: str, role: Role, central_dns_suffix=CENTRAL_ENDPOINT, - ): + ) -> dict: if not tenant_id: raise CLIError("Must specify --tenant-id when adding a service principal") @@ -55,38 +55,36 @@ def add_service_principal( role=role, token=self._token, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) - def get_user_list( - self, central_dns_suffix=CENTRAL_ENDPOINT, - ): + def get_user_list(self, central_dns_suffix=CENTRAL_ENDPOINT,) -> dict: return central_services.user.get_user_list( cmd=self._cmd, app_id=self._app_id, token=self._token, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) - def get_user( - self, assignee, central_dns_suffix=CENTRAL_ENDPOINT, - ): + def get_user(self, assignee, central_dns_suffix=CENTRAL_ENDPOINT,) -> dict: return central_services.user.get_user( cmd=self._cmd, app_id=self._app_id, assignee=assignee, token=self._token, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) - def delete_user( - self, assignee, central_dns_suffix=CENTRAL_ENDPOINT, - ): + def delete_user(self, assignee, central_dns_suffix=CENTRAL_ENDPOINT,) -> dict: return central_services.user.delete_user( cmd=self._cmd, app_id=self._app_id, assignee=assignee, token=self._token, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) def add_email( @@ -95,7 +93,7 @@ def add_email( email: str, role: Role, central_dns_suffix=CENTRAL_ENDPOINT, - ): + ) -> dict: if not email: raise CLIError("Must specify --email when adding a user by email") @@ -107,4 +105,5 @@ def add_email( role=role, token=self._token, central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, ) diff --git a/azext_iot/central/services/api_token.py b/azext_iot/central/services/api_token.py index fd1a0123d..2e2038637 100644 --- a/azext_iot/central/services/api_token.py +++ b/azext_iot/central/services/api_token.py @@ -9,11 +9,11 @@ from knack.log import get_logger from azext_iot.constants import CENTRAL_ENDPOINT from azext_iot.central.services import _utility -from azext_iot.central.models.enum import Role +from azext_iot.central.models.enum import Role, ApiVersion logger = get_logger(__name__) -BASE_PATH = "api/preview/apiTokens" +BASE_PATH = "api/apiTokens" def add_api_token( @@ -22,8 +22,9 @@ def add_api_token( token_id: str, role: Role, token: str, + api_version=ApiVersion.v1.value, central_dns_suffix=CENTRAL_ENDPOINT, -): +) -> dict: """ Add an API token to a Central app @@ -45,15 +46,23 @@ def add_api_token( "roles": [{"role": role.value}], } + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + headers = _utility.get_headers(token, cmd, has_json_payload=True) - response = requests.put(url, headers=headers, json=payload) + response = requests.put(url, headers=headers, json=payload, params=query_parameters) return _utility.try_extract_result(response) def get_api_token_list( - cmd, app_id: str, token: str, central_dns_suffix=CENTRAL_ENDPOINT, -): + cmd, + app_id: str, + token: str, + api_version: ApiVersion.v1.value, + central_dns_suffix=CENTRAL_ENDPOINT, +) -> dict: """ Get the list of API tokens for a central app. @@ -69,15 +78,24 @@ def get_api_token_list( """ url = "https://{}.{}/{}".format(app_id, central_dns_suffix, BASE_PATH) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + headers = _utility.get_headers(token, cmd) - response = requests.get(url, headers=headers) + response = requests.get(url, params=query_parameters, headers=headers) return _utility.try_extract_result(response) def get_api_token( - cmd, app_id: str, token: str, token_id: str, central_dns_suffix=CENTRAL_ENDPOINT, -): + cmd, + app_id: str, + token: str, + token_id: str, + api_version=ApiVersion.v1.value, + central_dns_suffix=CENTRAL_ENDPOINT, +) -> dict: """ Get information about a specified API token. @@ -96,13 +114,22 @@ def get_api_token( headers = _utility.get_headers(token, cmd) - response = requests.get(url, headers=headers) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.get(url, headers=headers, params=query_parameters) return _utility.try_extract_result(response) def delete_api_token( - cmd, app_id: str, token: str, token_id: str, central_dns_suffix=CENTRAL_ENDPOINT, -): + cmd, + app_id: str, + token: str, + token_id: str, + api_version=ApiVersion.v1.value, + central_dns_suffix=CENTRAL_ENDPOINT, +) -> dict: """ delete API token from the app. @@ -121,5 +148,9 @@ def delete_api_token( headers = _utility.get_headers(token, cmd) - response = requests.delete(url, headers=headers) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.delete(url, headers=headers, params=query_parameters) return _utility.try_extract_result(response) diff --git a/azext_iot/central/services/device.py b/azext_iot/central/services/device.py index 1d44c191b..21e2c78f9 100644 --- a/azext_iot/central/services/device.py +++ b/azext_iot/central/services/device.py @@ -5,26 +5,31 @@ # -------------------------------------------------------------------------------------------- # This is largely derived from https://docs.microsoft.com/en-us/rest/api/iotcentral/devices +from typing import List, Union import requests from knack.util import CLIError from knack.log import get_logger -from typing import List from azext_iot.constants import CENTRAL_ENDPOINT from azext_iot.central.services import _utility -from azext_iot.central.models.device import Device -from azext_iot.central.models.enum import DeviceStatus +from azext_iot.central import models as central_models +from azext_iot.central.models.enum import DeviceStatus, ApiVersion from azure.cli.core.util import should_disable_connection_verify logger = get_logger(__name__) -BASE_PATH = "api/preview/devices" +BASE_PATH = "api/devices" def get_device( - cmd, app_id: str, device_id: str, token: str, central_dns_suffix=CENTRAL_ENDPOINT, -) -> Device: + cmd, + app_id: str, + device_id: str, + token: str, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +) -> Union[central_models.DevicePreview, central_models.DeviceV1]: """ Get device info given a device id @@ -43,14 +48,32 @@ def get_device( url = "https://{}.{}/{}/{}".format(app_id, central_dns_suffix, BASE_PATH, device_id) headers = _utility.get_headers(token, cmd) - response = requests.get(url, headers=headers, verify=not should_disable_connection_verify()) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.get( + url, + headers=headers, + params=query_parameters, + verify=not should_disable_connection_verify(), + ) result = _utility.try_extract_result(response) - return Device(result) + + if api_version == ApiVersion.preview.value: + return central_models.DevicePreview(result) + else: + return central_models.DeviceV1(result) def list_devices( - cmd, app_id: str, token: str, max_pages=1, central_dns_suffix=CENTRAL_ENDPOINT, -) -> List[Device]: + cmd, + app_id: str, + token: str, + max_pages=1, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +) -> List[Union[central_models.DevicePreview, central_models.DeviceV1]]: """ Get a list of all devices in IoTC app @@ -70,17 +93,28 @@ def list_devices( url = "https://{}.{}/{}".format(app_id, central_dns_suffix, BASE_PATH) headers = _utility.get_headers(token, cmd) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + pages_processed = 0 while (pages_processed <= max_pages) and url: - response = requests.get(url, headers=headers) + response = requests.get(url, headers=headers, params=query_parameters) result = _utility.try_extract_result(response) if "value" not in result: raise CLIError("Value is not present in body: {}".format(result)) - devices = devices + [Device(device) for device in result["value"]] + if api_version == ApiVersion.preview.value: + devices = devices + [ + central_models.DevicePreview(device) for device in result["value"] + ] + else: + devices = devices + [ + central_models.DeviceV1(device) for device in result["value"] + ] - url = result.get("nextLink") + url = result.get("nextLink", params=query_parameters) pages_processed = pages_processed + 1 return devices @@ -88,7 +122,7 @@ def list_devices( def get_device_registration_summary( cmd, app_id: str, token: str, central_dns_suffix=CENTRAL_ENDPOINT, -): +) -> dict: """ Get device registration summary for a given app @@ -105,23 +139,32 @@ def get_device_registration_summary( registration_summary = {status.value: 0 for status in DeviceStatus} - url = "https://{}.{}/{}".format(app_id, central_dns_suffix, BASE_PATH) + url = "https://{}.{}/{}?api-version={}".format( + app_id, central_dns_suffix, BASE_PATH, ApiVersion.v1.value + ) headers = _utility.get_headers(token, cmd) + logger.warning( "This command may take a long time to complete if your app contains a lot of devices" ) + while url: - response = requests.get(url, headers=headers, verify=not should_disable_connection_verify()) + response = requests.get( + url, headers=headers, verify=not should_disable_connection_verify() + ) result = _utility.try_extract_result(response) if "value" not in result: raise CLIError("Value is not present in body: {}".format(result)) for device in result["value"]: - registration_summary[Device(device).device_status.value] += 1 + registration_summary[ + central_models.DeviceV1(device).device_status.value + ] += 1 print("Processed {} devices...".format(sum(registration_summary.values()))) url = result.get("nextLink") + return registration_summary @@ -130,11 +173,12 @@ def create_device( app_id: str, device_id: str, device_name: str, - instance_of: str, + template: str, simulated: bool, token: str, central_dns_suffix=CENTRAL_ENDPOINT, -) -> Device: + api_version=ApiVersion.v1.value, +) -> Union[central_models.DevicePreview, central_models.DeviceV1]: """ Create a device in IoTC @@ -143,7 +187,7 @@ def create_device( app_id: name of app (used for forming request URL) device_id: unique case-sensitive device id device_name: (non-unique) human readable name for the device - instance_of: (optional) string that maps to the device_template_id + template: (optional) string that maps to the device_template_id of the device template that this device is to be an instance of simulated: if IoTC is to simulate data for this device token: (OPTIONAL) authorization token to fetch device details from IoTC. @@ -159,21 +203,44 @@ def create_device( url = "https://{}.{}/{}/{}".format(app_id, central_dns_suffix, BASE_PATH, device_id) headers = _utility.get_headers(token, cmd, has_json_payload=True) - payload = { - "displayName": device_name, - "simulated": simulated, - "approved": True, - } - if instance_of: - payload["instanceOf"] = instance_of - - response = requests.put(url, headers=headers, json=payload) + + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + if api_version == ApiVersion.preview.value: + payload = { + "displayName": device_name, + "simulated": simulated, + "approved": True, + } + if template: + payload["instanceOf"] = template + else: + payload = { + "displayName": device_name, + "simulated": simulated, + "enabled": True, + } + if template: + payload["template"] = template + + response = requests.put(url, headers=headers, json=payload, params=query_parameters) result = _utility.try_extract_result(response) - return Device(result) + + if api_version == ApiVersion.preview.value: + return central_models.DevicePreview(result) + else: + return central_models.DeviceV1(result) def delete_device( - cmd, app_id: str, device_id: str, token: str, central_dns_suffix=CENTRAL_ENDPOINT, + cmd, + app_id: str, + device_id: str, + token: str, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ) -> dict: """ Delete a device from IoTC @@ -193,12 +260,21 @@ def delete_device( url = "https://{}.{}/{}/{}".format(app_id, central_dns_suffix, BASE_PATH, device_id) headers = _utility.get_headers(token, cmd) - response = requests.delete(url, headers=headers) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.delete(url, headers=headers, params=query_parameters) return _utility.try_extract_result(response) def get_device_credentials( - cmd, app_id: str, device_id: str, token: str, central_dns_suffix=CENTRAL_ENDPOINT, + cmd, + app_id: str, + device_id: str, + token: str, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): """ Get device credentials from IoTC @@ -219,7 +295,11 @@ def get_device_credentials( ) headers = _utility.get_headers(token, cmd) - response = requests.get(url, headers=headers, verify=not should_disable_connection_verify()) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.get(url, headers=headers, params=query_parameters) return _utility.try_extract_result(response) @@ -232,6 +312,7 @@ def run_component_command( command_name: str, payload: dict, central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): """ Execute a direct method on a device @@ -255,7 +336,13 @@ def run_component_command( ) headers = _utility.get_headers(token, cmd) - response = requests.post(url, headers=headers, json=payload, verify=not should_disable_connection_verify()) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.post( + url, headers=headers, json=payload, params=query_parameters + ) # execute command response has caveats in it due to Async/Sync device methods # return the response if we get 201, otherwise try to apply generic logic @@ -273,6 +360,7 @@ def get_component_command_history( interface_id: str, command_name: str, central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ): """ Get component command history @@ -295,7 +383,11 @@ def get_component_command_history( ) headers = _utility.get_headers(token, cmd) - response = requests.get(url, headers=headers, verify=not should_disable_connection_verify()) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.get(url, headers=headers, params=query_parameters) return _utility.try_extract_result(response) @@ -310,7 +402,6 @@ def run_manual_failover( """ Execute a manual failover of device across multiple IoT Hubs to validate device firmware's ability to reconnect using DPS to a different IoT Hub. - Args: cmd: command passed into az app_id: id of an app (used for forming request URL) @@ -321,7 +412,6 @@ def run_manual_failover( token: (OPTIONAL) authorization token to fetch device details from IoTC. MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') central_dns_suffix:(OPTIONAL) {centralDnsSuffixInPath} as found in docs - Returns: result (currently a 200) """ @@ -331,28 +421,27 @@ def run_manual_failover( ) headers = _utility.get_headers(token, cmd) json = {} - if ttl_minutes : + if ttl_minutes: json = {"ttl": ttl_minutes} else: - print("""Using default time to live - - see https://github.com/iot-for-all/iot-central-high-availability-clients#readme for more information""") + print( + """Using default time to live - + see https://github.com/iot-for-all/iot-central-high-availability-clients#readme for more information""" + ) - response = requests.post(url, headers=headers, verify=not should_disable_connection_verify(), json=json) + response = requests.post( + url, headers=headers, verify=not should_disable_connection_verify(), json=json + ) _utility.log_response_debug(response=response, logger=logger) return _utility.try_extract_result(response) def run_manual_failback( - cmd, - app_id: str, - device_id: str, - token: str, - central_dns_suffix=CENTRAL_ENDPOINT, + cmd, app_id: str, device_id: str, token: str, central_dns_suffix=CENTRAL_ENDPOINT, ): """ Execute a manual failback for device. Reverts the previously executed failover command by moving the device back to it's original IoT Hub. - Args: cmd: command passed into az app_id: id of an app (used for forming request URL) @@ -360,7 +449,6 @@ def run_manual_failback( token: (OPTIONAL) authorization token to fetch device details from IoTC. MUST INCLUDE type (e.g. 'SharedAccessToken ...', 'Bearer ...') central_dns_suffix: {centralDnsSuffixInPath} as found in docs - Returns: result (currently a 200) """ @@ -369,7 +457,9 @@ def run_manual_failback( app_id, central_dns_suffix, "system/iothub/devices", device_id ) headers = _utility.get_headers(token, cmd) - response = requests.post(url, headers=headers, verify=not should_disable_connection_verify()) + response = requests.post( + url, headers=headers, verify=not should_disable_connection_verify() + ) _utility.log_response_debug(response=response, logger=logger) return _utility.try_extract_result(response) diff --git a/azext_iot/central/services/device_template.py b/azext_iot/central/services/device_template.py index 44d3a9590..7b8ec02e0 100644 --- a/azext_iot/central/services/device_template.py +++ b/azext_iot/central/services/device_template.py @@ -5,19 +5,20 @@ # -------------------------------------------------------------------------------------------- # This is largely derived from https://docs.microsoft.com/en-us/rest/api/iotcentral/devicetemplates +from typing import Union import requests - from typing import List from knack.util import CLIError from knack.log import get_logger from azext_iot.constants import CENTRAL_ENDPOINT from azext_iot.central.services import _utility -from azext_iot.central.models.template import Template +from azext_iot.central import models as central_models +from azext_iot.central.models.enum import ApiVersion logger = get_logger(__name__) -BASE_PATH = "api/preview/deviceTemplates" +BASE_PATH = "api/deviceTemplates" def get_device_template( @@ -26,7 +27,8 @@ def get_device_template( device_template_id: str, token: str, central_dns_suffix=CENTRAL_ENDPOINT, -) -> Template: + api_version=ApiVersion.v1.value, +) -> Union[central_models.TemplatePreview, central_models.TemplateV1]: """ Get a specific device template from IoTC @@ -46,13 +48,25 @@ def get_device_template( ) headers = _utility.get_headers(token, cmd) - response = requests.get(url, headers=headers) - return Template(_utility.try_extract_result(response)) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.get(url, headers=headers, params=query_parameters) + + if api_version == ApiVersion.preview.value: + return central_models.TemplatePreview(_utility.try_extract_result(response)) + else: + return central_models.TemplateV1(_utility.try_extract_result(response)) def list_device_templates( - cmd, app_id: str, token: str, central_dns_suffix=CENTRAL_ENDPOINT, -) -> List[Template]: + cmd, + app_id: str, + token: str, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +) -> List[Union[central_models.TemplatePreview, central_models.TemplateV1]]: """ Get a list of all device templates in IoTC @@ -70,14 +84,21 @@ def list_device_templates( url = "https://{}.{}/{}".format(app_id, central_dns_suffix, BASE_PATH) headers = _utility.get_headers(token, cmd) - response = requests.get(url, headers=headers) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.get(url, headers=headers, params=query_parameters) result = _utility.try_extract_result(response) if "value" not in result: raise CLIError("Value is not present in body: {}".format(result)) - return [Template(item) for item in result["value"]] + if api_version == ApiVersion.preview.value: + return [central_models.TemplatePreview(item) for item in result["value"]] + else: + return [central_models.TemplateV1(item) for item in result["value"]] def create_device_template( @@ -87,7 +108,8 @@ def create_device_template( payload: dict, token: str, central_dns_suffix=CENTRAL_ENDPOINT, -) -> Template: + api_version=ApiVersion.v1.value, +) -> Union[central_models.TemplatePreview, central_models.TemplateV1]: """ Create a device template in IoTC @@ -112,8 +134,15 @@ def create_device_template( ) headers = _utility.get_headers(token, cmd, has_json_payload=True) - response = requests.put(url, headers=headers, json=payload) - return Template(_utility.try_extract_result(response)) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.put(url, headers=headers, json=payload, params=query_parameters) + if api_version == ApiVersion.preview.value: + return central_models.TemplatePreview(_utility.try_extract_result(response)) + else: + return central_models.TemplateV1(_utility.try_extract_result(response)) def delete_device_template( @@ -122,6 +151,7 @@ def delete_device_template( device_template_id: str, token: str, central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, ) -> dict: """ Delete a device template from IoTC @@ -142,5 +172,9 @@ def delete_device_template( ) headers = _utility.get_headers(token, cmd) - response = requests.delete(url, headers=headers) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.delete(url, headers=headers, params=query_parameters) return _utility.try_extract_result(response) diff --git a/azext_iot/central/services/user.py b/azext_iot/central/services/user.py index 40b84987f..62eda8242 100644 --- a/azext_iot/central/services/user.py +++ b/azext_iot/central/services/user.py @@ -9,11 +9,11 @@ from knack.log import get_logger from azext_iot.constants import CENTRAL_ENDPOINT from azext_iot.central.services import _utility -from azext_iot.central.models.enum import Role, UserType +from azext_iot.central.models.enum import Role, ApiVersion, UserTypePreview, UserTypeV1 logger = get_logger(__name__) -BASE_PATH = "api/preview/users" +BASE_PATH = "api/users" def add_service_principal( @@ -25,7 +25,8 @@ def add_service_principal( role: Role, token: str, central_dns_suffix=CENTRAL_ENDPOINT, -): + api_version=ApiVersion.v1.value, +) -> dict: """ Add a user to a Central app @@ -43,16 +44,25 @@ def add_service_principal( """ url = "https://{}.{}/{}/{}".format(app_id, central_dns_suffix, BASE_PATH, assignee) + if api_version == ApiVersion.v1.value: + user_type = UserTypeV1.service_principal.value + else: + user_type = UserTypePreview.service_principal.value + payload = { "tenantId": tenant_id, "objectId": object_id, - "type": UserType.service_principal.value, + "type": user_type, "roles": [{"role": role.value}], } headers = _utility.get_headers(token, cmd, has_json_payload=True) - response = requests.put(url, headers=headers, json=payload) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.put(url, headers=headers, json=payload, params=query_parameters) return _utility.try_extract_result(response) @@ -64,7 +74,8 @@ def add_email( role: Role, token: str, central_dns_suffix=CENTRAL_ENDPOINT, -): + api_version=ApiVersion.v1.value, +) -> dict: """ Add a user to a Central app @@ -81,21 +92,34 @@ def add_email( """ url = "https://{}.{}/{}/{}".format(app_id, central_dns_suffix, BASE_PATH, assignee) + if api_version == ApiVersion.v1.value: + user_type = UserTypeV1.email.value + else: + user_type = UserTypePreview.email.value + payload = { "email": email, - "type": UserType.email.value, + "type": user_type, "roles": [{"role": role.value}], } headers = _utility.get_headers(token, cmd, has_json_payload=True) - response = requests.put(url, headers=headers, json=payload) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.put(url, headers=headers, json=payload, params=query_parameters) return _utility.try_extract_result(response) def get_user_list( - cmd, app_id: str, token: str, central_dns_suffix=CENTRAL_ENDPOINT, -): + cmd, + app_id: str, + token: str, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +) -> dict: """ Get the list of users for central app. @@ -113,13 +137,22 @@ def get_user_list( headers = _utility.get_headers(token, cmd) - response = requests.get(url, headers=headers) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.get(url, headers=headers, params=query_parameters) return _utility.try_extract_result(response) def get_user( - cmd, app_id: str, token: str, assignee: str, central_dns_suffix=CENTRAL_ENDPOINT, -): + cmd, + app_id: str, + token: str, + assignee: str, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +) -> dict: """ Get information for the specified user. @@ -138,13 +171,22 @@ def get_user( headers = _utility.get_headers(token, cmd) - response = requests.get(url, headers=headers) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.get(url, headers=headers, params=query_parameters) return _utility.try_extract_result(response) def delete_user( - cmd, app_id: str, token: str, assignee: str, central_dns_suffix=CENTRAL_ENDPOINT, -): + cmd, + app_id: str, + token: str, + assignee: str, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +) -> dict: """ delete user from theapp. @@ -163,5 +205,9 @@ def delete_user( headers = _utility.get_headers(token, cmd) - response = requests.delete(url, headers=headers) + # Construct parameters + query_parameters = {} + query_parameters["api-version"] = api_version + + response = requests.delete(url, headers=headers, params=query_parameters) return _utility.try_extract_result(response) diff --git a/azext_iot/monitor/handlers/central_handler.py b/azext_iot/monitor/handlers/central_handler.py index 54311ff1c..07d68b588 100644 --- a/azext_iot/monitor/handlers/central_handler.py +++ b/azext_iot/monitor/handlers/central_handler.py @@ -11,9 +11,9 @@ from knack.log import get_logger from azext_iot.monitor.utility import stop_monitor, get_loop -from azext_iot.central.providers import ( - CentralDeviceProvider, - CentralDeviceTemplateProvider, +from azext_iot.central.providers.v1 import ( + CentralDeviceProviderV1, + CentralDeviceTemplateProviderV1, ) from azext_iot.monitor.handlers import CommonHandler from azext_iot.monitor.models.arguments import CentralHandlerArguments @@ -26,8 +26,8 @@ class CentralHandler(CommonHandler): def __init__( self, - central_device_provider: CentralDeviceProvider, - central_template_provider: CentralDeviceTemplateProvider, + central_device_provider: CentralDeviceProviderV1, + central_template_provider: CentralDeviceTemplateProviderV1, central_handler_args: CentralHandlerArguments, ): super(CentralHandler, self).__init__( diff --git a/azext_iot/monitor/parsers/central_parser.py b/azext_iot/monitor/parsers/central_parser.py index f5a8c4686..bd719492d 100644 --- a/azext_iot/monitor/parsers/central_parser.py +++ b/azext_iot/monitor/parsers/central_parser.py @@ -8,11 +8,11 @@ from uamqp.message import Message -from azext_iot.central.providers import ( - CentralDeviceProvider, - CentralDeviceTemplateProvider, +from azext_iot.central.providers.v1 import ( + CentralDeviceProviderV1, + CentralDeviceTemplateProviderV1, ) -from azext_iot.central.models.template import Template +from azext_iot.central import models as central_models from azext_iot.monitor.parsers import strings from azext_iot.monitor.central_validator import validate, extract_schema_type from azext_iot.monitor.models.arguments import CommonParserArguments @@ -25,8 +25,8 @@ def __init__( self, message: Message, common_parser_args: CommonParserArguments, - central_device_provider: CentralDeviceProvider, - central_template_provider: CentralDeviceTemplateProvider, + central_device_provider: CentralDeviceProviderV1, + central_template_provider: CentralDeviceTemplateProviderV1, ): super(CentralParser, self).__init__( message=message, common_parser_args=common_parser_args @@ -89,7 +89,7 @@ def _perform_dynamic_validations(self, payload: dict): template = self._get_template() - if not isinstance(template, Template): + if not isinstance(template, central_models.TemplateV1): return # if component name is not defined then data should be mapped to root/inherited interfaces @@ -120,7 +120,7 @@ def _get_template(self): try: device = self._central_device_provider.get_device(self.device_id) template = self._central_template_provider.get_device_template( - device.instance_of + device.template ) self._template_id = template.id return template @@ -131,7 +131,9 @@ def _get_template(self): # currently validates: # 1) primitive types match (e.g. boolean is indeed bool etc) # 2) names match (i.e. Humidity vs humidity etc) - def _validate_payload(self, payload: dict, template: Template, is_component: bool): + def _validate_payload( + self, payload: dict, template: central_models.TemplateV1, is_component: bool + ): name_miss = [] for telemetry_name, telemetry in payload.items(): schema = template.get_schema( diff --git a/azext_iot/monitor/property.py b/azext_iot/monitor/property.py index b91a7e889..4fc62bb15 100644 --- a/azext_iot/monitor/property.py +++ b/azext_iot/monitor/property.py @@ -17,10 +17,11 @@ ) from azext_iot.central.models.devicetwin import DeviceTwin, Property -from azext_iot.central.providers import ( - CentralDeviceProvider, - CentralDeviceTemplateProvider, - CentralDeviceTwinProvider, + +from azext_iot.central.providers import CentralDeviceTwinProvider +from azext_iot.central.providers.v1 import ( + CentralDeviceProviderV1, + CentralDeviceTemplateProviderV1, ) from azext_iot.monitor.parsers.issue import IssueHandler @@ -45,10 +46,10 @@ def __init__( token=self._token, device_id=self._device_id, ) - self._central_device_provider = CentralDeviceProvider( + self._central_device_provider = CentralDeviceProviderV1( cmd=self._cmd, app_id=self._app_id, token=self._token ) - self._central_template_provider = CentralDeviceTemplateProvider( + self._central_template_provider = CentralDeviceTemplateProviderV1( cmd=self._cmd, app_id=self._app_id, token=self._token ) self._template = self._get_device_template() @@ -162,7 +163,7 @@ def _validate_payload_against_entities(self, payload: dict, name, minimum_severi def _get_device_template(self): device = self._central_device_provider.get_device(self._device_id) template = self._central_template_provider.get_device_template( - device_template_id=device.instance_of, + device_template_id=device.template, central_dns_suffix=self._central_dns_suffix, ) return template diff --git a/azext_iot/tests/central/json/deeply_nested_template.json b/azext_iot/tests/central/json/deeply_nested_template.json index 7231d657dac0eb680581e67046cae53b6e22b486..03bdd9061d559ab4316d81d637a6923379c39620 100644 GIT binary patch literal 27710 zcmeHQZExd55T4IS{0Ee;fS|p;KoAnN$H9qv5fqLOve3ez1}3PRi)m<-kI^dJTtpq+yDLhH+@VW(I@z8QJ*GsM5k!AqwjyB^$-0*GdiIz z?a&z=&>lUf7xXE8O)u$pY!B#|p5m7=J)>*1hx7;iMVI*gB|bml*GqZ=syp;Ey~F<3 z^abss@3+L%TlAUW-=lN%8PNd8-_a}lA3`Q2O%LsUwD*oa2h~1~o#1~D-*`Km_vE%o zw+l)me2zhXfX@WfU(ywN_VGQYuk{e?;yWDI`@nmObGw9Aj-ZSEh{jX&y@~o?fi}x| z3A)cB*~ie-5XYY4w<)A-N1T-GuW+oA{5Q0v+#P(o!|@x&87PTZ?h$$@jXaNd_95E| zxOJmFTAy*RF3yuBi{%^7`ySF;q51g8dbA(Y?$hmDh91s6mjCdB<@p%?Gi{)LZPBu9 zu}c^5-85@R+1#z7doQw%ib-{(+NNtr?Ond6evP8$9KJY3BwR(_{~B@k4*u7L2W06| zo<&lp947;OIL;>U_d6>-RByFyA?C8sBu`R_Ad05kwh&b&K2@hUhK|mnvpkx|NVdIM zo^`PKd)h|ScaSqK@Sjn$h##o4OZtNJr#vggG_pOxo!$vNi~TVR%cQTUcD^;$k?U&J z7l?^F=;oTfFKlCd&e?X7w2RnX(@jQdc}is>RLQ~Hhhf(lqInDc-vPd#BC}ti{wNyJ zNo`b(Zj)ZN2f0SNNxN*+ODe??K=yjbUc+_t2s!->HLp8nm|DuVzRAf2K};@5H1S2Z zDz=zXF1u#VtA^T?RuxEOZey&KhBKzV+d!cs`U-e-1}~b@F6y#QDKtvz&nGgOw6p!R z3QC!Ld?1#jSPs1;g~x3*rbceApWD$VANbtKQSxTkpW)*k!#=in0!P1zqiGD=%9 z#_k{kzmGETHOA@NsOQSma#F{#%@JQ0lA3(NpsLB!H^NHl)zMZ`Ymm2y-|J(t=l!H+ zUE0Nw`Uz0hoXd6LlXh$tYS+Q7E(Dk7)_Aq{Sgt41(Oe(HUfsh{Tq&Oc?K>F7Z(vr{ zC*mjV69WBBp5Zfr$-mC5AUXNX5R!5eS2|{@#g(SBRa=xP%N5TqTi&yal7Y8bnWk>e zA+nBnuFZHt&EWZrlwDjk93x8|V>~j#T7nU>Rb5#sY0uCZ%jCb@>}ARlDw9dB^<_25 zrO9wQGu0orn%8!aC{~s(o)Nce&4@}K-ezUTkWz%sl%@`vtQjtAn$fq3>m?Uhi!s7y ziVt6V3z1oq_Fra}HDy_yjBCoZjO;5V2$P8=$0jT5SPh+(({Vw|vx9ZMyrSKsgj4-qR!$dIK zwTa@@i{eYHRUaVMd95zrZE}pevqINy>=((=p2C?>aU1(*_DF9zGVz^RjJR(wE+3%9 zsJ!S7sWkNT^;#^WseK5OYmb+z-xJ33M~gI$%9=gLn4NY*4V zl5MUSlCgfmB3USI$@}xAeSEiDxF=WDo(I-F$SsT@;wK24!Y{4^X}p;|D{kecj$U5& z`4KU}PXM`^M@h(>l9xwVD|%)sDQT!0*{(!aMJ{`;tcClTO?%d#UqV#JVJhMVhn9y! zZ{WB5gq15~=+(s_Ddno*&vIl+!=4*q!w|SrXDwUbHP(76L;qC$YQUd-ekfHxyJ~Wj z?_ExEcYba?ic3|DZqcfucxABu*?rGBGv-y;`gs+5PBS%jOuFtQYb$LeuT@rgB1fJFRio`=g{Ygis9A}J>@~WI-ynC)P#TK8Df^n0 z$*F8bY@>-Ux>d2o)Wy25`Hy`e4QEV!x1*<{O);l>gZTu0rtZMQiYkscpU7m|CfiS| zpp?nS2VzNz<@h*#lrd5;AX{(QujgoxU zzSdC}tM}!(t9J1*h3dgucYba?ic3|DZZpzR&(zgph<)eUH!n^lZI}48Ym@Sn>^O5n z|Mf?Dw(BkK-s8^&5QgvKn>8aHpjMGTH*ZlCAF|iTy44*sl!kblhaV;<7X&f6B(u6N zx>d2olycoiI*W0})HjcGIzXZsu&9fg=LS#zzHrVxQg^X0H6xv*P8Pi#!#-~DFOLby z(*5#aKGLz@ClPAAvsi+%m?-USS6g-SvvIQ-`$&tJZU1ARylc4G92bwU()TP{m6(_P z)815+l-%T~<(A{SMF@6_C)n~^ka!i=HmYKN!^jZddT0%B-+CYWcoieR`{^9N^Shkh zp~nm>fG5aUH~5v`^&?}Dd71NVhsIjdmUxF$8V5;>y`UAz!JPO_P4fLyN)uAM%w4}7 zh~J(RufR>p(8Hb~XttyCnnbjDwdQp+>)1}v_=fBi#cVr8gIhglEWMwWlUw)nKh`M> A_y7O^ literal 35148 zcmeHQS#KLR5ax4${)b{;)1>j0Gy)2wahf)0(<4b!plx8tmT%dzCCjm0xIew^H%pH> z-1{K8O0p2-U9HKP;c(`gA-UZB@87@GXX=jn9G^&Ssi8W-cT?@FF}`Q&QoU3YHB#I7 z9jO6Inew{M6V#fhW0c)i7x=47{dr53O6ih&`0p9$57d|V4AqsIt0`*jqyCw?AJaL) zf6mns{@PS$xbgz!-UxZ_s{=KQc}Etvcj^o4cch+x$LL0~DCT+$DX8ud_*0unDr)66 z&c0-gTF`d^{>+FUb$uW#& zWNK2zd(gU(@?I>Plrt^e{!KfmJ=<}g&2sz8?q}1Besp=MR@Dkxy^HTL&UQ2PE2XgY zoVGVp_Qj2LPSp;yxrLU~73$p~j`Z8MAFi3SBfcKNCW`tf%`e##hSG6C{}$WeBXtNp zUtn<;>D8+a*$20&Db8?xI!5ZXh+@xg`=XlVFr}&%#O2HINNT1E_Ap)Dq9hKrqt+CEY?&4d zi=;*d7$wY*8;*fgb7X}Vh``mKId<6^v(Y3k1Ce#)d0mUtvb)DODN{SFla%V= zo~*gp7?^E|&w!C9$cOeY9&Cn`Newi~&LjkI>f94!@;*ZTvyY;uqJYtnM&g zQf-d+lFG+`{n!?dc$lVwDNYGXd8op&sXEH1gY zjxn-5pNg+tsfWnUPUGxsikY%Cj327blSzFQo%{4c;u~4BeDy}YMRL3HK`mpr7$)1~ zC!R?TZep9H_-Xr|iG?f9J(0ES+anDh7qe4Lt?1~|SD8=s*qOh|#I%|mi{hv+jy_|m z)Ism#dPanNe1sW18WHXx{%&9%ZwV`su48ag_pZl_lD<6m6`LgwAHGlY%lzLSA-l<) zWxKQtZPr(k)XILlkL8kM@AG9oZ({$dR|Mc*36%#hKRWsyH%m_P+!sbLk20)~J?mGn;7q(s0a8S1VJ3?=Ppo3 z^R**(-8IR&HtHL5bep=FSIEG%%*2TxVzHV@a))uqZJ4~7U`504%#%9_1lPN=( zwv6@uL)=4pg6|w(x&y}NypHTodEPbJXZgpQ%{k$G3o3vDa%J%7gXC67h< z;-W~H&Lc_7v=>Typu4*xlG@4peQoVD%`4^V`nzDI-J|?l zcr`sUcY-{pM|gUwFFhSSRPGxx?=2gb*$6M~Vv*3jJGruu{akoY7_0F=LhyTMl|T9Y zPS(BJ#Cb;xENWw86DP*|79`U ztU)|Q7E|xuhV)8SJ8J28Sm|KnPkz49wExhe>csbRXER<=wK~Sbf$0oMR z^~;de)@5C6WiE6~PE&_QSV(dS}8M>pka~iK91NjUC-H?;?+oy4KcVqh*_! zh&KM9W$}yavh9BMt2W1bTegqY2l%~za_5-KSwJ5i!ad_YelTS^w)GvRRTfvWdZ0~o zVZL-sM;gD}(y@nH?{ZIK_0h1@1E1F$d2=l~Hsf2rRy_%;)4#4U7OaPvi#tMFZ!{Kj zfmm*ksV)MQA&nW$uREO^XhJQG6!ol*d~&fZ?*dPRq33leR+zR#Ip%DJlrlcLKuw$8YxoPlv$yHn4kg99Xe#TL zA*-#+HrZP`V{=K{1?t**S=4b6S#&l6+xi^fEnOS%wJV_X7IM{dAk<-_yg}-P&Ru(i zrfH*PKR1xndY#|(Gmb3kA7U0#%?Dn?C+H5q^t-P683M9|HW5WyQdxA#?}s*##+D%Y zCf*P{)6uA(Ew}@_s{ZjeTTmsklyfhhpUD4jk!O{8%Zr2Cw3O->awiya(-5wT`EbPSp?U4{)QsEGu}9zQ&OC>^Cl@z2;i|513xKV*mgE diff --git a/azext_iot/tests/central/json/device_template.json b/azext_iot/tests/central/json/device_template.json index 41cedaf66..61c580948 100644 --- a/azext_iot/tests/central/json/device_template.json +++ b/azext_iot/tests/central/json/device_template.json @@ -1,285 +1,204 @@ { - "id": "urn:d9cltbeus:tvj4oal1a0", - "etag": "\"~WgqHZmg+d95gTA53P8AnqBsDLGgj2wa0msOL7xozC9Y=\"", - "types": [ - "DeviceModel" - ], + "etag": "\"~Wh8etPOe6WmVnj+WRh7k721V17PaQqCFzgzhNhjyzI4=\"", "displayName": "duplicate-field-name", "capabilityModel": { - "@id": "urn:sampleApp:modelOne_bz:2", - "@type": "CapabilityModel", - "implements": [ + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_bz:2", + "@type": "Interface", + "contents": [], + "displayName": "larger-telemetry-device", + "extends": [ { - "@id": "urn:sampleApp:modelOne_bz:_rpgcmdpo:1", - "@type": [ - "InterfaceInstance" - ], - "displayName": "Interface", - "name": "modelOne_g4", - "schema": { - "@id": "urn:sampleApp:modelOne_g4:1", - "@type": [ - "Interface" - ], - "displayName": "Interface", - "contents": [ - { - "@id": "urn:sampleApp:modelOne_g4:Bool:1", - "@type": [ - "Telemetry" - ], - "displayName": "Bool", - "name": "Bool", - "schema": "boolean" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Date:1", - "@type": [ - "Telemetry" - ], - "displayName": "Date", - "name": "Date", - "schema": "date" - }, - { - "@id": "urn:sampleApp:modelOne_g4:DateTime:1", - "@type": [ - "Telemetry" - ], - "displayName": "DateTime", - "name": "DateTime", - "schema": "dateTime" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Double:1", - "@type": [ - "Telemetry" - ], - "displayName": "Double", - "name": "Double", - "schema": "double" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Duration:1", - "@type": [ - "Telemetry" - ], - "displayName": "Duration", - "name": "Duration", - "schema": "duration" - }, - { - "@id": "urn:sampleApp:modelOne_g4:IntEnum:1", - "@type": [ - "Telemetry" - ], - "displayName": "IntEnum", - "name": "IntEnum", - "schema": { - "@id": "urn:sampleApp:modelOne_g4:IntEnum:pgkbdhard:1", - "@type": [ - "Enum" - ], - "displayName": "Enum", - "valueSchema": "integer", - "enumValues": [ - { - "@id": "urn:sampleApp:modelOne_g4:IntEnum:pgkbdhard:Enum1:1", - "@type": [ - "EnumValue" - ], - "displayName": "Enum1", - "enumValue": 1, - "name": "Enum1" - }, - { - "@id": "urn:sampleApp:modelOne_g4:IntEnum:pgkbdhard:Enum2:1", - "@type": [ - "EnumValue" - ], - "displayName": "Enum2", - "enumValue": 2, - "name": "Enum2" - } - ] - } - }, - { - "@id": "urn:sampleApp:modelOne_g4:StringEnum:1", - "@type": [ - "Telemetry" - ], - "displayName": "StringEnum", - "name": "StringEnum", - "schema": { - "@id": "urn:sampleApp:modelOne_g4:StringEnum:kyesuinpsx:1", - "@type": [ - "Enum" - ], - "displayName": "Enum", - "valueSchema": "string", - "enumValues": [ - { - "@id": "urn:sampleApp:modelOne_g4:StringEnum:kyesuinpsx:EnumA:1", - "@type": [ - "EnumValue" - ], - "displayName": "EnumA", - "enumValue": "A", - "name": "EnumA" - }, - { - "@id": "urn:sampleApp:modelOne_g4:StringEnum:kyesuinpsx:EnumB:1", - "@type": [ - "EnumValue" - ], - "displayName": "EnumB", - "enumValue": "B", - "name": "EnumB" - } - ] - } - }, - { - "@id": "urn:sampleApp:modelOne_g4:Float:1", - "@type": [ - "Telemetry" - ], - "displayName": "Float", - "name": "Float", - "schema": "float" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Geopoint:1", - "@type": [ - "Telemetry" - ], - "displayName": "Geopoint", - "name": "Geopoint", - "schema": "geopoint" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Int:1", - "@type": [ - "Telemetry" - ], - "displayName": "Int", - "name": "Int", - "schema": "integer" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Long:1", - "@type": [ - "Telemetry" - ], - "displayName": "Long", - "name": "Long", - "schema": "long" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Object:1", - "@type": [ - "Telemetry" - ], + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:1", + "@type": "Interface", + "contents": [ + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:Bool:1", + "@type": "Telemetry", + "displayName": "Bool", + "name": "Bool", + "schema": "boolean" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:Date:1", + "@type": "Telemetry", + "displayName": "Date", + "name": "Date", + "schema": "date" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:DateTime:1", + "@type": "Telemetry", + "displayName": "DateTime", + "name": "DateTime", + "schema": "dateTime" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:Double:1", + "@type": "Telemetry", + "displayName": "Double", + "name": "Double", + "schema": "double" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:Duration:1", + "@type": "Telemetry", + "displayName": "Duration", + "name": "Duration", + "schema": "duration" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:IntEnum:1", + "@type": "Telemetry", + "displayName": "IntEnum", + "name": "IntEnum", + "schema": { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:IntEnum:pgkbdhard:1", + "@type": "Enum", + "displayName": "Enum", + "enumValues": [ + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:IntEnum:pgkbdhard:Enum1:1", + "displayName": "Enum1", + "enumValue": 1, + "name": "Enum1" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:IntEnum:pgkbdhard:Enum2:1", + "displayName": "Enum2", + "enumValue": 2, + "name": "Enum2" + } + ], + "valueSchema": "integer" + } + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:StringEnum:1", + "@type": "Telemetry", + "displayName": "StringEnum", + "name": "StringEnum", + "schema": { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:StringEnum:kyesuinpsx:1", + "@type": "Enum", + "displayName": "Enum", + "enumValues": [ + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:StringEnum:kyesuinpsx:EnumA:1", + "displayName": "EnumA", + "enumValue": "A", + "name": "EnumA" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:StringEnum:kyesuinpsx:EnumB:1", + "displayName": "EnumB", + "enumValue": "B", + "name": "EnumB" + } + ], + "valueSchema": "string" + } + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:Float:1", + "@type": "Telemetry", + "displayName": "Float", + "name": "Float", + "schema": "float" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:Geopoint:1", + "@type": "Telemetry", + "displayName": "Geopoint", + "name": "Geopoint", + "schema": "geopoint" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:Int:1", + "@type": "Telemetry", + "displayName": "Int", + "name": "Int", + "schema": "integer" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:Long:1", + "@type": "Telemetry", + "displayName": "Long", + "name": "Long", + "schema": "long" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:Object:1", + "@type": "Telemetry", + "displayName": "Object", + "name": "Object", + "schema": { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:Object:8ot2x5whp8:1", + "@type": "Object", "displayName": "Object", - "name": "Object", - "schema": { - "@id": "urn:sampleApp:modelOne_g4:Object:8ot2x5whp8:1", - "@type": [ - "Object" - ], - "displayName": "Object", - "fields": [ - { - "@id": "urn:sampleApp:modelOne_g4:Object:8ot2x5whp8:Double:1", - "@type": [ - "SchemaField" - ], - "displayName": "Double", - "name": "Double", - "schema": "double" - } - ] - } - }, - { - "@id": "urn:sampleApp:modelOne_g4:String:1", - "@type": [ - "Telemetry" - ], - "displayName": "String", - "name": "String", - "schema": "string" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Time:1", - "@type": [ - "Telemetry" - ], - "displayName": "Time", - "name": "Time", - "schema": "time" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Vector:1", - "@type": [ - "Telemetry" - ], - "displayName": "Vector", - "name": "Vector", - "schema": "vector" + "fields": [ + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:Object:8ot2x5whp8:Double:1", + "displayName": "Double", + "name": "Double", + "schema": "double" + } + ] } - ] - } + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:String:1", + "@type": "Telemetry", + "displayName": "String", + "name": "String", + "schema": "string" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:Time:1", + "@type": "Telemetry", + "displayName": "Time", + "name": "Time", + "schema": "time" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:largerTelemetryDevice_g4:Vector:1", + "@type": "Telemetry", + "displayName": "Vector", + "name": "Vector", + "schema": "vector" + } + ], + "displayName": "Interface" }, { - "@id": "urn:sampleApp:modelOne_bz:myxqftpsr:2", - "@type": [ - "InterfaceInstance" + "@id": "urn:azCliDevelopmentFlashmagnus:duplicateFieldName_ed:1", + "@type": "Interface", + "contents": [ + { + "@id": "urn:azCliDevelopmentFlashmagnus:duplicateFieldName_ed:Bool:1", + "@type": "Telemetry", + "displayName": "Bool", + "name": "Bool", + "schema": "boolean" + }, + { + "@id": "urn:azCliDevelopmentFlashmagnus:duplicateFieldName_ed:bool:1", + "@type": "Telemetry", + "displayName": "bool", + "name": "bool", + "schema": "boolean" + } ], - "displayName": "Interface", - "name": "modelTwo_ed", - "schema": { - "@id": "urn:sampleApp:modelTwo_ed:1", - "@type": [ - "Interface" - ], - "displayName": "Interface", - "contents": [ - { - "@id": "urn:sampleApp:modelTwo_ed:Bool:1", - "@type": [ - "Telemetry" - ], - "displayName": "Bool", - "name": "Bool", - "schema": "boolean" - }, - { - "@id": "urn:sampleApp:modelTwo_ed:bool:1", - "@type": [ - "Telemetry" - ], - "displayName": "bool", - "name": "bool", - "schema": "boolean" - } - ] - } + "displayName": "Interface" } - ], - "displayName": "larger-telemetry-device", - "@context": [ - "http://azureiot.com/v1/contexts/IoTModel.json" ] }, - "solutionModel": { - "@id": "urn:d9cltbeus:lz1tl4a_jz", - "@type": [ - "SolutionModel" - ], - "cloudProperties": [], - "initialValues": [], - "overrides": [] - } + "@id": "urn:d9cltbeus:tvj4oal1a0", + "@type": [ + "ModelDefinition", + "DeviceModel" + ], + "@context": [ + "dtmi:iotcentral:context;2", + "dtmi:dtdl:context;2" + ] } \ No newline at end of file diff --git a/azext_iot/tests/central/json/device_template_int_test.json b/azext_iot/tests/central/json/device_template_int_test.json index 689656bf8..1e238e4ba 100644 --- a/azext_iot/tests/central/json/device_template_int_test.json +++ b/azext_iot/tests/central/json/device_template_int_test.json @@ -1,320 +1,211 @@ { - "types": [ - "DeviceModel" - ], - "displayName": "int-test-device-template", + "displayName": "dtmi:intTestDeviceTemplate", "capabilityModel": { - "@id": "urn:sampleApp:modelOne_bz:2", - "@type": "CapabilityModel", + "@id": "dtmi:sampleApp:modelOnebz;3", + "@type": "Interface", "contents": [ { - "@id": "urn:testazuresphere:AzureSphereSampleDevice_614:testDefaultCapability:2", + "@id": "dtmi:testazuresphere:AzureSphereSampleDevice614:testDefaultCapability;2", "@type": "Telemetry", "displayName": "testDefaultCapability", "name": "testDefaultCapability", "schema": "double" - } - ], - "implements": [ + }, { - "@id": "urn:sampleApp:modelOne_bz:_rpgcmdpo:1", - "@type": [ - "InterfaceInstance" - ], - "displayName": "Interface", - "name": "modelOne_g4", + "@id": "dtmi:sampleApp:modelOnebz:dtmiIntTestDeviceTemplateV33jl;3", + "@type": "Component", + "displayName": "Component", + "name": "dtmiIntTestDeviceTemplateV33jl", "schema": { - "@id": "urn:sampleApp:modelOne_g4:1", - "@type": [ - "Interface" - ], - "displayName": "Interface", + "@id": "dtmi:cliIntegrationtestApp:dtmiIntTestDeviceTemplateV33jl;1", + "@type": "Interface", "contents": [ { - "@id": "urn:sampleApp:modelOne_g4:Bool:1", - "@type": [ - "Telemetry" - ], - "displayName": "Bool", - "name": "Bool", - "schema": "boolean" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Date:1", - "@type": [ - "Telemetry" - ], - "displayName": "Date", - "name": "Date", - "schema": "date" - }, - { - "@id": "urn:sampleApp:modelOne_g4:DateTime:1", - "@type": [ - "Telemetry" - ], - "displayName": "DateTime", - "name": "DateTime", - "schema": "dateTime" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Double:1", - "@type": [ - "Telemetry" - ], - "displayName": "Double", - "name": "Double", - "schema": "double" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Duration:1", - "@type": [ - "Telemetry" - ], - "displayName": "Duration", - "name": "Duration", - "schema": "duration" - }, - { - "@id": "urn:sampleApp:modelOne_g4:IntEnum:1", - "@type": [ - "Telemetry" - ], - "displayName": "IntEnum", - "name": "IntEnum", - "schema": { - "@id": "urn:sampleApp:modelOne_g4:IntEnum:pgkbdhard:1", - "@type": [ - "Enum" - ], - "displayName": "Enum", - "valueSchema": "integer", - "enumValues": [ - { - "@id": "urn:sampleApp:modelOne_g4:IntEnum:pgkbdhard:Enum1:1", - "@type": [ - "EnumValue" - ], - "displayName": "Enum1", - "enumValue": 1, - "name": "Enum1" - }, - { - "@id": "urn:sampleApp:modelOne_g4:IntEnum:pgkbdhard:Enum2:1", - "@type": [ - "EnumValue" - ], - "displayName": "Enum2", - "enumValue": 2, - "name": "Enum2" - } - ] - } - }, - { - "@id": "urn:sampleApp:modelOne_g4:StringEnum:1", - "@type": [ - "Telemetry" - ], - "displayName": "StringEnum", - "name": "StringEnum", - "schema": { - "@id": "urn:sampleApp:modelOne_g4:StringEnum:kyesuinpsx:1", - "@type": [ - "Enum" - ], - "displayName": "Enum", - "valueSchema": "string", - "enumValues": [ - { - "@id": "urn:sampleApp:modelOne_g4:StringEnum:kyesuinpsx:EnumA:1", - "@type": [ - "EnumValue" - ], - "displayName": "EnumA", - "enumValue": "A", - "name": "EnumA" - }, - { - "@id": "urn:sampleApp:modelOne_g4:StringEnum:kyesuinpsx:EnumB:1", - "@type": [ - "EnumValue" - ], - "displayName": "EnumB", - "enumValue": "B", - "name": "EnumB" - } - ] - } - }, - { - "@id": "urn:sampleApp:modelOne_g4:Float:1", - "@type": [ - "Telemetry" - ], - "displayName": "Float", - "name": "Float", - "schema": "float" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Geopoint:1", - "@type": [ - "Telemetry" - ], - "displayName": "Geopoint", - "name": "Geopoint", - "schema": "geopoint" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Int:1", - "@type": [ - "Telemetry" - ], - "displayName": "Int", - "name": "Int", - "schema": "integer" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Long:1", - "@type": [ - "Telemetry" - ], - "displayName": "Long", - "name": "Long", - "schema": "long" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Object:1", - "@type": [ - "Telemetry" - ], - "displayName": "Object", - "name": "Object", - "schema": { - "@id": "urn:sampleApp:modelOne_g4:Object:8ot2x5whp8:1", - "@type": [ - "Object" - ], - "displayName": "Object", - "fields": [ - { - "@id": "urn:sampleApp:modelOne_g4:Object:8ot2x5whp8:Double:1", - "@type": [ - "SchemaField" - ], - "displayName": "Double", - "name": "Double", - "schema": "double" - } - ] - } - }, - { - "@id": "urn:sampleApp:modelOne_g4:String:1", - "@type": [ - "Telemetry" - ], - "displayName": "String", - "name": "String", - "schema": "string" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Time:1", - "@type": [ - "Telemetry" - ], - "displayName": "Time", - "name": "Time", - "schema": "time" - }, - { - "@id": "urn:sampleApp:modelOne_g4:Vector:1", - "@type": [ - "Telemetry" - ], - "displayName": "Vector", - "name": "Vector", - "schema": "vector" - }, - { - "@id": "urn:sampleApp:modelOne_g4:sync_cmd:1", - "@type": [ - "Command" - ], + "@id": "dtmi:cliIntegrationtestApp:dtmiIntTestDeviceTemplateV33jl:testCommand;1", + "@type": "Command", "commandType": "synchronous", - "displayName": "sync_cmd", - "durable": false, - "name": "sync_cmd", - "request": { - "@id": "urn:sampleApp:modelOne_g4:sync_cmd:argument:1", - "@type": [ - "SchemaField" - ], - "displayName": "argument", - "name": "argument", - "schema": "string" - }, - "response": { - "@id": "urn:sampleApp:modelOne_g4:sync_cmd:status:1", - "@type": [ - "SchemaField" - ], - "displayName": "status", - "name": "status", - "schema": "double" - } + "displayName": "testCommand", + "name": "testCommand" } - ] - } - }, - { - "@id": "urn:sampleApp:modelOne_bz:myxqftpsr:2", - "@type": [ - "InterfaceInstance" - ], - "displayName": "Interface", - "name": "modelTwo_ed", - "schema": { - "@id": "urn:sampleApp:modelTwo_ed:1", - "@type": [ - "Interface" ], - "displayName": "Interface", - "contents": [ - { - "@id": "urn:sampleApp:modelTwo_ed:Bool:1", - "@type": [ - "Telemetry" - ], - "displayName": "Bool", - "name": "Bool", - "schema": "boolean" - }, - { - "@id": "urn:sampleApp:modelTwo_ed:bool:1", - "@type": [ - "Telemetry" - ], - "displayName": "bool", - "name": "bool", - "schema": "boolean" - } - ] + "displayName": "Component" } } ], "displayName": "larger-telemetry-device", - "@context": [ - "http://azureiot.com/v1/contexts/IoTModel.json" + "extends": [ + { + "@id": "dtmi:sampleApp:modelOneg4;1", + "@type": "Interface", + "contents": [ + { + "@id": "dtmi:sampleApp:modelOneg4:Bool;1", + "@type": "Telemetry", + "displayName": "Bool", + "name": "Bool", + "schema": "boolean" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:Date;1", + "@type": "Telemetry", + "displayName": "Date", + "name": "Date", + "schema": "date" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:DateTime;1", + "@type": "Telemetry", + "displayName": "DateTime", + "name": "DateTime", + "schema": "dateTime" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:Double;1", + "@type": "Telemetry", + "displayName": "Double", + "name": "Double", + "schema": "double" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:Duration;1", + "@type": "Telemetry", + "displayName": "Duration", + "name": "Duration", + "schema": "duration" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:IntEnum;1", + "@type": "Telemetry", + "displayName": "IntEnum", + "name": "IntEnum", + "schema": { + "@id": "dtmi:sampleApp:modelOneg4:IntEnum:pgkbdhard;1", + "@type": "Enum", + "displayName": "Enum", + "enumValues": [ + { + "@id": "dtmi:sampleApp:modelOneg4:IntEnum:pgkbdhard:Enum1;1", + "displayName": "Enum1", + "enumValue": 1, + "name": "Enum1" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:IntEnum:pgkbdhard:Enum2;1", + "displayName": "Enum2", + "enumValue": 2, + "name": "Enum2" + } + ], + "valueSchema": "integer" + } + }, + { + "@id": "dtmi:sampleApp:modelOneg4:StringEnum;1", + "@type": "Telemetry", + "displayName": "StringEnum", + "name": "StringEnum", + "schema": { + "@id": "dtmi:sampleApp:modelOneg4:StringEnum:kyesuinpsx;1", + "@type": "Enum", + "displayName": "Enum", + "enumValues": [ + { + "@id": "dtmi:sampleApp:modelOneg4:StringEnum:kyesuinpsx:EnumA;1", + "displayName": "EnumA", + "enumValue": "A", + "name": "EnumA" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:StringEnum:kyesuinpsx:EnumB;1", + "displayName": "EnumB", + "enumValue": "B", + "name": "EnumB" + } + ], + "valueSchema": "string" + } + }, + { + "@id": "dtmi:sampleApp:modelOneg4:Float;1", + "@type": "Telemetry", + "displayName": "Float", + "name": "Float", + "schema": "float" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:Geopoint;1", + "@type": "Telemetry", + "displayName": "Geopoint", + "name": "Geopoint", + "schema": "geopoint" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:Int;1", + "@type": "Telemetry", + "displayName": "Int", + "name": "Int", + "schema": "integer" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:Long;1", + "@type": "Telemetry", + "displayName": "Long", + "name": "Long", + "schema": "long" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:String;1", + "@type": "Telemetry", + "displayName": "String", + "name": "String", + "schema": "string" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:Time;1", + "@type": "Telemetry", + "displayName": "Time", + "name": "Time", + "schema": "time" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:Vector;1", + "@type": "Telemetry", + "displayName": "Vector", + "name": "Vector", + "schema": "vector" + }, + { + "@id": "dtmi:sampleApp:modelOneg4:synccmd;1", + "@type": "Command", + "commandType": "synchronous", + "displayName": "synccmd", + "name": "synccmd", + "request": { + "@type": "CommandPayload", + "displayName": "argument", + "name": "argument", + "schema": "string" + }, + "response": { + "@type": "CommandPayload", + "displayName": "status", + "name": "status", + "schema": "double" + }, + "durable": false + } + ], + "displayName": "Interface" + } ] }, - "solutionModel": { - "@id": "urn:d9cltbeus:lz1tl4a_jz", - "@type": [ - "SolutionModel" - ], - "cloudProperties": [], - "initialValues": [], - "overrides": [] - } + "@id": "dtmi:ulu38i3jr:intTestDeviceTemplateid", + "@type": [ + "DeviceModel", + "ModelDefinition" + ], + "@context": [ + "dtmi:iotcentral:context;2", + "dtmi:dtdl:context;2" + ] } \ No newline at end of file diff --git a/azext_iot/tests/central/json/property_validation_template.json b/azext_iot/tests/central/json/property_validation_template.json index ce0bee64b..4e9a4178c 100644 --- a/azext_iot/tests/central/json/property_validation_template.json +++ b/azext_iot/tests/central/json/property_validation_template.json @@ -131,138 +131,106 @@ "writable": true } ], - "implements": [ + "extends": [ { "@id": "urn:sampleApp:groupOne_bz:_rpgcmdpo:1", - "@type": [ - "InterfaceInstance" - ], - "displayName": "Interface", - "name": "groupOne_g4", - "schema": { - "@id": "urn:sampleApp:groupOne_g4:1", - "@type": [ - "Interface" - ], - "displayName": "Interface", - "contents": [ - { - "@id": "urn:sampleApp:groupOne_g4:Model:1", - "@type": [ - "Property" - ], - "displayName": "Model", - "name": "Model", - "schema": "string" - }, - { - "@id": "urn:sampleApp:groupOne_g4:Version:1", - "@type": [ - "Property" - ], - "displayName": "Version", - "name": "Version", - "schema": "string" - }, - { - "@id": "urn:sampleApp:groupOne_g4:TotalStorage:1", - "@type": [ - "Property" - ], - "displayName": "TotalStorage", - "name": "TotalStorage", - "schema": "string" - } - ] - } + "@type": "InterfaceInstance", + "contents": [ + { + "@id": "urn:sampleApp:groupOne_g4:Model:1", + "@type": [ + "Property" + ], + "displayName": "Model", + "name": "Model", + "schema": "string" + }, + { + "@id": "urn:sampleApp:groupOne_g4:Version:1", + "@type": [ + "Property" + ], + "displayName": "Version", + "name": "Version", + "schema": "string" + }, + { + "@id": "urn:sampleApp:groupOne_g4:TotalStorage:1", + "@type": [ + "Property" + ], + "displayName": "TotalStorage", + "name": "TotalStorage", + "schema": "string" + } + ] }, { "@id": "urn:sampleApp:groupTwo_bz:myxqftpsr:2", - "@type": [ - "InterfaceInstance" - ], + "@type": "InterfaceInstance", "displayName": "Interface", - "name": "groupTwo_ed", - "schema": { - "@id": "urn:sampleApp:groupTwo_ed:1", - "@type": [ - "Interface" - ], - "displayName": "Interface", - "contents": [ - { - "@id": "urn:sampleApp:groupTwo_ed:Model:1", - "@type": [ - "Property" - ], - "displayName": "Model", - "name": "Model", - "schema": "string" - }, - { - "@id": "urn:sampleApp:groupThree_ed:Manufacturer:1", - "@type": [ - "Property" - ], - "displayName": "Manufacturer", - "name": "Manufacturer", - "schema": "string" - } - ] - } + "contents": [ + { + "@id": "urn:sampleApp:groupTwo_ed:Model:1", + "@type": [ + "Property" + ], + "displayName": "Model", + "name": "Model", + "schema": "string" + }, + { + "@id": "urn:sampleApp:groupThree_ed:Manufacturer:1", + "@type": [ + "Property" + ], + "displayName": "Manufacturer", + "name": "Manufacturer", + "schema": "string" + } + ] }, { "@id": "urn:sampleApp:groupThree_bz:myxqftpsr:2", - "@type": [ - "InterfaceInstance" - ], - "displayName": "Interface", - "name": "groupThree_ed", - "schema": { - "@id": "urn:sampleApp:groupThree_ed:1", - "@type": [ - "Interface" - ], - "displayName": "Interface", - "contents": [ - { - "@id": "urn:sampleApp:groupThree_ed:Manufacturer:1", - "@type": [ - "Property" - ], - "displayName": "Manufacturer", - "name": "Manufacturer", - "schema": "string" - }, - { - "@id": "urn:sampleApp:groupThree_g4:Version:1", - "@type": [ - "Property" - ], - "displayName": "Version", - "name": "Version", - "schema": "string" - }, - { - "@id": "urn:sampleApp:groupThree_ed:Model:1", - "@type": [ - "Property" - ], - "displayName": "Model", - "name": "Model", - "schema": "string" - }, - { - "@id": "urn:sampleApp:groupThree_ed:OsName:1", - "@type": [ - "Property" - ], - "displayName": "OsName", - "name": "OsName", - "schema": "string" - } - ] - } + "@type": "Interface", + "contents": [ + { + "@id": "urn:sampleApp:groupThree_ed:Manufacturer:1", + "@type": [ + "Property" + ], + "displayName": "Manufacturer", + "name": "Manufacturer", + "schema": "string" + }, + { + "@id": "urn:sampleApp:groupThree_g4:Version:1", + "@type": [ + "Property" + ], + "displayName": "Version", + "name": "Version", + "schema": "string" + }, + { + "@id": "urn:sampleApp:groupThree_ed:Model:1", + "@type": [ + "Property" + ], + "displayName": "Model", + "name": "Model", + "schema": "string" + }, + { + "@id": "urn:sampleApp:groupThree_ed:OsName:1", + "@type": [ + "Property" + ], + "displayName": "OsName", + "name": "OsName", + "schema": "string" + } + ] } ], "displayName": "property_validation", diff --git a/azext_iot/tests/central/test_iot_central_int.py b/azext_iot/tests/central/test_iot_central_int.py index 0c16519df..d5fde866d 100644 --- a/azext_iot/tests/central/test_iot_central_int.py +++ b/azext_iot/tests/central/test_iot_central_int.py @@ -25,10 +25,7 @@ DEVICE_ID = os.environ.get("azext_iot_central_device_id") TOKEN = os.environ.get("azext_iot_central_token") DNS_SUFFIX = os.environ.get("azext_iot_central_dns_suffix") - -device_template_path = get_context_path( - __file__, "json/device_template_int_test.json" -) +device_template_path = get_context_path(__file__, "json/device_template_int_test.json") sync_command_params = get_context_path(__file__, "json/sync_command_args.json") if not all([APP_ID]): @@ -77,7 +74,7 @@ def test_central_device_twin_show_fail(self): def test_central_device_twin_show_success(self): (template_id, _) = self._create_device_template() - (device_id, _) = self._create_device(instance_of=template_id, simulated=True) + (device_id, _) = self._create_device(template=template_id, simulated=True) # wait about a few seconds for simulator to kick in so that provisioning completes time.sleep(60) @@ -101,7 +98,7 @@ def test_central_device_twin_show_success(self): def test_central_monitor_events(self): (template_id, _) = self._create_device_template() - (device_id, _) = self._create_device(instance_of=template_id) + (device_id, _) = self._create_device(template=template_id) credentials = self._get_credentials(device_id) device_client = helpers.dps_connect_device(device_id, credentials) @@ -134,7 +131,7 @@ def test_central_monitor_events(self): def test_central_validate_messages_success(self): (template_id, _) = self._create_device_template() - (device_id, _) = self._create_device(instance_of=template_id) + (device_id, _) = self._create_device(template=template_id) credentials = self._get_credentials(device_id) device_client = helpers.dps_connect_device(device_id, credentials) @@ -192,7 +189,7 @@ def test_device_connect(self): def test_central_validate_messages_issues_detected(self): expected_messages = [] (template_id, _) = self._create_device_template() - (device_id, _) = self._create_device(instance_of=template_id) + (device_id, _) = self._create_device(template=template_id) credentials = self._get_credentials(device_id) device_client = helpers.dps_connect_device(device_id, credentials) @@ -267,12 +264,13 @@ def test_central_validate_messages_issues_detected(self): assert issue in output def test_central_device_methods_CRD(self): + (device_id, device_name) = self._create_device() self.cmd( "iot central device show --app-id {} -d {}".format(APP_ID, device_id), checks=[ - self.check("approved", True), + self.check("enabled", True), self.check("displayName", device_name), self.check("id", device_id), self.check("simulated", False), @@ -332,22 +330,23 @@ def test_central_device_template_methods_CRD(self): # currently: create, show, list, delete (template_id, template_name) = self._create_device_template() - self.cmd( + result = self.cmd( "iot central device-template show --app-id {} --device-template-id {}".format( APP_ID, template_id ), - checks=[ - self.check("displayName", template_name), - self.check("id", template_id), - ], + checks=[self.check("displayName", template_name)], ) + json_result = result.get_output_in_json() + + assert json_result["@id"] == template_id + self._delete_device_template(template_id) def test_central_device_registration_info_registered(self): (template_id, _) = self._create_device_template() (device_id, device_name) = self._create_device( - instance_of=template_id, simulated=False + template=template_id, simulated=False ) result = self.cmd( @@ -374,7 +373,7 @@ def test_central_device_registration_info_registered(self): assert device_registration_info.get("device_status") == "registered" assert device_registration_info.get("id") == device_id assert device_registration_info.get("display_name") == device_name - assert device_registration_info.get("instance_of") == template_id + assert device_registration_info.get("template") == template_id assert not device_registration_info.get("simulated") # Validation - dps state @@ -384,10 +383,10 @@ def test_central_device_registration_info_registered(self): assert dps_state.get("error") == "Device is not yet provisioned." def test_central_run_command(self): - interface_id = "modelOne_g4" - command_name = "sync_cmd" + interface_id = "dtmiIntTestDeviceTemplateV33jl" + command_name = "testCommand" (template_id, _) = self._create_device_template() - (device_id, _) = self._create_device(instance_of=template_id, simulated=True) + (device_id, _) = self._create_device(template=template_id, simulated=True) self._wait_for_provisioned(device_id) @@ -451,7 +450,7 @@ def test_central_device_registration_info_unassociated(self): assert device_registration_info.get("device_status") == "unassociated" assert device_registration_info.get("id") == device_id assert device_registration_info.get("display_name") == device_name - assert device_registration_info.get("instance_of") is None + assert device_registration_info.get("template") is None assert not device_registration_info.get("simulated") # Validation - dps state @@ -495,7 +494,8 @@ def test_central_device_should_start_failover_and_failback(self): # connect & disconnect device & wait to be provisioned self._connect_gettwin_disconnect_wait_tobeprovisioned(device_id, credentials) command = "iot central device manual-failover --app-id {} --device-id {} --ttl {}".format( - APP_ID, device_id, 5) + APP_ID, device_id, 5 + ) command = self._appendOptionalArgsToCommand(command, TOKEN, DNS_SUFFIX) @@ -510,7 +510,8 @@ def test_central_device_should_start_failover_and_failback(self): self._connect_gettwin_disconnect_wait_tobeprovisioned(device_id, credentials) command = "iot central device manual-failback --app-id {} --device-id {}".format( - APP_ID, device_id) + APP_ID, device_id + ) command = self._appendOptionalArgsToCommand(command, TOKEN, DNS_SUFFIX) @@ -528,8 +529,7 @@ def test_central_device_should_start_failover_and_failback(self): "iot central device manual-failover" " --app-id {}" " --device-id {}" - " --ttl {}" - .format(APP_ID, device_id, 5) + " --ttl {}".format(APP_ID, device_id, 5) ) command = self._appendOptionalArgsToCommand(command, TOKEN, DNS_SUFFIX) @@ -556,20 +556,21 @@ def _create_device(self, **kwargs) -> (str, str): device_name = self.create_random_name(prefix="aztest", length=24) command = "iot central device create --app-id {} -d {} --device-name {}".format( - APP_ID, device_id, device_name) + APP_ID, device_id, device_name + ) command = self._appendOptionalArgsToCommand(command, TOKEN, DNS_SUFFIX) checks = [ - self.check("approved", True), + self.check("enabled", True), self.check("displayName", device_name), self.check("id", device_id), ] - instance_of = kwargs.get("instance_of") - if instance_of: - command = command + " --instance-of {}".format(instance_of) - checks.append(self.check("instanceOf", instance_of)) + template = kwargs.get("template") + if template: + command = command + " --template {}".format(template) + checks.append(self.check("template", template)) simulated = bool(kwargs.get("simulated")) if simulated: @@ -593,7 +594,7 @@ def _create_users(self,): checks = [ self.check("id", user_id), self.check("email", email), - self.check("type", "EmailUser"), + self.check("type", "email"), self.check("roles[0].role", role.value), ] users.append(self.cmd(command, checks=checks).get_output_in_json()) @@ -632,8 +633,7 @@ def _delete_api_token(self, token_id) -> None: ) def _wait_for_provisioned(self, device_id): - command = "iot central device show --app-id {} -d {}".format( - APP_ID, device_id) + command = "iot central device show --app-id {} -d {}".format(APP_ID, device_id) command = self._appendOptionalArgsToCommand(command, TOKEN, DNS_SUFFIX) while True: @@ -650,7 +650,8 @@ def _wait_for_provisioned(self, device_id): def _delete_device(self, device_id) -> None: command = "iot central device delete --app-id {} -d {} ".format( - APP_ID, device_id) + APP_ID, device_id + ) command = self._appendOptionalArgsToCommand(command, TOKEN, DNS_SUFFIX) self.cmd(command, checks=[self.check("result", "success")]) @@ -662,19 +663,15 @@ def _create_device_template(self): template_name = template["displayName"] template_id = template_name + "id" - command = "iot central device-template create --app-id {} --device-template-id {} -k '{}'".format( - APP_ID, template_id, device_template_path - ) - command = self._appendOptionalArgsToCommand(command, TOKEN, DNS_SUFFIX) - - self.cmd( - command, - checks=[ - self.check("displayName", template_name), - self.check("id", template_id), - ], + result = self.cmd( + "iot central device-template create --app-id {} --device-template-id {} -k '{}'".format( + APP_ID, template_id, device_template_path + ), + checks=[self.check("displayName", template_name)], ) + json_result = result.get_output_in_json() + assert json_result["@id"] == template_id return (template_id, template_name) def _delete_device_template(self, template_id): @@ -747,9 +744,9 @@ def _connect_gettwin_disconnect_wait_tobeprovisioned(self, device_id, credential self._wait_for_provisioned(device_id) def _appendOptionalArgsToCommand(self, command: str, token: str, dnsSuffix: str): - if token : - command = command + " --token \"{}\"".format(token) - if dnsSuffix : - command = command + " --central-dns-suffix \"{}\"".format(dnsSuffix) + if token: + command = command + ' --token "{}"'.format(token) + if dnsSuffix: + command = command + ' --central-dns-suffix "{}"'.format(dnsSuffix) return command diff --git a/azext_iot/tests/central/test_iot_central_unit.py b/azext_iot/tests/central/test_iot_central_unit.py index 10eb398c7..3d4c42fab 100644 --- a/azext_iot/tests/central/test_iot_central_unit.py +++ b/azext_iot/tests/central/test_iot_central_unit.py @@ -15,12 +15,12 @@ from azext_iot.central import commands_device_twin from azext_iot.central import commands_device from azext_iot.central import commands_monitor -from azext_iot.central.providers import ( - CentralDeviceProvider, - CentralDeviceTemplateProvider, +from azext_iot.central.providers.v1 import ( + CentralDeviceProviderV1, + CentralDeviceTemplateProviderV1, ) from azext_iot.central.models.devicetwin import DeviceTwin -from azext_iot.central.models.template import Template +from azext_iot.central import models as central_models from azext_iot.monitor.property import PropertyMonitor from azext_iot.monitor.models.enum import Severity from azext_iot.tests.helpers import load_json @@ -161,7 +161,7 @@ class TestCentralDeviceProvider: @mock.patch("azext_iot.central.services.device") def test_should_return_device(self, mock_device_svc, mock_device_template_svc): # setup - provider = CentralDeviceProvider(cmd=None, app_id=app_id) + provider = CentralDeviceProviderV1(cmd=None, app_id=app_id) mock_device_svc.get_device.return_value = self._device mock_device_template_svc.get_device_template.return_value = ( self._device_template @@ -184,7 +184,7 @@ def test_should_return_device_template( self, mock_device_svc, mock_device_template_svc ): # setup - provider = CentralDeviceTemplateProvider(cmd=None, app_id=app_id) + provider = CentralDeviceTemplateProviderV1(cmd=None, app_id=app_id) mock_device_svc.get_device.return_value = self._device mock_device_template_svc.get_device_template.return_value = ( self._device_template @@ -282,7 +282,7 @@ def test_validate_properties_declared_multiple_interfaces( ): # setup - mock_device_template_svc.get_device_template.return_value = Template( + mock_device_template_svc.get_device_template.return_value = central_models.TemplateV1( self._duplicate_property_template ) @@ -323,7 +323,7 @@ def test_validate_properties_name_miss_under_interface( ): # setup - mock_device_template_svc.get_device_template.return_value = Template( + mock_device_template_svc.get_device_template.return_value = central_models.TemplateV1( self._duplicate_property_template ) @@ -360,7 +360,7 @@ def test_validate_properties_severity_level( ): # setup - mock_device_template_svc.get_device_template.return_value = Template( + mock_device_template_svc.get_device_template.return_value = central_models.TemplateV1( self._duplicate_property_template ) @@ -405,7 +405,7 @@ def test_validate_properties_name_miss_under_component( ): # setup - mock_device_template_svc.get_device_template.return_value = Template( + mock_device_template_svc.get_device_template.return_value = central_models.TemplateV1( self._duplicate_property_template ) diff --git a/azext_iot/tests/central/test_iot_central_validator_unit.py b/azext_iot/tests/central/test_iot_central_validator_unit.py index 75bafe4bc..7fcf05fb2 100644 --- a/azext_iot/tests/central/test_iot_central_validator_unit.py +++ b/azext_iot/tests/central/test_iot_central_validator_unit.py @@ -7,7 +7,7 @@ import pytest import collections -from azext_iot.central.models.template import Template +from azext_iot.central import models as central_models from azext_iot.monitor.central_validator import validate, extract_schema_type from azext_iot.tests.helpers import load_json @@ -22,7 +22,7 @@ def test_template_interface_list(self): "urn:sampleApp:groupThree_bz:myxqftpsr:2", "urn:sampleApp:groupOne_bz:2", ] - template = Template( + template = central_models.TemplateV1( load_json(FileNames.central_property_validation_template_file) ) @@ -35,7 +35,7 @@ def test_template_component_list(self): "_rpgcmdpo", "RS40OccupancySensorV36fy", ] - template = Template( + template = central_models.TemplateV1( load_json(FileNames.central_property_validation_template_file) ) @@ -52,7 +52,7 @@ def test_extract_schema_type_component(self): "component1PropReadonly": "boolean", "component1Prop2": "boolean", } - template = Template( + template = central_models.TemplateV1( load_json(FileNames.central_property_validation_template_file) ) for key, val in expected_mapping.items(): @@ -67,7 +67,7 @@ def test_extract_schema_type_component_identifier(self): "testComponent": "boolean", "component2PropReadonly": "boolean", } - template = Template( + template = central_models.TemplateV1( load_json(FileNames.central_property_validation_template_file) ) for key, val in expected_mapping.items(): @@ -94,7 +94,9 @@ def test_extract_schema_type(self): "Time": "time", "Vector": "vector", } - template = Template(load_json(FileNames.central_device_template_file)) + template = central_models.TemplateV1( + load_json(FileNames.central_device_template_file) + ) for key, val in expected_mapping.items(): schema = template.get_schema(key) schema_type = extract_schema_type(schema) @@ -243,7 +245,9 @@ class TestComplexType: [(1, True), (2, True), (3, False), ("1", False), ("2", False)], ) def test_int_enum(self, value, expected_result): - template = Template(load_json(FileNames.central_device_template_file)) + template = central_models.TemplateV1( + load_json(FileNames.central_device_template_file) + ) schema = template.get_schema("IntEnum") assert validate(schema, value) == expected_result @@ -252,7 +256,9 @@ def test_int_enum(self, value, expected_result): [("A", True), ("B", True), ("C", False), (1, False), (2, False)], ) def test_str_enum(self, value, expected_result): - template = Template(load_json(FileNames.central_device_template_file)) + template = central_models.TemplateV1( + load_json(FileNames.central_device_template_file) + ) schema = template.get_schema("StringEnum") assert validate(schema, value) == expected_result @@ -266,7 +272,9 @@ def test_str_enum(self, value, expected_result): ], ) def test_object_simple(self, value, expected_result): - template = Template(load_json(FileNames.central_device_template_file)) + template = central_models.TemplateV1( + load_json(FileNames.central_device_template_file) + ) schema = template.get_schema("Object") assert validate(schema, value) == expected_result @@ -282,7 +290,7 @@ def test_object_simple(self, value, expected_result): ], ) def test_object_medium(self, value, expected_result): - template = Template( + template = central_models.TemplateV1( load_json(FileNames.central_deeply_nested_device_template_file) ) schema = template.get_schema("RidiculousObject") @@ -361,7 +369,7 @@ def test_object_medium(self, value, expected_result): ], ) def test_object_deep(self, value, expected_result): - template = Template( + template = central_models.TemplateV1( load_json(FileNames.central_deeply_nested_device_template_file) ) schema = template.get_schema("RidiculousObject") diff --git a/azext_iot/tests/utility/test_monitor_parsers_unit.py b/azext_iot/tests/utility/test_monitor_parsers_unit.py index 3d1622181..e12e43623 100644 --- a/azext_iot/tests/utility/test_monitor_parsers_unit.py +++ b/azext_iot/tests/utility/test_monitor_parsers_unit.py @@ -9,12 +9,11 @@ import pytest from uamqp.message import Message, MessageProperties -from azext_iot.central.providers import ( - CentralDeviceProvider, - CentralDeviceTemplateProvider, +from azext_iot.central.providers.v1 import ( + CentralDeviceProviderV1, + CentralDeviceTemplateProviderV1, ) -from azext_iot.central.models.template import Template -from azext_iot.central.models.device import Device +from azext_iot.central import models as central_models from azext_iot.monitor.parsers import common_parser, central_parser from azext_iot.monitor.parsers import strings from azext_iot.monitor.models.arguments import CommonParserArguments @@ -440,7 +439,7 @@ def test_validate_against_no_component_template_should_fail(self): def test_validate_against_invalid_component_template_should_fail(self): # setup - device_template = Template( + device_template = central_models.TemplateV1( load_json(FileNames.central_property_validation_template_file) ) @@ -487,7 +486,7 @@ def test_validate_against_invalid_component_template_should_fail(self): def test_validate_invalid_telmetry_component_template_should_fail(self): # setup - device_template = Template( + device_template = central_models.TemplateV1( load_json(FileNames.central_property_validation_template_file) ) @@ -553,7 +552,7 @@ def test_validate_against_bad_template_should_not_throw(self): ) # haven't found a better way to force the error to occur within parser - parser._central_template_provider.get_device_template = lambda x: Template( + parser._central_template_provider.get_device_template = lambda x: central_models.TemplateV1( device_template ) @@ -605,14 +604,21 @@ def test_type_mismatch_should_error(self): _validate_issues(parser, Severity.error, 1, 1, [expected_details]) def _get_template(self): - return Template(load_json(FileNames.central_device_template_file)) + return central_models.TemplateV1( + load_json(FileNames.central_device_template_file) + ) def _create_parser( - self, device_template: Template, message: Message, args: CommonParserArguments + self, + device_template: central_models.TemplateV1, + message: Message, + args: CommonParserArguments, ): - device_provider = CentralDeviceProvider(cmd=None, app_id=None) - template_provider = CentralDeviceTemplateProvider(cmd=None, app_id=None) - device_provider.get_device = mock.MagicMock(return_value=Device({})) + device_provider = CentralDeviceProviderV1(cmd=None, app_id=None) + template_provider = CentralDeviceTemplateProviderV1(cmd=None, app_id=None) + device_provider.get_device = mock.MagicMock( + return_value=central_models.DeviceV1({}) + ) template_provider.get_device_template = mock.MagicMock( return_value=device_template ) From 4bf999acb676eeaa90c45f7a19227cf782c14f91 Mon Sep 17 00:00:00 2001 From: avagraw <51140335+avagraw@users.noreply.github.com> Date: Mon, 17 May 2021 17:20:23 -0700 Subject: [PATCH 03/42] Add warning for qos deprecation and update contributing guide (#342) * Add warning for qos deprecation and update contributing guide * removing version from deprecate_info * Integration tests command update in Contributing guide --- CONTRIBUTING.md | 11 +++++------ azext_iot/_params.py | 5 +++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e2322253..8301c0439 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -98,10 +98,10 @@ pip install -r path/to/source/dev_requirements ``` _Hub:_ -`pytest azext_iot/tests/test_iot_ext_unit.py` +`pytest azext_iot/tests/iothub/test_iot_ext_unit.py` _DPS:_ -`pytest azext_iot/tests/test_iot_dps_unit.py` +`pytest azext_iot/tests/dps/test_iot_dps_unit.py` ### Integration Tests @@ -127,7 +127,6 @@ You can either manually set the environment variables or use the `pytest.ini.exa AZURE_TEST_RUN_LIVE=True azext_iot_testrg="Resource Group that contains your IoT Hub" azext_iot_testhub="IoT Hub Name" - azext_iot_testhub_cs="IoT Hub Connection String" azext_iot_testdps="IoT Hub DPS Name" azext_iot_teststorageuri="Blob Container SAS Uri" azext_iot_identity_teststorageid="Storage Account ID" @@ -141,13 +140,13 @@ You can either manually set the environment variables or use the `pytest.ini.exa Execute the following command to run the IoT Hub integration tests: -`pytest azext_iot/tests/test_iot_ext_int.py` +`pytest azext_iot/tests/iothub/ -k "_int"` ##### Device Provisioning Service Execute the following command to run the IoT Hub DPS integration tests: -`pytest azext_iot/tests/test_iot_dps_int.py` +`pytest azext_iot/tests/dps/ -k "_int"` #### Unit and Integration Tests Single Command @@ -264,7 +263,7 @@ https://medium.com/@marcobelo/setting-up-python-black-on-visual-studio-code-5318 https://docs.python.org/3/library/pdb.html -1. `pip install pdb` +1. `pip install pdbpp` 2. If you need a breakpoint, put `import pdb; pdb.set_trace()` in your code 3. Run your command, it should break execution wherever you put the breakpoint. diff --git a/azext_iot/_params.py b/azext_iot/_params.py index 99f3cfcca..f8e3982b4 100644 --- a/azext_iot/_params.py +++ b/azext_iot/_params.py @@ -82,7 +82,8 @@ type=str, nargs="?", choices=["0", "1"], - help="Quality of Service. 0 = At most once, 1 = At least once. 2 (Exactly once) is not supported.", + help="Quality of Service. 0 = At most once, 1 = At least once. 2 (Exactly once) is not supported." + "This command parameter has been deprecated and will be removed in the next release." ) event_timeout_type = CLIArgumentType( @@ -557,7 +558,7 @@ def load_arguments(self, _): arg_type=get_enum_type(ProtocolType), help="Indicates device-to-cloud message protocol", ) - context.argument("qos", arg_type=qos_type) + context.argument("qos", arg_type=qos_type, deprecate_info=context.deprecate()) with self.argument_context("iot device simulate") as context: context.argument( From b45c7f56d73d98e397be1a073c08c30061194e0a Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Tue, 18 May 2021 11:42:21 -0700 Subject: [PATCH 04/42] Integrate TQDM to show progress bar when simulator sends d2c messages --- azext_iot/operations/_mqtt.py | 12 ++++-------- setup.py | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index 1c6941723..52038f509 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -74,16 +74,12 @@ def method_request_handler(self, method_request): self.device_client.send_method_response(method_response) def execute(self, data, properties={}, publish_delay=2, msg_count=100): + from tqdm import tqdm try: - msgs = 0 - while True: - if msgs < msg_count: - msgs += 1 - self.send_d2c_message(message_text=data.generate(True), properties=properties) - six.print_(".", end="", flush=True) - else: - break + for msgs in tqdm(range(msg_count), desc='Simulation in progress'): + self.send_d2c_message(message_text=data.generate(True), properties=properties) sleep(publish_delay) + except Exception as x: raise x diff --git a/setup.py b/setup.py index 585adc6ca..9a012a144 100644 --- a/setup.py +++ b/setup.py @@ -48,7 +48,7 @@ # though that is installed out of band (managed by the extension) # for compatibility reasons. -DEPENDENCIES = ["jsonschema==3.2.0", "packaging", "azure-iot-device~=2.5"] +DEPENDENCIES = ["jsonschema==3.2.0", "packaging", "azure-iot-device~=2.5", "tqdm"] CLASSIFIERS = [ From 72df95592d9e56f0c30d97d6ab75736769a184d8 Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Tue, 18 May 2021 15:16:08 -0700 Subject: [PATCH 05/42] remove unused loop varable --- azext_iot/operations/_mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index 52038f509..512c99b01 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -76,7 +76,7 @@ def method_request_handler(self, method_request): def execute(self, data, properties={}, publish_delay=2, msg_count=100): from tqdm import tqdm try: - for msgs in tqdm(range(msg_count), desc='Simulation in progress'): + for _ in tqdm(range(msg_count), desc='Simulation in progress'): self.send_d2c_message(message_text=data.generate(True), properties=properties) sleep(publish_delay) From 1714e84c3738151b5a1c58444f9cd79cafaa0a10 Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Tue, 18 May 2021 16:00:36 -0700 Subject: [PATCH 06/42] Update progress bar descriptionb --- azext_iot/operations/_mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index 512c99b01..ee456b647 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -76,7 +76,7 @@ def method_request_handler(self, method_request): def execute(self, data, properties={}, publish_delay=2, msg_count=100): from tqdm import tqdm try: - for _ in tqdm(range(msg_count), desc='Simulation in progress'): + for _ in tqdm(range(msg_count), desc='Device simulation in progress'): self.send_d2c_message(message_text=data.generate(True), properties=properties) sleep(publish_delay) From b268dbee26e59f45b4b8e411f00f031d17cd38b5 Mon Sep 17 00:00:00 2001 From: valluriraj Date: Mon, 24 May 2021 11:54:06 -0700 Subject: [PATCH 07/42] Iotc command ga (#348) * remove preview tags * history updates --- HISTORY.rst | 7 +++++-- azext_iot/central/command_map.py | 14 ++++++-------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/HISTORY.rst b/HISTORY.rst index 1b218b2d8..3340377ab 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -8,8 +8,11 @@ Release History **IoT Central updates** -* Public API GA update - add support for preview and 1.0 routes -* Addition of the optional '--av' argument to specify the version of API for the requested operation. +* Public API GA update + * Remove preview tag for api-token, device, device-template, user routes. Default routes use central GA API's. + * Add support for preview and 1.0 routes. + * Addition of the optional '--av' argument to specify the version of API for the requested operation. + **IoT Hub updates** * Removed deprecated edge offline commands and artifacts. diff --git a/azext_iot/central/command_map.py b/azext_iot/central/command_map.py index fba1e64f6..0c97daca9 100644 --- a/azext_iot/central/command_map.py +++ b/azext_iot/central/command_map.py @@ -60,7 +60,7 @@ def load_central_commands(self, _): ) with self.command_group( - "iot central user", command_type=central_user_ops, is_preview=True, + "iot central user", command_type=central_user_ops, ) as cmd_group: cmd_group.command("create", "add_user") cmd_group.command("list", "list_users") @@ -68,7 +68,7 @@ def load_central_commands(self, _): cmd_group.command("delete", "delete_user") with self.command_group( - "iot central api-token", command_type=central_api_token_ops, is_preview=True, + "iot central api-token", command_type=central_api_token_ops, ) as cmd_group: cmd_group.command("create", "add_api_token") cmd_group.command("list", "list_api_tokens") @@ -76,7 +76,7 @@ def load_central_commands(self, _): cmd_group.command("delete", "delete_api_token") with self.command_group( - "iot central device", command_type=central_device_ops, is_preview=True, + "iot central device", command_type=central_device_ops, ) as cmd_group: # cmd_group.command("list", "list_devices") cmd_group.show_command("show", "get_device") @@ -89,15 +89,13 @@ def load_central_commands(self, _): cmd_group.command("manual-failback", "run_manual_failback") with self.command_group( - "iot central device command", command_type=central_device_ops, is_preview=True, + "iot central device command", command_type=central_device_ops, ) as cmd_group: cmd_group.command("run", "run_command") cmd_group.command("history", "get_command_history") with self.command_group( - "iot central device-template", - command_type=central_device_templates_ops, - is_preview=True, + "iot central device-template", command_type=central_device_templates_ops, ) as cmd_group: # cmd_group.command("list", "list_device_templates") # cmd_group.command("map", "map_device_templates") @@ -106,7 +104,7 @@ def load_central_commands(self, _): cmd_group.command("delete", "delete_device_template") with self.command_group( - "iot central device twin", command_type=central_device_twin_ops, is_preview=True + "iot central device twin", command_type=central_device_twin_ops, ) as cmd_group: cmd_group.show_command( "show", "device_twin_show", From a4baacb2f9705746b89362c0825b855d38f04004 Mon Sep 17 00:00:00 2001 From: Paymaun Date: Mon, 24 May 2021 17:05:08 -0700 Subject: [PATCH 08/42] Use enum value instead of literal str. (#349) --- azext_iot/_factory.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/azext_iot/_factory.py b/azext_iot/_factory.py index d7bd2d6f6..aee601e82 100644 --- a/azext_iot/_factory.py +++ b/azext_iot/_factory.py @@ -10,7 +10,7 @@ from azext_iot.common.sas_token_auth import SasTokenAuthentication from azext_iot.iothub.providers.aad_oauth import IoTHubOAuth -from azext_iot.common.shared import SdkType +from azext_iot.common.shared import SdkType, AuthenticationTypeDataplane from azext_iot.constants import USER_AGENT, IOTHUB_RESOURCE_ID from msrestazure.azure_exceptions import CloudError @@ -102,7 +102,7 @@ def _get_iothub_service_sdk(self): if self.auth_override: credentials = self.auth_override - elif self.target["policy"] == "login": + elif self.target["policy"] == AuthenticationTypeDataplane.login.value: credentials = IoTHubOAuth( cmd=self.target["cmd"], resource_id=IOTHUB_RESOURCE_ID From 79bf6cb786684cbef28297e8cc08d7a5291b81cf Mon Sep 17 00:00:00 2001 From: Ryan K Date: Tue, 25 May 2021 11:06:58 -0700 Subject: [PATCH 09/42] Managed identity support for device-identity import and export (#344) * Fix for new identity parameter format * test updates * better differentiation of storage vars * updated to remove *all* user-identitites after storage tests until CLI core is patched --- azext_iot/_help.py | 20 ++ azext_iot/_params.py | 29 +- azext_iot/constants.py | 2 +- azext_iot/operations/hub.py | 43 ++- azext_iot/tests/iothub/test_iot_ext_int.py | 242 ++++++++++++-- azext_iot/tests/iothub/test_iot_hub_unit.py | 341 ++++++++++++++++++++ 6 files changed, 643 insertions(+), 34 deletions(-) create mode 100644 azext_iot/tests/iothub/test_iot_hub_unit.py diff --git a/azext_iot/_help.py b/azext_iot/_help.py index 0729b4d07..7c178e9a4 100644 --- a/azext_iot/_help.py +++ b/azext_iot/_help.py @@ -256,6 +256,17 @@ - name: Export all device identities to a configured blob container using a file path which contains the SAS uri. text: > az iot hub device-identity export -n {iothub_name} --bcu {sas_uri_filepath} + - name: Export all device identities to a configured blob container and include device keys. Uses system assigned identity that has + Storage Blob Data Contributor roles for the storage account. The blob container uri does not need the blob SAS token. + text: > + az iot hub device-identity export -n {iothub_name} --ik --bcu + 'https://mystorageaccount.blob.core.windows.net/devices' --auth-type identity --identity [system] + - name: Export all device identities to a configured blob container and include device keys. Uses user assigned managed identity + that has Storage Blob Data Contributor roles for the storage account and contributor for the IoT hub. The blob container + uri does not need the blob SAS token. + text: > + az iot hub device-identity export -n {iothub_name} --ik --bcu + 'https://mystorageaccount.blob.core.windows.net/devices' --auth-type identity --identity {managed_identity_resource_id} """ helps[ @@ -273,6 +284,15 @@ - name: Import all device identities from a blob using a file path which contains SAS uri. text: > az iot hub device-identity import -n {iothub_name} --ibcu {input_sas_uri_filepath} --obcu {output_sas_uri_filepath} + - name: Import all device identities from a blob using system assigned identity that has Storage Blob Data Contributor + roles for both storage accounts. The blob container uri does not need the blob SAS token. + text: > + az iot hub device-identity import -n {iothub_name} --ibcu {input_sas_uri} --obcu {output_sas_uri} --auth-type identity --identity [system] + - name: Import all device identities from a blob using user assigned managed identity that has Storage Blob Data Contributor + roles for both storage accounts and contributor for the IoT hub. The blob container uri does not need the blob SAS token. + text: > + az iot hub device-identity import -n {iothub_name} --ibcu {input_sas_uri} --obcu {output_sas_uri} + --auth-type identity --identity {managed_identity_resource_id} """ helps[ diff --git a/azext_iot/_params.py b/azext_iot/_params.py index f8e3982b4..f9e324555 100644 --- a/azext_iot/_params.py +++ b/azext_iot/_params.py @@ -422,7 +422,8 @@ def load_arguments(self, _): help="Blob Shared Access Signature URI with write, read, and delete access to " "a blob container. This is used to output the status of the " "job and the results. Note: when using Identity-based authentication an " - "https:// URI is still required. Input for this argument can be inline or from a file path.", + "https:// URI is still required - but no SAS token is necessary. Input for this argument " + "can be inline or from a file path.", ) context.argument( "include_keys", @@ -437,6 +438,15 @@ def load_arguments(self, _): arg_type=get_enum_type(AuthenticationType), help="Authentication type for communicating with the storage container.", ) + context.argument( + "identity", + options_list=["--identity"], + help="Managed identity type to determine if system assigned managed identity or " + "user assigned managed identity is used. For system assigned managed identity, use " + "[system]. For user assigned managed identity, provide the user assigned managed " + "identity resource id. This identity requires a Storage Blob Data Contributor roles for the Storage " + "Account.", + ) with self.argument_context("iot hub device-identity import") as context: context.argument( @@ -445,8 +455,8 @@ def load_arguments(self, _): help="Blob Shared Access Signature URI with read access to a blob " "container. This blob contains the operations to be performed on " "the identity registry. Note: when using Identity-based authentication " - "an https:// URI is still required. Input for this argument can be inline " - "or from a file path.", + "an https:// URI is still required - but no SAS token is necessary. Input for this " + "argument can be inline or from a file path.", ) context.argument( "output_blob_container_uri", @@ -454,8 +464,8 @@ def load_arguments(self, _): help="Blob Shared Access Signature URI with write access " "to a blob container. This is used to output the status of " "the job and the results. Note: when using Identity-based " - "authentication an https:// URI is still required. Input for " - "this argument can be inline or from a file path.", + "authentication an https:// URI without the SAS token is still required. " + "Input for this argument can be inline or from a file path.", ) context.argument( "storage_authentication_type", @@ -463,6 +473,15 @@ def load_arguments(self, _): arg_type=get_enum_type(AuthenticationType), help="Authentication type for communicating with the storage container.", ) + context.argument( + "identity", + options_list=["--identity"], + help="Managed identity type to determine if system assigned managed identity or " + "user assigned managed identity is used. For system assigned managed identity, use " + "[system]. For user assigned managed identity, provide the user assigned managed " + "identity resource id. This identity requires a Storage Blob Data Contributor role for the target Storage " + "Account and Contributor role for the IoT Hub.", + ) with self.argument_context("iot hub device-identity parent set") as context: context.argument( diff --git a/azext_iot/constants.py b/azext_iot/constants.py index 4954b7c51..dec1ad26d 100644 --- a/azext_iot/constants.py +++ b/azext_iot/constants.py @@ -51,4 +51,4 @@ CONFIG_KEY_UAMQP_EXT_VERSION = "uamqp_ext_version" # Initial Track 2 SDK version -IOTHUB_TRACK_2_SDK_MIN_VERSION = "1.0.0" +IOTHUB_TRACK_2_SDK_MIN_VERSION = '2.0.0' diff --git a/azext_iot/operations/hub.py b/azext_iot/operations/hub.py index 541b7284c..77f91424b 100644 --- a/azext_iot/operations/hub.py +++ b/azext_iot/operations/hub.py @@ -16,6 +16,7 @@ TRACING_PROPERTY, TRACING_ALLOWED_FOR_LOCATION, TRACING_ALLOWED_FOR_SKU, + IOTHUB_TRACK_2_SDK_MIN_VERSION, ) from azext_iot.common.sas_token_auth import SasTokenAuthentication from azext_iot.common.shared import ( @@ -2543,10 +2544,10 @@ def iot_device_export( blob_container_uri, include_keys=False, storage_authentication_type=None, + identity=None, resource_group_name=None, ): from azext_iot._factory import iot_hub_service_factory - client = iot_hub_service_factory(cmd.cli_ctx) discovery = IotHubDiscovery(cmd) target = discovery.get_target( @@ -2565,11 +2566,30 @@ def iot_device_export( if storage_authentication_type else None ) + export_request = ExportDevicesRequest( export_blob_container_uri=blob_container_uri, exclude_keys=not include_keys, authentication_type=storage_authentication_type, ) + + user_identity = identity not in [None, '[system]'] + if user_identity and storage_authentication_type != AuthenticationType.identityBased.name: + raise CLIError( + "Device export with user-assigned identities requires identity-based authentication [--storage-auth-type]" + ) + # Track 2 CLI SDKs provide support for user-assigned identity objects + if ensure_iothub_sdk_min_version(IOTHUB_TRACK_2_SDK_MIN_VERSION) and user_identity: + from azure.mgmt.iothub.models import ManagedIdentity # pylint: disable=no-name-in-module + export_request.identity = ManagedIdentity(user_assigned_identity=identity) + + # if the user supplied a user-assigned identity, let them know they need a new CLI/SDK + elif user_identity: + raise CLIError( + "Device export with user-assigned identities requires a dependency of azure-mgmt-iothub>={}" + .format(IOTHUB_TRACK_2_SDK_MIN_VERSION) + ) + return client.export_devices( target["resourcegroup"], hub_name, @@ -2594,6 +2614,7 @@ def iot_device_import( output_blob_container_uri, storage_authentication_type=None, resource_group_name=None, + identity=None, ): from azext_iot._factory import iot_hub_service_factory @@ -2611,6 +2632,7 @@ def iot_device_import( if ensure_iothub_sdk_min_version("0.12.0"): from azure.mgmt.iothub.models import ImportDevicesRequest + from azext_iot.common.shared import AuthenticationType storage_authentication_type = ( @@ -2618,6 +2640,7 @@ def iot_device_import( if storage_authentication_type else None ) + import_request = ImportDevicesRequest( input_blob_container_uri=input_blob_container_uri, output_blob_container_uri=output_blob_container_uri, @@ -2625,6 +2648,23 @@ def iot_device_import( output_blob_name=None, authentication_type=storage_authentication_type, ) + + user_identity = identity not in [None, '[system]'] + if user_identity and storage_authentication_type != AuthenticationType.identityBased.name: + raise CLIError( + "Device import with user-assigned identities requires identity-based authentication [--storage-auth-type]" + ) + # Track 2 CLI SDKs provide support for user-assigned identity objects + if ensure_iothub_sdk_min_version(IOTHUB_TRACK_2_SDK_MIN_VERSION) and user_identity: + from azure.mgmt.iothub.models import ManagedIdentity # pylint: disable=no-name-in-module + import_request.identity = ManagedIdentity(user_assigned_identity=identity) + # if the user supplied a user-assigned identity, let them know they need a new CLI/SDK + elif user_identity: + raise CLIError( + "Device import with user-assigned identities requires a dependency of azure-mgmt-iothub>={}" + .format(IOTHUB_TRACK_2_SDK_MIN_VERSION) + ) + return client.import_devices( target["resourcegroup"], hub_name, @@ -2971,7 +3011,6 @@ def _get_hub_connection_string( hub.name, hub.additional_properties["resourcegroup"], policy_name ) ) - if default_eventhub: cs_template_eventhub = ( "Endpoint={};SharedAccessKeyName={};SharedAccessKey={};EntityPath={}" diff --git a/azext_iot/tests/iothub/test_iot_ext_int.py b/azext_iot/tests/iothub/test_iot_ext_int.py index eb58cb06e..9b88cea0c 100644 --- a/azext_iot/tests/iothub/test_iot_ext_int.py +++ b/azext_iot/tests/iothub/test_iot_ext_int.py @@ -6,12 +6,15 @@ import os import pytest +from time import sleep from azext_iot.tests import IoTLiveScenarioTest from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC +from azext_iot.common.utility import ensure_iothub_sdk_min_version +from azext_iot.tests.generators import generate_generic_id # TODO: assert DEVICE_DEVICESCOPE_PREFIX format in parent device twin. -# from azext_iot.constants import DEVICE_DEVICESCOPE_PREFIX +from azext_iot.constants import IOTHUB_TRACK_2_SDK_MIN_VERSION opt_env_set = ["azext_iot_teststorageuri", "azext_iot_identity_teststorageid"] @@ -24,21 +27,48 @@ # Set this environment variable to your empty blob container sas uri to test device export and enable file upload test. # For file upload, you will need to have configured your IoT Hub before running. -LIVE_STORAGE = settings.env.azext_iot_teststorageuri +LIVE_STORAGE_URI = settings.env.azext_iot_teststorageuri # Set this environment variable to enable identity-based integration tests # You will need permissions to add and remove role assignments for this storage account -LIVE_STORAGE_ID = settings.env.azext_iot_identity_teststorageid +LIVE_STORAGE_RESOURCE_ID = settings.env.azext_iot_identity_teststorageid CWD = os.path.dirname(os.path.abspath(__file__)) +user_managed_identity_name = generate_generic_id() + class TestIoTStorage(IoTLiveScenarioTest): def __init__(self, test_case): super(TestIoTStorage, self).__init__(test_case, LIVE_HUB, LIVE_RG) + self.managed_identity = None + + def get_managed_identity(self): + # Check if there is a managed identity already + if self.managed_identity: + return self.managed_identity + + # Create managed identity + result = self.cmd( + "identity create -n {} -g {}".format( + user_managed_identity_name, LIVE_RG + )).get_output_in_json() + + # ensure resource is created before hub immediately tries to assign it + sleep(10) + + self.managed_identity = result + return self.managed_identity + + def tearDown(self): + if self.managed_identity: + self.cmd('identity delete -n {} -g {}'.format( + user_managed_identity_name, LIVE_RG + )) + return super().tearDown() @pytest.mark.skipif( - not LIVE_STORAGE, reason="empty azext_iot_teststorageuri env var" + not LIVE_STORAGE_URI, reason="empty azext_iot_teststorageuri env var" ) def test_storage(self): device_count = 1 @@ -70,85 +100,245 @@ def test_storage(self): self.cmd( 'iot hub device-identity export -n {} --bcu "{}"'.format( - LIVE_HUB, LIVE_STORAGE + LIVE_HUB, LIVE_STORAGE_URI + ), + checks=[ + self.check("outputBlobContainerUri", LIVE_STORAGE_URI), + self.check("failureReason", None), + self.check("type", "export"), + self.check("excludeKeysInExport", True), + self.exists("jobId"), + ], + ) + + # give time to finish job + sleep(30) + + self.cmd( + 'iot hub device-identity export -n {} --bcu "{}" --auth-type {} --ik true'.format( + LIVE_HUB, LIVE_STORAGE_URI, "key" ), checks=[ - self.check("outputBlobContainerUri", LIVE_STORAGE), + self.check("outputBlobContainerUri", LIVE_STORAGE_URI), self.check("failureReason", None), self.check("type", "export"), + self.check("excludeKeysInExport", False), + self.exists("jobId"), + ], + ) + + # give time to finish job + sleep(30) + + self.cmd( + 'iot hub device-identity import -n {} --ibcu "{}" --obcu "{}" --auth-type {}'.format( + LIVE_HUB, LIVE_STORAGE_URI, LIVE_STORAGE_URI, "key" + ), + checks=[ + self.check("outputBlobContainerUri", LIVE_STORAGE_URI), + self.check("inputBlobContainerUri", LIVE_STORAGE_URI), + self.check("failureReason", None), + self.check("type", "import"), + self.check("storageAuthenticationType", "keyBased"), self.exists("jobId"), ], ) @pytest.mark.skipif( - not all([LIVE_STORAGE_ID, LIVE_STORAGE]), + not all([LIVE_STORAGE_RESOURCE_ID, LIVE_STORAGE_URI]), reason="azext_iot_identity_teststorageid and azext_iot_teststorageuri env vars not set", ) - def test_identity_storage(self): + @pytest.mark.skipif( + not ensure_iothub_sdk_min_version(IOTHUB_TRACK_2_SDK_MIN_VERSION), + reason="Skipping track 2 tests because SDK is track 1") + def test_system_identity_storage(self): identity_type_enable = "SystemAssigned" - identity_type_disable = "None" storage_role = "Storage Blob Data Contributor" # check hub identity identity_enabled = False hub_identity = self.cmd( - "iot hub show -n {}".format(LIVE_HUB) - ).get_output_in_json()["identity"] + "iot hub identity show -n {}".format(LIVE_HUB) + ).get_output_in_json() - if hub_identity.get("type", None) != identity_type_enable: + if identity_type_enable not in hub_identity.get("type", None): # enable hub identity and get ID hub_identity = self.cmd( - 'iot hub update -n {} --set identity.type="{}"'.format( - LIVE_HUB, identity_type_enable + "iot hub identity assign -n {} --system".format( + LIVE_HUB, ) - ).get_output_in_json()["identity"] + ).get_output_in_json() identity_enabled = True + # principal id for system assigned user identity hub_id = hub_identity.get("principalId", None) assert hub_id # setup RBAC for storage account storage_account_roles = self.cmd( 'role assignment list --scope "{}" --role "{}" --query "[].principalId"'.format( - LIVE_STORAGE_ID, storage_role + LIVE_STORAGE_RESOURCE_ID, storage_role ) ).get_output_in_json() if hub_id not in storage_account_roles: self.cmd( 'role assignment create --assignee "{}" --role "{}" --scope "{}"'.format( - hub_id, storage_role, LIVE_STORAGE_ID + hub_id, storage_role, LIVE_STORAGE_RESOURCE_ID ) ) - # give RBAC time to catch up - from time import sleep - sleep(30) + # give time to finish job + sleep(60) + + self.cmd( + 'iot hub device-identity export -n {} --bcu "{}" --auth-type {} --identity {} --ik true'.format( + LIVE_HUB, LIVE_STORAGE_URI, "identity", "[system]" + ), + checks=[ + self.check("outputBlobContainerUri", LIVE_STORAGE_URI), + self.check("failureReason", None), + self.check("type", "export"), + self.check("excludeKeysInExport", False), + self.check("storageAuthenticationType", "identityBased"), + self.exists("jobId"), + ], + ) + + self.cmd( + 'iot hub device-identity import -n {} --ibcu "{}" --obcu "{}" --auth-type {} --identity {}'.format( + LIVE_HUB, LIVE_STORAGE_URI, LIVE_STORAGE_URI, "identity", "[system]" + ), + checks=[ + self.check("outputBlobContainerUri", LIVE_STORAGE_URI), + self.check("inputBlobContainerUri", LIVE_STORAGE_URI), + self.check("failureReason", None), + self.check("type", "import"), + self.check("storageAuthenticationType", "identityBased"), + self.exists("jobId"), + ], + ) + + self.cmd( + 'iot hub device-identity export -n {} --bcu "{}" --auth-type {} --identity {}'.format( + LIVE_HUB, LIVE_STORAGE_URI, "identity", "fake_managed_identity" + ), + expect_failure=True + ) + + # if we enabled identity for this hub, undo identity and RBAC + if identity_enabled: + # delete role assignment first, disabling identity removes the assignee ID from AAD + self.cmd( + 'role assignment delete --assignee "{}" --role "{}" --scope "{}"'.format( + hub_id, storage_role, LIVE_STORAGE_RESOURCE_ID + ) + ) + self.cmd( + "iot hub identity remove -n {} --system".format( + LIVE_HUB + ) + ) + + @pytest.mark.skipif( + not all([LIVE_STORAGE_RESOURCE_ID, LIVE_STORAGE_URI]), + reason="azext_iot_identity_teststorageid and azext_iot_teststorageuri env vars not set", + ) + @pytest.mark.skipif( + not ensure_iothub_sdk_min_version(IOTHUB_TRACK_2_SDK_MIN_VERSION), + reason="Skipping track 2 tests because SDK is track 1") + def test_user_identity_storage(self): + # User Assigned Managed Identity + storage_role = "Storage Blob Data Contributor" + user_identity = self.get_managed_identity() + identity_id = user_identity["id"] + # check hub identity + identity_enabled = False + hub_identity = self.cmd( + "iot hub identity show -n {}".format(LIVE_HUB) + ).get_output_in_json() + + if hub_identity.get("userAssignedIdentities", None) != user_identity["principalId"]: + # enable hub identity and get ID + hub_identity = self.cmd( + "iot hub identity assign -n {} --user {}".format( + LIVE_HUB, identity_id + ) + ).get_output_in_json() + + identity_enabled = True + + identity_principal = hub_identity["userAssignedIdentities"][identity_id]["principalId"] + assert identity_principal == user_identity["principalId"] + + # setup RBAC for storage account + storage_account_roles = self.cmd( + 'role assignment list --scope "{}" --role "{}" --query "[].principalId"'.format( + LIVE_STORAGE_RESOURCE_ID, storage_role + ) + ).get_output_in_json() + + if identity_principal not in storage_account_roles: + self.cmd( + 'role assignment create --assignee "{}" --role "{}" --scope "{}"'.format( + identity_principal, storage_role, LIVE_STORAGE_RESOURCE_ID + ) + ) + # give time to finish job + sleep(60) # identity-based device-identity export self.cmd( - 'iot hub device-identity export -n {} --bcu "{}" --auth-type {}'.format( - LIVE_HUB, LIVE_STORAGE, "identity" + 'iot hub device-identity export -n {} --bcu "{}" --auth-type {} --identity {} --ik true'.format( + LIVE_HUB, LIVE_STORAGE_URI, "identity", identity_id ), checks=[ - self.check("outputBlobContainerUri", LIVE_STORAGE), + self.check("outputBlobContainerUri", LIVE_STORAGE_URI), self.check("failureReason", None), self.check("type", "export"), + self.check("excludeKeysInExport", False), + self.check("storageAuthenticationType", "identityBased"), self.exists("jobId"), ], ) + # give time to finish job + sleep(30) + + self.cmd( + 'iot hub device-identity import -n {} --ibcu "{}" --obcu "{}" --auth-type {} --identity {}'.format( + LIVE_HUB, LIVE_STORAGE_URI, LIVE_STORAGE_URI, "identity", identity_id + ), + checks=[ + self.check("outputBlobContainerUri", LIVE_STORAGE_URI), + self.check("inputBlobContainerUri", LIVE_STORAGE_URI), + self.check("failureReason", None), + self.check("type", "import"), + self.check("storageAuthenticationType", "identityBased"), + self.exists("jobId"), + ], + ) + + self.cmd( + 'iot hub device-identity export -n {} --bcu "{}" --auth-type {} --identity {}'.format( + LIVE_HUB, LIVE_STORAGE_URI, "identity", "fake_managed_identity" + ), + expect_failure=True + ) + # if we enabled identity for this hub, undo identity and RBAC if identity_enabled: # delete role assignment first, disabling identity removes the assignee ID from AAD self.cmd( 'role assignment delete --assignee "{}" --role "{}" --scope "{}"'.format( - hub_id, storage_role, LIVE_STORAGE_ID + identity_principal, storage_role, LIVE_STORAGE_RESOURCE_ID ) ) self.cmd( - "iot hub update -n {} --set 'identity.type=\"{}\"'".format( - LIVE_HUB, identity_type_disable + "iot hub identity remove -n {} --user".format( + LIVE_HUB ) ) + + self.tearDown() diff --git a/azext_iot/tests/iothub/test_iot_hub_unit.py b/azext_iot/tests/iothub/test_iot_hub_unit.py new file mode 100644 index 000000000..fd37e6325 --- /dev/null +++ b/azext_iot/tests/iothub/test_iot_hub_unit.py @@ -0,0 +1,341 @@ +# coding=utf-8 +# -------------------------------------------------------------------------------------------- +# Copyright (c) Microsoft Corporation. All rights reserved. +# Licensed under the MIT License. See License.txt in the project root for license information. +# -------------------------------------------------------------------------------------------- + +import re +import pytest +import responses +import json +from knack.cli import CLIError +from azext_iot.operations import hub as subject +from azext_iot.tests.generators import generate_generic_id +from azext_iot.common.utility import ensure_iothub_sdk_min_version +from azext_iot.constants import IOTHUB_TRACK_2_SDK_MIN_VERSION + +hub_name = "HUBNAME" +blob_container_uri = "https://example.com" +resource_group_name = "RESOURCEGROUP" +managed_identity = "EXAMPLEMANAGEDIDENTITY" +generic_job_response = {"JobResponse": generate_generic_id()} +qualified_hostname = "{}.subdomain.domain".format(hub_name) + + +@pytest.fixture +def get_mgmt_client(mocker, fixture_cmd): + from azure.mgmt.iothub import IotHubClient + + # discovery call to find iothub + patch_discovery = mocker.patch( + "azext_iot.iothub.providers.discovery.IotHubDiscovery.get_target" + ) + patch_discovery.return_value = { + "resourcegroup": resource_group_name + } + + # raw token for login credentials + patched_get_raw_token = mocker.patch( + "azure.cli.core._profile.Profile.get_raw_token" + ) + patched_get_raw_token.return_value = ( + mocker.MagicMock(name="creds"), + mocker.MagicMock(name="subscription"), + mocker.MagicMock(name="tenant"), + ) + + patched_get_login_credentials = mocker.patch( + "azure.cli.core._profile.Profile.get_login_credentials" + ) + patched_get_login_credentials.return_value = ( + mocker.MagicMock(name="subscription"), + mocker.MagicMock(name="tenant"), + ) + + patch = mocker.patch( + "azext_iot._factory.iot_hub_service_factory" + ) + # pylint: disable=no-value-for-parameter, unexpected-keyword-arg + if ensure_iothub_sdk_min_version(IOTHUB_TRACK_2_SDK_MIN_VERSION): + patch.return_value = IotHubClient( + credential='', + subscription_id="00000000-0000-0000-0000-000000000000", + ).iot_hub_resource + else: + patch.return_value = IotHubClient( + credentials='', + subscription_id="00000000-0000-0000-0000-000000000000", + ).iot_hub_resource + + return patch + + +def generate_device_identity(include_keys=False, auth_type=None, identity=None, rg=None): + return { + "include_keys": include_keys, + "storage_authentication_type": auth_type, + "identity": identity, + "resource_group_name": rg + } + + +def assert_device_identity_result(actual, expected): + # the body from the call will be put into additional_properties + assert actual.job_id is None + assert actual.start_time_utc is None + assert actual.end_time_utc is None + assert actual.type is None + assert actual.status is None + assert actual.failure_reason is None + assert actual.status_message is None + assert actual.parent_job_id is None + assert actual.additional_properties == expected + + +class TestIoTHubDeviceIdentityExport(object): + @pytest.fixture + def service_client(self, mocked_response, get_mgmt_client): + mocked_response.assert_all_requests_are_fired = False + + mocked_response.add( + method=responses.GET, + content_type="application/json", + url=re.compile( + "https://(.*)management.azure.com/subscriptions/(.*)/" + "providers/Microsoft.Devices/IotHubs" + ), + status=200, + match_querystring=False, + body=json.dumps({"hostName": qualified_hostname}), + ) + + mocked_response.add( + method=responses.POST, + url=re.compile( + "https://management.azure.com/subscriptions/(.*)/" + "providers/Microsoft.Devices/IotHubs/{}/exportDevices".format( + hub_name + ) + ), + body=json.dumps(generic_job_response), + status=200, + content_type="application/json", + match_querystring=False, + ) + + yield mocked_response + + @pytest.mark.parametrize( + "req", + [ + generate_device_identity(), + generate_device_identity(include_keys=True), + generate_device_identity(auth_type="identity"), + generate_device_identity(auth_type="key"), + generate_device_identity(rg=resource_group_name), + ] + ) + def test_device_identity_export_track1(self, fixture_cmd, service_client, req): + result = subject.iot_device_export( + cmd=fixture_cmd, + hub_name=hub_name, + blob_container_uri=blob_container_uri, + include_keys=req["include_keys"], + storage_authentication_type=req["storage_authentication_type"], + resource_group_name=req["resource_group_name"], + ) + + request = service_client.calls[0].request + request_body = json.loads(request.body) + + assert request_body["exportBlobContainerUri"] == blob_container_uri + assert request_body["excludeKeys"] == (not req["include_keys"]) + if req["storage_authentication_type"]: + assert request_body["authenticationType"] == req["storage_authentication_type"] + "Based" + if req["storage_authentication_type"] == "identityBased" and req["identity"] not in (None, "[system]"): + assert request_body["identity"]["userAssignedIdentity"] == req["identity"] + + assert_device_identity_result(result, generic_job_response) + + @pytest.mark.parametrize( + "req", + [ + generate_device_identity(), + generate_device_identity(include_keys=True), + generate_device_identity(auth_type="identity"), + generate_device_identity(auth_type="key"), + generate_device_identity(rg=resource_group_name), + generate_device_identity(auth_type="identity", identity="[system]"), + generate_device_identity(auth_type="identity", identity="system"), + generate_device_identity(auth_type="identity", identity="managed_identity"), + ] + ) + @pytest.mark.skipif( + not ensure_iothub_sdk_min_version(IOTHUB_TRACK_2_SDK_MIN_VERSION), + reason="Skipping track 2 tests because SDK is track 1") + def test_device_identity_export_track2(self, fixture_cmd, service_client, req): + result = subject.iot_device_export( + cmd=fixture_cmd, + hub_name=hub_name, + blob_container_uri=blob_container_uri, + include_keys=req["include_keys"], + storage_authentication_type=req["storage_authentication_type"], + identity=req["identity"], + resource_group_name=req["resource_group_name"], + ) + + request = service_client.calls[0].request + request_body = json.loads(request.body) + + assert request_body["exportBlobContainerUri"] == blob_container_uri + assert request_body["excludeKeys"] == (not req["include_keys"]) + if req["storage_authentication_type"]: + assert request_body["authenticationType"] == req["storage_authentication_type"] + "Based" + if req["storage_authentication_type"] == "identityBased" and req["identity"] not in (None, "[system]"): + assert request_body["identity"]["userAssignedIdentity"] == req["identity"] + + assert_device_identity_result(result, generic_job_response) + + @pytest.mark.parametrize( + "req", + [ + generate_device_identity(auth_type="key", identity="[system]"), + generate_device_identity(auth_type="key", identity="system"), + ] + ) + @pytest.mark.skipif( + not ensure_iothub_sdk_min_version(IOTHUB_TRACK_2_SDK_MIN_VERSION), + reason="Skipping track 2 tests because SDK is track 1") + def test_device_identity_export_input(self, fixture_cmd, req): + with pytest.raises(CLIError): + subject.iot_device_export( + cmd=fixture_cmd, + hub_name=hub_name, + blob_container_uri=blob_container_uri, + include_keys=req["include_keys"], + storage_authentication_type=req["storage_authentication_type"], + identity=req["identity"], + resource_group_name=req["resource_group_name"], + ) + + +class TestIoTHubDeviceIdentityImport(object): + @pytest.fixture + def service_client(self, mocked_response, get_mgmt_client): + mocked_response.assert_all_requests_are_fired = False + + mocked_response.add( + method=responses.GET, + content_type="application/json", + url=re.compile( + "https://(.*)management.azure.com/subscriptions/(.*)/" + "providers/Microsoft.Devices/IotHubs" + ), + status=200, + match_querystring=False, + body=json.dumps({"hostName": qualified_hostname}), + ) + + mocked_response.add( + method=responses.POST, + content_type="application/json", + url=re.compile( + "https://management.azure.com/subscriptions/(.*)/" + "providers/Microsoft.Devices/IotHubs/{}/importDevices".format( + hub_name + ) + ), + status=200, + match_querystring=False, + body=json.dumps(generic_job_response), + ) + + yield mocked_response + + @pytest.mark.parametrize( + "req", + [ + generate_device_identity(), + generate_device_identity(auth_type="identity"), + generate_device_identity(auth_type="key"), + generate_device_identity(rg=resource_group_name), + ] + ) + def test_device_identity_import_track1(self, fixture_cmd, service_client, req): + result = subject.iot_device_import( + cmd=fixture_cmd, + hub_name=hub_name, + input_blob_container_uri=blob_container_uri, + output_blob_container_uri=blob_container_uri + "2", + storage_authentication_type=req["storage_authentication_type"], + resource_group_name=req["resource_group_name"], + ) + request = service_client.calls[0].request + request_body = json.loads(request.body) + + assert request_body["inputBlobContainerUri"] == blob_container_uri + assert request_body["outputBlobContainerUri"] == blob_container_uri + "2" + if req["storage_authentication_type"]: + assert request_body["authenticationType"] == req["storage_authentication_type"] + "Based" + if req["storage_authentication_type"] == "identityBased" and req["identity"] not in (None, "[system]"): + assert request_body["identity"]["userAssignedIdentity"] == req["identity"] + + assert_device_identity_result(result, generic_job_response) + + @pytest.mark.parametrize( + "req", + [ + generate_device_identity(), + generate_device_identity(auth_type="identity"), + generate_device_identity(auth_type="key"), + generate_device_identity(rg=resource_group_name), + generate_device_identity(auth_type="identity", identity="[system]"), + generate_device_identity(auth_type="identity", identity="managed_identity"), + ] + ) + @pytest.mark.skipif( + not ensure_iothub_sdk_min_version(IOTHUB_TRACK_2_SDK_MIN_VERSION), + reason="Skipping track 2 tests because SDK is track 1") + def test_device_identity_import_track2(self, fixture_cmd, service_client, req): + result = subject.iot_device_import( + cmd=fixture_cmd, + hub_name=hub_name, + input_blob_container_uri=blob_container_uri, + output_blob_container_uri=blob_container_uri + "2", + storage_authentication_type=req["storage_authentication_type"], + identity=req["identity"], + resource_group_name=req["resource_group_name"], + ) + request = service_client.calls[0].request + request_body = json.loads(request.body) + + assert request_body["inputBlobContainerUri"] == blob_container_uri + assert request_body["outputBlobContainerUri"] == blob_container_uri + "2" + if req["storage_authentication_type"]: + assert request_body["authenticationType"] == req["storage_authentication_type"] + "Based" + if req["storage_authentication_type"] == "identityBased" and req["identity"] not in (None, "[system]"): + assert request_body["identity"]["userAssignedIdentity"] == req["identity"] + + assert_device_identity_result(result, generic_job_response) + + @pytest.mark.parametrize( + "req", + [ + generate_device_identity(auth_type="key", identity="[system]"), + generate_device_identity(auth_type="key", identity="managed_identity"), + ] + ) + @pytest.mark.skipif( + not ensure_iothub_sdk_min_version(IOTHUB_TRACK_2_SDK_MIN_VERSION), + reason="Skipping track 2 tests because SDK is track 1") + def test_device_identity_import_input(self, fixture_cmd, req): + with pytest.raises(CLIError): + subject.iot_device_import( + cmd=fixture_cmd, + hub_name=hub_name, + input_blob_container_uri=blob_container_uri, + output_blob_container_uri=blob_container_uri + "2", + storage_authentication_type=req["storage_authentication_type"], + identity=req["identity"], + resource_group_name=req["resource_group_name"], + ) From 616e2c0d7c7f1871e56eb3dc1083a37e0acd4cd4 Mon Sep 17 00:00:00 2001 From: Paymaun Date: Tue, 25 May 2021 15:02:48 -0700 Subject: [PATCH 10/42] Update azext_metadata.json (#351) --- azext_iot/azext_metadata.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azext_iot/azext_metadata.json b/azext_iot/azext_metadata.json index 5bed66c74..761a3e110 100644 --- a/azext_iot/azext_metadata.json +++ b/azext_iot/azext_metadata.json @@ -1,3 +1,3 @@ { - "azext.minCliCoreVersion": "2.3.1" + "azext.minCliCoreVersion": "2.17.1" } From 9ee1ddc2c0726e666269b7e5c5f75c083989abd2 Mon Sep 17 00:00:00 2001 From: Paymaun Heidari Date: Tue, 25 May 2021 15:10:46 -0700 Subject: [PATCH 11/42] Increment version to v0.10.13 --- azext_iot/constants.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azext_iot/constants.py b/azext_iot/constants.py index dec1ad26d..b1d9d47f0 100644 --- a/azext_iot/constants.py +++ b/azext_iot/constants.py @@ -7,7 +7,7 @@ import os -VERSION = "0.10.12" +VERSION = "0.10.13" EXTENSION_NAME = "azure-iot" EXTENSION_ROOT = os.path.dirname(os.path.abspath(__file__)) EXTENSION_CONFIG_ROOT_KEY = "iotext" From 3f12f2113fb593d721280c7b9c021984c2055ed1 Mon Sep 17 00:00:00 2001 From: Paymaun Date: Tue, 25 May 2021 16:56:29 -0700 Subject: [PATCH 12/42] Update README.md --- README.md | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 8378708a6..cabd475f2 100644 --- a/README.md +++ b/README.md @@ -6,22 +6,23 @@ The **Azure IoT extension for Azure CLI** aims to accelerate the development, management and automation of Azure IoT solutions. It does this via addition of rich features and functionality to the official [Azure CLI](https://docs.microsoft.com/en-us/cli/azure). ## News +- Starting with version `0.10.13` of the IoT extension, you will need an Azure CLI core version of `2.17.1` or higher. IoT extension version `0.10.11` remains on the extension index to support environments that cannot upgrade core CLI versions. -The legacy IoT extension Id `azure-cli-iot-ext` is deprecated in favor of the new modern Id `azure-iot`. `azure-iot` is a superset of `azure-cli-iot-ext` and any new features or fixes will apply to `azure-iot` only. Also the legacy and modern IoT extension should **never** co-exist in the same CLI environment. +- The legacy IoT extension Id `azure-cli-iot-ext` is deprecated in favor of the new modern Id `azure-iot`. `azure-iot` is a superset of `azure-cli-iot-ext` and any new features or fixes will apply to `azure-iot` only. Also the legacy and modern IoT extension should **never** co-exist in the same CLI environment. -Uninstall the legacy extension with the following command: `az extension remove --name azure-cli-iot-ext`. + Uninstall the legacy extension with the following command: `az extension remove --name azure-cli-iot-ext`. -Related - if you see an error with a stacktrace similar to: -``` -... -azure-cli-iot-ext/azext_iot/common/_azure.py, ln 90, in get_iot_hub_connection_string - client = iot_hub_service_factory(cmd.cli_ctx) -cliextensions/azure-cli-iot-ext/azext_iot/_factory.py, ln 29, in iot_hub_service_factory - from azure.mgmt.iothub.iot_hub_client import IotHubClient -ModuleNotFoundError: No module named 'azure.mgmt.iothub.iot_hub_client' -``` + Related - if you see an error with a stacktrace similar to: + ``` + ... + azure-cli-iot-ext/azext_iot/common/_azure.py, ln 90, in get_iot_hub_connection_string + client = iot_hub_service_factory(cmd.cli_ctx) + cliextensions/azure-cli-iot-ext/azext_iot/_factory.py, ln 29, in iot_hub_service_factory + from azure.mgmt.iothub.iot_hub_client import IotHubClient + ModuleNotFoundError: No module named 'azure.mgmt.iothub.iot_hub_client' + ``` -The resolution is to remove the deprecated `azure-cli-iot-ext` and install any version of the `azure-iot` extension. + The resolution is to remove the deprecated `azure-cli-iot-ext` and install any version of the `azure-iot` extension. ## Commands From 058648a794739d5f58c2574b3bc9081422d55f4e Mon Sep 17 00:00:00 2001 From: Paymaun Date: Tue, 25 May 2021 16:58:36 -0700 Subject: [PATCH 13/42] Update HISTORY.rst --- HISTORY.rst | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/HISTORY.rst b/HISTORY.rst index 3340377ab..949580e1a 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -8,7 +8,8 @@ Release History **IoT Central updates** -* Public API GA update +* Public API GA update + * Remove preview tag for api-token, device, device-template, user routes. Default routes use central GA API's. * Add support for preview and 1.0 routes. * Addition of the optional '--av' argument to specify the version of API for the requested operation. From 5a251d943df06c52c1410276e38de85fdc9869cd Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Wed, 26 May 2021 23:31:24 -0700 Subject: [PATCH 14/42] update twin reported properties during simulation --- azext_iot/operations/_mqtt.py | 16 ++++++++ .../tests/iothub/test_iot_messaging_int.py | 39 +++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index ee456b647..90d09b78d 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -10,12 +10,14 @@ from time import sleep from azext_iot.constants import USER_AGENT, BASE_MQTT_API_VERSION from azext_iot.common.utility import url_encode_str +from azext_iot.operations.hub import _iot_device_twin_show from azure.iot.device import IoTHubDeviceClient as mqtt_device_client, Message, MethodResponse class mqtt_client(object): def __init__(self, target, device_conn_string, device_id, method_response_code=None, method_response_payload=None): self.device_id = device_id + self.target = target self.device_client = mqtt_device_client.create_from_connection_string(device_conn_string) self.device_client.connect() self.device_client.on_message_received = self.message_handler @@ -77,6 +79,20 @@ def execute(self, data, properties={}, publish_delay=2, msg_count=100): from tqdm import tqdm try: for _ in tqdm(range(msg_count), desc='Device simulation in progress'): + device_twin = _iot_device_twin_show(self.target, self.device_id) + if device_twin: + desired_properties = device_twin.get("properties").get("desired") + reported_properties = device_twin.get("properties").get("reported") + twin_properties_to_update = {} + + for prop in desired_properties: + if not prop.startswith("$"): + if prop not in reported_properties or desired_properties[prop] != reported_properties[prop]: + twin_properties_to_update[prop] = desired_properties[prop] + + if twin_properties_to_update: + self.device_client.patch_twin_reported_properties(twin_properties_to_update) + self.send_d2c_message(message_text=data.generate(True), properties=properties) sleep(publish_delay) diff --git a/azext_iot/tests/iothub/test_iot_messaging_int.py b/azext_iot/tests/iothub/test_iot_messaging_int.py index 76d44535a..d191edf7a 100644 --- a/azext_iot/tests/iothub/test_iot_messaging_int.py +++ b/azext_iot/tests/iothub/test_iot_messaging_int.py @@ -309,6 +309,45 @@ def test_mqtt_device_direct_method_with_custom_response_status_payload(self): token.set() thread.join() + def test_twin_properties_update(self): + device_count = 1 + device_ids = self.generate_device_names(device_count) + + self.cmd( + "iot hub device-identity create -d {} -n {} -g {}".format( + device_ids[0], LIVE_HUB, LIVE_RG + ), + checks=[self.check("deviceId", device_ids[0])], + ) + + test_twin_props = {'twin_test_prop_1': 'twin_test_value_1'} + self.kwargs["twin_desired_properties"] = json.dumps(test_twin_props) + + # invoke device twin property update + self.cmd( + """iot hub device-twin update -d {} --login {} --desired '{}'""".format( + device_ids[0], self.connection_string, "{twin_desired_properties}" + ) + ) + + self.cmd( + "iot device simulate -d {} -n {} -g {} --mc {} --mi {} --rs 'complete'".format( + device_ids[0], LIVE_HUB, LIVE_RG, 2, 1 + ) + ) + + # get device twin + result = self.cmd( + "iot hub device-twin show -d {} --login {}".format( + device_ids[0], self.connection_string + ) + ).get_output_in_json() + + assert result is not None + + for key in test_twin_props: + assert result["properties"]["reported"][key] == result["properties"]["desired"][key] + def test_device_messaging(self): device_count = 1 device_ids = self.generate_device_names(device_count) From 70f806f70ae25d263c6b2fa3469f170bc1a1f8e9 Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Thu, 27 May 2021 01:14:44 -0700 Subject: [PATCH 15/42] update unit tests --- azext_iot/operations/_mqtt.py | 4 +- azext_iot/tests/conftest.py | 60 +++++++++++++++++++++ azext_iot/tests/iothub/test_iot_ext_unit.py | 2 +- 3 files changed, 63 insertions(+), 3 deletions(-) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index 90d09b78d..d8d12dd53 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -84,7 +84,7 @@ def execute(self, data, properties={}, publish_delay=2, msg_count=100): desired_properties = device_twin.get("properties").get("desired") reported_properties = device_twin.get("properties").get("reported") twin_properties_to_update = {} - + for prop in desired_properties: if not prop.startswith("$"): if prop not in reported_properties or desired_properties[prop] != reported_properties[prop]: @@ -92,7 +92,7 @@ def execute(self, data, properties={}, publish_delay=2, msg_count=100): if twin_properties_to_update: self.device_client.patch_twin_reported_properties(twin_properties_to_update) - + self.send_d2c_message(message_text=data.generate(True), properties=properties) sleep(publish_delay) diff --git a/azext_iot/tests/conftest.py b/azext_iot/tests/conftest.py index 13b39f62b..e3238124a 100644 --- a/azext_iot/tests/conftest.py +++ b/azext_iot/tests/conftest.py @@ -28,6 +28,7 @@ "azext_iot.operations.hub._iot_hub_monitor_events" ) hub_entity = "myhub.azure-devices.net" +path_device_twin_show_entrypoint = "azext_iot.operations.hub._iot_device_twin_show" instance_name = generate_generic_id() hostname = "{}.subdomain.domain".format(instance_name) @@ -168,6 +169,65 @@ def mqttclient_generic_error(mocker, fixture_ghcs, fixture_sas): def fixture_monitor_events_entrypoint(mocker): return mocker.patch(path_iot_hub_monitor_events_entrypoint) +@pytest.fixture() +def fixture_device_twin_show_entrypoint(mocker): + device_twin_client = mocker.patch(path_device_twin_show_entrypoint) + device_twin_client.return_value = { + "authenticationType": "sas", + "capabilities": { + "iotEdge": True + }, + "cloudToDeviceMessageCount": 0, + "connectionState": "Disconnected", + "deviceEtag": "NTQ4ODMwNjY0", + "deviceId": "_Test_Device", + "deviceScope": "ms-azure-iot-edge://Test_Device-637535090608626001", + "etag": "AAAAAAAAAAU=", + "lastActivityTime": "2021-05-27T04:48:03.681238Z", + "modelId": "", + "properties": { + "desired": { + "$metadata": { + "$lastUpdated": "2021-05-27T04:45:38.5203899Z", + "$lastUpdatedVersion": 5, + "test_prop_1": { + "$lastUpdated": "2021-05-27T04:44:45.9299421Z", + "$lastUpdatedVersion": 4 + }, + "test_prop_2": { + "$lastUpdated": "2021-05-27T04:45:38.5203899Z", + "$lastUpdatedVersion": 5 + } + }, + "$version": 5, + "test_prop_1": "test_val_2", + "test_prop_2": "test_val_4" + }, + "reported": { + "$metadata": { + "$lastUpdated": "2021-05-27T04:45:39.5521362Z", + "test_prop_1": { + "$lastUpdated": "2021-05-27T04:43:33.3650357Z" + }, + "test_prop_2": { + "$lastUpdated": "2021-05-27T04:45:39.5521362Z" + } + }, + "$version": 5, + "test_prop_1": "test_val_2", + "test_prop_2": "test_val_4" + } + }, + "status": "enabled", + "statusUpdateTime": "0001-01-01T00:00:00Z", + "version": 10, + "x509Thumbprint": { + "primaryThumbprint": None, + "secondaryThumbprint": None + } + } + return device_twin_client + # TODO: To be deprecated asap. Leverage mocked_response fixture for this functionality. def build_mock_response( diff --git a/azext_iot/tests/iothub/test_iot_ext_unit.py b/azext_iot/tests/iothub/test_iot_ext_unit.py index 73ea3f944..1a831b633 100644 --- a/azext_iot/tests/iothub/test_iot_ext_unit.py +++ b/azext_iot/tests/iothub/test_iot_ext_unit.py @@ -2166,7 +2166,7 @@ def test_generate_sas_token(self): class TestDeviceSimulate: @pytest.fixture(params=[204]) - def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request, fixture_device): + def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request, fixture_device, fixture_device_twin_show_entrypoint): service_client = mocker.patch(path_service_client) service_client.return_value = build_mock_response(mocker, request.param, {}) return service_client From 759503af8d43cba308432f7a6244a708bc6a6a6c Mon Sep 17 00:00:00 2001 From: vilit1 <73560279+vilit1@users.noreply.github.com> Date: Thu, 27 May 2021 10:01:29 -0700 Subject: [PATCH 16/42] Add dataplane reset (#352) * pr comments * update dt reset to only support deleting both * Remove unused import --- azext_iot/digitaltwins/_help.py | 11 +++++ azext_iot/digitaltwins/command_map.py | 1 + azext_iot/digitaltwins/commands_resource.py | 7 ++++ .../test_dt_twin_lifecycle_int.py | 40 +++++++++++++++++++ 4 files changed, 59 insertions(+) diff --git a/azext_iot/digitaltwins/_help.py b/azext_iot/digitaltwins/_help.py index a8ce93e48..33fb66e0a 100644 --- a/azext_iot/digitaltwins/_help.py +++ b/azext_iot/digitaltwins/_help.py @@ -113,6 +113,17 @@ def load_digitaltwins_help(): az dt delete -n {instance_name} -y --no-wait """ + helps["dt reset"] = """ + type: command + short-summary: Reset an existing Digital Twins instance by deleting associated + assets. Currently only supports deleting models and twins. + + examples: + - name: Reset all assets for a Digital Twins instance. + text: > + az dt reset -n {instance_name} + """ + helps["dt endpoint"] = """ type: group short-summary: Manage and configure Digital Twins instance endpoints. diff --git a/azext_iot/digitaltwins/command_map.py b/azext_iot/digitaltwins/command_map.py index da67ed879..d5234a44b 100644 --- a/azext_iot/digitaltwins/command_map.py +++ b/azext_iot/digitaltwins/command_map.py @@ -42,6 +42,7 @@ def load_digitaltwins_commands(self, _): cmd_group.show_command("show", "show_instance") cmd_group.command("list", "list_instances") cmd_group.command("delete", "delete_instance", confirmation=True, supports_no_wait=True) + cmd_group.command("reset", "reset_instance", confirmation=True, is_preview=True) with self.command_group( "dt endpoint", command_type=digitaltwins_resource_ops diff --git a/azext_iot/digitaltwins/commands_resource.py b/azext_iot/digitaltwins/commands_resource.py index 96d176bc0..62275b818 100644 --- a/azext_iot/digitaltwins/commands_resource.py +++ b/azext_iot/digitaltwins/commands_resource.py @@ -4,6 +4,8 @@ # Licensed under the MIT License. See License.txt in the project root for license information. # -------------------------------------------------------------------------------------------- +from azext_iot.digitaltwins.commands_twins import delete_all_twin +from azext_iot.digitaltwins.commands_models import delete_all_models from azext_iot.digitaltwins.providers.resource import ResourceProvider from azext_iot.digitaltwins.common import ( ADTEndpointType, @@ -57,6 +59,11 @@ def delete_instance(cmd, name, resource_group_name=None): return rp.delete(name=name, resource_group_name=resource_group_name) +def reset_instance(cmd, name, resource_group_name=None): + delete_all_models(cmd, name, resource_group_name) + delete_all_twin(cmd, name, resource_group_name) + + def list_endpoints(cmd, name, resource_group_name=None): rp = ResourceProvider(cmd) return rp.list_endpoints(name=name, resource_group_name=resource_group_name) diff --git a/azext_iot/tests/digitaltwins/test_dt_twin_lifecycle_int.py b/azext_iot/tests/digitaltwins/test_dt_twin_lifecycle_int.py index 757104999..f799d4e4e 100644 --- a/azext_iot/tests/digitaltwins/test_dt_twin_lifecycle_int.py +++ b/azext_iot/tests/digitaltwins/test_dt_twin_lifecycle_int.py @@ -516,6 +516,17 @@ def test_dt_twin(self): assert len(twin_query_result["result"]) == 0 assert twin_query_result["cost"] + self.cmd( + "dt reset -n {} --yes".format( + instance_name, + ) + ) + + model_query_result = self.cmd( + "dt model list -n {} -g {}".format(instance_name, self.rg) + ).get_output_in_json() + assert len(model_query_result) == 0 + def test_dt_twin_bulk_delete(self): self.wait_for_capacity() instance_name = generate_resource_id() @@ -685,6 +696,35 @@ def test_dt_twin_bulk_delete(self): assert len(twin_query_result["result"]) == 0 assert twin_query_result["cost"] + model_query_result = self.cmd( + "dt model list -n {} -g {}".format(instance_name, self.rg) + ).get_output_in_json() + assert len(model_query_result) > 0 + + self.cmd( + "dt twin create -n {} --dtmi {} --twin-id {}".format( + instance_name, floor_dtmi, floor_twin_id + ) + ) + + self.cmd( + "dt reset -n {} --yes".format( + instance_name, + ) + ) + + model_query_result = self.cmd( + "dt model list -n {} -g {}".format(instance_name, self.rg) + ).get_output_in_json() + assert len(model_query_result) == 0 + + twin_query_result = self.cmd( + "dt twin query -n {} -g {} -q 'select * from digitaltwins' --cost".format( + instance_name, self.rg + ) + ).get_output_in_json() + assert len(twin_query_result["result"]) == 0 + # TODO: Refactor - limited interface def assert_twin_attributes( From f4333a9e24c6c7cfe066f4323b47da0d5bd4386b Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Thu, 27 May 2021 12:11:01 -0700 Subject: [PATCH 17/42] styling updates --- azext_iot/tests/conftest.py | 53 +++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/azext_iot/tests/conftest.py b/azext_iot/tests/conftest.py index e3238124a..2e8303a11 100644 --- a/azext_iot/tests/conftest.py +++ b/azext_iot/tests/conftest.py @@ -169,6 +169,7 @@ def mqttclient_generic_error(mocker, fixture_ghcs, fixture_sas): def fixture_monitor_events_entrypoint(mocker): return mocker.patch(path_iot_hub_monitor_events_entrypoint) + @pytest.fixture() def fixture_device_twin_show_entrypoint(mocker): device_twin_client = mocker.patch(path_device_twin_show_entrypoint) @@ -187,35 +188,35 @@ def fixture_device_twin_show_entrypoint(mocker): "modelId": "", "properties": { "desired": { - "$metadata": { - "$lastUpdated": "2021-05-27T04:45:38.5203899Z", - "$lastUpdatedVersion": 5, - "test_prop_1": { - "$lastUpdated": "2021-05-27T04:44:45.9299421Z", - "$lastUpdatedVersion": 4 + "$metadata": { + "$lastUpdated": "2021-05-27T04:45:38.5203899Z", + "$lastUpdatedVersion": 5, + "test_prop_1": { + "$lastUpdated": "2021-05-27T04:44:45.9299421Z", + "$lastUpdatedVersion": 4 + }, + "test_prop_2": { + "$lastUpdated": "2021-05-27T04:45:38.5203899Z", + "$lastUpdatedVersion": 5 + } }, - "test_prop_2": { - "$lastUpdated": "2021-05-27T04:45:38.5203899Z", - "$lastUpdatedVersion": 5 - } - }, - "$version": 5, - "test_prop_1": "test_val_2", - "test_prop_2": "test_val_4" + "$version": 5, + "test_prop_1": "test_val_2", + "test_prop_2": "test_val_4" }, "reported": { - "$metadata": { - "$lastUpdated": "2021-05-27T04:45:39.5521362Z", - "test_prop_1": { - "$lastUpdated": "2021-05-27T04:43:33.3650357Z" + "$metadata": { + "$lastUpdated": "2021-05-27T04:45:39.5521362Z", + "test_prop_1": { + "$lastUpdated": "2021-05-27T04:43:33.3650357Z" + }, + "test_prop_2": { + "$lastUpdated": "2021-05-27T04:45:39.5521362Z" + } }, - "test_prop_2": { - "$lastUpdated": "2021-05-27T04:45:39.5521362Z" - } - }, - "$version": 5, - "test_prop_1": "test_val_2", - "test_prop_2": "test_val_4" + "$version": 5, + "test_prop_1": "test_val_2", + "test_prop_2": "test_val_4" } }, "status": "enabled", @@ -227,7 +228,7 @@ def fixture_device_twin_show_entrypoint(mocker): } } return device_twin_client - + # TODO: To be deprecated asap. Leverage mocked_response fixture for this functionality. def build_mock_response( From f82ef1267eeddaf42b6d89ba6a78e7fe87e8399e Mon Sep 17 00:00:00 2001 From: Paymaun Date: Thu, 27 May 2021 13:14:56 -0700 Subject: [PATCH 18/42] C2D messaging improvements. (#354) * Remove hiding of warnings from sample pytest.ini --- .github/CODEOWNERS | 19 ++++----------- HISTORY.rst | 23 +++++++++++++++++++ azext_iot/_params.py | 4 ++-- azext_iot/constants.py | 2 +- azext_iot/monitor/event.py | 4 ++-- azext_iot/operations/hub.py | 10 ++++---- .../messaging/test_iothub_c2d_messages_int.py | 5 ++-- pytest.ini.example | 1 - 8 files changed, 39 insertions(+), 29 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 78122037e..ebd3bb01c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,22 +5,11 @@ * @digimaun # Central Code Owner(s) -azext_iot/central/ @prbans -azext_iot/tests/central/ @prbans -azext_iot/tests/test_iot_central_int.py @prbans -azext_iot/tests/test_iot_central_unit.py @prbans - -# Monitor Code Owner(s) -azext_iot/monitor/ @prbans @digimaun +azext_iot/central/ @valluriraj +azext_iot/tests/central/ @valluriraj +azext_iot/tests/test_iot_central_int.py @valluriraj +azext_iot/tests/test_iot_central_unit.py @valluriraj # AICS Code Owner(s) azext_iot/product/ @montgomp @c-ryan-k azext_iot/tests/product/ @montgomp @c-ryan-k - -# PnP Repository Code Owners(s) -azext_iot/pnp/ @c-ryan-k -azext_iot/tests/pnp/ @c-ryan-k - -# Test Code Owners -azext_iot/tests/test_monitor_parsers_unit.py @prbans @digimaun -azext_iot/tests/test_uamqp_import.py @prbans @digimaun diff --git a/HISTORY.rst b/HISTORY.rst index 949580e1a..1f827555e 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -3,6 +3,29 @@ Release History =============== +0.10.14 ++++++++++++++++ + +**IoT Hub updates** + +* Fix for "az iot hub c2d-message receive" - the command will use the "ContentEncoding" header value (which indicates the message body encoding) + or fallback to utf-8 to decode the received message body. + +**Azure Digital Twins updates** + +* Addition of the following commands + + * az dt reset - Preview command which deletes all data entities from the target instance (models, twins, twin relationships). + + +0.10.13 ++++++++++++++++ + +**General updates** + +* Min CLI core version raised to 2.17.1 + + 0.10.12 +++++++++++++++ diff --git a/azext_iot/_params.py b/azext_iot/_params.py index f9e324555..908152723 100644 --- a/azext_iot/_params.py +++ b/azext_iot/_params.py @@ -623,12 +623,12 @@ def load_arguments(self, _): context.argument( "content_type", options_list=["--content-type", "--ct"], - help="The content type associated with the C2D message.", + help="The content type for the C2D message body.", ) context.argument( "content_encoding", options_list=["--content-encoding", "--ce"], - help="The content encoding associated with the C2D message.", + help="The encoding for the C2D message body.", ) with self.argument_context("iot device c2d-message send") as context: diff --git a/azext_iot/constants.py b/azext_iot/constants.py index b1d9d47f0..4f37a9004 100644 --- a/azext_iot/constants.py +++ b/azext_iot/constants.py @@ -7,7 +7,7 @@ import os -VERSION = "0.10.13" +VERSION = "0.10.14" EXTENSION_NAME = "azure-iot" EXTENSION_ROOT = os.path.dirname(os.path.abspath(__file__)) EXTENSION_CONFIG_ROOT_KEY = "iotext" diff --git a/azext_iot/monitor/event.py b/azext_iot/monitor/event.py index 7e250478f..9c00fa684 100644 --- a/azext_iot/monitor/event.py +++ b/azext_iot/monitor/event.py @@ -59,7 +59,7 @@ def send_c2d_message( # Ensures valid json when content_type is application/json content_type = content_type.lower() - if content_type == "application/json": + if "application/json" in content_type: data = json.dumps(process_json_arg(data, "data")) if content_encoding: @@ -68,7 +68,7 @@ def send_c2d_message( if expiry_time_utc: msg_props.absolute_expiry_time = int(expiry_time_utc) - msg_body = str.encode(data) + msg_body = data.encode(encoding=content_encoding) message = uamqp.Message( body=msg_body, properties=msg_props, application_properties=app_props diff --git a/azext_iot/operations/hub.py b/azext_iot/operations/hub.py index 77f91424b..62efb6f63 100644 --- a/azext_iot/operations/hub.py +++ b/azext_iot/operations/hub.py @@ -2305,12 +2305,10 @@ def _iot_c2d_message_receive(target, device_id, lock_timeout=60, ack=None): if sys_props: payload["properties"]["system"] = sys_props - if result.text: - payload["data"] = ( - result.text - if not isinstance(result.text, six.binary_type) - else result.text.decode("utf-8") - ) + if result.content: + target_encoding = result.headers.get("ContentEncoding", "utf-8") + logger.info(f"Decoding message data encoded with: {target_encoding}") + payload["data"] = result.content.decode(target_encoding) return payload return diff --git a/azext_iot/tests/iothub/messaging/test_iothub_c2d_messages_int.py b/azext_iot/tests/iothub/messaging/test_iothub_c2d_messages_int.py index 5d34cdc38..a2329ec39 100644 --- a/azext_iot/tests/iothub/messaging/test_iothub_c2d_messages_int.py +++ b/azext_iot/tests/iothub/messaging/test_iothub_c2d_messages_int.py @@ -8,6 +8,7 @@ from uuid import uuid4 from azext_iot.tests import IoTLiveScenarioTest +from azext_iot.common.shared import AuthenticationTypeDataplane from azext_iot.tests.iothub import DATAPLANE_AUTH_TYPES from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC from azext_iot.common.utility import ( @@ -35,13 +36,13 @@ def test_iothub_c2d_messages(self): ) for auth_phase in DATAPLANE_AUTH_TYPES: - test_body = str(uuid4()) + test_ce = "utf-16" if auth_phase == AuthenticationTypeDataplane.login.value else "utf-8" + test_body = f"{uuid4()} шеллы 😁" # Mixed unicode blocks test_props = f"key0={str(uuid4())};key1={str(uuid4())}" test_cid = str(uuid4()) test_mid = str(uuid4()) test_ct = "text/plain" test_et = calculate_millisec_since_unix_epoch_utc(3600) # milliseconds since epoch - test_ce = "utf8" self.kwargs["c2d_json_send_data"] = json.dumps({"data": str(uuid4())}) diff --git a/pytest.ini.example b/pytest.ini.example index ede7b5ff2..6118a600e 100644 --- a/pytest.ini.example +++ b/pytest.ini.example @@ -2,7 +2,6 @@ junit_family = xunit1 addopts = -v - -p no:warnings norecursedirs = dist From 48af5581d84b718c83d660b5ca265acff1b134cc Mon Sep 17 00:00:00 2001 From: vilit1 <73560279+vilit1@users.noreply.github.com> Date: Thu, 27 May 2021 14:42:15 -0700 Subject: [PATCH 19/42] Digital Twin wait commands (#345) * update to use wait * fix error status code * int testing * update help --- azext_iot/digitaltwins/_help.py | 50 +++++++++++++++++- azext_iot/digitaltwins/command_map.py | 7 +++ azext_iot/digitaltwins/commands_resource.py | 25 +++++++++ azext_iot/digitaltwins/params.py | 10 ++++ azext_iot/digitaltwins/providers/resource.py | 35 +++++++++---- azext_iot/tests/digitaltwins/__init__.py | 33 ++++++------ .../test_dt_privatelinks_lifecycle_int.py | 26 ++++++++++ .../test_dt_resource_lifecycle_int.py | 52 ++++++++++++++++++- 8 files changed, 207 insertions(+), 31 deletions(-) diff --git a/azext_iot/digitaltwins/_help.py b/azext_iot/digitaltwins/_help.py index 33fb66e0a..8764b4719 100644 --- a/azext_iot/digitaltwins/_help.py +++ b/azext_iot/digitaltwins/_help.py @@ -113,6 +113,22 @@ def load_digitaltwins_help(): az dt delete -n {instance_name} -y --no-wait """ + helps["dt wait"] = """ + type: command + short-summary: Wait until an operation on an Digital Twins instance is complete. + + examples: + - name: Wait until an arbitrary instance is created. + text: > + az dt wait -n {instance_name} --created + - name: Wait until an existing instance is deleted. + text: > + az dt wait -n {instance_name} --deleted + - name: Wait until an existing instance's publicNetworkAccess property is set to Enabled + text: > + az dt wait -n {instance_name} --custom "publicNetworkAccess=='Enabled'" + """ + helps["dt reset"] = """ type: command short-summary: Reset an existing Digital Twins instance by deleting associated @@ -232,6 +248,22 @@ def load_digitaltwins_help(): az dt endpoint delete -n {instance_name} --endpoint-name {endpoint_name} -y --no-wait """ + helps["dt endpoint wait"] = """ + type: command + short-summary: Wait until an endpoint operation is done. + + examples: + - name: Wait until an endpoint for an instance is created. + text: > + az dt endpoint wait -n {instance_name} --endpoint-name {endpoint_name} --created + - name: Wait until an existing endpoint is deleted from an instance. + text: > + az dt endpoint wait -n {instance_name} --endpoint-name {endpoint_name} --deleted + - name: Wait until an existing endpoint's primaryConnectionString is null. + text: > + az dt endpoint wait -n {instance_name} --endpoint-name {endpoint_name} --custom "properties.primaryConnectionString==null" + """ + helps["dt network"] = """ type: group short-summary: Manage Digital Twins network configuration including private links and endpoint connections. @@ -301,7 +333,6 @@ def load_digitaltwins_help(): - name: Approve a pending private-endpoint connection associated with the instance and add a description. text: > az dt network private-endpoint connection set -n {instance_name} --cn {connection_name} --status Approved --desc "A description." - - name: Reject a private-endpoint connection associated with the instance and add a description. text: > az dt network private-endpoint connection set -n {instance_name} --cn {connection_name} --status Rejected --desc "Does not comply." @@ -321,6 +352,23 @@ def load_digitaltwins_help(): az dt network private-endpoint connection delete -n {instance_name} --cn ba8408b6-1372-41b2-aef8-af43afc4729f -y --no-wait """ + helps["dt network private-endpoint connection wait"] = """ + type: command + short-summary: Wait until an operation on a private-endpoint connection is complete. + + examples: + - name: Wait until the existing private-endpoint connection named ba8408b6-1372-41b2-aef8-af43afc4729f state is updated. + text: > + az dt network private-endpoint connection wait -n {instance_name} --cn ba8408b6-1372-41b2-aef8-af43afc4729f --updated + + - name: Wait until the existing private-endpoint connection named ba8408b6-1372-41b2-aef8-af43afc4729f is deleted. + text: > + az dt network private-endpoint connection wait -n {instance_name} --cn ba8408b6-1372-41b2-aef8-af43afc4729f --deleted + - name: Wait until the existing private-endpoint connection named ba8408b6-1372-41b2-aef8-af43afc4729f has no actions required in the privateLinkServiceConnectionState property. + text: > + az dt network private-endpoint connection wait -n {instance_name} --cn ba8408b6-1372-41b2-aef8-af43afc4729f --custom "properties.privateLinkServiceConnectionState.actionsRequired=='None'" + """ + helps["dt role-assignment"] = """ type: group short-summary: Manage RBAC role assignments for a Digital Twins instance. diff --git a/azext_iot/digitaltwins/command_map.py b/azext_iot/digitaltwins/command_map.py index d5234a44b..f4e90eed5 100644 --- a/azext_iot/digitaltwins/command_map.py +++ b/azext_iot/digitaltwins/command_map.py @@ -42,6 +42,7 @@ def load_digitaltwins_commands(self, _): cmd_group.show_command("show", "show_instance") cmd_group.command("list", "list_instances") cmd_group.command("delete", "delete_instance", confirmation=True, supports_no_wait=True) + cmd_group.wait_command("wait", "wait_instance") cmd_group.command("reset", "reset_instance", confirmation=True, is_preview=True) with self.command_group( @@ -64,6 +65,9 @@ def load_digitaltwins_commands(self, _): ), ) cmd_group.command("delete", "delete_endpoint", confirmation=True, supports_no_wait=True) + cmd_group.wait_command( + "wait", "wait_endpoint" + ) with self.command_group( "dt endpoint create", command_type=digitaltwins_resource_ops @@ -169,3 +173,6 @@ def load_digitaltwins_commands(self, _): cmd_group.show_command("show", "show_private_endpoint_conn") cmd_group.command("list", "list_private_endpoint_conns") cmd_group.command("delete", "delete_private_endpoint_conn", confirmation=True, supports_no_wait=True) + cmd_group.wait_command( + "wait", "wait_private_endpoint_conn" + ) diff --git a/azext_iot/digitaltwins/commands_resource.py b/azext_iot/digitaltwins/commands_resource.py index 62275b818..5847a196d 100644 --- a/azext_iot/digitaltwins/commands_resource.py +++ b/azext_iot/digitaltwins/commands_resource.py @@ -59,6 +59,11 @@ def delete_instance(cmd, name, resource_group_name=None): return rp.delete(name=name, resource_group_name=resource_group_name) +def wait_instance(cmd, name, resource_group_name=None): + rp = ResourceProvider(cmd) + return rp.find_instance(name=name, resource_group_name=resource_group_name, wait=True) + + def reset_instance(cmd, name, resource_group_name=None): delete_all_models(cmd, name, resource_group_name) delete_all_twin(cmd, name, resource_group_name) @@ -83,6 +88,16 @@ def delete_endpoint(cmd, name, endpoint_name, resource_group_name=None): ) +def wait_endpoint(cmd, name, endpoint_name, resource_group_name=None): + rp = ResourceProvider(cmd) + return rp.get_endpoint( + name=name, + endpoint_name=endpoint_name, + resource_group_name=resource_group_name, + wait=True + ) + + def add_endpoint_eventgrid( cmd, name, @@ -225,3 +240,13 @@ def delete_private_endpoint_conn(cmd, name, conn_name, resource_group_name=None) return rp.delete_private_endpoint_conn( name=name, resource_group_name=resource_group_name, conn_name=conn_name ) + + +def wait_private_endpoint_conn(cmd, name, conn_name, resource_group_name=None): + rp = ResourceProvider(cmd) + return rp.get_private_endpoint_conn( + name=name, + resource_group_name=resource_group_name, + conn_name=conn_name, + wait=True + ) diff --git a/azext_iot/digitaltwins/params.py b/azext_iot/digitaltwins/params.py index 5214425b5..5842f284c 100644 --- a/azext_iot/digitaltwins/params.py +++ b/azext_iot/digitaltwins/params.py @@ -142,6 +142,9 @@ def load_digitaltwins_arguments(self, _): help="Role name or Id the system assigned identity will have.", ) + with self.argument_context("dt wait") as context: + context.ignore("updated") + with self.argument_context("dt endpoint create") as context: context.argument( "dead_letter_secret", @@ -249,6 +252,9 @@ def load_digitaltwins_arguments(self, _): arg_group="Service Bus Topic", ) + with self.argument_context("dt endpoint wait") as context: + context.ignore("updated") + with self.argument_context("dt twin") as context: context.argument( "query_command", @@ -423,3 +429,7 @@ def load_digitaltwins_arguments(self, _): help="A message indicating if changes on the service provider require any updates on the consumer.", arg_group="Private-Endpoint", ) + + with self.argument_context("dt network private-endpoint connection wait") as context: + context.ignore("created") + context.ignore("exists") diff --git a/azext_iot/digitaltwins/providers/resource.py b/azext_iot/digitaltwins/providers/resource.py index f643764e7..958ae3287 100644 --- a/azext_iot/digitaltwins/providers/resource.py +++ b/azext_iot/digitaltwins/providers/resource.py @@ -117,20 +117,22 @@ def list_by_resouce_group(self, resource_group_name): except ErrorResponseException as e: raise CLIError(unpack_msrest_error(e)) - def get(self, name, resource_group_name): + def get(self, name, resource_group_name, wait=False): try: return self.mgmt_sdk.digital_twins.get( resource_name=name, resource_group_name=resource_group_name ) except ErrorResponseException as e: + if wait: + e.status_code = e.response.status_code + raise e raise CLIError(unpack_msrest_error(e)) - def find_instance(self, name, resource_group_name=None): + def find_instance(self, name, resource_group_name=None, wait=False): if resource_group_name: - try: - return self.get(name=name, resource_group_name=resource_group_name) - except ErrorResponseException as e: - raise CLIError(unpack_msrest_error(e)) + return self.get( + name=name, resource_group_name=resource_group_name, wait=wait + ) dt_collection_pager = self.list() dt_collection = [] @@ -221,7 +223,7 @@ def remove_role(self, name, assignee, role_type=None, resource_group_name=None): # Endpoints - def get_endpoint(self, name, endpoint_name, resource_group_name=None): + def get_endpoint(self, name, endpoint_name, resource_group_name=None, wait=False): target_instance = self.find_instance( name=name, resource_group_name=resource_group_name ) @@ -235,6 +237,9 @@ def get_endpoint(self, name, endpoint_name, resource_group_name=None): resource_group_name=resource_group_name, ) except ErrorResponseException as e: + if wait: + e.status_code = e.response.status_code + raise e raise CLIError(unpack_msrest_error(e)) def list_endpoints(self, name, resource_group_name=None): @@ -417,7 +422,13 @@ def set_private_endpoint_conn( except ErrorResponseException as e: raise CLIError(unpack_msrest_error(e)) - def get_private_endpoint_conn(self, name, conn_name, resource_group_name=None): + def get_private_endpoint_conn( + self, + name, + conn_name, + resource_group_name=None, + wait=False + ): target_instance = self.find_instance( name=name, resource_group_name=resource_group_name ) @@ -428,10 +439,12 @@ def get_private_endpoint_conn(self, name, conn_name, resource_group_name=None): return self.mgmt_sdk.private_endpoint_connections.get( resource_group_name=resource_group_name, resource_name=name, - private_endpoint_connection_name=conn_name, - raw=True, - ).response.json() + private_endpoint_connection_name=conn_name + ) except ErrorResponseException as e: + if wait: + e.status_code = e.response.status_code + raise e raise CLIError(unpack_msrest_error(e)) def list_private_endpoint_conns(self, name, resource_group_name=None): diff --git a/azext_iot/tests/digitaltwins/__init__.py b/azext_iot/tests/digitaltwins/__init__.py index e36950edb..fcb978a9b 100644 --- a/azext_iot/tests/digitaltwins/__init__.py +++ b/azext_iot/tests/digitaltwins/__init__.py @@ -160,23 +160,22 @@ def tearDown(self): # Needed because the DT service will indicate provisioning is finished before it actually is. def wait_for_hostname( - self, instance: dict, wait_in_sec: int = 10, interval: int = 4 + self, instance: dict, wait_in_sec: int = 10, interval: int = 7 ): from time import sleep - - while interval >= 1: - logger.info( - "Waiting :{} (sec) for provisioning to complete.".format(wait_in_sec) + sleep(wait_in_sec) + + self.embedded_cli.invoke( + "dt wait -n {} -g {} --custom \"hostName && provisioningState=='Succeeded'\" --interval {} --timeout {}".format( + instance["name"], + instance["resourceGroup"], + wait_in_sec, + wait_in_sec * interval ) - sleep(wait_in_sec) - interval = interval - 1 - refereshed_instance = self.embedded_cli.invoke( - "dt show -n {} -g {}".format( - instance["name"], instance["resourceGroup"] - ) - ).as_json() - - if refereshed_instance.get("hostName") and refereshed_instance["provisioningState"] == "Succeeded": - return refereshed_instance - - return instance + ) + refereshed_instance = self.embedded_cli.invoke( + "dt show -n {} -g {}".format( + instance["name"], instance["resourceGroup"] + ) + ).as_json() + return refereshed_instance if refereshed_instance else instance diff --git a/azext_iot/tests/digitaltwins/test_dt_privatelinks_lifecycle_int.py b/azext_iot/tests/digitaltwins/test_dt_privatelinks_lifecycle_int.py index 706919f40..5854627c5 100644 --- a/azext_iot/tests/digitaltwins/test_dt_privatelinks_lifecycle_int.py +++ b/azext_iot/tests/digitaltwins/test_dt_privatelinks_lifecycle_int.py @@ -158,6 +158,26 @@ def test_dt_privatelinks(self): instance_name, self.rg, instance_connection_id, random_desc_rejected ) ).get_output_in_json() + + assert ( + set_connection_output["properties"]["privateLinkServiceConnectionState"]["status"] + == "Rejected" + ) + assert ( + set_connection_output["properties"]["privateLinkServiceConnectionState"]["description"] + == random_desc_rejected + ) + + self.cmd("dt network private-endpoint connection wait -n {} -g {} --cn {} --updated --interval 1 --timeout 30".format( + instance_name, self.rg, instance_connection_id + )) + + set_connection_output = self.cmd( + "dt network private-endpoint connection show -n {} -g {} --cn {}".format( + instance_name, self.rg, instance_connection_id + ) + ).get_output_in_json() + assert ( set_connection_output["properties"]["privateLinkServiceConnectionState"]["status"] == "Rejected" @@ -173,6 +193,12 @@ def test_dt_privatelinks(self): ) ) + self.cmd( + "dt network private-endpoint connection wait -n {} -g {} --cn {} --deleted --interval 1 --timeout 30".format( + instance_name, self.rg, instance_connection_id + ) + ) + list_priv_endpoints = self.cmd( "dt network private-endpoint connection list -n {} -g {}".format( instance_name, diff --git a/azext_iot/tests/digitaltwins/test_dt_resource_lifecycle_int.py b/azext_iot/tests/digitaltwins/test_dt_resource_lifecycle_int.py index 48d50b0ea..47545158c 100644 --- a/azext_iot/tests/digitaltwins/test_dt_resource_lifecycle_int.py +++ b/azext_iot/tests/digitaltwins/test_dt_resource_lifecycle_int.py @@ -372,6 +372,15 @@ def test_dt_endpoints_routes(self): MOCK_DEAD_LETTER_SECRET, ) ).get_output_in_json() + + self.cmd( + "dt endpoint wait --created -n {} -g {} --en {} --interval 1".format( + endpoints_instance_name, + self.rg, + eventgrid_endpoint + ) + ) + assert_common_endpoint_attributes( add_ep_output, eventgrid_endpoint, @@ -407,6 +416,14 @@ def test_dt_endpoints_routes(self): ) ).get_output_in_json() + self.cmd( + "dt endpoint wait --created -n {} -g {} --en {} --interval 1".format( + endpoints_instance_name, + self.rg, + servicebus_endpoint + ) + ) + assert_common_endpoint_attributes( add_ep_sb_key_output, servicebus_endpoint, @@ -434,6 +451,14 @@ def test_dt_endpoints_routes(self): ) ).get_output_in_json() + self.cmd( + "dt endpoint wait --created -n {} -g {} --en {} --interval 1".format( + endpoints_instance_name, + self.rg, + servicebus_endpoint_msi, + ) + ) + assert_common_endpoint_attributes( add_ep_sb_identity_output, servicebus_endpoint_msi, @@ -470,6 +495,14 @@ def test_dt_endpoints_routes(self): ) ).get_output_in_json() + self.cmd( + "dt endpoint wait --created -n {} -g {} --en {} --interval 1".format( + endpoints_instance_name, + self.rg, + eventhub_endpoint, + ) + ) + assert_common_endpoint_attributes( add_ep_output, eventhub_endpoint, @@ -499,6 +532,14 @@ def test_dt_endpoints_routes(self): ) ).get_output_in_json() + self.cmd( + "dt endpoint wait -n {} -g {} --en {} --created --interval 1".format( + endpoints_instance_name, + self.rg, + eventhub_endpoint_msi, + ) + ) + assert_common_endpoint_attributes( add_ep_output, eventhub_endpoint_msi, @@ -600,10 +641,17 @@ def test_dt_endpoints_routes(self): logger.debug("Deleting endpoint {}...".format(ep.endpoint_name)) is_last = ep.endpoint_name == endpoint_tuple_collection[-1].endpoint_name self.cmd( - "dt endpoint delete -y -n {} --en {} {}".format( + "dt endpoint delete -y -n {} --en {} --no-wait {}".format( + endpoints_instance_name, + ep.endpoint_name, + "-g {}".format(self.rg) if is_last else "", + ) + ) + self.cmd( + "dt endpoint wait -n {} --en {} --deleted --interval 1 {}".format( endpoints_instance_name, ep.endpoint_name, - "-g {} --no-wait".format(self.rg) if is_last else "", + "-g {}".format(self.rg) if is_last else "", ) ) From 354a98dc7eb504f9472d0cf16fc1b799419e2e57 Mon Sep 17 00:00:00 2001 From: Ryan K Date: Fri, 28 May 2021 14:19:37 -0700 Subject: [PATCH 20/42] Add Identity Storage Account ID param to sentinel values (#355) --- .azure-devops/templates/set-testenv-sentinel.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.azure-devops/templates/set-testenv-sentinel.yml b/.azure-devops/templates/set-testenv-sentinel.yml index 94950611e..562e43ed4 100644 --- a/.azure-devops/templates/set-testenv-sentinel.yml +++ b/.azure-devops/templates/set-testenv-sentinel.yml @@ -15,6 +15,7 @@ steps: "azext_iot_testhub": os.environ.get("AZEXT_IOT_TESTHUB", sentinel_value), "azext_iot_testdps": os.environ.get("AZEXT_IOT_TESTDPS", sentinel_value), "azext_iot_teststorageuri": os.environ.get("AZEXT_IOT_TESTSTORAGEURI", sentinel_value), + "azext_iot_identity_teststorageid": os.environ.get("AZEXT_IOT_IDENTITY_TESTSTORAGEID", sentinel_value), "azext_iot_central_app_id": os.environ.get("AZEXT_IOT_CENTRAL_APP_ID", sentinel_value), "azext_dt_ep_eventgrid_topic": os.environ.get("AZEXT_DT_EP_EVENTGRID_TOPIC", sentinel_value), "azext_dt_ep_servicebus_namespace": os.environ.get("AZEXT_DT_EP_SERVICEBUS_NAMESPACE", sentinel_value), @@ -39,6 +40,7 @@ steps: AZEXT_IOT_TESTHUB: $(azext_iot_testhub) AZEXT_IOT_TESTDPS: $(azext_iot_testdps) AZEXT_IOT_TESTSTORAGEURI: $(azext_iot_teststorageuri) + AZEXT_IOT_IDENTITY_TESTSTORAGEID: $(azext_iot_identity_teststorageid) AZEXT_IOT_CENTRAL_APP_ID: $(azext_iot_central_app_id) AZEXT_DT_EP_EVENTGRID_TOPIC: $(azext_dt_ep_eventgrid_topic) AZEXT_DT_EP_SERVICEBUS_NAMESPACE: $(azext_dt_ep_servicebus_namespace) From 0e983b8ab1c1090d89241b6d395eb19063eddc82 Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Tue, 1 Jun 2021 11:06:48 -0700 Subject: [PATCH 21/42] Using SDK Listener for Twin properties update --- azext_iot/operations/_mqtt.py | 28 +++++----- azext_iot/tests/conftest.py | 61 --------------------- azext_iot/tests/iothub/test_iot_ext_unit.py | 2 +- 3 files changed, 14 insertions(+), 77 deletions(-) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index d8d12dd53..53b124ffe 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -10,8 +10,8 @@ from time import sleep from azext_iot.constants import USER_AGENT, BASE_MQTT_API_VERSION from azext_iot.common.utility import url_encode_str -from azext_iot.operations.hub import _iot_device_twin_show from azure.iot.device import IoTHubDeviceClient as mqtt_device_client, Message, MethodResponse +import json class mqtt_client(object): @@ -24,6 +24,7 @@ def __init__(self, target, device_conn_string, device_id, method_response_code=N self.device_client.on_method_request_received = self.method_request_handler self.method_response_code = method_response_code self.method_response_payload = method_response_payload + self.device_client.on_twin_desired_properties_patch_received = self.twin_patch_handler def send_d2c_message(self, message_text, properties=None): message = Message(message_text) @@ -75,24 +76,21 @@ def method_request_handler(self, method_request): method_response = MethodResponse.create_from_method_request(method_request, status, payload) self.device_client.send_method_response(method_response) + def twin_patch_handler(self, patch): + modified_properties = {} + for prop in patch: + if not prop.startswith("$"): + modified_properties[prop] = patch[prop] + + if modified_properties: + formatted_properties = json.dumps(modified_properties, indent = 2) + six.print_("\nTwin patch handler [Updating device twin reported properties]:\n{}".format(formatted_properties)) + self.device_client.patch_twin_reported_properties(modified_properties) + def execute(self, data, properties={}, publish_delay=2, msg_count=100): from tqdm import tqdm try: for _ in tqdm(range(msg_count), desc='Device simulation in progress'): - device_twin = _iot_device_twin_show(self.target, self.device_id) - if device_twin: - desired_properties = device_twin.get("properties").get("desired") - reported_properties = device_twin.get("properties").get("reported") - twin_properties_to_update = {} - - for prop in desired_properties: - if not prop.startswith("$"): - if prop not in reported_properties or desired_properties[prop] != reported_properties[prop]: - twin_properties_to_update[prop] = desired_properties[prop] - - if twin_properties_to_update: - self.device_client.patch_twin_reported_properties(twin_properties_to_update) - self.send_d2c_message(message_text=data.generate(True), properties=properties) sleep(publish_delay) diff --git a/azext_iot/tests/conftest.py b/azext_iot/tests/conftest.py index 2e8303a11..13b39f62b 100644 --- a/azext_iot/tests/conftest.py +++ b/azext_iot/tests/conftest.py @@ -28,7 +28,6 @@ "azext_iot.operations.hub._iot_hub_monitor_events" ) hub_entity = "myhub.azure-devices.net" -path_device_twin_show_entrypoint = "azext_iot.operations.hub._iot_device_twin_show" instance_name = generate_generic_id() hostname = "{}.subdomain.domain".format(instance_name) @@ -170,66 +169,6 @@ def fixture_monitor_events_entrypoint(mocker): return mocker.patch(path_iot_hub_monitor_events_entrypoint) -@pytest.fixture() -def fixture_device_twin_show_entrypoint(mocker): - device_twin_client = mocker.patch(path_device_twin_show_entrypoint) - device_twin_client.return_value = { - "authenticationType": "sas", - "capabilities": { - "iotEdge": True - }, - "cloudToDeviceMessageCount": 0, - "connectionState": "Disconnected", - "deviceEtag": "NTQ4ODMwNjY0", - "deviceId": "_Test_Device", - "deviceScope": "ms-azure-iot-edge://Test_Device-637535090608626001", - "etag": "AAAAAAAAAAU=", - "lastActivityTime": "2021-05-27T04:48:03.681238Z", - "modelId": "", - "properties": { - "desired": { - "$metadata": { - "$lastUpdated": "2021-05-27T04:45:38.5203899Z", - "$lastUpdatedVersion": 5, - "test_prop_1": { - "$lastUpdated": "2021-05-27T04:44:45.9299421Z", - "$lastUpdatedVersion": 4 - }, - "test_prop_2": { - "$lastUpdated": "2021-05-27T04:45:38.5203899Z", - "$lastUpdatedVersion": 5 - } - }, - "$version": 5, - "test_prop_1": "test_val_2", - "test_prop_2": "test_val_4" - }, - "reported": { - "$metadata": { - "$lastUpdated": "2021-05-27T04:45:39.5521362Z", - "test_prop_1": { - "$lastUpdated": "2021-05-27T04:43:33.3650357Z" - }, - "test_prop_2": { - "$lastUpdated": "2021-05-27T04:45:39.5521362Z" - } - }, - "$version": 5, - "test_prop_1": "test_val_2", - "test_prop_2": "test_val_4" - } - }, - "status": "enabled", - "statusUpdateTime": "0001-01-01T00:00:00Z", - "version": 10, - "x509Thumbprint": { - "primaryThumbprint": None, - "secondaryThumbprint": None - } - } - return device_twin_client - - # TODO: To be deprecated asap. Leverage mocked_response fixture for this functionality. def build_mock_response( mocker=None, status_code=200, payload=None, headers=None, **kwargs diff --git a/azext_iot/tests/iothub/test_iot_ext_unit.py b/azext_iot/tests/iothub/test_iot_ext_unit.py index 1a831b633..73ea3f944 100644 --- a/azext_iot/tests/iothub/test_iot_ext_unit.py +++ b/azext_iot/tests/iothub/test_iot_ext_unit.py @@ -2166,7 +2166,7 @@ def test_generate_sas_token(self): class TestDeviceSimulate: @pytest.fixture(params=[204]) - def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request, fixture_device, fixture_device_twin_show_entrypoint): + def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request, fixture_device): service_client = mocker.patch(path_service_client) service_client.return_value = build_mock_response(mocker, request.param, {}) return service_client From b498a6076edf42c585cad725c8a6f214022b99cc Mon Sep 17 00:00:00 2001 From: vilit1 <73560279+vilit1@users.noreply.github.com> Date: Tue, 1 Jun 2021 11:17:17 -0700 Subject: [PATCH 22/42] Module identity renew key (#356) * initial changes * update params, help * add missing module to test * Word change --- azext_iot/_help.py | 12 ++++ azext_iot/_params.py | 8 +++ azext_iot/commands.py | 1 + azext_iot/operations/hub.py | 58 ++++++++++++++++++ .../iothub/modules/test_iothub_modules_int.py | 61 +++++++++++++++++++ 5 files changed, 140 insertions(+) diff --git a/azext_iot/_help.py b/azext_iot/_help.py index 7c178e9a4..ce8b2ac4f 100644 --- a/azext_iot/_help.py +++ b/azext_iot/_help.py @@ -472,6 +472,18 @@ authentication.symmetricKey.secondaryKey="" """ +helps[ + "iot hub module-identity renew-key" +] = """ + type: command + short-summary: Renew target keys of an IoT Hub device module with sas authentication. + examples: + - name: Renew the primary key. + text: az iot hub module-identity renew-key -m {module_name} -d {device_id} -n {iothub_name} --kt primary + - name: Swap the primary and secondary keys. + text: az iot hub module-identity renew-key -m {module_name} -d {device_id} -n {iothub_name} --kt swap +""" + helps[ "iot hub module-identity delete" ] = """ diff --git a/azext_iot/_params.py b/azext_iot/_params.py index 908152723..70e4a485c 100644 --- a/azext_iot/_params.py +++ b/azext_iot/_params.py @@ -513,6 +513,14 @@ def load_arguments(self, _): help="To remove all children.", ) + with self.argument_context("iot hub module-identity renew-key") as context: + context.argument( + "renew_key_type", + options_list=["--key-type", "--kt"], + arg_type=get_enum_type(RenewKeyType), + help="Target key type to regenerate.", + ) + with self.argument_context("iot hub distributed-tracing update") as context: context.argument( "sampling_mode", diff --git a/azext_iot/commands.py b/azext_iot/commands.py index 13203e0ce..cd14f3cf5 100644 --- a/azext_iot/commands.py +++ b/azext_iot/commands.py @@ -74,6 +74,7 @@ def load_command_table(self, _): getter_name="iot_device_module_show", setter_name="iot_device_module_update", ) + cmd_group.command("renew-key", "iot_device_module_key_regenerate") with self.command_group( "iot hub module-identity connection-string", command_type=iothub_ops diff --git a/azext_iot/operations/hub.py b/azext_iot/operations/hub.py index 62efb6f63..f98af95e2 100644 --- a/azext_iot/operations/hub.py +++ b/azext_iot/operations/hub.py @@ -885,6 +885,64 @@ def _parse_auth(parameters): return auth, pk, sk +def iot_device_module_key_regenerate( + cmd, + hub_name, + device_id, + module_id, + renew_key_type, + resource_group_name=None, + login=None, + etag=None, + auth_type_dataplane=None, +): + discovery = IotHubDiscovery(cmd) + target = discovery.get_target( + hub_name=hub_name, + resource_group_name=resource_group_name, + login=login, + auth_type=auth_type_dataplane, + ) + resolver = SdkResolver(target=target) + service_sdk = resolver.get_sdk(SdkType.service_sdk) + try: + module = service_sdk.modules.get_identity( + id=device_id, mid=module_id, raw=True + ).response.json() + except CloudError as e: + raise CLIError(unpack_msrest_error(e)) + + if module["authentication"]["type"] != "sas": + raise CLIError("Module authentication should be of type sas") + + pk = module["authentication"]["symmetricKey"]["primaryKey"] + sk = module["authentication"]["symmetricKey"]["secondaryKey"] + + if renew_key_type == RenewKeyType.primary.value: + pk = generate_key() + if renew_key_type == RenewKeyType.secondary.value: + sk = generate_key() + if renew_key_type == RenewKeyType.swap.value: + temp = pk + pk = sk + sk = temp + + module["authentication"]["symmetricKey"]["primaryKey"] = pk + module["authentication"]["symmetricKey"]["secondaryKey"] = sk + + try: + headers = {} + headers["If-Match"] = '"{}"'.format(etag if etag else "*") + return service_sdk.modules.create_or_update_identity( + id=device_id, + mid=module_id, + module=module, + custom_headers=headers, + ) + except CloudError as e: + raise CLIError(unpack_msrest_error(e)) + + def iot_device_module_list( cmd, device_id, diff --git a/azext_iot/tests/iothub/modules/test_iothub_modules_int.py b/azext_iot/tests/iothub/modules/test_iothub_modules_int.py index f9c7b1caf..cded3466b 100644 --- a/azext_iot/tests/iothub/modules/test_iothub_modules_int.py +++ b/azext_iot/tests/iothub/modules/test_iothub_modules_int.py @@ -201,6 +201,67 @@ def test_iothub_module_identity(self): expect_failure=True, ) + def test_iothub_module_renew_key(self): + device_count = 1 + device_ids = self.generate_device_names(device_count) + module_count = 2 + module_ids = self.generate_device_names(module_count) + + self.cmd( + f"iot hub device-identity create -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}" + ).get_output_in_json() + + symmetric_key_module = self.cmd( + f"iot hub module-identity create -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG}" + ).get_output_in_json() + + self.cmd( + f"iot hub module-identity create -m {module_ids[1]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} --am x509_ca" + ) + + for auth_phase in DATAPLANE_AUTH_TYPES: + renew_primary_key_module = self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity renew-key -m {module_ids[0]} " + f"-d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} --kt primary", + auth_type=auth_phase, + ) + ).get_output_in_json() + assert ( + renew_primary_key_module["authentication"]["symmetricKey"]["primaryKey"] + != symmetric_key_module["authentication"]["symmetricKey"]["primaryKey"] + ) + assert ( + renew_primary_key_module["authentication"]["symmetricKey"][ + "secondaryKey" + ] + == symmetric_key_module["authentication"]["symmetricKey"]["secondaryKey"] + ) + + swap_keys_module = self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity renew-key -m {module_ids[0]} -d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} --kt swap", + auth_type=auth_phase, + ) + ).get_output_in_json() + assert ( + renew_primary_key_module["authentication"]["symmetricKey"]["primaryKey"] + == swap_keys_module["authentication"]["symmetricKey"]["secondaryKey"] + ) + assert ( + renew_primary_key_module["authentication"]["symmetricKey"]["secondaryKey"] + == swap_keys_module["authentication"]["symmetricKey"]["primaryKey"] + ) + + self.cmd( + self.set_cmd_auth_type( + f"iot hub module-identity renew-key -m {module_ids[1]} " + f"-d {device_ids[0]} -n {LIVE_HUB} -g {LIVE_RG} --kt secondary", + auth_type=auth_phase, + ), + expect_failure=True, + ) + def test_iothub_module_connection_string_show(self): device_count = 1 device_ids = self.generate_device_names(device_count) From 72fa744f3524f05af2b7572a6b053cb94b621ce4 Mon Sep 17 00:00:00 2001 From: Paymaun Date: Tue, 1 Jun 2021 11:24:52 -0700 Subject: [PATCH 23/42] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index cabd475f2..6dc128549 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ The **Azure IoT extension for Azure CLI** aims to accelerate the development, ma ## News - Starting with version `0.10.13` of the IoT extension, you will need an Azure CLI core version of `2.17.1` or higher. IoT extension version `0.10.11` remains on the extension index to support environments that cannot upgrade core CLI versions. +- Azure CLI `2.24.0` requires an `azure-iot` extension update to `0.10.11` or later for IoT Hub commands to work properly. This can be done with `az extension update --name azure-iot`. A common error that arises when using an older `azure-iot` with Azure CLI `2.24.0` looks like `AttributeError: 'IotHubResourceOperations' object has no attribute 'config'`. + - The legacy IoT extension Id `azure-cli-iot-ext` is deprecated in favor of the new modern Id `azure-iot`. `azure-iot` is a superset of `azure-cli-iot-ext` and any new features or fixes will apply to `azure-iot` only. Also the legacy and modern IoT extension should **never** co-exist in the same CLI environment. Uninstall the legacy extension with the following command: `az extension remove --name azure-cli-iot-ext`. From e9f8803aa5c0c3def64ff02ba5255dc2ed37992d Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Tue, 1 Jun 2021 12:15:09 -0700 Subject: [PATCH 24/42] merge from remote --- azext_iot/operations/_mqtt.py | 17 +----- azext_iot/tests/conftest.py | 61 ------------------- azext_iot/tests/iothub/test_iot_ext_unit.py | 2 +- .../tests/iothub/test_iot_messaging_int.py | 37 ++++++++--- 4 files changed, 32 insertions(+), 85 deletions(-) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index 3777b0916..6bb254fa2 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -10,7 +10,6 @@ from time import sleep from azext_iot.constants import USER_AGENT, BASE_MQTT_API_VERSION from azext_iot.common.utility import url_encode_str -from azext_iot.operations.hub import _iot_device_twin_show from azure.iot.device import IoTHubDeviceClient as mqtt_device_client, Message, MethodResponse import json @@ -84,7 +83,7 @@ def twin_patch_handler(self, patch): modified_properties[prop] = patch[prop] if modified_properties: - formatted_properties = json.dumps(modified_properties, indent = 2) + formatted_properties = json.dumps(modified_properties, indent=2) six.print_("\nTwin patch handler [Updating device twin reported properties]:\n{}".format(formatted_properties)) self.device_client.patch_twin_reported_properties(modified_properties) @@ -92,20 +91,6 @@ def execute(self, data, properties={}, publish_delay=2, msg_count=100): from tqdm import tqdm try: for _ in tqdm(range(msg_count), desc='Device simulation in progress'): - device_twin = _iot_device_twin_show(self.target, self.device_id) - if device_twin: - desired_properties = device_twin.get("properties").get("desired") - reported_properties = device_twin.get("properties").get("reported") - twin_properties_to_update = {} - - for prop in desired_properties: - if not prop.startswith("$"): - if prop not in reported_properties or desired_properties[prop] != reported_properties[prop]: - twin_properties_to_update[prop] = desired_properties[prop] - - if twin_properties_to_update: - self.device_client.patch_twin_reported_properties(twin_properties_to_update) - self.send_d2c_message(message_text=data.generate(True), properties=properties) sleep(publish_delay) diff --git a/azext_iot/tests/conftest.py b/azext_iot/tests/conftest.py index 2e8303a11..13b39f62b 100644 --- a/azext_iot/tests/conftest.py +++ b/azext_iot/tests/conftest.py @@ -28,7 +28,6 @@ "azext_iot.operations.hub._iot_hub_monitor_events" ) hub_entity = "myhub.azure-devices.net" -path_device_twin_show_entrypoint = "azext_iot.operations.hub._iot_device_twin_show" instance_name = generate_generic_id() hostname = "{}.subdomain.domain".format(instance_name) @@ -170,66 +169,6 @@ def fixture_monitor_events_entrypoint(mocker): return mocker.patch(path_iot_hub_monitor_events_entrypoint) -@pytest.fixture() -def fixture_device_twin_show_entrypoint(mocker): - device_twin_client = mocker.patch(path_device_twin_show_entrypoint) - device_twin_client.return_value = { - "authenticationType": "sas", - "capabilities": { - "iotEdge": True - }, - "cloudToDeviceMessageCount": 0, - "connectionState": "Disconnected", - "deviceEtag": "NTQ4ODMwNjY0", - "deviceId": "_Test_Device", - "deviceScope": "ms-azure-iot-edge://Test_Device-637535090608626001", - "etag": "AAAAAAAAAAU=", - "lastActivityTime": "2021-05-27T04:48:03.681238Z", - "modelId": "", - "properties": { - "desired": { - "$metadata": { - "$lastUpdated": "2021-05-27T04:45:38.5203899Z", - "$lastUpdatedVersion": 5, - "test_prop_1": { - "$lastUpdated": "2021-05-27T04:44:45.9299421Z", - "$lastUpdatedVersion": 4 - }, - "test_prop_2": { - "$lastUpdated": "2021-05-27T04:45:38.5203899Z", - "$lastUpdatedVersion": 5 - } - }, - "$version": 5, - "test_prop_1": "test_val_2", - "test_prop_2": "test_val_4" - }, - "reported": { - "$metadata": { - "$lastUpdated": "2021-05-27T04:45:39.5521362Z", - "test_prop_1": { - "$lastUpdated": "2021-05-27T04:43:33.3650357Z" - }, - "test_prop_2": { - "$lastUpdated": "2021-05-27T04:45:39.5521362Z" - } - }, - "$version": 5, - "test_prop_1": "test_val_2", - "test_prop_2": "test_val_4" - } - }, - "status": "enabled", - "statusUpdateTime": "0001-01-01T00:00:00Z", - "version": 10, - "x509Thumbprint": { - "primaryThumbprint": None, - "secondaryThumbprint": None - } - } - return device_twin_client - - # TODO: To be deprecated asap. Leverage mocked_response fixture for this functionality. def build_mock_response( mocker=None, status_code=200, payload=None, headers=None, **kwargs diff --git a/azext_iot/tests/iothub/test_iot_ext_unit.py b/azext_iot/tests/iothub/test_iot_ext_unit.py index 1a831b633..73ea3f944 100644 --- a/azext_iot/tests/iothub/test_iot_ext_unit.py +++ b/azext_iot/tests/iothub/test_iot_ext_unit.py @@ -2166,7 +2166,7 @@ def test_generate_sas_token(self): class TestDeviceSimulate: @pytest.fixture(params=[204]) - def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request, fixture_device, fixture_device_twin_show_entrypoint): + def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request, fixture_device): service_client = mocker.patch(path_service_client) service_client.return_value = build_mock_response(mocker, request.param, {}) return service_client diff --git a/azext_iot/tests/iothub/test_iot_messaging_int.py b/azext_iot/tests/iothub/test_iot_messaging_int.py index d191edf7a..a0c2875bc 100644 --- a/azext_iot/tests/iothub/test_iot_messaging_int.py +++ b/azext_iot/tests/iothub/test_iot_messaging_int.py @@ -22,6 +22,7 @@ LIVE_RG = settings.env.azext_iot_testrg LIVE_CONSUMER_GROUPS = ["test1", "test2", "test3"] +MQTT_CLIENT_SETUP_TIME = 11 class TestIoTHubMessaging(IoTLiveScenarioTest): @@ -323,6 +324,32 @@ def test_twin_properties_update(self): test_twin_props = {'twin_test_prop_1': 'twin_test_value_1'} self.kwargs["twin_desired_properties"] = json.dumps(test_twin_props) + from azext_iot.operations.hub import iot_simulate_device + from azext_iot._factory import iot_hub_service_factory + from azure.cli.core.mock import DummyCli + from time import sleep + + cli_ctx = DummyCli() + client = iot_hub_service_factory(cli_ctx) + + token, thread = execute_onthread( + method=iot_simulate_device, + args=[ + client, + device_ids[0], + LIVE_HUB, + "complete", + "Testing device twin reported properties update", + 4, + 5, + "mqtt", + ], + max_runs=4, + return_handle=True, + ) + + sleep(MQTT_CLIENT_SETUP_TIME) + # invoke device twin property update self.cmd( """iot hub device-twin update -d {} --login {} --desired '{}'""".format( @@ -330,12 +357,6 @@ def test_twin_properties_update(self): ) ) - self.cmd( - "iot device simulate -d {} -n {} -g {} --mc {} --mi {} --rs 'complete'".format( - device_ids[0], LIVE_HUB, LIVE_RG, 2, 1 - ) - ) - # get device twin result = self.cmd( "iot hub device-twin show -d {} --login {}".format( @@ -344,10 +365,12 @@ def test_twin_properties_update(self): ).get_output_in_json() assert result is not None - for key in test_twin_props: assert result["properties"]["reported"][key] == result["properties"]["desired"][key] + token.set() + thread.join() + def test_device_messaging(self): device_count = 1 device_ids = self.generate_device_names(device_count) From 2ef0a41c389e003988250d7a65055196516bfc75 Mon Sep 17 00:00:00 2001 From: Ryan K Date: Tue, 1 Jun 2021 12:28:39 -0700 Subject: [PATCH 25/42] Pipeline updates (#359) * Pipeline updates Added nightly build pipeline Added template to run tests against minimum supported AZ CLI * Pipeline parameter refactoring * Moved deprecated ubuntu images to `ubuntu-latest` Co-authored-by: Paymaun --- .azure-devops/create-release.yml | 47 +++++++--- .azure-devops/merge.yml | 22 ++--- .azure-devops/nightly.yml | 88 +++++++++++++++++++ .../templates/install-azure-cli-min.yml | 14 +++ .azure-devops/templates/nightly-tests.yml | 55 ++++++++++++ .azure-devops/templates/run-tests.yml | 48 ++++++---- .../templates/setup-dev-test-env.yml | 25 ++++-- 7 files changed, 253 insertions(+), 46 deletions(-) create mode 100644 .azure-devops/nightly.yml create mode 100644 .azure-devops/templates/install-azure-cli-min.yml create mode 100644 .azure-devops/templates/nightly-tests.yml diff --git a/.azure-devops/create-release.yml b/.azure-devops/create-release.yml index 45d869c3b..172b2f159 100644 --- a/.azure-devops/create-release.yml +++ b/.azure-devops/create-release.yml @@ -2,9 +2,28 @@ pr: none trigger: none -variables: - pythonVersion: '3.6.x' - architecture: 'x64' +parameters: +- name: pythonVersion + type: string + default: '3.6.x' + values: + - 3.6.x + - 3.9.x +- name: architecture + type: string + default: 'x64' +- name: 'testCentral' + type: boolean + default: true +- name: 'testADT' + type: boolean + default: true +- name: 'testDPS' + type: boolean + default: true +- name: 'testHub' + type: boolean + default: true stages: - stage: 'build' @@ -13,13 +32,13 @@ stages: - job: 'Build_Publish_Azure_IoT_CLI_Extension' pool: - vmImage: 'Ubuntu-16.04' + vmImage: 'ubuntu-latest' steps: - task: UsePythonVersion@0 inputs: - versionSpec: $(pythonVersion) - architecture: $(architecture) + versionSpec: ${{ parameters.pythonVersion }} + architecture: ${{ parameters.architecture }} - template: templates/setup-ci-machine.yml @@ -27,13 +46,13 @@ stages: - job: 'Build_Publish_Azure_CLI_Test_SDK' pool: - vmImage: 'Ubuntu-16.04' + vmImage: 'ubuntu-latest' steps: - task: UsePythonVersion@0 inputs: - versionSpec: $(pythonVersion) - architecture: $(architecture) + versionSpec: ${{ parameters.pythonVersion }} + architecture: ${{ parameters.architecture }} - template: templates/setup-ci-machine.yml @@ -45,15 +64,15 @@ stages: steps: - template: templates/setup-dev-test-env.yml parameters: - pythonVersion: $(pythonVersion) - architecture: $(architecture) + pythonVersion: ${{ parameters.pythonVersion }} + architecture: ${{ parameters.architecture }} - template: templates/install-and-record-version.yml - stage: 'test' displayName: 'Run tests' pool: - vmImage: 'Ubuntu-16.04' + vmImage: 'ubuntu-latest' dependsOn: build jobs: - job: 'testCentral' @@ -105,8 +124,8 @@ stages: steps: - template: templates/calculate-code-coverage.yml parameters: - pythonVersion: $(pythonVersion) - architecture: $(architecture) + pythonVersion: ${{ parameters.pythonVersion }} + architecture: ${{ parameters.architecture }} - stage: 'release' displayName: 'Stage GitHub release' diff --git a/.azure-devops/merge.yml b/.azure-devops/merge.yml index 6a620155e..e4585386e 100644 --- a/.azure-devops/merge.yml +++ b/.azure-devops/merge.yml @@ -18,7 +18,7 @@ jobs: - job: 'build_and_publish_azure_iot_cli_ext' pool: - vmImage: 'Ubuntu-16.04' + vmImage: 'ubuntu-latest' steps: - task: UsePythonVersion@0 @@ -31,7 +31,7 @@ jobs: - job: 'build_and_publish_azure_cli_test_sdk' pool: - vmImage: 'Ubuntu-16.04' + vmImage: 'ubuntu-latest' steps: - task: UsePythonVersion@0 @@ -45,7 +45,7 @@ jobs: - job: 'run_unit_tests_ubuntu' dependsOn: [ 'build_and_publish_azure_iot_cli_ext', 'build_and_publish_azure_cli_test_sdk'] pool: - vmImage: 'Ubuntu-16.04' + vmImage: 'ubuntu-latest' strategy: matrix: Python36: @@ -62,8 +62,8 @@ jobs: - template: templates/run-tests.yml parameters: pythonVersion: '$(python.version)' - runUnitTests: 'true' - runIntTests: 'false' + runUnitTests: true + runIntTests: false - job: 'run_unit_tests_macOs' dependsOn: ['build_and_publish_azure_iot_cli_ext', 'build_and_publish_azure_cli_test_sdk'] @@ -74,8 +74,8 @@ jobs: - template: templates/run-tests.yml parameters: pythonVersion: '3.8.x' - runUnitTests: 'true' - runIntTests: 'false' + runUnitTests: true + runIntTests: false - template: templates/calculate-code-coverage.yml @@ -93,13 +93,13 @@ jobs: - template: templates/run-tests.yml parameters: pythonVersion: '3.8.x' - runUnitTests: 'true' - runIntTests: 'false' + runUnitTests: true + runIntTests: false - job: 'run_style_check' dependsOn: ['build_and_publish_azure_iot_cli_ext', 'build_and_publish_azure_cli_test_sdk'] pool: - vmImage: 'Ubuntu-16.04' + vmImage: 'ubuntu-latest' steps: - task: UsePythonVersion@0 @@ -124,7 +124,7 @@ jobs: dependsOn: ['build_and_publish_azure_iot_cli_ext'] displayName: 'Evaluate IoT extension command table' pool: - vmImage: 'Ubuntu-16.04' + vmImage: 'ubuntu-latest' steps: - task: UsePythonVersion@0 diff --git a/.azure-devops/nightly.yml b/.azure-devops/nightly.yml new file mode 100644 index 000000000..34ce2b14d --- /dev/null +++ b/.azure-devops/nightly.yml @@ -0,0 +1,88 @@ +# Run nightly at midnight. +schedules: +- cron: "0 0 * * *" + displayName: Nightly Integration Build + branches: + include: + - dev + +variables: + pythonVersion: '3.6.x' + architecture: 'x64' + +stages: + - stage: 'build' + displayName: 'Build and Publish Artifacts' + jobs: + + - job: 'Build_Publish_Azure_IoT_CLI_Extension' + pool: + vmImage: 'ubuntu-latest' + + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: $(pythonVersion) + architecture: $(architecture) + + - template: templates/setup-ci-machine.yml + + - template: templates/build-publish-azure-iot-cli-extension.yml + + - job: 'Build_Publish_Azure_CLI_Test_SDK' + pool: + vmImage: 'ubuntu-latest' + + steps: + - task: UsePythonVersion@0 + inputs: + versionSpec: $(pythonVersion) + architecture: $(architecture) + + - template: templates/setup-ci-machine.yml + + - template: templates/build-publish-azure-cli-test-sdk.yml + + - job: 'recordVersion' + displayName: 'Install and verify version' + dependsOn: [Build_Publish_Azure_IoT_CLI_Extension, Build_Publish_Azure_CLI_Test_SDK] + steps: + - template: templates/setup-dev-test-env.yml + parameters: + pythonVersion: $(pythonVersion) + architecture: $(architecture) + + - template: templates/install-and-record-version.yml + + - stage: 'test' + displayName: 'Run all tests' + pool: + vmImage: 'ubuntu-latest' + dependsOn: build + jobs: + - job: 'azEdge' + displayName: 'Test against edge AZ CLI' + steps: + - template: templates/nightly-tests.yml + parameters: + azureCLIVersion: 'edge' + - job: 'azMin' + dependsOn: 'azEdge' + displayName: 'Test against minimum supported AZ CLI' + steps: + - template: templates/nightly-tests.yml + parameters: + azureCLIVersion: 'min' + + - stage: 'kpi' + displayName: 'Build KPIs' + dependsOn: [build, test] + jobs: + - job: 'calculateCodeCoverage' + displayName: 'Calculate distributed code coverage' + steps: + - template: templates/calculate-code-coverage.yml + parameters: + pythonVersion: $(pythonVersion) + architecture: $(architecture) + diff --git a/.azure-devops/templates/install-azure-cli-min.yml b/.azure-devops/templates/install-azure-cli-min.yml new file mode 100644 index 000000000..ad25dfbaf --- /dev/null +++ b/.azure-devops/templates/install-azure-cli-min.yml @@ -0,0 +1,14 @@ +steps: +- task: PythonScript@0 + displayName: 'Check minimum supported version of Azure CLI' + inputs: + scriptSource: 'inline' + script: | + import json + with open("$(System.DefaultWorkingDirectory)/azext_iot/azext_metadata.json") as f: + metadata = json.load(f) + version = metadata['azext.minCliCoreVersion'] + print('##vso[task.setvariable variable=min_cli_version]{}'.format(version)) +- bash: | + pip install azure-cli==$(min_cli_version) + displayName: "Install minimum supported CLI version" \ No newline at end of file diff --git a/.azure-devops/templates/nightly-tests.yml b/.azure-devops/templates/nightly-tests.yml new file mode 100644 index 000000000..baaa94e6a --- /dev/null +++ b/.azure-devops/templates/nightly-tests.yml @@ -0,0 +1,55 @@ +parameters: +- name: pythonVersion + type: string + default: '3.6.x' +- name: architecture + type: string + default: 'x64' +- name: azureCLIVersion + type: string + default: released + values: + - min + - released + - edge + +steps: + - template: setup-dev-test-env.yml + parameters: + architecture: ${{ parameters.architecture }} + pythonVersion: ${{ parameters.pythonVersion }} + azureCLIVersion: ${{ parameters.azureCLIVersion }} + + - template: set-testenv-sentinel.yml + + - script: | + pytest -vv azext_iot/tests -k "_unit" --cov=azext_iot --cov-config .coveragerc --junitxml=junit/test-iotext-unit.xml + displayName: 'All unit tests' + env: + COVERAGE_FILE: .coverage.all + + - task: AzureCLI@2 + continueOnError: true + displayName: 'All integration tests' + inputs: + azureSubscription: az-cli-nightly + scriptType: bash + scriptLocation: inlineScript + inlineScript: | + export COVERAGE_FILE=.coverage.all + pytest -vv azext_iot/tests -k "_int" --cov=azext_iot --cov-config .coveragerc --junitxml=junit/test-iotext-int.xml + + - task: PublishBuildArtifacts@1 + inputs: + pathToPublish: .coverage.all + publishLocation: 'Container' + artifactName: 'coverage' + + - task: PublishTestResults@2 + condition: succeededOrFailed() + displayName: 'Publish Test Results' + inputs: + testResultsFormat: 'JUnit' + testResultsFiles: '**/test-*.xml' + testRunTitle: 'Publish test results for Python ${{ parameters.pythonVersion }} on OS $(Agent.OS)' + searchFolder: '$(System.DefaultWorkingDirectory)' diff --git a/.azure-devops/templates/run-tests.yml b/.azure-devops/templates/run-tests.yml index 91db4af4f..dec6cc9f9 100644 --- a/.azure-devops/templates/run-tests.yml +++ b/.azure-devops/templates/run-tests.yml @@ -1,21 +1,46 @@ parameters: - pythonVersion: '3.6.x' - architecture: 'x64' - runUnitTests: 'false' - runIntTests: 'true' - runWithAzureCliReleased: 'true' - path: 'azext_iot/tests' - name: 'all' +- name: pythonVersion + type: string + default: '3.6.x' +- name: architecture + type: string + default: 'x64' +- name: runUnitTests + type: boolean + default: false +- name: runIntTests + type: boolean + default: true +- name: azureCLIVersion + type: string + default: released + values: + - min + - released + - edge +- name: path + type: string + default: 'azext_iot/tests' +- name: name + type: string + default: 'all' steps: - template: setup-dev-test-env.yml parameters: architecture: ${{ parameters.architecture }} pythonVersion: ${{ parameters.pythonVersion }} - runWithAzureCliReleased: ${{ parameters.runWithAzureCliReleased }} + azureCLIVersion: ${{ parameters.azureCLIVersion }} - template: set-testenv-sentinel.yml + - ${{ if eq(parameters.runUnitTests, 'true') }}: + - script: | + pytest -vv ${{ parameters.path }} -k "_unit" --cov=azext_iot --cov-config .coveragerc --junitxml=junit/test-iotext-unit-${{ parameters.name }}.xml + displayName: '${{ parameters.name }} unit tests' + env: + COVERAGE_FILE: .coverage.${{ parameters.name }} + - ${{ if eq(parameters.runIntTests, 'true') }}: - task: AzureCLI@2 continueOnError: true @@ -28,13 +53,6 @@ steps: export COVERAGE_FILE=.coverage.${{ parameters.name }} pytest -vv ${{ parameters.path }} -k "_int" --cov=azext_iot --cov-config .coveragerc --junitxml=junit/test-iotext-int-${{ parameters.name }}.xml - - ${{ if eq(parameters.runUnitTests, 'true') }}: - - script: | - pytest -vv ${{ parameters.path }} -k "_unit" --cov=azext_iot --cov-config .coveragerc --junitxml=junit/test-iotext-unit-${{ parameters.name }}.xml - displayName: '${{ parameters.name }} unit tests' - env: - COVERAGE_FILE: .coverage.${{ parameters.name }} - - task: PublishBuildArtifacts@1 inputs: pathToPublish: .coverage.${{ parameters.name }} diff --git a/.azure-devops/templates/setup-dev-test-env.yml b/.azure-devops/templates/setup-dev-test-env.yml index cc6c1a875..029613517 100644 --- a/.azure-devops/templates/setup-dev-test-env.yml +++ b/.azure-devops/templates/setup-dev-test-env.yml @@ -1,7 +1,17 @@ parameters: - pythonVersion: '' - architecture: '' - runWithAzureCliReleased: 'true' +- name: pythonVersion + type: string + default: '3.6.x' +- name: architecture + type: string + default: 'x64' +- name: azureCLIVersion + type: string + default: 'released' + values: + - min + - released + - edge steps: - task: UsePythonVersion@0 @@ -9,12 +19,15 @@ steps: versionSpec: ${{ parameters.pythonVersion }} architecture: ${{ parameters.architecture }} - - ${{ if eq(parameters.runWithAzureCliReleased, 'false') }}: - - template: install-azure-cli-edge.yml + - ${{ if eq(parameters.azureCLIVersion, 'min') }}: + - template: install-azure-cli-min.yml - - ${{ if eq(parameters.runWithAzureCliReleased, 'true') }}: + - ${{ if eq(parameters.azureCLIVersion, 'released') }}: - template: install-azure-cli-released.yml + - ${{ if eq(parameters.azureCLIVersion, 'edge') }}: + - template: install-azure-cli-edge.yml + - template: download-install-local-azure-test-sdk.yml - template: setup-ci-machine.yml From d4fb8753f39b32f065f7c0ab62ea24785cbcb7f2 Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Wed, 2 Jun 2021 14:16:25 -0700 Subject: [PATCH 26/42] Structured mqtt formatting and eliminate dependency on six --- azext_iot/common/deps.py | 8 +++----- azext_iot/operations/_mqtt.py | 37 ++++++++++++++++++++++------------- azext_iot/operations/hub.py | 19 +++++++++--------- dev_requirements | 1 - 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/azext_iot/common/deps.py b/azext_iot/common/deps.py index 97abccb57..f67d6d715 100644 --- a/azext_iot/common/deps.py +++ b/azext_iot/common/deps.py @@ -6,8 +6,6 @@ import sys from os import linesep -import six -from six.moves import input from knack.util import CLIError from azext_iot.constants import EVENT_LIB, VERSION from azext_iot.common.utility import test_import @@ -25,13 +23,13 @@ def ensure_uamqp(config, yes=False, repair=False): if i.lower() != 'y': sys.exit('User has declined update...') - six.print_('Updating required dependency...') + print('Updating required dependency...') with HomebrewPipPatch(): # The version range defined in this custom_version parameter should be stable try: install(EVENT_LIB[0], compatible_version='{}'.format(EVENT_LIB[1])) update_uamqp_ext_version(config, EVENT_LIB[1]) - six.print_('Update complete. Executing command...') + print('Update complete. Executing command...') except RuntimeError as e: - six.print_('Failure updating {}. Aborting...'.format(EVENT_LIB[0])) + print('Failure updating {}. Aborting...'.format(EVENT_LIB[0])) raise CLIError(e) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index 6bb254fa2..e06f65070 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -5,13 +5,11 @@ # -------------------------------------------------------------------------------------------- -import six - from time import sleep from azext_iot.constants import USER_AGENT, BASE_MQTT_API_VERSION from azext_iot.common.utility import url_encode_str from azure.iot.device import IoTHubDeviceClient as mqtt_device_client, Message, MethodResponse -import json +import pprint class mqtt_client(object): @@ -25,6 +23,8 @@ def __init__(self, target, device_conn_string, device_id, method_response_code=N self.method_response_code = method_response_code self.method_response_payload = method_response_payload self.device_client.on_twin_desired_properties_patch_received = self.twin_patch_handler + self.printer = pprint.PrettyPrinter(indent=2) + self.output_indent = 2 def send_d2c_message(self, message_text, properties=None): message = Message(message_text) @@ -32,10 +32,6 @@ def send_d2c_message(self, message_text, properties=None): self.device_client.send_message(message) def message_handler(self, message): - six.print_() - six.print_("_Received C2D message with topic_: /devices/{}/messages/devicebound".format(self.device_id)) - six.print_("_Payload_: {}".format(message.data)) - property_names = [ "message_id", "expiry_time_utc", "correlation_id", "user_id", "content_encoding", "content_type", "_iothub_interface_id" @@ -49,13 +45,26 @@ def message_handler(self, message): message_properties[item] = message_attributes[item] message_properties.update(message.custom_properties) - six.print_("_Message Properties_: {}".format(message_properties)) + + output = { + "Topic": "/devices/{}/messages/devicebound".format(self.device_id), + "Payload": message.data, + "Message Properties": message_properties + } + print("\nMessage Handler [Received C2D message]:") + self.printer.pprint(output) def method_request_handler(self, method_request): - six.print_() - six.print_("Received method request with id: '{}' and method name: '{}' for device with id: '{}'".format( - method_request.request_id, method_request.name, self.device_id)) - six.print_("_Payload_: {}".format(method_request.payload)) + + output = { + "Device Id": self.device_id, + "Method Request Id": method_request.request_id, + "Method Request Name": method_request.name, + "Method Request Payload": method_request.payload + } + + print("\nMethod Request Handler [Received direct method invocation request]:") + self.printer.pprint(output) # set response payload if self.method_response_payload: @@ -83,8 +92,8 @@ def twin_patch_handler(self, patch): modified_properties[prop] = patch[prop] if modified_properties: - formatted_properties = json.dumps(modified_properties, indent=2) - six.print_("\nTwin patch handler [Updating device twin reported properties]:\n{}".format(formatted_properties)) + print("\nTwin patch handler [Updating device twin reported properties]:") + self.printer.pprint(modified_properties) self.device_client.patch_twin_reported_properties(modified_properties) def execute(self, data, properties={}, publish_delay=2, msg_count=100): diff --git a/azext_iot/operations/hub.py b/azext_iot/operations/hub.py index 148c39254..89e57c9c9 100644 --- a/azext_iot/operations/hub.py +++ b/azext_iot/operations/hub.py @@ -6,7 +6,6 @@ from os.path import exists, basename from time import time, sleep -import six from knack.log import get_logger from knack.util import CLIError from enum import Enum, EnumMeta @@ -2018,7 +2017,7 @@ def _iot_c2d_message_receive(target, device_id, lock_timeout=60, ack=None): if result.text: payload["data"] = ( result.text - if not isinstance(result.text, six.binary_type) + if not isinstance(result.text, bytes) else result.text.decode("utf-8") ) @@ -2173,7 +2172,7 @@ def generate(self, jsonify=True): def http_wrap(target, device_id, generator): d = generator.generate(False) _iot_device_send_message_http(target, device_id, d, headers=properties_to_send) - six.print_(".", end="", flush=True) + print(".", end="", flush=True) try: if protocol_type == ProtocolType.mqtt.name: @@ -2188,7 +2187,7 @@ def http_wrap(target, device_id, generator): ) client_mqtt.execute(data=generator(), properties=properties_to_send, publish_delay=msg_interval, msg_count=msg_count) else: - six.print_("Sending and receiving events via https") + print("Sending and receiving events via https") token, op = execute_onthread( method=http_wrap, args=[target, device_id, generator()], @@ -2235,17 +2234,17 @@ def _iot_simulate_get_default_properties(protocol): def _handle_c2d_msg(target, device_id, receive_settle, lock_timeout=60): result = _iot_c2d_message_receive(target, device_id, lock_timeout) if result: - six.print_() - six.print_("__Received C2D Message__") - six.print_(result) + print() + print("__Received C2D Message__") + print(result) if receive_settle == "reject": - six.print_("__Rejecting message__") + print("__Rejecting message__") _iot_c2d_message_reject(target, device_id, result["etag"]) elif receive_settle == "abandon": - six.print_("__Abandoning message__") + print("__Abandoning message__") _iot_c2d_message_abandon(target, device_id, result["etag"]) else: - six.print_("__Completing message__") + print("__Completing message__") _iot_c2d_message_complete(target, device_id, result["etag"]) return True return False diff --git a/dev_requirements b/dev_requirements index 00f570312..3482a34f5 100644 --- a/dev_requirements +++ b/dev_requirements @@ -8,6 +8,5 @@ responses black;python_version>='3.6' wheel==0.30.0 pre-commit -six>=1.12 pylint flake8 \ No newline at end of file From 1e9ada45d4bbd3fe4d4874b71844a5d242f4916b Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Wed, 2 Jun 2021 14:20:04 -0700 Subject: [PATCH 27/42] remove indent property not needed any more --- azext_iot/operations/_mqtt.py | 1 - 1 file changed, 1 deletion(-) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index e06f65070..caeacc62e 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -24,7 +24,6 @@ def __init__(self, target, device_conn_string, device_id, method_response_code=N self.method_response_payload = method_response_payload self.device_client.on_twin_desired_properties_patch_received = self.twin_patch_handler self.printer = pprint.PrettyPrinter(indent=2) - self.output_indent = 2 def send_d2c_message(self, message_text, properties=None): message = Message(message_text) From 3170fd8014118c0fbcc0764a89edde175b4fdad7 Mon Sep 17 00:00:00 2001 From: Ryan K Date: Wed, 2 Jun 2021 15:26:59 -0700 Subject: [PATCH 28/42] Check for conditionals before running test jobs (#363) --- .azure-devops/create-release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.azure-devops/create-release.yml b/.azure-devops/create-release.yml index 172b2f159..281aa5f11 100644 --- a/.azure-devops/create-release.yml +++ b/.azure-devops/create-release.yml @@ -77,6 +77,7 @@ stages: jobs: - job: 'testCentral' displayName: 'Test IoT Central' + condition: eq('${{ parameters.testCentral }}', true) steps: - template: templates/run-tests.yml parameters: @@ -85,6 +86,7 @@ stages: - job: 'testADT' displayName: 'Test Azure DigitalTwins' + condition: eq('${{ parameters.testADT }}', true) steps: - template: templates/run-tests.yml parameters: @@ -93,6 +95,7 @@ stages: - job: 'testDPS' displayName: 'Test DPS' + condition: eq('${{ parameters.testDPS }}', true) steps: - template: templates/run-tests.yml parameters: @@ -101,6 +104,7 @@ stages: - job: 'testHub' displayName: 'Test IoT Hub' + condition: eq('${{ parameters.testHub }}', true) steps: - template: templates/run-tests.yml parameters: From bb3c5d9ebaa260b9fc186b08ba690813e0ad8932 Mon Sep 17 00:00:00 2001 From: avagraw <51140335+avagraw@users.noreply.github.com> Date: Thu, 3 Jun 2021 12:06:46 -0700 Subject: [PATCH 29/42] Update d2c and simulate commands to return errors for non SaS devices - update help to reflect the change (#346) * Add warning for qos deprecation and update contributing guide * removing version from deprecate_info * Integration tests command update in Contributing guide * Update help message for d2c command and mqtt simulation to indicate only sas auth is supported * Checks and error messages when non SAS devices are used for MQTT operations * Indentation update in help file * fix styling * Adding enum and updating test * Addressed comments --- azext_iot/_help.py | 10 ++- azext_iot/common/shared.py | 10 +++ azext_iot/operations/hub.py | 46 ++++++++------ azext_iot/tests/conftest.py | 68 +++++++++++++++++++++ azext_iot/tests/iothub/test_iot_ext_unit.py | 64 ++++++++++--------- 5 files changed, 148 insertions(+), 50 deletions(-) diff --git a/azext_iot/_help.py b/azext_iot/_help.py index ce8b2ac4f..715828e63 100644 --- a/azext_iot/_help.py +++ b/azext_iot/_help.py @@ -840,8 +840,11 @@ "iot device send-d2c-message" ] = """ type: command - short-summary: Send an mqtt device-to-cloud message. - The command supports sending messages with application and system properties. + short-summary: | + Send an mqtt device-to-cloud message. + The command supports sending messages with application and system properties. + + Note: The command only works for symmetric key auth (SAS) based devices examples: - name: Basic usage text: az iot device send-d2c-message -n {iothub_name} -d {device_id} @@ -863,7 +866,8 @@ While the device simulation is running, the device will automatically receive and acknowledge cloud-to-device (c2d) messages. For mqtt simulation, all c2d messages will be acknowledged with completion. For http simulation c2d acknowledgement is based on user - selection which can be complete, reject or abandon. + selection which can be complete, reject or abandon. Additionally, mqtt simulation is only + supported for symmetric key auth (SAS) based devices Note: The command by default will set content-type to application/json and content-encoding to utf-8. This can be overriden. diff --git a/azext_iot/common/shared.py b/azext_iot/common/shared.py index 1513cc0f2..ce89fc919 100644 --- a/azext_iot/common/shared.py +++ b/azext_iot/common/shared.py @@ -56,6 +56,16 @@ class DeviceAuthType(Enum): x509_ca = "x509_ca" +class DeviceAuthApiType(Enum): + """ + Hub Device Authorization type. + """ + + sas = "sas" + selfSigned = "selfSigned" + certificateAuthority = "certificateAuthority" + + class KeyType(Enum): """ Shared private key. diff --git a/azext_iot/operations/hub.py b/azext_iot/operations/hub.py index f98af95e2..61ebaa58f 100644 --- a/azext_iot/operations/hub.py +++ b/azext_iot/operations/hub.py @@ -28,6 +28,7 @@ SettleType, RenewKeyType, IoTHubStateType, + DeviceAuthApiType, ) from azext_iot.iothub.providers.discovery import IotHubDiscovery from azext_iot.common.utility import ( @@ -259,21 +260,21 @@ def _assemble_auth(auth_method, pk, sk): ) auth = None - if auth_method in [DeviceAuthType.shared_private_key.name, "sas"]: + if auth_method in [DeviceAuthType.shared_private_key.name, DeviceAuthApiType.sas.value]: auth = AuthenticationMechanism( - symmetric_key=SymmetricKey(primary_key=pk, secondary_key=sk), type="sas" + symmetric_key=SymmetricKey(primary_key=pk, secondary_key=sk), type=DeviceAuthApiType.sas.value ) - elif auth_method in [DeviceAuthType.x509_thumbprint.name, "selfSigned"]: + elif auth_method in [DeviceAuthType.x509_thumbprint.name, DeviceAuthApiType.selfSigned.value]: if not pk: raise ValueError("primary thumbprint required with selfSigned auth") auth = AuthenticationMechanism( x509_thumbprint=X509Thumbprint( primary_thumbprint=pk, secondary_thumbprint=sk ), - type="selfSigned", + type=DeviceAuthApiType.selfSigned.value, ) - elif auth_method in [DeviceAuthType.x509_ca.name, "certificateAuthority"]: - auth = AuthenticationMechanism(type="certificateAuthority") + elif auth_method in [DeviceAuthType.x509_ca.name, DeviceAuthApiType.certificateAuthority.value]: + auth = AuthenticationMechanism(type=DeviceAuthApiType.certificateAuthority.value) else: raise ValueError("Authorization method {} invalid.".format(auth_method)) return auth @@ -306,7 +307,7 @@ def update_iot_device_custom( auth_type = instance["authentication"]["type"] if auth_method is not None: if auth_method == DeviceAuthType.shared_private_key.name: - auth = "sas" + auth = DeviceAuthApiType.sas.value if (primary_key and not secondary_key) or ( not primary_key and secondary_key ): @@ -314,7 +315,7 @@ def update_iot_device_custom( instance["authentication"]["symmetricKey"]["primaryKey"] = primary_key instance["authentication"]["symmetricKey"]["secondaryKey"] = secondary_key elif auth_method == DeviceAuthType.x509_thumbprint.name: - auth = "selfSigned" + auth = DeviceAuthApiType.selfSigned.value if not any([primary_thumbprint, secondary_thumbprint]): raise CLIError( "primary or secondary Thumbprint required with selfSigned auth" @@ -328,13 +329,13 @@ def update_iot_device_custom( "secondaryThumbprint" ] = secondary_thumbprint elif auth_method == DeviceAuthType.x509_ca.name: - auth = "certificateAuthority" + auth = DeviceAuthApiType.certificateAuthority.value else: raise ValueError("Authorization method {} invalid.".format(auth_method)) instance["authentication"]["type"] = auth # if no new auth_method is provided, validate secondary auth arguments and update accordingly - elif auth_type == "sas": + elif auth_type == DeviceAuthApiType.sas.value: if any([primary_thumbprint, secondary_thumbprint]): raise ValueError( "Device authorization method {} does not support primary or secondary thumbprints.".format( @@ -346,7 +347,7 @@ def update_iot_device_custom( if secondary_key: instance["authentication"]["symmetricKey"]["secondaryKey"] = secondary_key - elif auth_type == "selfSigned": + elif auth_type == DeviceAuthApiType.selfSigned.value: if any([primary_key, secondary_key]): raise ValueError( "Device authorization method {} does not support primary or secondary keys.".format( @@ -476,7 +477,7 @@ def iot_device_key_regenerate( auth_type=auth_type_dataplane, ) device = _iot_device_show(target, device_id) - if device["authentication"]["type"] != "sas": + if device["authentication"]["type"] != DeviceAuthApiType.sas.value: raise CLIError("Device authentication should be of type sas") pk = device["authentication"]["symmetricKey"]["primaryKey"] @@ -867,15 +868,15 @@ def _handle_module_update_params(parameters): def _parse_auth(parameters): - valid_auth = ["sas", "selfSigned", "certificateAuthority"] + valid_auth = [DeviceAuthApiType.sas.value, DeviceAuthApiType.selfSigned.value, DeviceAuthApiType.certificateAuthority.value] auth = parameters["authentication"].get("type") if auth not in valid_auth: raise CLIError("authentication.type must be one of {}".format(valid_auth)) pk = sk = None - if auth == "sas": + if auth == DeviceAuthApiType.sas.value: pk = parameters["authentication"]["symmetricKey"]["primaryKey"] sk = parameters["authentication"]["symmetricKey"]["secondaryKey"] - elif auth == "selfSigned": + elif auth == DeviceAuthApiType.selfSigned.value: pk = parameters["authentication"]["x509Thumbprint"]["primaryThumbprint"] sk = parameters["authentication"]["x509Thumbprint"]["secondaryThumbprint"] if not any([pk, sk]): @@ -1924,7 +1925,7 @@ def iot_get_sas_token( ) return { - "sas": _iot_build_sas_token( + DeviceAuthApiType.sas.value: _iot_build_sas_token( cmd, hub_name, device_id, @@ -2022,13 +2023,13 @@ def _build_device_or_module_connection_string(entity, key_type="primary"): ) auth = entity["authentication"] auth_type = auth["type"].lower() - if auth_type == "sas": + if auth_type == DeviceAuthApiType.sas.value.lower(): key = "SharedAccessKey={}".format( auth["symmetricKey"]["primaryKey"] if key_type == "primary" else auth["symmetricKey"]["secondaryKey"] ) - elif auth_type in ["certificateauthority", "selfsigned"]: + elif auth_type in [DeviceAuthApiType.certificateAuthority.value.lower(), DeviceAuthApiType.selfSigned.value.lower()]: key = "x509=true" else: raise CLIError("Unable to form target connection string") @@ -2128,6 +2129,10 @@ def _iot_device_send_message( import ssl import os + device = _iot_device_show(target, device_id) + if device and device.get("authentication", {}).get("type", "") != DeviceAuthApiType.sas.value: + raise CLIError('D2C send message command only supports symmetric key auth (SAS) based devices') + msgs = [] if properties: properties = validate_key_value_pairs(properties) @@ -2514,6 +2519,11 @@ def http_wrap(target, device_id, generator): try: if protocol_type == ProtocolType.mqtt.name: + + device = _iot_device_show(target, device_id) + if device and device.get("authentication", {}).get("type", "") != DeviceAuthApiType.sas.value: + raise CLIError('MQTT simulation is only supported for symmetric key auth (SAS) based devices') + wrap = mqtt_client_wrap( target=target, device_id=device_id, diff --git a/azext_iot/tests/conftest.py b/azext_iot/tests/conftest.py index a60ed08a6..8f7e44721 100644 --- a/azext_iot/tests/conftest.py +++ b/azext_iot/tests/conftest.py @@ -14,6 +14,7 @@ from azure.cli.core.commands import AzCliCommand from azure.cli.core.mock import DummyCli from azext_iot.tests.generators import generate_generic_id +from azext_iot.common.shared import DeviceAuthApiType path_iot_hub_service_factory = "azext_iot._factory.iot_hub_service_factory" path_service_client = "msrest.service_client.ServiceClient.send" @@ -26,6 +27,7 @@ path_iot_hub_monitor_events_entrypoint = ( "azext_iot.operations.hub._iot_hub_monitor_events" ) +path_iot_device_show = "azext_iot.operations.hub._iot_device_show" hub_entity = "myhub.azure-devices.net" instance_name = generate_generic_id() @@ -137,6 +139,72 @@ def fixture_monitor_events_entrypoint(mocker): return mocker.patch(path_iot_hub_monitor_events_entrypoint) +@pytest.fixture() +def fixture_iot_device_show_sas(mocker): + device = mocker.patch(path_iot_device_show) + device.return_value = { + "authentication": { + "symmetricKey": { + "primaryKey": "test_pk", + "secondaryKey": "test_sk" + }, + "type": DeviceAuthApiType.sas.value, + "x509Thumbprint": { + "primaryThumbprint": None, + "secondaryThumbprint": None + } + }, + "capabilities": { + "iotEdge": False + }, + "cloudToDeviceMessageCount": 0, + "connectionState": "Disconnected", + "connectionStateUpdatedTime": "2021-05-27T00:36:11.2861732Z", + "deviceId": "Test_Device_1", + "etag": "ODgxNTgwOA==", + "generationId": "637534345627501371", + "hub": "test-iot-hub.azure-devices.net", + "lastActivityTime": "2021-05-27T00:18:16.3154299Z", + "status": "enabled", + "statusReason": None, + "statusUpdatedTime": "0001-01-01T00:00:00Z" + } + return device + + +@pytest.fixture() +def fixture_self_signed_device_show_self_signed(mocker): + device = mocker.patch(path_iot_device_show) + device.return_value = { + "authentication": { + "symmetricKey": { + "primaryKey": "test_pk", + "secondaryKey": "test_sk" + }, + "type": DeviceAuthApiType.selfSigned.value, + "x509Thumbprint": { + "primaryThumbprint": None, + "secondaryThumbprint": None + } + }, + "capabilities": { + "iotEdge": False + }, + "cloudToDeviceMessageCount": 0, + "connectionState": "Disconnected", + "connectionStateUpdatedTime": "2021-05-27T00:36:11.2861732Z", + "deviceId": "Test_Device_1", + "etag": "ODgxNTgwOA==", + "generationId": "637534345627501371", + "hub": "test-iot-hub.azure-devices.net", + "lastActivityTime": "2021-05-27T00:18:16.3154299Z", + "status": "enabled", + "statusReason": None, + "statusUpdatedTime": "0001-01-01T00:00:00Z" + } + return device + + # TODO: To be deprecated asap. Leverage mocked_response fixture for this functionality. def build_mock_response( mocker=None, status_code=200, payload=None, headers=None, **kwargs diff --git a/azext_iot/tests/iothub/test_iot_ext_unit.py b/azext_iot/tests/iothub/test_iot_ext_unit.py index 3ae27c1cd..86412732e 100644 --- a/azext_iot/tests/iothub/test_iot_ext_unit.py +++ b/azext_iot/tests/iothub/test_iot_ext_unit.py @@ -35,7 +35,7 @@ mock_target, generate_cs, ) - +from azext_iot.common.shared import DeviceAuthApiType device_id = "mydevice" child_device_id = "child_device1" @@ -131,13 +131,13 @@ def test_device_create(self, serviceclient, req): assert body["capabilities"]["iotEdge"] == req["ee"] if req["auth"] == "shared_private_key": - assert body["authentication"]["type"] == "sas" + assert body["authentication"]["type"] == DeviceAuthApiType.sas.value elif req["auth"] == "x509_ca": - assert body["authentication"]["type"] == "certificateAuthority" + assert body["authentication"]["type"] == DeviceAuthApiType.certificateAuthority.value assert not body["authentication"].get("x509Thumbprint") assert not body["authentication"].get("symmetricKey") elif req["auth"] == "x509_thumbprint": - assert body["authentication"]["type"] == "selfSigned" + assert body["authentication"]["type"] == DeviceAuthApiType.selfSigned.value x509tp = body["authentication"]["x509Thumbprint"] assert x509tp["primaryThumbprint"] if req["stp"] is None: @@ -192,7 +192,7 @@ def generate_device_show(**kvp): "authentication": { "symmetricKey": {"primaryKey": None, "secondaryKey": None}, "x509Thumbprint": {"primaryThumbprint": None, "secondaryThumbprint": None}, - "type": "sas", + "type": DeviceAuthApiType.sas.value, }, "capabilities": {"iotEdge": True}, "deviceId": device_id, @@ -244,7 +244,7 @@ def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): generate_device_show( authentication={ "symmetricKey": {"primaryKey": "", "secondaryKey": ""}, - "type": "sas", + "type": DeviceAuthApiType.sas.value, } ) ), @@ -255,13 +255,13 @@ def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): "primaryThumbprint": "123", "secondaryThumbprint": "321", }, - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, } ) ), ( generate_device_show( - authentication={"type": "certificateAuthority"}, + authentication={"type": DeviceAuthApiType.certificateAuthority.value}, etag=generate_generic_id(), ) ), @@ -286,10 +286,10 @@ def test_device_update(self, fixture_cmd, serviceclient, req): assert body["status"] == req["status"] assert body["capabilities"]["iotEdge"] == req["capabilities"]["iotEdge"] assert req["authentication"]["type"] == body["authentication"]["type"] - if req["authentication"]["type"] == "certificateAuthority": + if req["authentication"]["type"] == DeviceAuthApiType.certificateAuthority.value: assert not body["authentication"].get("x509Thumbprint") assert not body["authentication"].get("symmetricKey") - elif req["authentication"]["type"] == "selfSigned": + elif req["authentication"]["type"] == DeviceAuthApiType.selfSigned.value: assert body["authentication"]["x509Thumbprint"]["primaryThumbprint"] assert body["authentication"]["x509Thumbprint"]["secondaryThumbprint"] @@ -320,7 +320,7 @@ def test_device_update(self, fixture_cmd, serviceclient, req): ( generate_device_show( authentication={ - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, "symmetricKey": {"primaryKey": None, "secondaryKey": None}, "x509Thumbprint": { "primaryThumbprint": "123", @@ -337,7 +337,7 @@ def test_device_update(self, fixture_cmd, serviceclient, req): ( generate_device_show( authentication={ - "type": "certificateAuthority", + "type": DeviceAuthApiType.certificateAuthority.value, "symmetricKey": {"primaryKey": None, "secondaryKey": None}, "x509Thumbprint": { "primaryThumbprint": None, @@ -356,7 +356,7 @@ def test_device_update(self, fixture_cmd, serviceclient, req): ( generate_device_show( authentication={ - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, "symmetricKey": {"primaryKey": None, "secondaryKey": None}, "x509Thumbprint": { "primaryThumbprint": "123", @@ -389,7 +389,7 @@ def test_iot_device_custom(self, fixture_cmd, serviceclient, req, arg): assert instance["statusReason"] == arg["status_reason"] if arg["auth_method"]: if arg["auth_method"] == "shared_private_key": - assert instance["authentication"]["type"] == "sas" + assert instance["authentication"]["type"] == DeviceAuthApiType.sas.value instance["authentication"]["symmetricKey"]["primaryKey"] == arg[ "primary_key" ] @@ -397,7 +397,7 @@ def test_iot_device_custom(self, fixture_cmd, serviceclient, req, arg): "secondary_key" ] if arg["auth_method"] == "x509_thumbprint": - assert instance["authentication"]["type"] == "selfSigned" + assert instance["authentication"]["type"] == DeviceAuthApiType.selfSigned.value if arg["primary_thumbprint"]: instance["authentication"]["x509Thumbprint"][ "primaryThumbprint" @@ -407,7 +407,7 @@ def test_iot_device_custom(self, fixture_cmd, serviceclient, req, arg): "secondaryThumbprint" ] = arg["secondary_thumbprint"] if arg["auth_method"] == "x509_ca": - assert instance["authentication"]["type"] == "certificateAuthority" + assert instance["authentication"]["type"] == DeviceAuthApiType.certificateAuthority.value @pytest.mark.parametrize( "req, arg, exp", @@ -437,7 +437,7 @@ def test_iot_device_custom(self, fixture_cmd, serviceclient, req, arg): ( generate_device_show( authentication={ - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, "symmetricKey": {"primaryKey": None, "secondaryKey": None}, "x509Thumbprint": { "primaryThumbprint": "123", @@ -474,7 +474,7 @@ def test_iot_device_custom_invalid_args(self, serviceclient, req, arg, exp): "primaryThumbprint": "", "secondaryThumbprint": "", }, - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, } ), CLIError, @@ -511,7 +511,7 @@ def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): "authentication", { "symmetricKey": {"primaryKey": "123", "secondaryKey": "321"}, - "type": "sas", + "type": DeviceAuthApiType.sas.value, }, ) test_side_effect = [ @@ -737,13 +737,13 @@ def test_device_module_create(self, serviceclient, req): assert body["moduleId"] == req["module_id"] if req["auth"] == "shared_private_key": - assert body["authentication"]["type"] == "sas" + assert body["authentication"]["type"] == DeviceAuthApiType.sas.value elif req["auth"] == "x509_ca": - assert body["authentication"]["type"] == "certificateAuthority" + assert body["authentication"]["type"] == DeviceAuthApiType.certificateAuthority.value assert not body["authentication"].get("x509Thumbprint") assert not body["authentication"].get("symmetricKey") elif req["auth"] == "x509_thumbprint": - assert body["authentication"]["type"] == "selfSigned" + assert body["authentication"]["type"] == DeviceAuthApiType.selfSigned.value x509tp = body["authentication"]["x509Thumbprint"] assert x509tp["primaryThumbprint"] if req["stp"] is None: @@ -783,7 +783,7 @@ def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): generate_device_module_show( authentication={ "symmetricKey": {"primaryKey": "", "secondaryKey": ""}, - "type": "sas", + "type": DeviceAuthApiType.sas.value, }, etag=generate_generic_id(), ) @@ -795,13 +795,13 @@ def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): "primaryThumbprint": "123", "secondaryThumbprint": "321", }, - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, } ) ), ( generate_device_module_show( - authentication={"type": "certificateAuthority"} + authentication={"type": DeviceAuthApiType.certificateAuthority.value} ) ), ], @@ -830,10 +830,10 @@ def test_device_module_update(self, serviceclient, req): assert body["moduleId"] == req["moduleId"] assert not body.get("capabilities") assert req["authentication"]["type"] == body["authentication"]["type"] - if req["authentication"]["type"] == "certificateAuthority": + if req["authentication"]["type"] == DeviceAuthApiType.certificateAuthority.value: assert not body["authentication"].get("x509Thumbprint") assert not body["authentication"].get("symmetricKey") - elif req["authentication"]["type"] == "selfSigned": + elif req["authentication"]["type"] == DeviceAuthApiType.selfSigned.value: assert body["authentication"]["x509Thumbprint"]["primaryThumbprint"] assert body["authentication"]["x509Thumbprint"]["secondaryThumbprint"] @@ -851,7 +851,7 @@ def test_device_module_update(self, serviceclient, req): "primaryThumbprint": "", "secondaryThumbprint": "", }, - "type": "selfSigned", + "type": DeviceAuthApiType.selfSigned.value, } ), CLIError, @@ -2001,7 +2001,7 @@ def test_generate_sas_token(self): class TestDeviceSimulate: @pytest.fixture(params=[204]) - def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): + def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request, fixture_iot_device_show_sas): service_client = mocker.patch(path_service_client) service_client.return_value = build_mock_response(mocker, request.param, {}) return service_client @@ -2123,6 +2123,12 @@ def test_device_simulate_mqtt_error(self, mqttclient_generic_error): fixture_cmd, device_id, hub_name=mock_target["entity"] ) + def test_device_simulate_mqtt_non_sas_device_error(self, fixture_ghcs, fixture_self_signed_device_show_self_signed): + with pytest.raises(CLIError): + subject.iot_simulate_device( + fixture_cmd, device_id, hub_name=mock_target["entity"] + ) + @pytest.mark.skipif( not validate_min_python_version(3, 5, exit_on_fail=False), From acf59c0c7f19c9667ae4ee47d48003dd53e9e39b Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Fri, 4 Jun 2021 17:56:46 -0700 Subject: [PATCH 30/42] Support C2D Message decoding and Add TQDM for HTTP simulation --- azext_iot/monitor/event.py | 2 +- azext_iot/operations/_mqtt.py | 13 ++++- azext_iot/operations/hub.py | 54 +++++++++---------- .../tests/iothub/test_iot_messaging_int.py | 30 +++++++++++ 4 files changed, 67 insertions(+), 32 deletions(-) diff --git a/azext_iot/monitor/event.py b/azext_iot/monitor/event.py index faa7afa85..b72271a8d 100644 --- a/azext_iot/monitor/event.py +++ b/azext_iot/monitor/event.py @@ -65,7 +65,7 @@ def send_c2d_message( if expiry_time_utc: msg_props.absolute_expiry_time = int(expiry_time_utc) - msg_body = str.encode(data) + msg_body = data.encode(encoding=content_encoding) message = uamqp.Message( body=msg_body, properties=msg_props, application_properties=app_props diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index caeacc62e..f3f2422db 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -24,6 +24,7 @@ def __init__(self, target, device_conn_string, device_id, method_response_code=N self.method_response_payload = method_response_payload self.device_client.on_twin_desired_properties_patch_received = self.twin_patch_handler self.printer = pprint.PrettyPrinter(indent=2) + self.default_data_encoding = 'utf-8' def send_d2c_message(self, message_text, properties=None): message = Message(message_text) @@ -45,12 +46,20 @@ def message_handler(self, message): message_properties.update(message.custom_properties) + if message.data and "content_encoding" in message_properties: + try: + payload = message.data.decode(encoding=message_properties["content_encoding"]) + except Exception as x: + raise x + else: + payload = message.data.decode(encoding=self.default_data_encoding) + output = { "Topic": "/devices/{}/messages/devicebound".format(self.device_id), - "Payload": message.data, + "Payload": payload, "Message Properties": message_properties } - print("\nMessage Handler [Received C2D message]:") + print("\nC2D Message Handler [Received C2D message]:") self.printer.pprint(output) def method_request_handler(self, method_request): diff --git a/azext_iot/operations/hub.py b/azext_iot/operations/hub.py index 89e57c9c9..9fba449cd 100644 --- a/azext_iot/operations/hub.py +++ b/azext_iot/operations/hub.py @@ -39,14 +39,14 @@ ) from azext_iot._factory import SdkResolver, CloudError from azext_iot.operations.generic import _execute_query, _process_top - +import pprint logger = get_logger(__name__) +printer = pprint.PrettyPrinter(indent=2) # Query - def iot_query( cmd, query_command, hub_name=None, top=None, resource_group_name=None, login=None ): @@ -2014,12 +2014,10 @@ def _iot_c2d_message_receive(target, device_id, lock_timeout=60, ack=None): if sys_props: payload["properties"]["system"] = sys_props - if result.text: - payload["data"] = ( - result.text - if not isinstance(result.text, bytes) - else result.text.decode("utf-8") - ) + if result.content: + target_encoding = result.headers.get("ContentEncoding", "utf-8") + logger.info(f"Decoding message data encoded with: {target_encoding}") + payload["data"] = result.content.decode(encoding=target_encoding) return payload return @@ -2117,7 +2115,8 @@ def iot_simulate_device( import datetime import json from azext_iot.operations._mqtt import mqtt_client - from azext_iot.common.utility import execute_onthread + from threading import Event, Thread + from tqdm import tqdm from azext_iot.constants import ( MIN_SIM_MSG_INTERVAL, MIN_SIM_MSG_COUNT, @@ -2149,7 +2148,6 @@ def iot_simulate_device( target = discovery.get_target( hub_name=hub_name, resource_group_name=resource_group_name, login=login ) - token = None if method_response_payload: method_response_payload = process_json_arg( @@ -2169,11 +2167,14 @@ def generate(self, jsonify=True): } return json.dumps(payload) if jsonify else payload - def http_wrap(target, device_id, generator): - d = generator.generate(False) - _iot_device_send_message_http(target, device_id, d, headers=properties_to_send) - print(".", end="", flush=True) + cancellation_token = Event() + def http_wrap(target, device_id, generator, msg_interval, msg_count): + for _ in tqdm(range(msg_count), desc='Sending and receiving events via https'): + d = generator.generate(False) + _iot_device_send_message_http(target, device_id, d, headers=properties_to_send) + if cancellation_token.wait(msg_interval): + break try: if protocol_type == ProtocolType.mqtt.name: device = _iot_device_show(target, device_id) @@ -2187,14 +2188,9 @@ def http_wrap(target, device_id, generator): ) client_mqtt.execute(data=generator(), properties=properties_to_send, publish_delay=msg_interval, msg_count=msg_count) else: - print("Sending and receiving events via https") - token, op = execute_onthread( - method=http_wrap, - args=[target, device_id, generator()], - interval=msg_interval, - max_runs=msg_count, - return_handle=True, - ) + op = Thread(target=http_wrap, args=(target, device_id, generator(), msg_interval, msg_count)) + op.start() + while op.is_alive(): _handle_c2d_msg(target, device_id, receive_settle) sleep(SIM_RECEIVE_SLEEP_SEC) @@ -2204,8 +2200,8 @@ def http_wrap(target, device_id, generator): except Exception as x: raise CLIError(x) finally: - if token: - token.set() + if cancellation_token: + cancellation_token.set() def iot_c2d_message_purge( @@ -2235,16 +2231,16 @@ def _handle_c2d_msg(target, device_id, receive_settle, lock_timeout=60): result = _iot_c2d_message_receive(target, device_id, lock_timeout) if result: print() - print("__Received C2D Message__") - print(result) + print("C2D Message Handler [Received C2D message]:") + printer.pprint(result) if receive_settle == "reject": - print("__Rejecting message__") + print("C2D Message Handler [Rejecting message]") _iot_c2d_message_reject(target, device_id, result["etag"]) elif receive_settle == "abandon": - print("__Abandoning message__") + print("C2D Message Handler [Abandoning message]") _iot_c2d_message_abandon(target, device_id, result["etag"]) else: - print("__Completing message__") + print("C2D Message Handler [Completing message]") _iot_c2d_message_complete(target, device_id, result["etag"]) return True return False diff --git a/azext_iot/tests/iothub/test_iot_messaging_int.py b/azext_iot/tests/iothub/test_iot_messaging_int.py index a0c2875bc..65f42e51f 100644 --- a/azext_iot/tests/iothub/test_iot_messaging_int.py +++ b/azext_iot/tests/iothub/test_iot_messaging_int.py @@ -106,6 +106,36 @@ def test_uamqp_device_messaging(self): checks=self.is_empty(), ) + utf_32_encoding = "utf-32" + string_payload = "Test payload encoding decoding" + + # Send C2D Message with UTF-32 encoding + self.cmd( + """iot device c2d-message send -d {} -n {} -g {} --data '{}' --cid {} --mid {} --ct {} --expiry {} + --ce {} --props {}""".format( + device_ids[0], + LIVE_HUB, + LIVE_RG, + string_payload, + test_cid, + test_mid, + test_ct, + test_et, + utf_32_encoding, + test_props, + ), + checks=self.is_empty(), + ) + + result = self.cmd( + "iot device c2d-message receive -d {} --hub-name {} -g {}".format( + device_ids[0], LIVE_HUB, LIVE_RG + ) + ).get_output_in_json() + + # Verify that the data was decoded correctly + assert result["data"] == string_payload + # Send C2D message via --login + application/json content ype test_ct = "application/json" From e64f460f555f0ed613f86dbe22e111e692c02fbd Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Mon, 7 Jun 2021 15:06:50 -0700 Subject: [PATCH 31/42] Update MQTT operations to run on web sockets --- azext_iot/operations/_mqtt.py | 2 +- .../tests/iothub/test_iot_messaging_int.py | 35 +++---------------- 2 files changed, 5 insertions(+), 32 deletions(-) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index f3f2422db..d295abcdc 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -16,7 +16,7 @@ class mqtt_client(object): def __init__(self, target, device_conn_string, device_id, method_response_code=None, method_response_payload=None): self.device_id = device_id self.target = target - self.device_client = mqtt_device_client.create_from_connection_string(device_conn_string) + self.device_client = mqtt_device_client.create_from_connection_string(device_conn_string, websockets=True) self.device_client.connect() self.device_client.on_message_received = self.message_handler self.device_client.on_method_request_received = self.method_request_handler diff --git a/azext_iot/tests/iothub/test_iot_messaging_int.py b/azext_iot/tests/iothub/test_iot_messaging_int.py index 65f42e51f..3a44edefd 100644 --- a/azext_iot/tests/iothub/test_iot_messaging_int.py +++ b/azext_iot/tests/iothub/test_iot_messaging_int.py @@ -106,36 +106,6 @@ def test_uamqp_device_messaging(self): checks=self.is_empty(), ) - utf_32_encoding = "utf-32" - string_payload = "Test payload encoding decoding" - - # Send C2D Message with UTF-32 encoding - self.cmd( - """iot device c2d-message send -d {} -n {} -g {} --data '{}' --cid {} --mid {} --ct {} --expiry {} - --ce {} --props {}""".format( - device_ids[0], - LIVE_HUB, - LIVE_RG, - string_payload, - test_cid, - test_mid, - test_ct, - test_et, - utf_32_encoding, - test_props, - ), - checks=self.is_empty(), - ) - - result = self.cmd( - "iot device c2d-message receive -d {} --hub-name {} -g {}".format( - device_ids[0], LIVE_HUB, LIVE_RG - ) - ).get_output_in_json() - - # Verify that the data was decoded correctly - assert result["data"] == string_payload - # Send C2D message via --login + application/json content ype test_ct = "application/json" @@ -299,6 +269,7 @@ def test_mqtt_device_direct_method_with_custom_response_status_payload(self): from azext_iot.operations.hub import iot_simulate_device from azext_iot._factory import iot_hub_service_factory from azure.cli.core.mock import DummyCli + from time import sleep cli_ctx = DummyCli() client = iot_hub_service_factory(cli_ctx) @@ -311,7 +282,7 @@ def test_mqtt_device_direct_method_with_custom_response_status_payload(self): LIVE_HUB, "complete", "Testing direct method invocations when simulator is run with custom method response status and payload", - 2, + 4, 5, "mqtt", None, @@ -324,6 +295,8 @@ def test_mqtt_device_direct_method_with_custom_response_status_payload(self): return_handle=True, ) + sleep(MQTT_CLIENT_SETUP_TIME) + # invoke device method with response status and payload result = self.cmd( "iot hub invoke-device-method -d {} --method-name Test_Method_2 --login {}".format( From 773bfbb64d8d3d9acbf75d2e51433566ba788f2d Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Mon, 7 Jun 2021 15:20:07 -0700 Subject: [PATCH 32/42] Remove duplicate test already in dev branch --- .../tests/iothub/test_iot_messaging_int.py | 30 ------------------- 1 file changed, 30 deletions(-) diff --git a/azext_iot/tests/iothub/test_iot_messaging_int.py b/azext_iot/tests/iothub/test_iot_messaging_int.py index 542c6526d..3a44edefd 100644 --- a/azext_iot/tests/iothub/test_iot_messaging_int.py +++ b/azext_iot/tests/iothub/test_iot_messaging_int.py @@ -106,36 +106,6 @@ def test_uamqp_device_messaging(self): checks=self.is_empty(), ) - utf_32_encoding = "utf-32" - string_payload = "Test payload encoding decoding" - - # Send C2D Message with UTF-32 encoding - self.cmd( - """iot device c2d-message send -d {} -n {} -g {} --data '{}' --cid {} --mid {} --ct {} --expiry {} - --ce {} --props {}""".format( - device_ids[0], - LIVE_HUB, - LIVE_RG, - string_payload, - test_cid, - test_mid, - test_ct, - test_et, - utf_32_encoding, - test_props, - ), - checks=self.is_empty(), - ) - - result = self.cmd( - "iot device c2d-message receive -d {} --hub-name {} -g {}".format( - device_ids[0], LIVE_HUB, LIVE_RG - ) - ).get_output_in_json() - - # Verify that the data was decoded correctly - assert result["data"] == string_payload - # Send C2D message via --login + application/json content ype test_ct = "application/json" From fecc2d8fc9a2a3687769216859e68f003afbe92e Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Mon, 7 Jun 2021 15:39:03 -0700 Subject: [PATCH 33/42] Device connection is automatic now --- azext_iot/operations/_mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index d295abcdc..fc59042c0 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -16,8 +16,8 @@ class mqtt_client(object): def __init__(self, target, device_conn_string, device_id, method_response_code=None, method_response_payload=None): self.device_id = device_id self.target = target + # The client automatically connects when we send/receive a message or method invocation self.device_client = mqtt_device_client.create_from_connection_string(device_conn_string, websockets=True) - self.device_client.connect() self.device_client.on_message_received = self.message_handler self.device_client.on_method_request_received = self.method_request_handler self.method_response_code = method_response_code From 260e273351cd72826ba1ad8f0fa3ebebb01c2a20 Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Mon, 7 Jun 2021 15:45:12 -0700 Subject: [PATCH 34/42] Styling update --- azext_iot/operations/_mqtt.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index fc59042c0..ca141c92d 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -16,7 +16,7 @@ class mqtt_client(object): def __init__(self, target, device_conn_string, device_id, method_response_code=None, method_response_payload=None): self.device_id = device_id self.target = target - # The client automatically connects when we send/receive a message or method invocation + # The client automatically connects when we send/receive a message or method invocation self.device_client = mqtt_device_client.create_from_connection_string(device_conn_string, websockets=True) self.device_client.on_message_received = self.message_handler self.device_client.on_method_request_received = self.method_request_handler From 589d6781aa7c23f0e31dcc58abfdc413cf35a456 Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Tue, 22 Jun 2021 16:47:07 -0700 Subject: [PATCH 35/42] Feature to support setting twin reported properties during simulation --- azext_iot/_help.py | 6 +- azext_iot/_params.py | 6 ++ azext_iot/operations/_mqtt.py | 17 +++++- azext_iot/operations/hub.py | 26 +++++++-- azext_iot/tests/conftest.py | 6 ++ azext_iot/tests/iothub/test_iot_ext_unit.py | 57 +++++++++++-------- .../tests/iothub/test_iot_messaging_int.py | 49 ++++++++++++++++ 7 files changed, 136 insertions(+), 31 deletions(-) diff --git a/azext_iot/_help.py b/azext_iot/_help.py index f2c025eeb..025b6b113 100644 --- a/azext_iot/_help.py +++ b/azext_iot/_help.py @@ -868,7 +868,7 @@ be acknowledged with completion. For http simulation c2d acknowledgement is based on user selection which can be complete, reject or abandon. Additionally, mqtt simulation is only supported for symmetric key auth (SAS) based devices. The mqtt simulation also supports direct - method invocation which can be acknowledged by a response status code and response payload + method invocation which can be acknowledged by a response status code and response payload. Note: The command by default will set content-type to application/json and content-encoding to utf-8. This can be overriden. @@ -881,6 +881,10 @@ text: az iot device simulate -n {iothub_name} -d {device_id} --method-response-code 201 --method-response-payload '{"result":"Direct method successful"}' - name: Basic usage (mqtt) with sending direct method response status code and direct method response payload as path to local file text: az iot device simulate -n {iothub_name} -d {device_id} --method-response-code 201 --method-response-payload '../my_direct_method_payload.json' + - name: Basic usage (mqtt) with sending the initial state of device twin properties as raw json for the target device + text: az iot device simulate -n {iothub_name} -d {device_id} --init-reported-properties '{"reported_prop_1":"val_1", "reported_prop_2":val_2}'' + - name: Basic usage (mqtt) with sending the initial state of device twin properties as as path to local file for the target device + text: az iot device simulate -n {iothub_name} -d {device_id} --init-reported-properties '../my_device_twin_reported_properties.json' - name: Basic usage (http) text: az iot device simulate -n {iothub_name} -d {device_id} --protocol http - name: Basic usage (http) with sending mixed properties diff --git a/azext_iot/_params.py b/azext_iot/_params.py index d9f1e4f68..96e7f0788 100644 --- a/azext_iot/_params.py +++ b/azext_iot/_params.py @@ -592,6 +592,12 @@ def load_arguments(self, _): help="Payload to be returned when direct method is executed on device. Provide file path or raw json. " "Optional param, only supported for mqtt.", ) + context.argument( + "init_reported_properties", + options_list=["--init-reported-properties", "--irp"], + help="Initial state of twin reported properties for the target device when the simulator is run. " + "Optional param, only supported for mqtt.", + ) with self.argument_context("iot device c2d-message") as context: context.argument( diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index ca141c92d..6b60ccd96 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -13,7 +13,10 @@ class mqtt_client(object): - def __init__(self, target, device_conn_string, device_id, method_response_code=None, method_response_payload=None): + def __init__( + self, target, device_conn_string, device_id, + method_response_code=None, method_response_payload=None, init_reported_properties=None + ): self.device_id = device_id self.target = target # The client automatically connects when we send/receive a message or method invocation @@ -25,6 +28,7 @@ def __init__(self, target, device_conn_string, device_id, method_response_code=N self.device_client.on_twin_desired_properties_patch_received = self.twin_patch_handler self.printer = pprint.PrettyPrinter(indent=2) self.default_data_encoding = 'utf-8' + self.init_reported_properties = init_reported_properties def send_d2c_message(self, message_text, properties=None): message = Message(message_text) @@ -105,8 +109,19 @@ def twin_patch_handler(self, patch): self.device_client.patch_twin_reported_properties(modified_properties) def execute(self, data, properties={}, publish_delay=2, msg_count=100): + from azext_iot.operations.hub import _iot_device_twin_update from tqdm import tqdm + try: + if self.init_reported_properties: + twin_properties = { + "properties": { + "desired": self.init_reported_properties + } + } + + _iot_device_twin_update(self.target, self.device_id, twin_properties) + for _ in tqdm(range(msg_count), desc='Device simulation in progress'): self.send_d2c_message(message_text=data.generate(True), properties=properties) sleep(publish_delay) diff --git a/azext_iot/operations/hub.py b/azext_iot/operations/hub.py index 6f08f7f9f..a348ceb2d 100644 --- a/azext_iot/operations/hub.py +++ b/azext_iot/operations/hub.py @@ -1705,8 +1705,6 @@ def iot_device_twin_update( etag=None, auth_type_dataplane=None, ): - from azext_iot.common.utility import verify_transform - discovery = IotHubDiscovery(cmd) target = discovery.get_target( hub_name=hub_name, @@ -1714,6 +1712,17 @@ def iot_device_twin_update( login=login, auth_type=auth_type_dataplane, ) + return _iot_device_twin_update(target, device_id, parameters, etag) + + +def _iot_device_twin_update( + target, + device_id, + parameters, + etag=None, +): + from azext_iot.common.utility import verify_transform + resolver = SdkResolver(target=target) service_sdk = resolver.get_sdk(SdkType.service_sdk) @@ -2418,7 +2427,8 @@ def iot_simulate_device( resource_group_name=None, login=None, method_response_code=None, - method_response_payload=None + method_response_payload=None, + init_reported_properties=None ): import sys import uuid @@ -2449,6 +2459,8 @@ def iot_simulate_device( raise CLIError("'method-response-code' not supported, {} doesn't allow direct methods.".format(protocol_type)) if method_response_payload: raise CLIError("'method-response-payload' not supported, {} doesn't allow direct methods.".format(protocol_type)) + if init_reported_properties: + raise CLIError("'init-reported-properties' not supported, {} doesn't allow setting twin props".format(protocol_type)) properties_to_send = _iot_simulate_get_default_properties(protocol_type) user_properties = validate_key_value_pairs(properties) or {} @@ -2464,6 +2476,11 @@ def iot_simulate_device( method_response_payload, argument_name="method-response-payload" ) + if init_reported_properties: + init_reported_properties = process_json_arg( + init_reported_properties, argument_name="init-reported-properties" + ) + class generator(object): def __init__(self): self.calls = 0 @@ -2498,7 +2515,8 @@ def http_wrap(target, device_id, generator, msg_interval, msg_count): device_conn_string=device_connection_string, device_id=device_id, method_response_code=method_response_code, - method_response_payload=method_response_payload + method_response_payload=method_response_payload, + init_reported_properties=init_reported_properties ) client_mqtt.execute(data=generator(), properties=properties_to_send, publish_delay=msg_interval, msg_count=msg_count) else: diff --git a/azext_iot/tests/conftest.py b/azext_iot/tests/conftest.py index aded184ab..8fe4a9784 100644 --- a/azext_iot/tests/conftest.py +++ b/azext_iot/tests/conftest.py @@ -29,6 +29,7 @@ "azext_iot.operations.hub._iot_hub_monitor_events" ) path_iot_device_show = "azext_iot.operations.hub._iot_device_show" +path_update_device_twin = "azext_iot.operations.hub._iot_device_twin_update" hub_entity = "myhub.azure-devices.net" instance_name = generate_generic_id() @@ -171,6 +172,11 @@ def fixture_monitor_events_entrypoint(mocker): return mocker.patch(path_iot_hub_monitor_events_entrypoint) +@pytest.fixture() +def fixture_update_device_twin(mocker): + return mocker.patch(path_update_device_twin) + + @pytest.fixture() def fixture_iot_device_show_sas(mocker): device = mocker.patch(path_iot_device_show) diff --git a/azext_iot/tests/iothub/test_iot_ext_unit.py b/azext_iot/tests/iothub/test_iot_ext_unit.py index a668dce82..38a526ec5 100644 --- a/azext_iot/tests/iothub/test_iot_ext_unit.py +++ b/azext_iot/tests/iothub/test_iot_ext_unit.py @@ -1998,28 +1998,31 @@ def test_generate_sas_token(self): class TestDeviceSimulate: @pytest.fixture(params=[204]) - def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request, fixture_device, fixture_iot_device_show_sas): + def serviceclient( + self, mocker, fixture_ghcs, fixture_sas, request, fixture_device, fixture_iot_device_show_sas, fixture_update_device_twin + ): service_client = mocker.patch(path_service_client) service_client.return_value = build_mock_response(mocker, request.param, {}) return service_client @pytest.mark.parametrize( - "rs, mc, mi, protocol, properties, mrc, mrp", + "rs, mc, mi, protocol, properties, mrc, mrp, irp", [ - ("complete", 1, 1, "http", None, None, None), - ("reject", 1, 1, "http", None, None, None), - ("abandon", 2, 1, "http", "iothub-app-myprop=myvalue;iothub-messageid=1", None, None), - ("complete", 1, 1, "http", "invalidprop;content-encoding=utf-16", None, None), - ("complete", 1, 1, "http", "iothub-app-myprop=myvalue;content-type=application/text", None, None), - ("complete", 3, 1, "mqtt", None, None, None), - ("complete", 3, 1, "mqtt", "invalid", None, None), - ("complete", 2, 1, "mqtt", "myprop=myvalue;$.ce=utf-16", 201, None), - ("complete", 2, 1, "mqtt", "myprop=myvalue;$.ce=utf-16", None, "{'result':'method succeded'}"), - ("complete", 2, 1, "mqtt", "myinvalidprop;myvalidprop=myvalidpropvalue", 204, "{'result':'method succeded'}"), + ("complete", 1, 1, "http", None, None, None, None), + ("reject", 1, 1, "http", None, None, None, None), + ("abandon", 2, 1, "http", "iothub-app-myprop=myvalue;iothub-messageid=1", None, None, None), + ("complete", 1, 1, "http", "invalidprop;content-encoding=utf-16", None, None, None), + ("complete", 1, 1, "http", "iothub-app-myprop=myvalue;content-type=application/text", None, None, None), + ("complete", 3, 1, "mqtt", None, None, None, None), + ("complete", 3, 1, "mqtt", "invalid", None, None, None), + ("complete", 2, 1, "mqtt", "myprop=myvalue;$.ce=utf-16", 201, None, None), + ("complete", 2, 1, "mqtt", "myprop=myvalue;$.ce=utf-16", None, "{'result':'method succeded'}", None), + ("complete", 2, 1, "mqtt", "myinvalidprop;myvalidprop=myvalidpropvalue", 204, "{'result':'method succeded'}", None), + ("complete", 2, 1, "mqtt", "myinvalidprop;myvalidprop=myvalidpropvalue", None, None, "{'rep_1':'val1', 'rep_2':2}"), ], ) def test_device_simulate( - self, serviceclient, mqttclient, rs, mc, mi, protocol, properties, mrc, mrp + self, serviceclient, mqttclient, rs, mc, mi, protocol, properties, mrc, mrp, irp ): from azext_iot.operations.hub import _iot_simulate_get_default_properties @@ -2033,7 +2036,8 @@ def test_device_simulate( protocol_type=protocol, properties=properties, method_response_code=mrc, - method_response_payload=mrp + method_response_payload=mrp, + init_reported_properties=irp ) properties_to_send = _iot_simulate_get_default_properties(protocol) @@ -2074,19 +2078,22 @@ def test_device_simulate( assert serviceclient.call_count == 0 @pytest.mark.parametrize( - "rs, mc, mi, protocol, exception, mrc, mrp", + "rs, mc, mi, protocol, exception, mrc, mrp, irp", [ - ("complete", 2, 0, "mqtt", CLIError, None, None), - ("complete", 0, 1, "mqtt", CLIError, None, None), - ("reject", 1, 1, "mqtt", CLIError, None, None), - ("abandon", 1, 0, "http", CLIError, None, None), - ("complete", 0, 1, "http", CLIError, 201, None), - ("complete", 0, 1, "http", CLIError, None, "{'result':'method succeded'}"), - ("complete", 0, 1, "http", CLIError, 201, "{'result':'method succeded'}"), + ("complete", 2, 0, "mqtt", CLIError, None, None, None), + ("complete", 0, 1, "mqtt", CLIError, None, None, None), + ("complete", 1, 1, "mqtt", CLIError, 200, "invalid_method_response_payload", None), + ("complete", 1, 1, "mqtt", CLIError, None, None, "invalid_reported_properties_format"), + ("reject", 1, 1, "mqtt", CLIError, None, None, None), + ("abandon", 1, 0, "http", CLIError, None, None, None), + ("complete", 0, 1, "http", CLIError, 201, None, None), + ("complete", 0, 1, "http", CLIError, None, "{'result':'method succeded'}", None), + ("complete", 0, 1, "http", CLIError, 201, "{'result':'method succeded'}", None), + ("complete", 0, 1, "http", CLIError, None, None, "{'rep_prop_1':'val1', 'rep_prop_2':'val2'}"), ], ) def test_device_simulate_invalid_args( - self, serviceclient, rs, mc, mi, protocol, exception, mrc, mrp + self, serviceclient, rs, mc, mi, protocol, exception, mrc, mrp, irp ): with pytest.raises(exception): subject.iot_simulate_device( @@ -2098,8 +2105,8 @@ def test_device_simulate_invalid_args( msg_interval=mi, protocol_type=protocol, method_response_code=mrc, - method_response_payload=mrp - + method_response_payload=mrp, + init_reported_properties=irp ) def test_device_simulate_http_error(self, serviceclient_generic_error): diff --git a/azext_iot/tests/iothub/test_iot_messaging_int.py b/azext_iot/tests/iothub/test_iot_messaging_int.py index 3a44edefd..3d33bb577 100644 --- a/azext_iot/tests/iothub/test_iot_messaging_int.py +++ b/azext_iot/tests/iothub/test_iot_messaging_int.py @@ -255,6 +255,55 @@ def test_uamqp_device_messaging(self): expect_failure=True, ) + def test_mqtt_device_simulation_with_init_reported_properties(self): + device_count = 1 + device_ids = self.generate_device_names(device_count) + + self.cmd( + "iot hub device-identity create -d {} -n {} -g {}".format( + device_ids[0], LIVE_HUB, LIVE_RG + ), + checks=[self.check("deviceId", device_ids[0])], + ) + + from azext_iot.operations.hub import iot_simulate_device + from azext_iot._factory import iot_hub_service_factory + from azure.cli.core.mock import DummyCli + + cli_ctx = DummyCli() + client = iot_hub_service_factory(cli_ctx) + + twin_init_props = {'prop_1': 'val_1', 'prop_2': 'val_2'} + twin_props_json = json.dumps(twin_init_props) + + iot_simulate_device( + client, + device_ids[0], + LIVE_HUB, + "complete", + "Testing init reported twin properties", + 2, + 5, + "mqtt", + None, + None, + None, + None, + None, + twin_props_json + ) + + # get device twin + result = self.cmd( + "iot hub device-twin show -d {} --login {}".format( + device_ids[0], self.connection_string + ) + ).get_output_in_json() + + assert result is not None + for key in twin_init_props: + assert result["properties"]["reported"][key] == twin_init_props[key] + def test_mqtt_device_direct_method_with_custom_response_status_payload(self): device_count = 1 device_ids = self.generate_device_names(device_count) From bbb82ca7cdfb58053ea5e445c20687dfdc696f89 Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Tue, 22 Jun 2021 17:20:28 -0700 Subject: [PATCH 36/42] Remove extra quote --- azext_iot/_help.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azext_iot/_help.py b/azext_iot/_help.py index 025b6b113..a8e03249f 100644 --- a/azext_iot/_help.py +++ b/azext_iot/_help.py @@ -882,7 +882,7 @@ - name: Basic usage (mqtt) with sending direct method response status code and direct method response payload as path to local file text: az iot device simulate -n {iothub_name} -d {device_id} --method-response-code 201 --method-response-payload '../my_direct_method_payload.json' - name: Basic usage (mqtt) with sending the initial state of device twin properties as raw json for the target device - text: az iot device simulate -n {iothub_name} -d {device_id} --init-reported-properties '{"reported_prop_1":"val_1", "reported_prop_2":val_2}'' + text: az iot device simulate -n {iothub_name} -d {device_id} --init-reported-properties '{"reported_prop_1":"val_1", "reported_prop_2":val_2}' - name: Basic usage (mqtt) with sending the initial state of device twin properties as as path to local file for the target device text: az iot device simulate -n {iothub_name} -d {device_id} --init-reported-properties '../my_device_twin_reported_properties.json' - name: Basic usage (http) From 54a46e5acec32ec5919364b32f2d2f0614f969eb Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Wed, 26 May 2021 23:31:24 -0700 Subject: [PATCH 37/42] update twin reported properties during simulation --- azext_iot/operations/_mqtt.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index 6b60ccd96..52d697250 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -8,6 +8,7 @@ from time import sleep from azext_iot.constants import USER_AGENT, BASE_MQTT_API_VERSION from azext_iot.common.utility import url_encode_str +from azext_iot.operations.hub import _iot_device_twin_show from azure.iot.device import IoTHubDeviceClient as mqtt_device_client, Message, MethodResponse import pprint @@ -123,6 +124,20 @@ def execute(self, data, properties={}, publish_delay=2, msg_count=100): _iot_device_twin_update(self.target, self.device_id, twin_properties) for _ in tqdm(range(msg_count), desc='Device simulation in progress'): + device_twin = _iot_device_twin_show(self.target, self.device_id) + if device_twin: + desired_properties = device_twin.get("properties").get("desired") + reported_properties = device_twin.get("properties").get("reported") + twin_properties_to_update = {} + + for prop in desired_properties: + if not prop.startswith("$"): + if prop not in reported_properties or desired_properties[prop] != reported_properties[prop]: + twin_properties_to_update[prop] = desired_properties[prop] + + if twin_properties_to_update: + self.device_client.patch_twin_reported_properties(twin_properties_to_update) + self.send_d2c_message(message_text=data.generate(True), properties=properties) sleep(publish_delay) From b7d9d127dc92c595776bd7073a8900488d3ead51 Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Thu, 27 May 2021 01:14:44 -0700 Subject: [PATCH 38/42] update unit tests --- azext_iot/operations/_mqtt.py | 4 +-- azext_iot/tests/conftest.py | 60 +++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index 52d697250..3e85a4bd0 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -129,7 +129,7 @@ def execute(self, data, properties={}, publish_delay=2, msg_count=100): desired_properties = device_twin.get("properties").get("desired") reported_properties = device_twin.get("properties").get("reported") twin_properties_to_update = {} - + for prop in desired_properties: if not prop.startswith("$"): if prop not in reported_properties or desired_properties[prop] != reported_properties[prop]: @@ -137,7 +137,7 @@ def execute(self, data, properties={}, publish_delay=2, msg_count=100): if twin_properties_to_update: self.device_client.patch_twin_reported_properties(twin_properties_to_update) - + self.send_d2c_message(message_text=data.generate(True), properties=properties) sleep(publish_delay) diff --git a/azext_iot/tests/conftest.py b/azext_iot/tests/conftest.py index 8fe4a9784..161a0d421 100644 --- a/azext_iot/tests/conftest.py +++ b/azext_iot/tests/conftest.py @@ -31,6 +31,7 @@ path_iot_device_show = "azext_iot.operations.hub._iot_device_show" path_update_device_twin = "azext_iot.operations.hub._iot_device_twin_update" hub_entity = "myhub.azure-devices.net" +path_device_twin_show_entrypoint = "azext_iot.operations.hub._iot_device_twin_show" instance_name = generate_generic_id() hostname = "{}.subdomain.domain".format(instance_name) @@ -171,6 +172,65 @@ def mqttclient_generic_error(mocker, fixture_ghcs, fixture_sas): def fixture_monitor_events_entrypoint(mocker): return mocker.patch(path_iot_hub_monitor_events_entrypoint) +@pytest.fixture() +def fixture_device_twin_show_entrypoint(mocker): + device_twin_client = mocker.patch(path_device_twin_show_entrypoint) + device_twin_client.return_value = { + "authenticationType": "sas", + "capabilities": { + "iotEdge": True + }, + "cloudToDeviceMessageCount": 0, + "connectionState": "Disconnected", + "deviceEtag": "NTQ4ODMwNjY0", + "deviceId": "_Test_Device", + "deviceScope": "ms-azure-iot-edge://Test_Device-637535090608626001", + "etag": "AAAAAAAAAAU=", + "lastActivityTime": "2021-05-27T04:48:03.681238Z", + "modelId": "", + "properties": { + "desired": { + "$metadata": { + "$lastUpdated": "2021-05-27T04:45:38.5203899Z", + "$lastUpdatedVersion": 5, + "test_prop_1": { + "$lastUpdated": "2021-05-27T04:44:45.9299421Z", + "$lastUpdatedVersion": 4 + }, + "test_prop_2": { + "$lastUpdated": "2021-05-27T04:45:38.5203899Z", + "$lastUpdatedVersion": 5 + } + }, + "$version": 5, + "test_prop_1": "test_val_2", + "test_prop_2": "test_val_4" + }, + "reported": { + "$metadata": { + "$lastUpdated": "2021-05-27T04:45:39.5521362Z", + "test_prop_1": { + "$lastUpdated": "2021-05-27T04:43:33.3650357Z" + }, + "test_prop_2": { + "$lastUpdated": "2021-05-27T04:45:39.5521362Z" + } + }, + "$version": 5, + "test_prop_1": "test_val_2", + "test_prop_2": "test_val_4" + } + }, + "status": "enabled", + "statusUpdateTime": "0001-01-01T00:00:00Z", + "version": 10, + "x509Thumbprint": { + "primaryThumbprint": None, + "secondaryThumbprint": None + } + } + return device_twin_client + @pytest.fixture() def fixture_update_device_twin(mocker): From 1487c1f63a82b20959bc7aee807682e1cd09e952 Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Thu, 27 May 2021 12:11:01 -0700 Subject: [PATCH 39/42] styling updates --- azext_iot/tests/conftest.py | 53 +++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/azext_iot/tests/conftest.py b/azext_iot/tests/conftest.py index 161a0d421..f23312ae3 100644 --- a/azext_iot/tests/conftest.py +++ b/azext_iot/tests/conftest.py @@ -172,6 +172,7 @@ def mqttclient_generic_error(mocker, fixture_ghcs, fixture_sas): def fixture_monitor_events_entrypoint(mocker): return mocker.patch(path_iot_hub_monitor_events_entrypoint) + @pytest.fixture() def fixture_device_twin_show_entrypoint(mocker): device_twin_client = mocker.patch(path_device_twin_show_entrypoint) @@ -190,35 +191,35 @@ def fixture_device_twin_show_entrypoint(mocker): "modelId": "", "properties": { "desired": { - "$metadata": { - "$lastUpdated": "2021-05-27T04:45:38.5203899Z", - "$lastUpdatedVersion": 5, - "test_prop_1": { - "$lastUpdated": "2021-05-27T04:44:45.9299421Z", - "$lastUpdatedVersion": 4 + "$metadata": { + "$lastUpdated": "2021-05-27T04:45:38.5203899Z", + "$lastUpdatedVersion": 5, + "test_prop_1": { + "$lastUpdated": "2021-05-27T04:44:45.9299421Z", + "$lastUpdatedVersion": 4 + }, + "test_prop_2": { + "$lastUpdated": "2021-05-27T04:45:38.5203899Z", + "$lastUpdatedVersion": 5 + } }, - "test_prop_2": { - "$lastUpdated": "2021-05-27T04:45:38.5203899Z", - "$lastUpdatedVersion": 5 - } - }, - "$version": 5, - "test_prop_1": "test_val_2", - "test_prop_2": "test_val_4" + "$version": 5, + "test_prop_1": "test_val_2", + "test_prop_2": "test_val_4" }, "reported": { - "$metadata": { - "$lastUpdated": "2021-05-27T04:45:39.5521362Z", - "test_prop_1": { - "$lastUpdated": "2021-05-27T04:43:33.3650357Z" + "$metadata": { + "$lastUpdated": "2021-05-27T04:45:39.5521362Z", + "test_prop_1": { + "$lastUpdated": "2021-05-27T04:43:33.3650357Z" + }, + "test_prop_2": { + "$lastUpdated": "2021-05-27T04:45:39.5521362Z" + } }, - "test_prop_2": { - "$lastUpdated": "2021-05-27T04:45:39.5521362Z" - } - }, - "$version": 5, - "test_prop_1": "test_val_2", - "test_prop_2": "test_val_4" + "$version": 5, + "test_prop_1": "test_val_2", + "test_prop_2": "test_val_4" } }, "status": "enabled", @@ -230,7 +231,7 @@ def fixture_device_twin_show_entrypoint(mocker): } } return device_twin_client - + @pytest.fixture() def fixture_update_device_twin(mocker): From 782e34bf8ddbba3ddb660f8b5c4c69a1c4fd7b08 Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Tue, 1 Jun 2021 11:06:48 -0700 Subject: [PATCH 40/42] Using SDK Listener for Twin properties update --- azext_iot/operations/_mqtt.py | 15 --------------- azext_iot/tests/conftest.py | 1 - 2 files changed, 16 deletions(-) diff --git a/azext_iot/operations/_mqtt.py b/azext_iot/operations/_mqtt.py index 3e85a4bd0..6b60ccd96 100644 --- a/azext_iot/operations/_mqtt.py +++ b/azext_iot/operations/_mqtt.py @@ -8,7 +8,6 @@ from time import sleep from azext_iot.constants import USER_AGENT, BASE_MQTT_API_VERSION from azext_iot.common.utility import url_encode_str -from azext_iot.operations.hub import _iot_device_twin_show from azure.iot.device import IoTHubDeviceClient as mqtt_device_client, Message, MethodResponse import pprint @@ -124,20 +123,6 @@ def execute(self, data, properties={}, publish_delay=2, msg_count=100): _iot_device_twin_update(self.target, self.device_id, twin_properties) for _ in tqdm(range(msg_count), desc='Device simulation in progress'): - device_twin = _iot_device_twin_show(self.target, self.device_id) - if device_twin: - desired_properties = device_twin.get("properties").get("desired") - reported_properties = device_twin.get("properties").get("reported") - twin_properties_to_update = {} - - for prop in desired_properties: - if not prop.startswith("$"): - if prop not in reported_properties or desired_properties[prop] != reported_properties[prop]: - twin_properties_to_update[prop] = desired_properties[prop] - - if twin_properties_to_update: - self.device_client.patch_twin_reported_properties(twin_properties_to_update) - self.send_d2c_message(message_text=data.generate(True), properties=properties) sleep(publish_delay) diff --git a/azext_iot/tests/conftest.py b/azext_iot/tests/conftest.py index f23312ae3..491ece892 100644 --- a/azext_iot/tests/conftest.py +++ b/azext_iot/tests/conftest.py @@ -31,7 +31,6 @@ path_iot_device_show = "azext_iot.operations.hub._iot_device_show" path_update_device_twin = "azext_iot.operations.hub._iot_device_twin_update" hub_entity = "myhub.azure-devices.net" -path_device_twin_show_entrypoint = "azext_iot.operations.hub._iot_device_twin_show" instance_name = generate_generic_id() hostname = "{}.subdomain.domain".format(instance_name) From a588704ea0c596a66b975d50b6ae40c22f3d6312 Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Fri, 4 Jun 2021 17:56:46 -0700 Subject: [PATCH 41/42] Support C2D Message decoding and Add TQDM for HTTP simulation --- .../tests/iothub/test_iot_messaging_int.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/azext_iot/tests/iothub/test_iot_messaging_int.py b/azext_iot/tests/iothub/test_iot_messaging_int.py index 3d33bb577..660830006 100644 --- a/azext_iot/tests/iothub/test_iot_messaging_int.py +++ b/azext_iot/tests/iothub/test_iot_messaging_int.py @@ -106,6 +106,36 @@ def test_uamqp_device_messaging(self): checks=self.is_empty(), ) + utf_32_encoding = "utf-32" + string_payload = "Test payload encoding decoding" + + # Send C2D Message with UTF-32 encoding + self.cmd( + """iot device c2d-message send -d {} -n {} -g {} --data '{}' --cid {} --mid {} --ct {} --expiry {} + --ce {} --props {}""".format( + device_ids[0], + LIVE_HUB, + LIVE_RG, + string_payload, + test_cid, + test_mid, + test_ct, + test_et, + utf_32_encoding, + test_props, + ), + checks=self.is_empty(), + ) + + result = self.cmd( + "iot device c2d-message receive -d {} --hub-name {} -g {}".format( + device_ids[0], LIVE_HUB, LIVE_RG + ) + ).get_output_in_json() + + # Verify that the data was decoded correctly + assert result["data"] == string_payload + # Send C2D message via --login + application/json content ype test_ct = "application/json" From a15c8a0b2734e0f44e91c034bc76560956bdcb0d Mon Sep 17 00:00:00 2001 From: Avin Agrawal Date: Tue, 22 Jun 2021 18:45:25 -0700 Subject: [PATCH 42/42] Merging changes --- azext_iot/tests/conftest.py | 60 ------------------- .../tests/iothub/test_iot_messaging_int.py | 30 ---------- 2 files changed, 90 deletions(-) diff --git a/azext_iot/tests/conftest.py b/azext_iot/tests/conftest.py index 491ece892..8fe4a9784 100644 --- a/azext_iot/tests/conftest.py +++ b/azext_iot/tests/conftest.py @@ -172,66 +172,6 @@ def fixture_monitor_events_entrypoint(mocker): return mocker.patch(path_iot_hub_monitor_events_entrypoint) -@pytest.fixture() -def fixture_device_twin_show_entrypoint(mocker): - device_twin_client = mocker.patch(path_device_twin_show_entrypoint) - device_twin_client.return_value = { - "authenticationType": "sas", - "capabilities": { - "iotEdge": True - }, - "cloudToDeviceMessageCount": 0, - "connectionState": "Disconnected", - "deviceEtag": "NTQ4ODMwNjY0", - "deviceId": "_Test_Device", - "deviceScope": "ms-azure-iot-edge://Test_Device-637535090608626001", - "etag": "AAAAAAAAAAU=", - "lastActivityTime": "2021-05-27T04:48:03.681238Z", - "modelId": "", - "properties": { - "desired": { - "$metadata": { - "$lastUpdated": "2021-05-27T04:45:38.5203899Z", - "$lastUpdatedVersion": 5, - "test_prop_1": { - "$lastUpdated": "2021-05-27T04:44:45.9299421Z", - "$lastUpdatedVersion": 4 - }, - "test_prop_2": { - "$lastUpdated": "2021-05-27T04:45:38.5203899Z", - "$lastUpdatedVersion": 5 - } - }, - "$version": 5, - "test_prop_1": "test_val_2", - "test_prop_2": "test_val_4" - }, - "reported": { - "$metadata": { - "$lastUpdated": "2021-05-27T04:45:39.5521362Z", - "test_prop_1": { - "$lastUpdated": "2021-05-27T04:43:33.3650357Z" - }, - "test_prop_2": { - "$lastUpdated": "2021-05-27T04:45:39.5521362Z" - } - }, - "$version": 5, - "test_prop_1": "test_val_2", - "test_prop_2": "test_val_4" - } - }, - "status": "enabled", - "statusUpdateTime": "0001-01-01T00:00:00Z", - "version": 10, - "x509Thumbprint": { - "primaryThumbprint": None, - "secondaryThumbprint": None - } - } - return device_twin_client - - @pytest.fixture() def fixture_update_device_twin(mocker): return mocker.patch(path_update_device_twin) diff --git a/azext_iot/tests/iothub/test_iot_messaging_int.py b/azext_iot/tests/iothub/test_iot_messaging_int.py index 660830006..3d33bb577 100644 --- a/azext_iot/tests/iothub/test_iot_messaging_int.py +++ b/azext_iot/tests/iothub/test_iot_messaging_int.py @@ -106,36 +106,6 @@ def test_uamqp_device_messaging(self): checks=self.is_empty(), ) - utf_32_encoding = "utf-32" - string_payload = "Test payload encoding decoding" - - # Send C2D Message with UTF-32 encoding - self.cmd( - """iot device c2d-message send -d {} -n {} -g {} --data '{}' --cid {} --mid {} --ct {} --expiry {} - --ce {} --props {}""".format( - device_ids[0], - LIVE_HUB, - LIVE_RG, - string_payload, - test_cid, - test_mid, - test_ct, - test_et, - utf_32_encoding, - test_props, - ), - checks=self.is_empty(), - ) - - result = self.cmd( - "iot device c2d-message receive -d {} --hub-name {} -g {}".format( - device_ids[0], LIVE_HUB, LIVE_RG - ) - ).get_output_in_json() - - # Verify that the data was decoded correctly - assert result["data"] == string_payload - # Send C2D message via --login + application/json content ype test_ct = "application/json"