diff --git a/.azure-devops/create-release.yml b/.azure-devops/create-release.yml index 281aa5f11..5ad9a05c0 100644 --- a/.azure-devops/create-release.yml +++ b/.azure-devops/create-release.yml @@ -32,7 +32,7 @@ stages: - job: 'Build_Publish_Azure_IoT_CLI_Extension' pool: - vmImage: 'ubuntu-latest' + vmImage: 'Ubuntu-16.04' steps: - task: UsePythonVersion@0 @@ -46,7 +46,7 @@ stages: - job: 'Build_Publish_Azure_CLI_Test_SDK' pool: - vmImage: 'ubuntu-latest' + vmImage: 'Ubuntu-16.04' steps: - task: UsePythonVersion@0 @@ -72,7 +72,7 @@ stages: - stage: 'test' displayName: 'Run tests' pool: - vmImage: 'ubuntu-latest' + vmImage: 'Ubuntu-16.04' dependsOn: build jobs: - job: 'testCentral' @@ -171,7 +171,7 @@ stages: - task: GitHubRelease@0 inputs: - gitHubConnection: AzIoTCLIGitHub + gitHubConnection: $(GithubReleaseConnection) repositoryName: $(Build.Repository.Name) action: 'create' target: '$(Build.SourceVersion)' diff --git a/.azure-devops/merge.yml b/.azure-devops/merge.yml index e4585386e..f54e0b6e8 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-latest' + vmImage: 'Ubuntu-16.04' steps: - task: UsePythonVersion@0 @@ -31,7 +31,7 @@ jobs: - job: 'build_and_publish_azure_cli_test_sdk' pool: - vmImage: 'ubuntu-latest' + vmImage: 'Ubuntu-16.04' 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-latest' + vmImage: 'Ubuntu-16.04' strategy: matrix: Python36: @@ -99,7 +99,7 @@ jobs: - job: 'run_style_check' dependsOn: ['build_and_publish_azure_iot_cli_ext', 'build_and_publish_azure_cli_test_sdk'] pool: - vmImage: 'ubuntu-latest' + vmImage: 'Ubuntu-16.04' 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-latest' + vmImage: 'Ubuntu-16.04' steps: - task: UsePythonVersion@0 diff --git a/.azure-devops/nightly.yml b/.azure-devops/nightly.yml index 34ce2b14d..c58fe3050 100644 --- a/.azure-devops/nightly.yml +++ b/.azure-devops/nightly.yml @@ -17,7 +17,7 @@ stages: - job: 'Build_Publish_Azure_IoT_CLI_Extension' pool: - vmImage: 'ubuntu-latest' + vmImage: 'Ubuntu-16.04' steps: - task: UsePythonVersion@0 @@ -31,7 +31,7 @@ stages: - job: 'Build_Publish_Azure_CLI_Test_SDK' pool: - vmImage: 'ubuntu-latest' + vmImage: 'Ubuntu-16.04' steps: - task: UsePythonVersion@0 @@ -57,7 +57,7 @@ stages: - stage: 'test' displayName: 'Run all tests' pool: - vmImage: 'ubuntu-latest' + vmImage: 'Ubuntu-16.04' dependsOn: build jobs: - job: 'azEdge' diff --git a/.pylintrc b/.pylintrc index 62bb79bf5..1ca22732e 100644 --- a/.pylintrc +++ b/.pylintrc @@ -281,7 +281,8 @@ disable=import-outside-toplevel, comprehension-escape, not-callable, raise-missing-from, - super-with-arguments + super-with-arguments, + consider-using-dict-items, # Enable the message, report, category or checker with the given id(s). You can # either give multiple identifier separated by comma (,) or put this option diff --git a/HISTORY.rst b/HISTORY.rst index 1f827555e..6a1cb17df 100644 --- a/HISTORY.rst +++ b/HISTORY.rst @@ -6,17 +6,30 @@ Release History 0.10.14 +++++++++++++++ +**IoT Central updates** + +* Adds support to run root/interface level device commands. +* Adds support to get command history for root/interface level device commands. +* The --interface-id parameter for commands "device command run" , "device command history" changed to optional. + **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. +* Addition for "az iot hub generate-sas-token" - the command will allow offline generation of a SAS Token using a connection string. + +* Changes to Edge validation for set-modules and edge deployment creation: + + By default only properties of system modules $edgeAgent and $edgeHub are validated against schemas installed with the IoT extension. + This can be disabled by using the --no-validation switch. + **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 +++++++++++++++ @@ -34,7 +47,7 @@ Release History * 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. + * 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** @@ -102,7 +115,7 @@ For more information about IoT Hub support for AAD visit: https://docs.microsoft * Addition of the following commands - * az iot central device manual-failover - Execute a manual failover of device across multiple IoT Hubs + * az iot central device manual-failover - Execute a manual failover of device across multiple IoT Hubs * az iot central device manual-failback - Reverts the previously executed failover command by moving the device back to it's original IoT Hub For more information about device high availability visit https://github.com/iot-for-all/iot-central-high-availability-clients#readme @@ -138,7 +151,7 @@ For more information about device high availability visit https://github.com/iot **IoT Hub updates** * Improve http debug logging. -* Fix bug related to issue #296. Adds a clause to device-identity update that allows user to update primary-key / secondary-key +* Fix bug related to issue #296. Adds a clause to device-identity update that allows user to update primary-key / secondary-key and primary-thumbprint / secondary-thumbprint values (respectively, per auth method) without needing to specify the auth_method in the update command. diff --git a/azext_iot/_help.py b/azext_iot/_help.py index a8e03249f..ed14049eb 100644 --- a/azext_iot/_help.py +++ b/azext_iot/_help.py @@ -577,6 +577,18 @@ text: > az iot hub generate-sas-token -d {device_id} --login 'HostName=myhub.azure-devices.net;SharedAccessKeyName=iothubowner;SharedAccessKey=12345' + - name: Generate an Iot Hub SAS token using an IoT Hub connection string + text: > + az iot hub generate-sas-token + --connection-string 'HostName=myhub.azure-devices.net;SharedAccessKeyName=iothubowner;SharedAccessKey=12345' + - name: Generate a Device SAS token using a Device connection string + text: > + az iot hub generate-sas-token --connection-string + 'HostName=myhub.azure-devices.net;DeviceId=mydevice;SharedAccessKeyName=iothubowner;SharedAccessKey=12345' + - name: Generate a Module SAS token using a Module connection string + text: > + az iot hub generate-sas-token --connection-string + 'HostName=myhub.azure-devices.net;DeviceId=mydevice;ModuleId=mymodule;SharedAccessKeyName=iothubowner;SharedAccessKey=12345' """ helps[ @@ -928,6 +940,9 @@ long-summary: | Modules content is json and in the form of {"modulesContent":{...}} or {"content":{"modulesContent":{...}}}. + By default properties of system modules $edgeAgent and $edgeHub are validated against schemas installed with the IoT extension. + This can be disabled by using the --no-validation switch. + Note: Upon execution the command will output the collection of modules applied to the device. examples: - name: Test edge modules while in development by setting modules on a target device. @@ -950,6 +965,9 @@ long-summary: | Deployment content is json and in the form of {"modulesContent":{...}} or {"content":{"modulesContent":{...}}}. + By default properties of system modules $edgeAgent and $edgeHub are validated against schemas installed with the IoT extension. + This can be disabled by using the --no-validation switch. + Edge deployments can be created with user defined metrics for on demand evaluation. User metrics are json and in the form of {"queries":{...}} or {"metrics":{"queries":{...}}}. examples: diff --git a/azext_iot/_params.py b/azext_iot/_params.py index 96e7f0788..5a0da9992 100644 --- a/azext_iot/_params.py +++ b/azext_iot/_params.py @@ -220,6 +220,13 @@ def load_arguments(self, _): arg_type=get_three_state_flag(), help="Flag indicating edge enablement.", ) + context.argument( + "connection_string", + options_list=["--connection-string", "--cs"], + help="Target connection string. This bypasses the IoT Hub registry and generates the SAS token directly" + " from the supplied symmetric key without further validation. All other command parameters aside from" + " duration will be ignored. Supported connection string types: Iot Hub, Device, Module." + ) with self.argument_context("iot hub") as context: context.argument( diff --git a/azext_iot/_validators.py b/azext_iot/_validators.py index 822726a3c..05b2ff2ab 100644 --- a/azext_iot/_validators.py +++ b/azext_iot/_validators.py @@ -16,6 +16,7 @@ def mode2_iot_login_handler(cmd, namespace): login_value = args['login'] iot_cmd_type = None entity_value = None + offline = None if 'hub_name' in args: iot_cmd_type = 'IoT Hub' @@ -24,5 +25,8 @@ def mode2_iot_login_handler(cmd, namespace): iot_cmd_type = 'DPS' entity_value = args['dps_name'] - if not any([login_value, entity_value]): + if 'connection_string' in args: + offline = args['connection_string'] + + if not any([login_value, entity_value, offline]): raise CLIError(error_no_hub_or_login_on_input(iot_cmd_type)) diff --git a/azext_iot/assets/azure-iot-edgeagent-deployment-1.0.json b/azext_iot/assets/azure-iot-edgeagent-deployment-1.0.json new file mode 100644 index 000000000..3df1f0d39 --- /dev/null +++ b/azext_iot/assets/azure-iot-edgeagent-deployment-1.0.json @@ -0,0 +1,239 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "JSON schema for Azure IoT EdgeAgent Deployment version 1.0", + "required": [ + "$edgeAgent" + ], + "properties": { + "$edgeAgent": { + "type": "object", + "title": "Configuration for the edgeAgent module", + "required": [ + "properties.desired" + ], + "properties": { + "properties.desired": { + "type": "object", + "required": [ + "schemaVersion", + "runtime", + "systemModules", + "modules" + ], + "properties": { + "schemaVersion": { + "type": "string", + "pattern": "1.0" + }, + "runtime": { + "type": "object", + "required": [ + "type", + "settings" + ], + "properties": { + "type": { + "$ref": "#/definitions/moduleType" + }, + "settings": { + "type": "object", + "properties": { + "minDockerVersion": { + "type": "string", + "examples": [ + "v1.25" + ] + }, + "loggingOptions": { + "type": "string" + }, + "registryCredentials": { + "type": "object", + "patternProperties": { + "^.+$": { + "type": "object", + "required": [ + "username", + "password", + "address" + ], + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "address": { + "type": "string", + "pattern": "^[^\\s]+$" + } + } + } + }, + "additionalProperties": false + } + } + } + } + }, + "systemModules": { + "type": "object", + "required": [ + "edgeAgent", + "edgeHub" + ], + "properties": { + "edgeAgent": { + "type": "object", + "required": [ + "type", + "settings" + ], + "properties": { + "type": { + "$ref": "#/definitions/moduleType" + }, + "settings": { + "$ref": "#/definitions/moduleSettings" + }, + "env": { + "$ref": "#/definitions/env" + } + } + }, + "edgeHub": { + "type": "object", + "title": "The Edgehub Schema", + "required": [ + "type", + "settings", + "status", + "restartPolicy" + ], + "properties": { + "type": { + "$ref": "#/definitions/moduleType" + }, + "settings": { + "$ref": "#/definitions/moduleSettings" + }, + "env": { + "$ref": "#/definitions/env" + }, + "status": { + "$ref": "#/definitions/status" + }, + "restartPolicy": { + "$ref": "#/definitions/restartPolicy" + } + } + } + }, + "additionalProperties": false + }, + "modules": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "type": "object", + "required": [ + "type", + "status", + "restartPolicy", + "settings" + ], + "properties": { + "version": { + "type": "string", + "examples": [ + "1.0" + ] + }, + "type": { + "$ref": "#/definitions/moduleType" + }, + "status": { + "$ref": "#/definitions/status" + }, + "restartPolicy": { + "$ref": "#/definitions/restartPolicy" + }, + "env": { + "$ref": "#/definitions/env" + }, + "settings": { + "$ref": "#/definitions/moduleSettings" + } + } + } + }, + "additionalProperties": false + } + } + } + } + } + }, + "additionalProperties": false, + "definitions": { + "moduleType": { + "enum": [ + "docker" + ] + }, + "status": { + "enum": [ + "running", + "stopped" + ] + }, + "restartPolicy": { + "enum": [ + "never", + "on-failure", + "on-unhealthy", + "always" + ] + }, + "moduleSettings": { + "type": "object", + "required": [ + "image" + ], + "properties": { + "image": { + "type": "string", + "examples": [ + "mcr.microsoft.com/azureiotedge-agent:1.0" + ] + }, + "createOptions": { + "$ref": "#/definitions/createOptions" + } + } + }, + "env": { + "type": "object", + "patternProperties": { + "^[^\\+#$\\s\\.]+$": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": ["number", "string", "boolean"] + } + } + } + }, + "additionalProperties": false + }, + "createOptions": { + "type": "string", + "contentMediaType": "application/json" + } + } +} \ No newline at end of file diff --git a/azext_iot/assets/azure-iot-edgeagent-deployment-1.1.json b/azext_iot/assets/azure-iot-edgeagent-deployment-1.1.json new file mode 100644 index 000000000..1a97c6994 --- /dev/null +++ b/azext_iot/assets/azure-iot-edgeagent-deployment-1.1.json @@ -0,0 +1,281 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "title": "JSON schema for Azure IoT EdgeAgent Deployment version 1.1", + "required": [ + "$edgeAgent" + ], + "properties": { + "$edgeAgent": { + "type": "object", + "title": "Configuration for the edgeAgent module", + "required": [ + "properties.desired" + ], + "properties": { + "properties.desired": { + "type": "object", + "required": [ + "schemaVersion", + "runtime", + "systemModules", + "modules" + ], + "properties": { + "schemaVersion": { + "type": "string", + "pattern": "1.1" + }, + "runtime": { + "type": "object", + "required": [ + "type", + "settings" + ], + "properties": { + "type": { + "$ref": "#/definitions/moduleType" + }, + "settings": { + "type": "object", + "properties": { + "minDockerVersion": { + "type": "string", + "examples": [ + "v1.25" + ] + }, + "loggingOptions": { + "type": "string" + }, + "registryCredentials": { + "type": "object", + "patternProperties": { + "^[^\\.\\$# ]+$": { + "type": "object", + "required": [ + "username", + "password", + "address" + ], + "properties": { + "username": { + "type": "string" + }, + "password": { + "type": "string" + }, + "address": { + "type": "string", + "pattern": "^[^\\s]+$" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "systemModules": { + "type": "object", + "required": [ + "edgeAgent", + "edgeHub" + ], + "properties": { + "edgeAgent": { + "type": "object", + "required": [ + "type", + "settings" + ], + "properties": { + "type": { + "$ref": "#/definitions/moduleType" + }, + "settings": { + "$ref": "#/definitions/moduleSettings" + }, + "env": { + "$ref": "#/definitions/env" + }, + "imagePullPolicy": { + "$ref": "#/definitions/imagePullPolicy" + } + }, + "additionalProperties": false + }, + "edgeHub": { + "type": "object", + "title": "The Edgehub Schema", + "required": [ + "type", + "settings", + "status", + "restartPolicy" + ], + "properties": { + "type": { + "$ref": "#/definitions/moduleType" + }, + "settings": { + "$ref": "#/definitions/moduleSettings" + }, + "env": { + "$ref": "#/definitions/env" + }, + "status": { + "$ref": "#/definitions/status" + }, + "restartPolicy": { + "$ref": "#/definitions/restartPolicy" + }, + "imagePullPolicy": { + "$ref": "#/definitions/imagePullPolicy" + }, + "startupOrder": { + "$ref": "#/definitions/startupOrder" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "modules": { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "type": "object", + "required": [ + "type", + "status", + "restartPolicy", + "settings" + ], + "properties": { + "version": { + "type": "string", + "examples": [ + "1.0" + ] + }, + "type": { + "$ref": "#/definitions/moduleType" + }, + "status": { + "$ref": "#/definitions/status" + }, + "restartPolicy": { + "$ref": "#/definitions/restartPolicy" + }, + "env": { + "$ref": "#/definitions/env" + }, + "settings": { + "$ref": "#/definitions/moduleSettings" + }, + "imagePullPolicy": { + "$ref": "#/definitions/imagePullPolicy" + }, + "startupOrder": { + "$ref": "#/definitions/startupOrder" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "definitions": { + "moduleType": { + "enum": [ + "docker" + ] + }, + "status": { + "enum": [ + "running", + "stopped" + ] + }, + "restartPolicy": { + "enum": [ + "never", + "on-failure", + "on-unhealthy", + "always" + ] + }, + "imagePullPolicy": { + "enum": [ + "never", + "on-create" + ] + }, + "startupOrder": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + }, + "moduleSettings": { + "type": "object", + "required": [ + "image" + ], + "properties": { + "image": { + "type": "string", + "examples": [ + "mcr.microsoft.com/azureiotedge-agent:1.0" + ] + } + }, + "patternProperties": { + "^(createoptions|createOptions)[0-9]*$": { + "$ref": "#/definitions/createOptions" + } + }, + "additionalProperties": false + }, + "env": { + "type": "object", + "patternProperties": { + "^[^\\+#$\\s\\.]+$": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": [ + "number", + "string", + "boolean" + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "createOptions": { + "type": "string", + "contentMediaType": "application/json" + } + } +} \ No newline at end of file diff --git a/azext_iot/assets/azure-iot-edgehub-deployment-1.0.json b/azext_iot/assets/azure-iot-edgehub-deployment-1.0.json new file mode 100644 index 000000000..a33cc29c2 --- /dev/null +++ b/azext_iot/assets/azure-iot-edgehub-deployment-1.0.json @@ -0,0 +1,59 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "title": "JSON schema for Azure IoT EdgeHub Deployment version 1.0", + "required": [ + "$edgeHub" + ], + "properties": { + "$edgeHub": { + "type": "object", + "title": "Configuration for the edgeHub module", + "required": [ + "properties.desired" + ], + "properties": { + "properties.desired": { + "type": "object", + "required": [ + "schemaVersion", + "routes" + ], + "properties": { + "schemaVersion": { + "type": "string", + "pattern": "1.0" + }, + "routes": { + "type": "object", + "patternProperties": { + "^.+$": { + "type": "string", + "examples": [ + "FROM /* INTO $upstream" + ], + "pattern": "^.+$" + } + } + }, + "storeAndForwardConfiguration": { + "type": "object", + "required": [ + "timeToLiveSecs" + ], + "properties": { + "timeToLiveSecs": { + "type": "integer", + "examples": [ + 7200 + ] + } + } + } + } + } + } + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/azext_iot/assets/azure-iot-edgehub-deployment-1.1.json b/azext_iot/assets/azure-iot-edgehub-deployment-1.1.json new file mode 100644 index 000000000..566357ce1 --- /dev/null +++ b/azext_iot/assets/azure-iot-edgehub-deployment-1.1.json @@ -0,0 +1,91 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "title": "JSON schema for Azure IoT EdgeHub Deployment version 1.1", + "required": [ + "$edgeHub" + ], + "properties": { + "$edgeHub": { + "type": "object", + "title": "Configuration for the edgeHub module", + "required": [ + "properties.desired" + ], + "properties": { + "properties.desired": { + "type": "object", + "required": [ + "schemaVersion", + "routes" + ], + "properties": { + "schemaVersion": { + "type": "string", + "pattern": "1.1" + }, + "routes": { + "type": "object", + "patternProperties": { + "^[^\\.\\$# ]+$": { + "anyOf": [{ + "type": "object", + "required": [ + "route" + ], + "properties": { + "route": { + "type": "string", + "examples": [ + "FROM /* INTO $upstream" + ], + "pattern": "^.+$" + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 9 + }, + "timeToLiveSecs": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + }, + "additionalProperties": false + }, { + "type": "string", + "examples": [ + "FROM /* INTO $upstream" + ], + "pattern": "^.+$" + } + ] + } + }, + "additionalProperties": false + }, + "storeAndForwardConfiguration": { + "type": "object", + "required": [ + "timeToLiveSecs" + ], + "properties": { + "timeToLiveSecs": { + "type": "integer", + "examples": [ + 7200 + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} \ No newline at end of file diff --git a/azext_iot/assets/azure-iot-edgehub-deployment-1.2.json b/azext_iot/assets/azure-iot-edgehub-deployment-1.2.json new file mode 100644 index 000000000..9380f7c0c --- /dev/null +++ b/azext_iot/assets/azure-iot-edgehub-deployment-1.2.json @@ -0,0 +1,191 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "type": "object", + "title": "JSON schema for Azure IoT EdgeHub Deployment version 1.2", + "required": [ + "$edgeHub" + ], + "properties": { + "$edgeHub": { + "type": "object", + "title": "Configuration for the edgeHub module", + "required": [ + "properties.desired" + ], + "properties": { + "properties.desired": { + "type": "object", + "required": [ + "schemaVersion", + "routes" + ], + "properties": { + "schemaVersion": { + "type": "string", + "pattern": "1.2" + }, + "routes": { + "type": "object", + "patternProperties": { + "^[^\\.\\$# ]+$": { + "anyOf": [{ + "type": "object", + "required": [ + "route" + ], + "properties": { + "route": { + "type": "string", + "examples": [ + "FROM /* INTO $upstream" + ], + "pattern": "^.+$" + }, + "priority": { + "type": "integer", + "minimum": 0, + "maximum": 9 + }, + "timeToLiveSecs": { + "type": "integer", + "minimum": 0, + "maximum": 4294967295 + } + }, + "additionalProperties": false + }, { + "type": "string", + "examples": [ + "FROM /* INTO $upstream" + ], + "pattern": "^.+$" + } + ] + } + }, + "additionalProperties": false + }, + "storeAndForwardConfiguration": { + "type": "object", + "required": [ + "timeToLiveSecs" + ], + "properties": { + "timeToLiveSecs": { + "type": "integer", + "examples": [ + 7200 + ] + } + }, + "additionalProperties": false + }, + "mqttBroker": { + "type": "object", + "properties": { + "bridges": { + "type": "array", + "items": { + "type": "object", + "properties": { + "endpoint": { + "type": "string", + "pattern": "\\$upstream" + }, + "settings": { + "type": "array", + "items": { + "type": "object", + "properties": { + "direction": { + "type": "string", + "pattern": "^in$|^out$" + }, + "topic": { + "type": "string", + "pattern": "^.*$" + }, + "inPrefix": { + "type": "string", + "pattern": "^.*$" + }, + "outPrefix": { + "type": "string", + "pattern": "^.*$" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "authorizations": { + "type": "array", + "items": { + "type": "object", + "properties": { + "identities": { + "type": "array", + "items": { + "type": "string", + "pattern": "^.+$", + "examples": [ + "{{iot:identity}}", + "contoso.azure-devices.net/MyLeafDevice1", + "contoso.azure-devices.net/MyEdgeDevice/MyModule" + ] + } + }, + "allow": { + "$ref": "#/definitions/policy" + }, + "deny": { + "$ref": "#/definitions/policy" + } + }, + "additionalProperties": false + } + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false, + "definitions": { + "policy": { + "type": "array", + "items": { + "type": "object", + "properties": { + "operations": { + "type": "array", + "items": { + "type": "string", + "pattern": "^mqtt:connect$|^mqtt:subscribe$|^mqtt:publish$" + } + }, + "resources": { + "type": "array", + "items": { + "type": "string", + "pattern": "^.+$", + "examples": [ + "$iothub/clients/+/twin/res/#", + "events/alerts" + ] + } + } + }, + "additionalProperties": false + } + } + } +} \ No newline at end of file diff --git a/azext_iot/assets/edge-deploy-2.0.schema.json b/azext_iot/assets/edge-deploy-2.0.schema.json deleted file mode 100644 index fb8794bfc..000000000 --- a/azext_iot/assets/edge-deploy-2.0.schema.json +++ /dev/null @@ -1,433 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-04/schema#", - "type": "object", - "title": "JSON schema for Azure IoT Edge Deployment version 2.0", - "required": [ - "modulesContent" - ], - "properties": { - "modulesContent": { - "type": "object", - "title": "The configuration for all the modules.", - "required": [ - "$edgeAgent", - "$edgeHub" - ], - "properties": { - "$edgeAgent": { - "type": "object", - "title": "Configuration for the edgeAgent module", - "required": [ - "properties.desired" - ], - "properties": { - "properties.desired": { - "type": "object", - "required": [ - "schemaVersion", - "runtime", - "systemModules", - "modules" - ], - "properties": { - "schemaVersion": { - "type": "string", - "examples": [ - "1.0", - "1.1" - ] - }, - "runtime": { - "type": "object", - "required": [ - "type", - "settings" - ], - "properties": { - "type": { - "$ref": "#/definitions/moduleType" - }, - "settings": { - "type": "object", - "properties": { - "minDockerVersion": { - "type": "string", - "examples": [ - "v1.25" - ] - }, - "loggingOptions": { - "type": "string" - }, - "registryCredentials": { - "type": "object", - "patternProperties": { - "^[^\\.\\$# ]+$": { - "type": "object", - "required": [ - "username", - "password", - "address" - ], - "properties": { - "username": { - "type": "string" - }, - "password": { - "type": "string" - }, - "address": { - "type": "string", - "pattern": "^[^\\s]+$" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - "patternProperties": { - "^[^\\.\\$# ]+$":{ - "type": ["array", "boolean", "integer", "null", "number", "object", "string"] - } - }, - "additionalProperties": false - } - }, - "patternProperties": { - "^[^\\.\\$# ]+$":{ - "type": ["array", "boolean", "integer", "null", "number", "object", "string"] - } - }, - "additionalProperties": false - }, - "systemModules": { - "type": "object", - "required": [ - "edgeAgent", - "edgeHub" - ], - "properties": { - "edgeAgent": { - "type": "object", - "required": [ - "type", - "settings" - ], - "properties": { - "type": { - "$ref": "#/definitions/moduleType" - }, - "settings": { - "$ref": "#/definitions/moduleSettings" - }, - "env": { - "$ref": "#/definitions/env" - }, - "imagePullPolicy": { - "$ref": "#/definitions/imagePullPolicy" - } - }, - "patternProperties": { - "^[^\\.\\$# ]+$":{ - "type": ["array", "boolean", "integer", "null", "number", "object", "string"] - } - }, - "additionalProperties": false - }, - "edgeHub": { - "type": "object", - "title": "The Edgehub Schema", - "required": [ - "type", - "settings", - "status", - "restartPolicy" - ], - "properties": { - "type": { - "$ref": "#/definitions/moduleType" - }, - "settings": { - "$ref": "#/definitions/moduleSettings" - }, - "env": { - "$ref": "#/definitions/env" - }, - "status": { - "$ref": "#/definitions/status" - }, - "restartPolicy": { - "$ref": "#/definitions/restartPolicy" - }, - "imagePullPolicy": { - "$ref": "#/definitions/imagePullPolicy" - }, - "startupOrder": { - "$ref": "#/definitions/startupOrder" - } - }, - "patternProperties": { - "^[^\\.\\$# ]+$":{ - "type": ["array", "boolean", "integer", "null", "number", "object", "string"] - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "modules": { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9_-]+$": { - "type": "object", - "required": [ - "type", - "status", - "restartPolicy", - "settings" - ], - "properties": { - "version": { - "type": "string", - "examples": [ - "1.0" - ] - }, - "type": { - "$ref": "#/definitions/moduleType" - }, - "status": { - "$ref": "#/definitions/status" - }, - "restartPolicy": { - "$ref": "#/definitions/restartPolicy" - }, - "env": { - "$ref": "#/definitions/env" - }, - "settings": { - "$ref": "#/definitions/moduleSettings" - }, - "imagePullPolicy": { - "$ref": "#/definitions/imagePullPolicy" - }, - "startupOrder": { - "$ref": "#/definitions/startupOrder" - } - }, - "patternProperties": { - "^[^\\.\\$# ]+$":{ - "type": ["array", "boolean", "integer", "null", "number", "object", "string"] - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - "patternProperties": { - "^[^\\.\\$# ]+$":{ - "type": ["array", "boolean", "integer", "null", "number", "object", "string"] - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "$edgeHub": { - "type": "object", - "title": "Configuration for the edgeHub module", - "required": [ - "properties.desired" - ], - "properties": { - "properties.desired": { - "type": "object", - "required": [ - "schemaVersion", - "routes" - ], - "properties": { - "schemaVersion": { - "type": "string", - "examples": [ - "1.0", - "1.1" - ] - }, - "routes": { - "type": "object", - "patternProperties": { - "^[^\\.\\$# ]+$": { - "anyOf": [ - { - "type": "object", - "required": [ - "route" - ], - "properties": { - "route": { - "type": "string", - "examples": [ - "FROM /* INTO $upstream" - ], - "pattern": "^.+$" - }, - "priority": { - "type": "integer", - "minimum": 0, - "maximum": 9 - }, - "timeToLiveSecs": { - "type": "integer", - "minimum": 0, - "maximum": 4294967295 - } - }, - "additionalProperties": false - }, - { - "type": "string", - "examples": [ - "FROM /* INTO $upstream" - ], - "pattern": "^.+$" - } - ] - } - }, - "additionalProperties": false - }, - "storeAndForwardConfiguration": { - "type": "object", - "required": [ - "timeToLiveSecs" - ], - "properties": { - "timeToLiveSecs": { - "type": "integer", - "examples": [ - 7200 - ] - } - }, - "patternProperties": { - "^[^\\.\\$# ]+$":{ - "type": ["array", "boolean", "integer", "null", "number", "object", "string"] - } - }, - "additionalProperties": false - } - }, - "patternProperties": { - "^[^\\.\\$# ]+$":{ - "type": ["array", "boolean", "integer", "null", "number", "object", "string"] - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - "patternProperties": { - "^[a-zA-Z0-9_-]+$": { - "type": "object", - "required": [ - "properties.desired" - ], - "properties": { - "properties.desired": { - "type": "object" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false, - "definitions": { - "moduleType": { - "enum": [ - "docker" - ] - }, - "status": { - "enum": [ - "running", - "stopped" - ] - }, - "restartPolicy": { - "enum": [ - "never", - "on-failure", - "on-unhealthy", - "always" - ] - }, - "imagePullPolicy": { - "enum": [ - "never", - "on-create" - ] - }, - "startupOrder": { - "type": "integer", - "minimum": 0, - "maximum": 4294967295 - }, - "moduleSettings": { - "type": "object", - "required": [ - "image" - ], - "properties": { - "image": { - "type": "string", - "examples": [ - "mcr.microsoft.com/azureiotedge-agent:1.0" - ] - }, - "createOptions": { - "$ref": "#/definitions/createOptions" - } - }, - "patternProperties": { - "^[^\\.\\$# ]+$":{ - "type": ["array", "boolean", "integer", "null", "number", "object", "string"] - } - }, - "additionalProperties": false - }, - "env": { - "type": "object", - "patternProperties": { - "^[^\\+#$\\s\\.]+$": { - "type": "object", - "required": [ - "value" - ], - "properties": { - "value": { - "type": ["number", "string", "boolean"] - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "createOptions": { - "type": "string", - "contentMediaType": "application/json" - } - } -} diff --git a/azext_iot/central/commands_device.py b/azext_iot/central/commands_device.py index df254e2a6..8e20f0aa7 100644 --- a/azext_iot/central/commands_device.py +++ b/azext_iot/central/commands_device.py @@ -105,9 +105,9 @@ def run_command( cmd, app_id: str, device_id: str, - interface_id: str, command_name: str, content: str, + interface_id=None, token=None, central_dns_suffix=CENTRAL_ENDPOINT, api_version=ApiVersion.v1.value, @@ -122,7 +122,7 @@ def run_command( else: provider = CentralDeviceProviderV1(cmd=cmd, app_id=app_id, token=token) - return provider.run_component_command( + return provider.run_command( device_id=device_id, interface_id=interface_id, command_name=command_name, @@ -163,9 +163,9 @@ def get_command_history( cmd, app_id: str, device_id: str, - interface_id: str, command_name: str, token=None, + interface_id=None, central_dns_suffix=CENTRAL_ENDPOINT, api_version=ApiVersion.v1.value, ): @@ -174,7 +174,7 @@ def get_command_history( else: provider = CentralDeviceProviderV1(cmd=cmd, app_id=app_id, token=token) - return provider.get_component_command_history( + return provider.get_command_history( device_id=device_id, interface_id=interface_id, command_name=command_name, diff --git a/azext_iot/central/params.py b/azext_iot/central/params.py index fe1a2e4c6..d6f5d7e56 100644 --- a/azext_iot/central/params.py +++ b/azext_iot/central/params.py @@ -132,8 +132,8 @@ def load_central_arguments(self, _): context.argument( "interface_id", options_list=["--interface-id", "-i"], - help="The name of the interface as specified in the device template. You can find it by navigating to Device" - " Template and view the interface identity under the corresponding device capability.", + help="The name of the interface/component as specified in the device template.You can find it by navigating" + " to Device Template and view the interface/component identity under the corresponding device capability.", ) context.argument( "command_name", diff --git a/azext_iot/central/providers/preview/device_provider_preview.py b/azext_iot/central/providers/preview/device_provider_preview.py index 49368258e..c016a4dfd 100644 --- a/azext_iot/central/providers/preview/device_provider_preview.py +++ b/azext_iot/central/providers/preview/device_provider_preview.py @@ -126,7 +126,7 @@ def delete_device(self, device_id, central_dns_suffix=CENTRAL_ENDPOINT,) -> dict return result - def run_component_command( + def run_command( self, device_id: str, interface_id: str, @@ -134,31 +134,56 @@ def run_component_command( payload: dict, central_dns_suffix=CENTRAL_ENDPOINT, ) -> dict: - return central_services.device.run_component_command( + + if interface_id: + 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, + ) + + return central_services.device.run_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( + def get_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( + + if interface_id: + 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, + ) + + return central_services.device.get_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, diff --git a/azext_iot/central/providers/v1/device_provider_v1.py b/azext_iot/central/providers/v1/device_provider_v1.py index ceed21644..5dbd0e012 100644 --- a/azext_iot/central/providers/v1/device_provider_v1.py +++ b/azext_iot/central/providers/v1/device_provider_v1.py @@ -194,7 +194,7 @@ def get_device_registration_summary(self, central_dns_suffix=CENTRAL_ENDPOINT): central_dns_suffix=central_dns_suffix, ) - def run_component_command( + def run_command( self, device_id: str, interface_id: str, @@ -202,31 +202,62 @@ def run_component_command( payload: dict, central_dns_suffix=CENTRAL_ENDPOINT, ): - return central_services.device.run_component_command( + if interface_id and self._is_interface_id_component( + device_id=device_id, + interface_id=interface_id, + central_dns_suffix=central_dns_suffix, + ): + 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.v1.value, + ) + return central_services.device.run_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.v1.value, ) - def get_component_command_history( + def get_command_history( self, device_id: str, interface_id: str, command_name: str, central_dns_suffix=CENTRAL_ENDPOINT, ): - return central_services.device.get_component_command_history( + + if interface_id and self._is_interface_id_component( + device_id=device_id, + interface_id=interface_id, + central_dns_suffix=central_dns_suffix, + ): + 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.v1.value, + ) + + return central_services.device.get_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.v1.value, @@ -272,3 +303,20 @@ def _dps_populate_essential_info(self, dps_info, device_status: DeviceStatus): "error": error.get(device_status), } return filtered_dps_info + + def _is_interface_id_component( + self, device_id: str, interface_id: str, central_dns_suffix=CENTRAL_ENDPOINT, + ) -> bool: + + current_device = self.get_device(device_id, central_dns_suffix) + + template = central_services.device_template.get_device_template( + cmd=self._cmd, + app_id=self._app_id, + device_template_id=current_device.template, + token=self._token, + central_dns_suffix=central_dns_suffix, + api_version=ApiVersion.v1.value, + ) + + return bool(interface_id in template.components) diff --git a/azext_iot/central/services/device.py b/azext_iot/central/services/device.py index 21e2c78f9..60170f350 100644 --- a/azext_iot/central/services/device.py +++ b/azext_iot/central/services/device.py @@ -303,6 +303,53 @@ def get_device_credentials( return _utility.try_extract_result(response) +def run_command( + cmd, + app_id: str, + token: str, + device_id: str, + command_name: str, + payload: dict, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +): + """ + Execute a direct method on a device + + Args: + cmd: command passed into az + app_id: name of app (used for forming request URL) + device_id: unique case-sensitive device id + command_name: name of command to execute + payload: params for command + 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 201) + """ + url = "https://{}.{}/{}/{}/commands/{}".format( + app_id, central_dns_suffix, BASE_PATH, device_id, command_name + ) + headers = _utility.get_headers(token, cmd) + + # 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 + if response.status_code == 201: + return response.json() + + return _utility.try_extract_result(response) + + def run_component_command( cmd, app_id: str, @@ -352,6 +399,43 @@ def run_component_command( return _utility.try_extract_result(response) +def get_command_history( + cmd, + app_id: str, + token: str, + device_id: str, + command_name: str, + central_dns_suffix=CENTRAL_ENDPOINT, + api_version=ApiVersion.v1.value, +): + """ + Get command history + + Args: + cmd: command passed into az + app_id: name of app (used for forming request URL) + device_id: unique case-sensitive device id + command_name: name of command to view execution history + 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: + Command history (List) - currently limited to 1 item + """ + url = "https://{}.{}/{}/{}/commands/{}".format( + app_id, central_dns_suffix, BASE_PATH, device_id, command_name + ) + headers = _utility.get_headers(token, cmd) + + # 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_component_command_history( cmd, app_id: str, diff --git a/azext_iot/common/_azure.py b/azext_iot/common/_azure.py index ffa76293c..0712a6fc5 100644 --- a/azext_iot/common/_azure.py +++ b/azext_iot/common/_azure.py @@ -23,11 +23,6 @@ def _parse_connection_string(cs, validate=None, cstring_type="entity"): return decomposed -def parse_pnp_connection_string(cs): - validate = ["HostName", "RepositoryId", "SharedAccessKeyName", "SharedAccessKey"] - return _parse_connection_string(cs, validate, "PnP Model Repository") - - def parse_iot_hub_connection_string(cs): validate = ["HostName", "SharedAccessKeyName", "SharedAccessKey"] return _parse_connection_string(cs, validate, "IoT Hub") diff --git a/azext_iot/common/certops.py b/azext_iot/common/certops.py index 7997f65a1..fef3d146e 100644 --- a/azext_iot/common/certops.py +++ b/azext_iot/common/certops.py @@ -48,10 +48,12 @@ def create_self_signed_certificate(subject, valid_days, cert_output_dir, cert_on cert_file = subject + '-cert.pem' key_file = subject + '-key.pem' - open(join(cert_output_dir, cert_file), "wt").write(cert_dump) + with open(join(cert_output_dir, cert_file), "wt") as f: + f.write(cert_dump) if not cert_only: - open(join(cert_output_dir, key_file), "wt").write(key_dump) + with open(join(cert_output_dir, key_file), "wt") as f: + f.write(key_dump) result = { 'certificate': cert_dump, diff --git a/azext_iot/common/shared.py b/azext_iot/common/shared.py index ce89fc919..be947ca06 100644 --- a/azext_iot/common/shared.py +++ b/azext_iot/common/shared.py @@ -267,3 +267,18 @@ class IoTHubStateType(Enum): KeyEncryptionKeyRevoking = "KeyEncryptionKeyRevoking" KeyEncryptionKeyRevoked = "KeyEncryptionKeyRevoked" ReActivating = "ReActivating" + + +class ConnectionStringParser(Enum): + """ + All connection string parser with respective functions + """ + from azext_iot.common._azure import ( + parse_iot_device_connection_string, + parse_iot_device_module_connection_string, + parse_iot_hub_connection_string + ) + + Module = parse_iot_device_module_connection_string + Device = parse_iot_device_connection_string + IotHub = parse_iot_hub_connection_string diff --git a/azext_iot/constants.py b/azext_iot/constants.py index 4f37a9004..b3e2dcf63 100644 --- a/azext_iot/constants.py +++ b/azext_iot/constants.py @@ -11,8 +11,8 @@ EXTENSION_NAME = "azure-iot" EXTENSION_ROOT = os.path.dirname(os.path.abspath(__file__)) EXTENSION_CONFIG_ROOT_KEY = "iotext" -EDGE_DEPLOYMENT_SCHEMA_2_PATH = os.path.join( - EXTENSION_ROOT, "assets", "edge-deploy-2.0.schema.json" +EDGE_DEPLOYMENT_ROOT_SCHEMAS_PATH = os.path.join( + EXTENSION_ROOT, "assets" ) BASE_MQTT_API_VERSION = "2018-06-30" MESSAGING_HTTP_C2D_SYSTEM_PROPERTIES = [ diff --git a/azext_iot/operations/hub.py b/azext_iot/operations/hub.py index a348ceb2d..603b6066d 100644 --- a/azext_iot/operations/hub.py +++ b/azext_iot/operations/hub.py @@ -27,10 +27,10 @@ RenewKeyType, IoTHubStateType, DeviceAuthApiType, + ConnectionStringParser, ) from azext_iot.iothub.providers.discovery import IotHubDiscovery from azext_iot.common.utility import ( - shell_safe_json_parse, read_file_content, validate_key_value_pairs, unpack_msrest_error, @@ -257,11 +257,18 @@ def _assemble_auth(auth_method, pk, sk): ) auth = None - if auth_method in [DeviceAuthType.shared_private_key.name, DeviceAuthApiType.sas.value]: + if auth_method in [ + DeviceAuthType.shared_private_key.name, + DeviceAuthApiType.sas.value, + ]: auth = AuthenticationMechanism( - symmetric_key=SymmetricKey(primary_key=pk, secondary_key=sk), type=DeviceAuthApiType.sas.value + symmetric_key=SymmetricKey(primary_key=pk, secondary_key=sk), + type=DeviceAuthApiType.sas.value, ) - elif auth_method in [DeviceAuthType.x509_thumbprint.name, DeviceAuthApiType.selfSigned.value]: + elif auth_method in [ + DeviceAuthType.x509_thumbprint.name, + DeviceAuthApiType.selfSigned.value, + ]: if not pk: raise ValueError("primary thumbprint required with selfSigned auth") auth = AuthenticationMechanism( @@ -270,8 +277,13 @@ def _assemble_auth(auth_method, pk, sk): ), type=DeviceAuthApiType.selfSigned.value, ) - elif auth_method in [DeviceAuthType.x509_ca.name, DeviceAuthApiType.certificateAuthority.value]: - auth = AuthenticationMechanism(type=DeviceAuthApiType.certificateAuthority.value) + 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 @@ -865,7 +877,11 @@ def _handle_module_update_params(parameters): def _parse_auth(parameters): - valid_auth = [DeviceAuthApiType.sas.value, DeviceAuthApiType.selfSigned.value, DeviceAuthApiType.certificateAuthority.value] + 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)) @@ -1377,31 +1393,67 @@ def _process_config_content(content, config_type): def _validate_payload_schema(content): import json - from azext_iot.constants import EDGE_DEPLOYMENT_SCHEMA_2_PATH as schema_path + from os.path import join from azext_iot.models.validators import JsonSchemaType, JsonSchemaValidator + from azext_iot.constants import EDGE_DEPLOYMENT_ROOT_SCHEMAS_PATH as root_schema_path + from azext_iot.common.utility import shell_safe_json_parse + + EDGE_AGENT_SCHEMA_PATH = "azure-iot-edgeagent-deployment-{}.json" + EDGE_HUB_SCHEMA_PATH = "azure-iot-edgehub-deployment-{}.json" + EDGE_SCHEMA_PATH_DICT = { + "$edgeAgent": EDGE_AGENT_SCHEMA_PATH, + "$edgeHub": EDGE_HUB_SCHEMA_PATH, + } - if not exists(schema_path): - logger.info("Invalid schema path %s, skipping validation...", schema_path) - return - - logger.info("Validating deployment payload...") - schema_content = str(read_file_content(schema_path)) + modules_content = content["modulesContent"] + system_modules_for_validation = ["$edgeAgent", "$edgeHub"] - try: - schema_content = shell_safe_json_parse(schema_content) - except CLIError: - logger.info("Issue parsing Edge deployment schema, skipping validation...") - return + for sys_module in system_modules_for_validation: + if sys_module in modules_content: + if ( + "properties.desired" in modules_content[sys_module] + and "schemaVersion" + in modules_content[sys_module]["properties.desired"] + ): + target_schema_ver = modules_content[sys_module][ + "properties.desired" + ]["schemaVersion"] + target_schema_def_path = join(root_schema_path, f"{EDGE_SCHEMA_PATH_DICT[sys_module].format(target_schema_ver)}") - v = JsonSchemaValidator(schema_content, JsonSchemaType.draft4) - errors = v.validate(content) - if errors: - # Pretty printing schema validation errors - raise CLIError( - json.dumps({"validationErrors": errors}, separators=(",", ":"), indent=2) - ) + logger.info("Attempting to fetch schema content from %s...", target_schema_def_path) + if not exists(target_schema_def_path): + logger.info("Invalid schema path %s, skipping validation...", target_schema_def_path) + continue - return + try: + target_schema_def = str(read_file_content(target_schema_def_path)) + target_schema_def = shell_safe_json_parse(target_schema_def) + except Exception: + logger.info( + "Unable to fetch schema content from %s skipping validation...", + target_schema_def_path, + ) + continue + + logger.info(f"Validating {sys_module} of deployment payload against schema...") + to_validate_content = { + sys_module: modules_content[sys_module] + } + draft_version = JsonSchemaType.draft4 + if "$schema" in target_schema_def and "/draft-07/" in target_schema_def["$schema"]: + draft_version = JsonSchemaType.draft7 + + v = JsonSchemaValidator(target_schema_def, draft_version) + errors = v.validate(to_validate_content) + if errors: + # Pretty printing schema validation errors + raise CLIError( + json.dumps( + {"validationErrors": errors}, + separators=(",", ":"), + indent=2, + ) + ) def iot_hub_configuration_update( @@ -1913,6 +1965,7 @@ def iot_get_sas_token( login=None, module_id=None, auth_type_dataplane=None, + connection_string=None, ): key_type = key_type.lower() policy_name = policy_name.lower() @@ -1930,6 +1983,14 @@ def iot_get_sas_token( "You are unable to get sas token for module without device information." ) + if connection_string: + return { + DeviceAuthApiType.sas.value: _iot_build_sas_token_from_cs( + connection_string, + duration, + ).generate_sas_token() + } + return { DeviceAuthApiType.sas.value: _iot_build_sas_token( cmd, @@ -1946,6 +2007,44 @@ def iot_get_sas_token( } +def _iot_build_sas_token_from_cs(connection_string, duration=3600): + uri = None + policy = None + key = None + + parsed_cs = None + all_parsers = [ + ConnectionStringParser.Module, + ConnectionStringParser.Device, + ConnectionStringParser.IotHub, + ] + + for parser in all_parsers: + try: + parsed_cs = parser(connection_string) + + if "SharedAccessKeyName" in parsed_cs: + policy = parsed_cs["SharedAccessKeyName"] + key = parsed_cs["SharedAccessKey"] + + if parser == ConnectionStringParser.IotHub: + uri = parsed_cs["HostName"] + elif parser == ConnectionStringParser.Module: + uri = "{}/devices/{}/modules/{}".format( + parsed_cs["HostName"], parsed_cs["DeviceId"], parsed_cs["ModuleId"] + ) + elif parser == ConnectionStringParser.Device: + uri = "{}/devices/{}".format(parsed_cs["HostName"], parsed_cs["DeviceId"]) + else: + raise CLIError("Given Connection String was not in a supported format.") + + return SasTokenAuthentication(uri, policy, key, duration) + except ValueError: + continue + + raise CLIError("Given Connection String was not in a supported format.") + + def _iot_build_sas_token( cmd, hub_name=None, @@ -1978,6 +2077,7 @@ def _iot_build_sas_token( uri = None policy = None key = None + if device_id: logger.info( 'Obtaining device "%s" details from registry, using IoT Hub policy "%s"', @@ -2035,7 +2135,10 @@ def _build_device_or_module_connection_string(entity, key_type="primary"): if key_type == "primary" else auth["symmetricKey"]["secondaryKey"] ) - elif auth_type in [DeviceAuthApiType.certificateAuthority.value.lower(), DeviceAuthApiType.selfSigned.value.lower()]: + elif auth_type in [ + DeviceAuthApiType.certificateAuthority.value.lower(), + DeviceAuthApiType.selfSigned.value.lower(), + ]: key = "x509=true" else: raise CLIError("Unable to form target connection string") @@ -2596,6 +2699,7 @@ def iot_device_export( 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( @@ -2621,21 +2725,31 @@ def iot_device_export( authentication_type=storage_authentication_type, ) - user_identity = identity not in [None, '[system]'] - if user_identity and storage_authentication_type != AuthenticationType.identityBased.name: + 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 + 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) + "Device export with user-assigned identities requires a dependency of azure-mgmt-iothub>={}".format( + IOTHUB_TRACK_2_SDK_MIN_VERSION + ) ) return client.export_devices( @@ -2697,20 +2811,30 @@ def iot_device_import( authentication_type=storage_authentication_type, ) - user_identity = identity not in [None, '[system]'] - if user_identity and storage_authentication_type != AuthenticationType.identityBased.name: + 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 + 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) + "Device import with user-assigned identities requires a dependency of azure-mgmt-iothub>={}".format( + IOTHUB_TRACK_2_SDK_MIN_VERSION + ) ) return client.import_devices( diff --git a/azext_iot/tests/__init__.py b/azext_iot/tests/__init__.py index 00e227551..45bea8a2e 100644 --- a/azext_iot/tests/__init__.py +++ b/azext_iot/tests/__init__.py @@ -80,6 +80,7 @@ def __init__(self, test_scenario, entity_name, entity_rg): self.entity_rg = entity_rg super(IoTLiveScenarioTest, self).__init__(test_scenario) + self.region = self.get_region() self.connection_string = self.get_hub_cstring() 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 1e238e4ba..bb0e0b5db 100644 --- a/azext_iot/tests/central/json/device_template_int_test.json +++ b/azext_iot/tests/central/json/device_template_int_test.json @@ -30,6 +30,15 @@ ], "displayName": "Component" } + }, + { + "@id": "urn:rigado:RS40_Occupancy_Sensor:testRootCommand:9", + "@type": "Command", + "commandType": "synchronous", + "displayName": { + "en": "testRootCommand" + }, + "name": "testRootCommand" } ], "displayName": "larger-telemetry-device", diff --git a/azext_iot/tests/central/test_iot_central_int.py b/azext_iot/tests/central/test_iot_central_int.py index d5fde866d..0196cd99d 100644 --- a/azext_iot/tests/central/test_iot_central_int.py +++ b/azext_iot/tests/central/test_iot_central_int.py @@ -382,7 +382,43 @@ def test_central_device_registration_info_registered(self): assert device_registration_info.get("status") is None assert dps_state.get("error") == "Device is not yet provisioned." - def test_central_run_command(self): + def test_central_run_command_root_level(self): + command_name = "testRootCommand" + (template_id, _) = self._create_device_template() + (device_id, _) = self._create_device(template=template_id, simulated=True) + + self._wait_for_provisioned(device_id) + + run_command_result = self.cmd( + "iot central device command run" + " -n {}" + " -d {}" + " --cn {}" + " -k '{}'" + "".format(APP_ID, device_id, command_name, sync_command_params) + ) + + show_command_result = self.cmd( + "iot central device command history" + " -n {}" + " -d {}" + " --cn {}" + "".format(APP_ID, device_id, command_name) + ) + + self._delete_device(device_id) + self._delete_device_template(template_id) + + run_result = run_command_result.get_output_in_json() + show_result = show_command_result.get_output_in_json() + + # from file indicated by `sync_command_params` + assert run_result["request"] == {"argument": "value"} + + # check that run result and show result indeed match + assert run_result["response"] == show_result["value"][0]["response"] + + def test_central_run_command_component(self): interface_id = "dtmiIntTestDeviceTemplateV33jl" command_name = "testCommand" (template_id, _) = self._create_device_template() @@ -663,12 +699,12 @@ def _create_device_template(self): template_name = template["displayName"] template_id = template_name + "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)], + 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) + + result = self.cmd(command, checks=[self.check("displayName", template_name), ],) json_result = result.get_output_in_json() assert json_result["@id"] == template_id diff --git a/azext_iot/tests/iothub/configurations/test_edge_deployment_ea_v11_eh_v12.json b/azext_iot/tests/iothub/configurations/test_edge_deployment_ea_v11_eh_v12.json new file mode 100644 index 000000000..d460151e1 --- /dev/null +++ b/azext_iot/tests/iothub/configurations/test_edge_deployment_ea_v11_eh_v12.json @@ -0,0 +1,91 @@ +{ + "modulesContent": { + "$edgeAgent": { + "properties.desired": { + "schemaVersion": "1.1", + "runtime": { + "type": "docker", + "settings": { + "minDockerVersion": "v1.25", + "loggingOptions": "", + "registryCredentials": { + "ContosoRegistry": { + "username": "myacr", + "password": "", + "address": "myacr.azurecr.io" + } + } + } + }, + "systemModules": { + "edgeAgent": { + "type": "docker", + "settings": { + "image": "mcr.microsoft.com/azureiotedge-agent:1.0", + "createOptions": "" + } + }, + "edgeHub": { + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 0, + "settings": { + "image": "mcr.microsoft.com/azureiotedge-hub:1.0", + "createOptions": "{\"HostConfig\":{\"PortBindings\":{\"443/tcp\":[{\"HostPort\":\"443\"}],\"5671/tcp\":[{\"HostPort\":\"5671\"}],\"8883/tcp\":[{\"HostPort\":\"8883\"}]}}}" + } + } + }, + "modules": { + "SimulatedTemperatureSensor": { + "version": "1.0", + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 2, + "settings": { + "image": "mcr.microsoft.com/azureiotedge-simulated-temperature-sensor:1.0", + "createOptions": "{}" + } + }, + "filtermodule": { + "version": "1.0", + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 1, + "env": { + "tempLimit": { + "value": "100" + } + }, + "settings": { + "image": "myacr.azurecr.io/filtermodule:latest", + "createOptions": "{}" + } + } + } + } + }, + "$edgeHub": { + "properties.desired": { + "schemaVersion": "1.2", + "routes": { + "sensorToFilter": { + "route": "FROM /messages/modules/SimulatedTemperatureSensor/outputs/temperatureOutput INTO BrokeredEndpoint(\"/modules/filtermodule/inputs/input1\")", + "priority": 0, + "timeToLiveSecs": 1800 + }, + "filterToIoTHub": { + "route": "FROM /messages/modules/filtermodule/outputs/output1 INTO $upstream", + "priority": 1, + "timeToLiveSecs": 1800 + } + }, + "storeAndForwardConfiguration": { + "timeToLiveSecs": 100 + } + } + } + } +} \ No newline at end of file diff --git a/azext_iot/tests/iothub/configurations/test_edge_deployment_ea_v90_eh_v91.json b/azext_iot/tests/iothub/configurations/test_edge_deployment_ea_v90_eh_v91.json new file mode 100644 index 000000000..e08e7bef2 --- /dev/null +++ b/azext_iot/tests/iothub/configurations/test_edge_deployment_ea_v90_eh_v91.json @@ -0,0 +1,91 @@ +{ + "modulesContent": { + "$edgeAgent": { + "properties.desired": { + "schemaVersion": "9.0", + "runtime": { + "type": "docker", + "settings": { + "minDockerVersion": "v1.25", + "loggingOptions": "", + "registryCredentials": { + "ContosoRegistry": { + "username": "myacr", + "password": "", + "address": "myacr.azurecr.io" + } + } + } + }, + "systemModules": { + "edgeAgent": { + "type": "docker", + "settings": { + "image": "mcr.microsoft.com/azureiotedge-agent:1.0", + "createOptions": "" + } + }, + "edgeHub": { + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 0, + "settings": { + "image": "mcr.microsoft.com/azureiotedge-hub:1.0", + "createOptions": "{\"HostConfig\":{\"PortBindings\":{\"443/tcp\":[{\"HostPort\":\"443\"}],\"5671/tcp\":[{\"HostPort\":\"5671\"}],\"8883/tcp\":[{\"HostPort\":\"8883\"}]}}}" + } + } + }, + "modules": { + "SimulatedTemperatureSensor": { + "version": "1.0", + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 2, + "settings": { + "image": "mcr.microsoft.com/azureiotedge-simulated-temperature-sensor:1.0", + "createOptions": "{}" + } + }, + "filtermodule": { + "version": "1.0", + "type": "docker", + "status": "running", + "restartPolicy": "always", + "startupOrder": 1, + "env": { + "tempLimit": { + "value": "100" + } + }, + "settings": { + "image": "myacr.azurecr.io/filtermodule:latest", + "createOptions": "{}" + } + } + } + } + }, + "$edgeHub": { + "properties.desired": { + "schemaVersion": "9.1", + "routes": { + "sensorToFilter": { + "route": "FROM /messages/modules/SimulatedTemperatureSensor/outputs/temperatureOutput INTO BrokeredEndpoint(\"/modules/filtermodule/inputs/input1\")", + "priority": 0, + "timeToLiveSecs": 1800 + }, + "filterToIoTHub": { + "route": "FROM /messages/modules/filtermodule/outputs/output1 INTO $upstream", + "priority": 1, + "timeToLiveSecs": 1800 + } + }, + "storeAndForwardConfiguration": { + "timeToLiveSecs": 100 + } + } + } + } +} \ No newline at end of file diff --git a/azext_iot/tests/iothub/configurations/test_iot_config_unit.py b/azext_iot/tests/iothub/configurations/test_iot_config_unit.py index cb54909c2..c41afa5b8 100644 --- a/azext_iot/tests/iothub/configurations/test_iot_config_unit.py +++ b/azext_iot/tests/iothub/configurations/test_iot_config_unit.py @@ -14,7 +14,12 @@ from knack.cli import CLIError from azext_iot.operations import hub as subject from azext_iot.common.utility import read_file_content, evaluate_literal -from azext_iot.tests.conftest import build_mock_response, path_service_client, mock_target, get_context_path +from azext_iot.tests.conftest import ( + build_mock_response, + path_service_client, + mock_target, + get_context_path, +) config_id = "myconfig-{}".format(str(uuid4()).replace("-", "")) @@ -33,12 +38,25 @@ def sample_config_edge_malformed(set_cwd): return result -@pytest.fixture(params=["file", "inlineA", "inlineB", "layered", "v1", "v11"]) +@pytest.fixture( + params=[ + "file", + "inlineA", + "inlineB", + "layered", + "v1", + "v11", + "ea_v11_eh_v12", + "ea_v90_eh_v91", + ] +) def sample_config_edge(set_cwd, request): path = "test_edge_deployment.json" layered_path = "test_edge_deployment_layered.json" v1_path = "test_edge_deployment_v1.json" v11_path = "test_edge_deployment_v11.json" + ea_v11_eh_v12_path = "test_edge_deployment_ea_v11_eh_v12.json" + ea_v90_eh_v91_path = "test_edge_deployment_ea_v90_eh_v91.json" payload = None if request.param == "inlineA": @@ -53,6 +71,10 @@ def sample_config_edge(set_cwd, request): payload = json.dumps(json.loads(read_file_content(v1_path))) elif request.param == "v11": payload = json.dumps(json.loads(read_file_content(v11_path))) + elif request.param == "ea_v11_eh_v12": + payload = json.dumps(json.loads(read_file_content(ea_v11_eh_v12_path))) + elif request.param == "ea_v90_eh_v91": + payload = json.dumps(json.loads(read_file_content(ea_v90_eh_v91_path))) return (request.param, payload) @@ -102,7 +124,7 @@ def service_client( headers={}, status=200, content_type="application/json", - match_querystring=False + match_querystring=False, ) mocked_response.add( @@ -112,7 +134,7 @@ def service_client( headers={"x-ms-continuation": ""}, status=200, content_type="application/json", - match_querystring=False + match_querystring=False, ) yield mocked_response @@ -181,6 +203,7 @@ def test_config_metric_show_invalid_args( self, fixture_cmd, service_client, metric_id, content_type, metric_type ): from functools import partial + service_client.assert_all_requests_are_fired = False with pytest.raises(CLIError): @@ -293,7 +316,12 @@ def test_config_create_edge( assert body.get("priority") == priority assert body.get("labels") == evaluate_literal(labels, dict) - if sample_config_edge[0] == "inlineB" or sample_config_edge[0] == "v11": + if ( + sample_config_edge[0] == "inlineB" + or sample_config_edge[0] == "v11" + or sample_config_edge[0] == "ea_v11_eh_v12" + or sample_config_edge[0] == "ea_v90_eh_v91" + ): assert ( body["content"]["modulesContent"] == json.loads(sample_config_edge[1])["modulesContent"] @@ -574,7 +602,8 @@ def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): return service_client @pytest.mark.parametrize( - "etag", [generate_generic_id(), None], + "etag", + [generate_generic_id(), None], ) def test_config_delete(self, serviceclient, fixture_cmd, etag): subject.iot_hub_configuration_delete( @@ -604,7 +633,8 @@ def serviceclient(self, mocker, fixture_ghcs, fixture_sas, request): return service_client @pytest.mark.parametrize( - "etag", [generate_generic_id(), None], + "etag", + [generate_generic_id(), None], ) def test_config_update(self, fixture_cmd, serviceclient, sample_config_show, etag): subject.iot_hub_configuration_update( @@ -612,7 +642,7 @@ def test_config_update(self, fixture_cmd, serviceclient, sample_config_show, eta config_id=config_id, hub_name=mock_target["entity"], parameters=sample_config_show, - etag=etag + etag=etag, ) args = serviceclient.call_args url = args[0][0].url @@ -649,9 +679,13 @@ def test_config_update_invalid_args( ) type_name = "class" if "class" in str(type) else "type" - assert str(exc_label.value) == ("The property \"labels\" must be of <{0} 'dict'> but is <{0} 'str'>. " - "Input: not a dictionary. Review inline JSON examples here --> " - "https://github.com/Azure/azure-iot-cli-extension/wiki/Tips".format(type_name)) + assert str(exc_label.value) == ( + "The property \"labels\" must be of <{0} 'dict'> but is <{0} 'str'>. " + "Input: not a dictionary. Review inline JSON examples here --> " + "https://github.com/Azure/azure-iot-cli-extension/wiki/Tips".format( + type_name + ) + ) def test_config_update_error(self, fixture_cmd, serviceclient_generic_error): with pytest.raises(CLIError): @@ -671,18 +705,24 @@ def service_client(self, mocked_response, fixture_ghcs, request): # Create mock edge deployments and ADM device and module configurations for i in range(size): - result.append({ - "id": "edgeDeployment{}".format(i), - "content": {"modulesContent": {"key": {}}}, - }) - result.append({ - "id": "moduleConfiguration{}".format(i), - "content": {"moduleContent": {"key": {}}}, - }) - result.append({ - "id": "deviceConfiguration{}".format(i), - "content": {"deviceContent": {"key": {}}}, - }) + result.append( + { + "id": "edgeDeployment{}".format(i), + "content": {"modulesContent": {"key": {}}}, + } + ) + result.append( + { + "id": "moduleConfiguration{}".format(i), + "content": {"moduleContent": {"key": {}}}, + } + ) + result.append( + { + "id": "deviceConfiguration{}".format(i), + "content": {"deviceContent": {"key": {}}}, + } + ) mocked_response.add( method=responses.GET, @@ -691,7 +731,7 @@ def service_client(self, mocked_response, fixture_ghcs, request): headers={"x-ms-continuation": ""}, status=200, content_type="application/json", - match_querystring=False + match_querystring=False, ) mocked_response.expected_size = size @@ -778,7 +818,12 @@ def test_config_apply_edge( in url ) - if sample_config_edge[0] == "inlineB" or sample_config_edge[0] == "v11": + if ( + sample_config_edge[0] == "inlineB" + or sample_config_edge[0] == "v11" + or sample_config_edge[0] == "ea_v11_eh_v12" + or sample_config_edge[0] == "ea_v90_eh_v91" + ): assert ( body["modulesContent"] == json.loads(sample_config_edge[1])["modulesContent"] 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 fd44943f2..6d3004202 100644 --- a/azext_iot/tests/iothub/jobs/test_iothub_jobs_int.py +++ b/azext_iot/tests/iothub/jobs/test_iothub_jobs_int.py @@ -5,6 +5,7 @@ # -------------------------------------------------------------------------------------------- import json + from datetime import datetime, timedelta from azext_iot.tests import IoTLiveScenarioTest from azext_iot.tests.settings import DynamoSettings, ENV_SET_TEST_IOTHUB_BASIC diff --git a/azext_iot/tests/iothub/test_iothub_utilities_int.py b/azext_iot/tests/iothub/test_iothub_utilities_int.py index 992bd0c60..2158a1b99 100644 --- a/azext_iot/tests/iothub/test_iothub_utilities_int.py +++ b/azext_iot/tests/iothub/test_iothub_utilities_int.py @@ -62,6 +62,17 @@ def test_iothub_generate_sas_token(self): expect_failure=True, ) + # Offline SAS token generation + self.cmd( + f"iot hub generate-sas-token --connection-string {self.connection_string}", + checks=[self.exists("sas")], + ) + + self.cmd( + f"iot hub generate-sas-token --connection-string {self.connection_string} --du 1000", + checks=[self.exists("sas")], + ) + def test_iothub_connection_string_show(self): conn_str_pattern = r"^HostName={0}.azure-devices.net;SharedAccessKeyName=iothubowner;SharedAccessKey=".format( LIVE_HUB diff --git a/azext_iot/tests/iothub/test_iothub_utilities_unit.py b/azext_iot/tests/iothub/test_iothub_utilities_unit.py new file mode 100644 index 000000000..1cb3dc307 --- /dev/null +++ b/azext_iot/tests/iothub/test_iothub_utilities_unit.py @@ -0,0 +1,85 @@ +# 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.common.sas_token_auth import SasTokenAuthentication +import pytest +from knack.cli import CLIError +from azext_iot.operations import hub as subject +from azext_iot.tests.generators import generate_generic_id + + +def generate_valid_cs(validate_pairs=[]): + host_name = generate_generic_id() + shared_access_key = generate_generic_id() + cs = f"HostName={host_name};" + input_pairs = dict((k, generate_generic_id()) for k in validate_pairs) + policy = input_pairs["SharedAccessKeyName"] if "SharedAccessKeyName" in input_pairs else None + + for key, value in input_pairs.items(): + cs += "{}={};".format( + key, value + ) + + cs = f"{cs}SharedAccessKey={shared_access_key}" + uri = host_name + if "DeviceId" in input_pairs: + uri = f"{uri}/devices/{input_pairs['DeviceId']}" + if "ModuleId" in input_pairs: + uri = f"{uri}/modules/{input_pairs['ModuleId']}" + + return { + "connection_string": cs, + "uri": uri, + "policy": policy, + "key": shared_access_key + } + + +class TestGenerateSasToken: + @pytest.mark.parametrize( + "duration, req", + [ + (3600, generate_valid_cs(["DeviceId"])), + (30, generate_valid_cs(["DeviceId"])), + (60000, generate_valid_cs(["DeviceId"])), + (3600, generate_valid_cs(["SharedAccessKeyName"])), + (3600, generate_valid_cs(["DeviceId"])), + (3600, generate_valid_cs(["DeviceId", "ModuleId"])), + (3600, generate_valid_cs(["Test", "DeviceId", "ModuleId"])), + (3600, generate_valid_cs(["RepositoryId", "DeviceId", "ModuleId"])), + ], + ) + def test_generate_sas_token_from_cs(self, mocker, fixture_cmd, duration, req): + patched_time = mocker.patch( + "azext_iot.common.sas_token_auth.time" + ) + patched_time.return_value = 0 + result = subject.iot_get_sas_token( + cmd=fixture_cmd, + connection_string=req["connection_string"], + duration=duration + ) + + duration = duration if duration else 3600 + expected_sas = SasTokenAuthentication( + req["uri"], req["policy"], req["key"], duration + ).generate_sas_token() + assert result["sas"] == expected_sas + + @pytest.mark.parametrize( + "req", + [ + (generate_valid_cs()), + (generate_valid_cs(["ModuleId"])), + (generate_valid_cs(["Test"])) + ], + ) + def test_generate_sas_token_from_cs_error(self, mocker, fixture_cmd, req): + with pytest.raises(CLIError): + subject.iot_get_sas_token( + cmd=fixture_cmd, + connection_string=req["connection_string"], + ) diff --git a/setup.py b/setup.py index 9a012a144..0b9f62906 100644 --- a/setup.py +++ b/setup.py @@ -81,7 +81,7 @@ EXTENSION_REF_NAME: [ "azext_metadata.json", "digicert.pem", - "assets/edge-deploy-2.0.schema.json", + "assets/*", ] }, install_requires=DEPENDENCIES,