diff --git a/.builder/actions/build_gg_samples.py b/.builder/actions/build_gg_samples.py new file mode 100644 index 000000000..69284a7a3 --- /dev/null +++ b/.builder/actions/build_gg_samples.py @@ -0,0 +1,37 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. + +import Builder +import os + + +class BuildGGSamples(Builder.Action): + """ + Defines a custom build step for Greengrass samples. + It's used via builder.json config file in the project root directory. + """ + + def run(self, env): + # parse extra cmake configs + cmd_args = env.args + + steps = [] + samples = [ + 'samples/greengrass/ipc', + 'samples/greengrass/basic_discovery', + ] + + for sample_path in samples: + build_path = os.path.join('build', sample_path) + steps.append(['cmake', + f'-B{build_path}', + f'-H{sample_path}', + f'-DCMAKE_PREFIX_PATH={env.install_dir}', + '-DCMAKE_BUILD_TYPE=RelWithDebInfo']) + # append extra cmake configs + steps[-1].extend(cmd_args.cmake_extra) + steps.append(['cmake', + '--build', build_path, + '--config', 'RelWithDebInfo']) + + return Builder.Script(steps) diff --git a/.builder/actions/build_samples.py b/.builder/actions/build_samples.py index d007b769d..79475011b 100644 --- a/.builder/actions/build_samples.py +++ b/.builder/actions/build_samples.py @@ -1,10 +1,17 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: Apache-2.0. + import Builder import os import sys -import argparse class BuildSamples(Builder.Action): + """ + Defines a custom build step for samples and tests. + It's used via builder.json config file in the project root directory. + """ + def run(self, env): # parse extra cmake configs cmd_args = env.args diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ca3c74fe..8eed8015f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,6 +37,7 @@ env: CI_X509_ROLE: arn:aws:iam::180635532705:role/CI_X509_Role CI_MQTT5_ROLE: arn:aws:iam::180635532705:role/CI_MQTT5_Role CI_GREENGRASS_ROLE: arn:aws:iam::180635532705:role/CI_Greengrass_Role + CI_GREENGRASS_INSTALLER_ROLE: arn:aws:iam::180635532705:role/CI_GreengrassInstaller_Role CI_JOBS_SERVICE_CLIENT_ROLE: arn:aws:iam::180635532705:role/CI_JobsServiceClient_Role CI_SHADOW_SERVICE_CLIENT_ROLE: arn:aws:iam::180635532705:role/CI_ShadowServiceClient_Role @@ -641,14 +642,66 @@ jobs: - name: run X509 sample run: | python3 ${{ env.CI_UTILS_FOLDER }}/run_sample_ci.py --file ${{ env.CI_SAMPLES_CFG_FOLDER }}/ci_run_x509_connect_cfg.json - - name: configure AWS credentials (Greengrass) - uses: aws-actions/configure-aws-credentials@v2 - with: - role-to-assume: ${{ env.CI_GREENGRASS_ROLE }} - aws-region: ${{ env.AWS_DEFAULT_REGION }} - - name: run Greengrass Discovery sample + + # Runs the Greengrass samples + linux-greengrass-tests: + runs-on: ubuntu-latest + permissions: + id-token: write # This is required for requesting the JWT + steps: + - name: Setup C++ + run: | + sudo apt-get -qq update -y + sudo apt-get -qq install -y build-essential + sudo apt install cmake + gcc --version + cmake --version + - name: Build ${{ env.PACKAGE_NAME }} + consumers run: | - python3 ${{ env.CI_UTILS_FOLDER }}/run_sample_ci.py --file ${{ env.CI_SAMPLES_CFG_FOLDER }}/ci_run_greengrass_discovery_cfg.json + echo "Downloading source" + git clone --recursive https://github.com/aws/aws-iot-device-sdk-cpp-v2.git --branch ${{ env.HEAD_REF || github.ref_name }} + echo "Running builder" + python -c "from urllib.request import urlretrieve; urlretrieve('${{ env.BUILDER_HOST }}/${{ env.BUILDER_SOURCE }}/${{ env.BUILDER_VERSION }}/builder.pyz?run=${{ env.RUN }}', 'builder.pyz')" + python builder.pyz build -p ${{ env.PACKAGE_NAME }} --variant build_gg_samples_only + - name: Install Greengrass Development Kit + run: | + python3 -m pip install awsiotsdk + python3 -m pip install -U git+https://github.com/aws-greengrass/aws-greengrass-gdk-cli.git@v1.6.2 + - name: Configure AWS credentials (Greengrass) + uses: aws-actions/configure-aws-credentials@v2 + with: + role-to-assume: ${{ env.CI_GREENGRASS_INSTALLER_ROLE }} + aws-region: ${{ env.AWS_DEFAULT_REGION }} + - name: Build and run Greengrass basic discovery sample + working-directory: ./aws-iot-device-sdk-cpp-v2/tests/greengrass/basic_discovery + run: | + gdk component build + gdk test-e2e build + gdk test-e2e run + - name: Show logs + working-directory: ./aws-iot-device-sdk-cpp-v2/tests/greengrass/basic_discovery + # Print logs unconditionally to provide more details on Greengrass run even if the test failed. + if: always() + run: | + echo "=== greengrass.log" + cat testResults/gg*/greengrass.log + echo "=== software.amazon.awssdk.sdk-gg-test-discovery.log" + cat testResults/gg*/software.amazon.awssdk.sdk-gg-test-discovery.log + - name: Build and run Greengrass IPC sample + working-directory: ./aws-iot-device-sdk-cpp-v2/tests/greengrass/ipc + run: | + gdk component build + gdk test-e2e build + gdk test-e2e run + - name: Show logs + working-directory: ./aws-iot-device-sdk-cpp-v2/tests/greengrass/ipc + # Print logs unconditionally to provide more details on Greengrass run even if the test failed. + if: always() + run: | + echo "=== greengrass.log" + cat testResults/gg*/greengrass.log + echo "=== software.amazon.awssdk.sdk-gg-ipc.log" + cat testResults/gg*/software.amazon.awssdk.sdk-gg-ipc.log # check that docs can still build check-docs: diff --git a/.github/workflows/ci_run_greengrass_discovery_cfg.json b/.github/workflows/ci_run_greengrass_discovery_cfg.json index b60d8106f..b89c1240d 100644 --- a/.github/workflows/ci_run_greengrass_discovery_cfg.json +++ b/.github/workflows/ci_run_greengrass_discovery_cfg.json @@ -1,39 +1,35 @@ { "language": "CPP", - "sample_file": "./aws-iot-device-sdk-cpp-v2/build/samples/greengrass/basic_discovery/basic-discovery", - "sample_region": "us-east-1", - "sample_main_class": "", + "runnable_file": "basic-discovery", + "runnable_region": "us-east-1", + "runnable_main_class": "", "arguments": [ { "name": "--cert", - "secret": "ci/Greengrass/cert", + "secret": "ci/GreengrassDiscovery/cert", "filename": "tmp_certificate.pem" }, { "name": "--key", - "secret": "ci/Greengrass/key", + "secret": "ci/GreengrassDiscovery/key", "filename": "tmp_key.pem" }, { - "name": "--ca_file", - "secret": "ci/Greengrass/ca", - "filename": "tmp_ca.pem" + "name": "--thing_name", + "data": "CI_Greengrass_Discovery_Thing" }, { "name": "--region", "data": "us-east-1" }, { - "name": "--thing_name", - "data": "CI_GreenGrass_Thing" - }, - { - "name": "--is_ci", - "data": "true" + "name": "--topic", + "data": "clients/CI_Greengrass_Discovery_Thing/hello/world/$INPUT_UUID" }, { - "name": "--print_discover_resp_only", - "data": "" + "name": "--mode", + "data": "publish" } - ] + ], + "stdin_file": "messages.txt" } diff --git a/.github/workflows/ci_run_greengrass_ipc_cfg.json b/.github/workflows/ci_run_greengrass_ipc_cfg.json new file mode 100644 index 000000000..11f95a1bb --- /dev/null +++ b/.github/workflows/ci_run_greengrass_ipc_cfg.json @@ -0,0 +1,21 @@ +{ + "language": "CPP", + "runnable_file": "greengrass-ipc", + "runnable_region": "us-east-1", + "runnable_main_class": "", + "arguments": [ + { + "name": "--topic", + "data": "test/gg-ipc-topic" + }, + { + "name": "--message", + "data": "hello" + }, + { + "name": "--is_ci", + "data": "true" + } + + ] +} diff --git a/builder.json b/builder.json index 2a014ee07..293a06351 100644 --- a/builder.json +++ b/builder.json @@ -28,6 +28,12 @@ "!build_steps": [ "build" ] + }, + "build_gg_samples_only": { + "!build_steps": [ + "build", + "build-gg-samples" + ] } } } diff --git a/samples/greengrass/basic_discovery/main.cpp b/samples/greengrass/basic_discovery/main.cpp index a8a7cbb6b..ae39f5c9a 100644 --- a/samples/greengrass/basic_discovery/main.cpp +++ b/samples/greengrass/basic_discovery/main.cpp @@ -13,12 +13,108 @@ #include #include #include +#include #include "../../utils/CommandLineUtils.h" using namespace Aws::Crt; using namespace Aws::Discovery; +std::shared_ptr getMqttConnection( + Aws::Iot::MqttClient &mqttClient, + DiscoverResponse *response, + Utils::cmdData &cmdData, + std::promise &shutdownCompletedPromise) +{ + std::shared_ptr connection; + + for (const auto &groupToUse : *response->GGGroups) + { + for (const auto &connectivityInfo : *groupToUse.Cores->at(0).Connectivity) + { + fprintf( + stdout, + "Connecting to group %s with thing arn %s, using endpoint %s:%d\n", + groupToUse.GGGroupId->c_str(), + groupToUse.Cores->at(0).ThingArn->c_str(), + connectivityInfo.HostAddress->c_str(), + (int)connectivityInfo.Port.value()); + + connection = mqttClient.NewConnection( + Aws::Iot::MqttClientConnectionConfigBuilder(cmdData.input_cert.c_str(), cmdData.input_key.c_str()) + .WithCertificateAuthority(ByteCursorFromCString(groupToUse.CAs->at(0).c_str())) + .WithPortOverride(connectivityInfo.Port.value()) + .WithEndpoint(connectivityInfo.HostAddress.value()) + .WithTcpConnectTimeout(3000) + .Build()); + + if (!connection) + { + fprintf(stderr, "Connection setup failed with error %s", ErrorDebugString(mqttClient.LastError())); + continue; + } + + std::promise connectPromise; + + connection->OnConnectionCompleted = [&connectPromise, connectivityInfo, groupToUse]( + Mqtt::MqttConnection & /*connection*/, + int errorCode, + Mqtt::ReturnCode /*returnCode*/, + bool /*sessionPresent*/) { + if (!errorCode) + { + fprintf( + stdout, + "Connected to group %s, using connection to %s:%d\n", + groupToUse.GGGroupId->c_str(), + connectivityInfo.HostAddress->c_str(), + (int)connectivityInfo.Port.value()); + connectPromise.set_value(true); + } + else + { + fprintf( + stderr, + "Error connecting to group %s (thing %s) using connection to %s:%d\n", + groupToUse.GGGroupId->c_str(), + groupToUse.Cores->at(0).ThingArn->c_str(), + connectivityInfo.HostAddress->c_str(), + (int)connectivityInfo.Port.value()); + fprintf(stderr, "Error: %s\n", aws_error_debug_str(errorCode)); + connectPromise.set_value(false); + } + }; + + connection->OnConnectionInterrupted = [](Mqtt::MqttConnection &, int errorCode) { + fprintf(stderr, "Connection interrupted with error %s\n", aws_error_debug_str(errorCode)); + }; + + connection->OnConnectionResumed = [](Mqtt::MqttConnection & /*connection*/, + Mqtt::ReturnCode /*connectCode*/, + bool /*sessionPresent*/) { fprintf(stdout, "Connection resumed\n"); }; + + connection->OnDisconnect = [&](Mqtt::MqttConnection & /*connection*/) { + fprintf(stdout, "Connection disconnected. Shutting Down.....\n"); + shutdownCompletedPromise.set_value(); + }; + + if (!connection->Connect(cmdData.input_thingName.c_str(), false)) + { + fprintf(stderr, "Connect failed with error %s\n", aws_error_debug_str(aws_last_error())); + continue; + } + + auto connectFuture = connectPromise.get_future(); + + if (connectFuture.get()) + { + return connection; + } + } + } + return nullptr; +} + int main(int argc, char *argv[]) { /************************ Setup ****************************/ @@ -88,6 +184,8 @@ int main(int argc, char *argv[]) /************************ Run the sample ****************************/ + fprintf(stdout, "Starting discovery\n"); + auto discoveryClient = DiscoveryClient::CreateClient(clientConfig); Aws::Iot::MqttClient mqttClient; @@ -98,11 +196,12 @@ int main(int argc, char *argv[]) discoveryClient->Discover( cmdData.input_thingName, [&](DiscoverResponse *response, int error, int httpResponseCode) { + fprintf(stdout, "Discovery completed with error code %d; http code %d\n", error, httpResponseCode); if (!error && response->GGGroups) { // Print the discovery response information and then exit. Does not use the discovery info. // (unless in CI, in which case just note it was successful and exit) - if (cmdData.input_PrintDiscoverRespOnly == true) + if (cmdData.input_PrintDiscoverRespOnly) { // Print the discovery response information and then exit (unless in CI, in which case just note it // was successful) @@ -139,117 +238,14 @@ int main(int argc, char *argv[]) exit(0); } - auto groupToUse = std::move(response->GGGroups->at(0)); - auto connectivityInfo = groupToUse.Cores->at(0).Connectivity->at(0); - - fprintf( - stdout, - "Connecting to group %s with thing arn %s, using endpoint %s:%d\n", - groupToUse.GGGroupId->c_str(), - groupToUse.Cores->at(0).ThingArn->c_str(), - connectivityInfo.HostAddress->c_str(), - (int)connectivityInfo.Port.value()); - - connection = mqttClient.NewConnection( - Aws::Iot::MqttClientConnectionConfigBuilder(cmdData.input_cert.c_str(), cmdData.input_key.c_str()) - .WithCertificateAuthority(ByteCursorFromCString(groupToUse.CAs->at(0).c_str())) - .WithPortOverride(connectivityInfo.Port.value()) - .WithEndpoint(connectivityInfo.HostAddress.value()) - .Build()); - - if (!connection) + connection = getMqttConnection(mqttClient, response, cmdData, shutdownCompletedPromise); + if (connection) { - fprintf(stderr, "Connection setup failed with error %s", ErrorDebugString(mqttClient.LastError())); - exit(-1); + connectionFinishedPromise.set_value(); } - - connection->OnConnectionCompleted = [&, connectivityInfo, groupToUse]( - Mqtt::MqttConnection &conn, - int errorCode, - Mqtt::ReturnCode /*returnCode*/, - bool /*sessionPresent*/) { - if (!errorCode) - { - fprintf( - stdout, - "Connected to group %s, using connection to %s:%d\n", - groupToUse.GGGroupId->c_str(), - connectivityInfo.HostAddress->c_str(), - (int)connectivityInfo.Port.value()); - - if (cmdData.input_mode == "both" || cmdData.input_mode == "subscribe") - { - auto onMessage = [&](Mqtt::MqttConnection & /*connection*/, - const String &receivedOnTopic, - const ByteBuf &payload, - bool /*dup*/, - Mqtt::QOS /*qos*/, - bool /*retain*/) { - fprintf(stdout, "Publish received on topic %s\n", receivedOnTopic.c_str()); - fprintf(stdout, "Message: \n"); - fwrite(payload.buffer, 1, payload.len, stdout); - fprintf(stdout, "\n"); - }; - - auto onSubAck = [&](Mqtt::MqttConnection & /*connection*/, - uint16_t /*packetId*/, - const String &topic, - Mqtt::QOS /*qos*/, - int errorCode) { - if (!errorCode) - { - fprintf(stdout, "Successfully subscribed to %s\n", topic.c_str()); - connectionFinishedPromise.set_value(); - } - else - { - fprintf( - stderr, - "Failed to subscribe to %s with error %s. Exiting\n", - topic.c_str(), - aws_error_debug_str(errorCode)); - exit(-1); - } - }; - - conn.Subscribe(cmdData.input_topic.c_str(), AWS_MQTT_QOS_AT_MOST_ONCE, onMessage, onSubAck); - } - else - { - connectionFinishedPromise.set_value(); - } - } - else - { - fprintf( - stderr, - "Error connecting to group %s, using connection to %s:%d\n", - groupToUse.GGGroupId->c_str(), - connectivityInfo.HostAddress->c_str(), - (int)connectivityInfo.Port.value()); - fprintf(stderr, "Error: %s\n", aws_error_debug_str(errorCode)); - exit(-1); - } - }; - - connection->OnConnectionInterrupted = [](Mqtt::MqttConnection &, int errorCode) { - fprintf(stderr, "Connection interrupted with error %s\n", aws_error_debug_str(errorCode)); - }; - - connection->OnConnectionResumed = [](Mqtt::MqttConnection & /*connection*/, - Mqtt::ReturnCode /*connectCode*/, - bool /*sessionPresent*/) { - fprintf(stdout, "Connection resumed\n"); - }; - - connection->OnDisconnect = [&](Mqtt::MqttConnection & /*connection*/) { - fprintf(stdout, "Connection disconnected. Shutting Down.....\n"); - shutdownCompletedPromise.set_value(); - }; - - if (!connection->Connect(cmdData.input_thingName.c_str(), false)) + else { - fprintf(stderr, "Connect failed with error %s\n", aws_error_debug_str(aws_last_error())); + fprintf(stderr, "All connection attempts failed\n"); exit(-1); } } @@ -263,17 +259,53 @@ int main(int argc, char *argv[]) exit(-1); } }); + + connectionFinishedPromise.get_future().wait(); + + if (cmdData.input_mode == "both" || cmdData.input_mode == "subscribe") { - connectionFinishedPromise.get_future().wait(); + auto onMessage = [&](Mqtt::MqttConnection & /*connection*/, + const String &receivedOnTopic, + const ByteBuf &payload, + bool /*dup*/, + Mqtt::QOS /*qos*/, + bool /*retain*/) { + fprintf(stdout, "Publish received on topic %s\n", receivedOnTopic.c_str()); + fprintf(stdout, "Message: \n"); + fwrite(payload.buffer, 1, payload.len, stdout); + fprintf(stdout, "\n"); + }; + + auto onSubAck = [&](Mqtt::MqttConnection & /*connection*/, + uint16_t /*packetId*/, + const String &topic, + Mqtt::QOS /*qos*/, + int errorCode) { + if (!errorCode) + { + fprintf(stdout, "Successfully subscribed to %s\n", topic.c_str()); + } + else + { + fprintf( + stderr, + "Failed to subscribe to %s with error %s. Exiting\n", + topic.c_str(), + aws_error_debug_str(errorCode)); + exit(-1); + } + }; + + connection->Subscribe(cmdData.input_topic.c_str(), AWS_MQTT_QOS_AT_MOST_ONCE, onMessage, onSubAck); } bool first_input = true; while (true) { - String input = ""; + String input; if (cmdData.input_mode == "both" || cmdData.input_mode == "publish") { - if (cmdData.input_message == "") + if (cmdData.input_message.empty()) { fprintf( stdout, @@ -283,7 +315,7 @@ int main(int argc, char *argv[]) std::getline(std::cin, input); cmdData.input_message = input; } - else if (first_input == false) + else if (!first_input) { fprintf(stdout, "Enter a new message or enter 'exit' or 'quit' to exit the program.\n"); std::getline(std::cin, input); @@ -299,7 +331,7 @@ int main(int argc, char *argv[]) if (input == "exit" || input == "quit") { - fprintf(stdout, "Exiting..."); + fprintf(stdout, "Exiting...\n"); break; } @@ -321,7 +353,7 @@ int main(int argc, char *argv[]) } }; connection->Publish( - cmdData.input_topic.c_str(), AWS_MQTT_QOS_AT_MOST_ONCE, false, payload, onPublishComplete); + cmdData.input_topic.c_str(), AWS_MQTT_QOS_AT_LEAST_ONCE, false, payload, onPublishComplete); } } diff --git a/samples/greengrass/ipc/main.cpp b/samples/greengrass/ipc/main.cpp index 86200f29d..d75eb89b1 100644 --- a/samples/greengrass/ipc/main.cpp +++ b/samples/greengrass/ipc/main.cpp @@ -29,6 +29,8 @@ int main(int argc, char *argv[]) */ Utils::cmdData cmdData = Utils::parseSampleInputGreengrassIPC(argc, argv, &apiHandle); + fprintf(stdout, "Running Greengrass IPC sample\n"); + /** * Create the default ClientBootstrap, which will create the default * EventLoopGroup (to process IO events) and HostResolver. diff --git a/tests/greengrass/basic_discovery/README.md b/tests/greengrass/basic_discovery/README.md new file mode 100644 index 000000000..77f1c5753 --- /dev/null +++ b/tests/greengrass/basic_discovery/README.md @@ -0,0 +1,44 @@ +# Test for Greengrass Discovery Sample + +Greengrass discovery test runs using [Greengrass Development Kit Command-Line Interface](https://docs.aws.amazon.com/greengrass/v2/developerguide/greengrass-development-kit-cli.html) (GDK CLI). + +### Greengrass discovery component + +For Greengrass discovery sample to work, a Greengrass component subscribed to a particular topic is required. +The following files defines this custom component: + +- [gdk-config.json](./gdk-config.json) - `gdk` reads this file to build and publish component. +- [copy_files.sh](./copy_files.sh) - utility to copy all required files for `gdk` to be able to build the component. +- [recipe.yaml](./recipe.yaml) - defines a component's details, dependencies, artifacts, and lifecycles. +- [hello_world_subscriber.py](./hello_world_subscriber.py) - a simple Greengrass client that subscribes to a given topic using Greengrass IPC. + +### How the test runs + +The first step is to build GreengrassV2 component artifacts and recipes from its source code: + +```shell +gdk component build +``` + +Then the following command builds the testing module: + +```shell +gdk test-e2e build +``` + +Finally, the test can run: + +```shell +gdk test-e2e run +``` + +The test behavior is defined in the [component.feature](./gg-e2e-tests/src/main/resources/greengrass/features/component.feature) +config file using a domain-specific language called [Gherkin](https://docs.aws.amazon.com/greengrass/v2/developerguide/gg-testing-framework.html). + +The test spins up Greengrass core, installs and configures Greengrass component dependencies (including the custom +Greengrass component described in the previous section). After everything is set up, it performs checks. They are defined +at the very bottom of the file and basically grep a log file for specific messages. + +On completion, the test creates log files in `testResult` directory with the run details. The component's logs are stored +in `testResult/gg-/software.amazon.awssdk.sdk-gg-test-discovery.log` file. Though, if error occurred before +the component started its execution, this file might be absent. diff --git a/tests/greengrass/basic_discovery/copy_files.sh b/tests/greengrass/basic_discovery/copy_files.sh new file mode 100644 index 000000000..525c767b2 --- /dev/null +++ b/tests/greengrass/basic_discovery/copy_files.sh @@ -0,0 +1,3 @@ +cp ../../../build/samples/greengrass/basic_discovery/basic-discovery . +cp ../../../utils/run_in_ci.py . +cp ../../../.github/workflows/ci_run_greengrass_discovery_cfg.json . diff --git a/tests/greengrass/basic_discovery/gdk-config.json b/tests/greengrass/basic_discovery/gdk-config.json new file mode 100644 index 000000000..468e794eb --- /dev/null +++ b/tests/greengrass/basic_discovery/gdk-config.json @@ -0,0 +1,22 @@ +{ + "component": { + "software.amazon.awssdk.sdk-gg-test-discovery": { + "author": "iot-device-sdk", + "version": "NEXT_PATCH", + "build": { + "build_system": "custom", + "custom_build_command": ["bash", "copy_files.sh"] + }, + "publish": { + "bucket": "iot-sdk-ci-bucket-us-east-1", + "region": "us-east-1" + } + } + }, + "gdk_version": "1.3.0", + "test-e2e": { + "gtf_options": { + "tags": "testgg" + } + } +} diff --git a/tests/greengrass/basic_discovery/gg-e2e-tests/pom.xml b/tests/greengrass/basic_discovery/gg-e2e-tests/pom.xml new file mode 100644 index 000000000..460832fcb --- /dev/null +++ b/tests/greengrass/basic_discovery/gg-e2e-tests/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + com.aws.greengrass + uat-features + jar + 1.0.0 + OTF + + + 1.2.0-SNAPSHOT + 1.8 + 1.8 + + + + + greengrass-common + greengrass common + + https://d2jrmugq4soldf.cloudfront.net/snapshots + + + + + + + com.aws.greengrass + aws-greengrass-testing-standalone + ${otf.version} + compile + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.2 + + + package + + shade + + + + + + + com.aws.greengrass.testing.launcher.TestLauncher + + + + + + + + + diff --git a/tests/greengrass/basic_discovery/gg-e2e-tests/src/main/resources/greengrass/features/component.feature b/tests/greengrass/basic_discovery/gg-e2e-tests/src/main/resources/greengrass/features/component.feature new file mode 100644 index 000000000..fb6d21d46 --- /dev/null +++ b/tests/greengrass/basic_discovery/gg-e2e-tests/src/main/resources/greengrass/features/component.feature @@ -0,0 +1,74 @@ +Feature: Testing features of Greengrassv2 basic discovery sample + + @testgg + Scenario: As a developer, I can create a component and deploy it on my device + Given my device is registered as a Thing + And my device is running Greengrass + When I create a Greengrass deployment with components + | aws.greengrass.clientdevices.Auth | LATEST | + | aws.greengrass.clientdevices.mqtt.Moquette | LATEST | + | aws.greengrass.clientdevices.mqtt.Bridge | LATEST | + | aws.greengrass.clientdevices.IPDetector | LATEST | + | software.amazon.awssdk.sdk-gg-test-discovery | file:recipe.yaml | + When I update my Greengrass deployment configuration, setting the component aws.greengrass.clientdevices.Auth configuration to: + """ + { + "MERGE": { + "deviceGroups": { + "formatVersion": "2021-03-05", + "definitions": { + "MyDeviceGroup": { + "selectionRule": "thingName: CI_Greengrass_Discovery_Thing", + "policyName": "MyRestrictivePolicy" + } + }, + "policies": { + "MyRestrictivePolicy": { + "AllowConnect": { + "statementDescription": "Allow client devices to connect.", + "operations": [ + "mqtt:connect" + ], + "resources": [ + "*" + ] + }, + "AllowPublish": { + "statementDescription": "Allow client devices to publish on topic.", + "operations": [ + "mqtt:publish" + ], + "resources": [ + "*clients/*/hello/world/*" + ] + } + } + } + } + } + } + """ + When I update my Greengrass deployment configuration, setting the component aws.greengrass.clientdevices.mqtt.Bridge configuration to: + """ + { + "MERGE": { + "mqttTopicMapping": { + "HelloWorldCoreMapping": { + "topic": "clients/+/hello/world/+", + "source": "LocalMqtt", + "target": "IotCore" + }, + "HelloWorldPubsubMapping": { + "topic": "clients/+/hello/world/+", + "source": "LocalMqtt", + "target": "Pubsub" + } + } + } + } + """ + And I deploy the Greengrass deployment configuration + Then the Greengrass deployment is COMPLETED on the device after 300 seconds + And the software.amazon.awssdk.sdk-gg-test-discovery log on the device contains the line "Successfully subscribed to topic" within 180 seconds + And the software.amazon.awssdk.sdk-gg-test-discovery log on the device contains the line "Received new message" within 240 seconds + And the software.amazon.awssdk.sdk-gg-test-discovery log on the device contains the line "disassociated CI_Greengrass_Discovery_Thing" within 260 seconds diff --git a/tests/greengrass/basic_discovery/hello_world_subscriber.py b/tests/greengrass/basic_discovery/hello_world_subscriber.py new file mode 100644 index 000000000..44fd99b71 --- /dev/null +++ b/tests/greengrass/basic_discovery/hello_world_subscriber.py @@ -0,0 +1,55 @@ +import argparse +import sys +import time +import traceback +import uuid + +from awsiot.greengrasscoreipc.clientv2 import GreengrassCoreIPCClientV2 + + +def on_message(event): + try: + print('Topic: {}'.format(event.binary_message.context.topic)) + message = str(event.binary_message.message, 'utf-8') + print('Received new message: {}'.format(message)) + except: + traceback.print_exc() + + +def main(): + argument_parser = argparse.ArgumentParser( + description="Run Greengrass subscriber component") + argument_parser.add_argument( + "--input_uuid", required=False, help="UUID for unique topic name. UUID will be generated if this option is omit") + parsed_commands = argument_parser.parse_args() + + input_uuid = parsed_commands.input_uuid if parsed_commands.input_uuid else str(uuid.uuid4()) + + try: + ipc_client = GreengrassCoreIPCClientV2() + + client_device_hello_world_topic = 'clients/+/hello/world/{}'.format(input_uuid) + + # SubscribeToTopic returns a tuple with the response and the operation. + _, operation = ipc_client.subscribe_to_topic( + topic=client_device_hello_world_topic, on_stream_event=on_message) + print('Successfully subscribed to topic: {}'.format(client_device_hello_world_topic)) + + # Keep the main thread alive, or the process will exit. + try: + while True: + time.sleep(10) + except InterruptedError: + print('Subscribe interrupted.') + + operation.close() + except Exception: + print('Exception occurred when using IPC.', file=sys.stderr) + traceback.print_exc() + exit(1) + + print("Subscriber done") + + +if __name__ == "__main__": + main() diff --git a/tests/greengrass/basic_discovery/messages.txt b/tests/greengrass/basic_discovery/messages.txt new file mode 100644 index 000000000..d5727f718 --- /dev/null +++ b/tests/greengrass/basic_discovery/messages.txt @@ -0,0 +1,2 @@ +hello +exit diff --git a/tests/greengrass/basic_discovery/recipe.yaml b/tests/greengrass/basic_discovery/recipe.yaml new file mode 100644 index 000000000..b7a019bcb --- /dev/null +++ b/tests/greengrass/basic_discovery/recipe.yaml @@ -0,0 +1,49 @@ +--- +RecipeFormatVersion: "2020-01-25" +ComponentName: software.amazon.awssdk.sdk-gg-test-discovery +ComponentVersion: "1.0.0" +ComponentDescription: "This is test for the Greengrass basic discovery sample" +ComponentPublisher: "iot-device-sdk" +ComponentConfiguration: + DefaultConfiguration: + accessControl: + aws.greengrass.ipc.pubsub: + software.amazon.awssdk.sdk-gg-test-discovery:pubsub:1: + policyDescription: "Allows access to subscribe to a Greengrass IPC test topic" + operations: + - aws.greengrass#SubscribeToTopic + - aws.greengrass#PublishToTopic + resources: + - "clients/*/hello/world/*" +Manifests: + - Platform: + os: all + Artifacts: + - URI: "file:hello_world_subscriber.py" + - URI: "file:run_in_ci.py" + - URI: "file:ci_run_greengrass_discovery_cfg.json" + - URI: "file:messages.txt" + - URI: "file:basic-discovery" + Permission: + Read: ALL + Execute: ALL + Lifecycle: + Install: | + echo "GG core:" {iot:thingName} + aws greengrassv2 batch-associate-client-device-with-core-device --core-device-thing-name {iot:thingName} --entries thingName=CI_Greengrass_Discovery_Thing + aws greengrassv2 list-client-devices-associated-with-core-device --core-device-thing-name {iot:thingName} + Run: | + UUID=$(python3 -c "import uuid; print (uuid.uuid4())") + echo "Starting subscriber" + python3 -u {artifacts:path}/hello_world_subscriber.py --input_uuid ${UUID} & + sleep 10 + echo "Starting discovery" + python3 {artifacts:path}/run_in_ci.py --runnable_dir {artifacts:path} --input_uuid ${UUID} --file {artifacts:path}/ci_run_greengrass_discovery_cfg.json + aws greengrassv2 batch-disassociate-client-device-from-core-device --core-device-thing-name {iot:thingName} --entries thingName=CI_Greengrass_Discovery_Thing + echo "Run: disassociated CI_Greengrass_Discovery_Thing" + Shutdown: | + echo "Shutdown: disassociating CI_Greengrass_Discovery_Thing" + aws greengrassv2 batch-disassociate-client-device-from-core-device --core-device-thing-name {iot:thingName} --entries thingName=CI_Greengrass_Discovery_Thing + Recover: | + echo "Recover: disassociating CI_Greengrass_Discovery_Thing" + aws greengrassv2 batch-disassociate-client-device-from-core-device --core-device-thing-name {iot:thingName} --entries thingName=CI_Greengrass_Discovery_Thing diff --git a/tests/greengrass/ipc/README.md b/tests/greengrass/ipc/README.md new file mode 100644 index 000000000..415195d68 --- /dev/null +++ b/tests/greengrass/ipc/README.md @@ -0,0 +1,43 @@ +# Test for Greengrass IPC Sample + +Greengrass IPC test runs using [Greengrass Development Kit Command-Line Interface](https://docs.aws.amazon.com/greengrass/v2/developerguide/greengrass-development-kit-cli.html) (GDK CLI). + +### Greengrass IPC component + +For Greengrass IPC sample to work, it should be deployed as a Greengrass component. +The following files defines this component: + +- [gdk-config.json](./gdk-config.json) - `gdk` reads this file to build and publish component. +- [copy_files.sh](./copy_files.sh) - utility to copy all required files for `gdk` to be able to build the component. +- [recipe.yaml](./recipe.yaml) - defines a component's details, dependencies, artifacts, and lifecycles. + +### How the test runs + +The first step is to build GreengrassV2 component artifacts and recipes from its source code: + +```shell +gdk component build +``` + +Then the following command builds the testing module: + +```shell +gdk test-e2e build +``` + +Finally, the test can run: + +```shell +gdk test-e2e run +``` + +The test behavior is defined in the [component.feature](./gg-e2e-tests/src/main/resources/greengrass/features/component.feature) +config file using a domain-specific language called [Gherkin](https://docs.aws.amazon.com/greengrass/v2/developerguide/gg-testing-framework.html). + +The test spins up Greengrass core, installs and configures Greengrass component dependencies (including the custom +Greengrass component described in the previous section). After everything is set up, it performs checks. They are defined +at the very bottom of the file and basically grep a log file for specific messages. + +On completion, the test creates log files in `testResult` directory with the run details. The component's logs are stored +in `testResult/gg-/software.amazon.awssdk.sdk-gg-ipc.log` file. Though, if error occurred before the +component started its execution, this file might be absent. diff --git a/tests/greengrass/ipc/copy_files.sh b/tests/greengrass/ipc/copy_files.sh new file mode 100644 index 000000000..1e22ce09a --- /dev/null +++ b/tests/greengrass/ipc/copy_files.sh @@ -0,0 +1,3 @@ +cp ../../../build/samples/greengrass/ipc/greengrass-ipc . +cp ../../../utils/run_in_ci.py . +cp ../../../.github/workflows/ci_run_greengrass_ipc_cfg.json . diff --git a/tests/greengrass/ipc/gdk-config.json b/tests/greengrass/ipc/gdk-config.json new file mode 100644 index 000000000..92222370e --- /dev/null +++ b/tests/greengrass/ipc/gdk-config.json @@ -0,0 +1,22 @@ +{ + "component": { + "software.amazon.awssdk.sdk-gg-ipc": { + "author": "iot-device-sdk", + "version": "NEXT_PATCH", + "build": { + "build_system": "custom", + "custom_build_command": ["bash", "copy_files.sh"] + }, + "publish": { + "bucket": "", + "region": "" + } + } + }, + "gdk_version": "1.3.0", + "test-e2e": { + "gtf_options": { + "tags": "testgg" + } + } +} diff --git a/tests/greengrass/ipc/gg-e2e-tests/pom.xml b/tests/greengrass/ipc/gg-e2e-tests/pom.xml new file mode 100644 index 000000000..460832fcb --- /dev/null +++ b/tests/greengrass/ipc/gg-e2e-tests/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + com.aws.greengrass + uat-features + jar + 1.0.0 + OTF + + + 1.2.0-SNAPSHOT + 1.8 + 1.8 + + + + + greengrass-common + greengrass common + + https://d2jrmugq4soldf.cloudfront.net/snapshots + + + + + + + com.aws.greengrass + aws-greengrass-testing-standalone + ${otf.version} + compile + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.2 + + + package + + shade + + + + + + + com.aws.greengrass.testing.launcher.TestLauncher + + + + + + + + + diff --git a/tests/greengrass/ipc/gg-e2e-tests/src/main/resources/greengrass/features/component.feature b/tests/greengrass/ipc/gg-e2e-tests/src/main/resources/greengrass/features/component.feature new file mode 100644 index 000000000..815331ad0 --- /dev/null +++ b/tests/greengrass/ipc/gg-e2e-tests/src/main/resources/greengrass/features/component.feature @@ -0,0 +1,11 @@ +Feature: Testing features of Greengrassv2 IPC sample + + @testgg + Scenario: As a developer, I can create a component and deploy it on my device + Given my device is registered as a Thing + And my device is running Greengrass + When I create a Greengrass deployment with components + | software.amazon.awssdk.sdk-gg-ipc | file:recipe.yaml | + And I deploy the Greengrass deployment configuration + Then the Greengrass deployment is COMPLETED on the device after 180 seconds + And the software.amazon.awssdk.sdk-gg-ipc log on the device contains the line "Received payload: hello" within 20 seconds diff --git a/tests/greengrass/ipc/recipe.yaml b/tests/greengrass/ipc/recipe.yaml new file mode 100644 index 000000000..004915886 --- /dev/null +++ b/tests/greengrass/ipc/recipe.yaml @@ -0,0 +1,32 @@ +--- +RecipeFormatVersion: "2020-01-25" +ComponentName: software.amazon.awssdk.sdk-gg-ipc +ComponentVersion: "1.0.0" +ComponentDescription: "This is test for the Greengrass IPC sample" +ComponentPublisher: "iot-device-sdk" +ComponentConfiguration: + DefaultConfiguration: + accessControl: + aws.greengrass.ipc.mqttproxy: + software.amazon.awssdk.sdk-gg-ipc:mqttproxy:1: + policyDescription: "Allows access to publish and subscribe to a Greengrass IPC test topic" + operations: + - aws.greengrass#PublishToIoTCore + - aws.greengrass#SubscribeToIoTCore + resources: + - "test/gg-ipc-topic*" + Message: "World" +Manifests: + - Platform: + os: all + Artifacts: + - URI: "file:greengrass-ipc" + Permission: + Read: ALL + Execute: ALL + - URI: "file:run_in_ci.py" + - URI: "file:ci_run_greengrass_ipc_cfg.json" + Lifecycle: + Run: | + echo "GG core:" {iot:thingName} + python3 {artifacts:path}/run_in_ci.py --runnable_dir {artifacts:path} --file {artifacts:path}/ci_run_greengrass_ipc_cfg.json diff --git a/utils/run_in_ci.py b/utils/run_in_ci.py index a999d2def..d8d284a6b 100644 --- a/utils/run_in_ci.py +++ b/utils/run_in_ci.py @@ -33,7 +33,8 @@ def setup_json_arguments_list(file, input_uuid=None): for argument in config_json['arguments']: # Add the name of the argument - config_json_arguments_list.append(argument['name']) + if( 'name' in argument): + config_json_arguments_list.append(argument['name']) # Based on the data present, we need to process and add the data differently try: @@ -147,7 +148,18 @@ def make_windows_pfx_file(certificate_file_path, private_key_path, pfx_file_path # Import the PFX into the Windows Certificate Store # (Passing '$mypwd' is required even though it is empty and our certificate has no password. It fails CI otherwise) - import_pfx_arguments = ["powershell.exe", "Import-PfxCertificate", "-FilePath", pfx_file_path, "-CertStoreLocation", "Cert:\\" + pfx_certificate_store_location, "-Password", "$mypwd"] + import_pfx_arguments = [ + "powershell.exe", + # Powershell 7.3 introduced an issue where launching powershell from cmd would not set PSModulePath correctly. + # As a workaround, we set `PSModulePath` to empty so powershell would automatically reset the PSModulePath to default. + # More details: https://github.com/PowerShell/PowerShell/issues/18530 + "$env:PSModulePath = '';", + "Import-PfxCertificate", + "-FilePath", pfx_file_path, + "-CertStoreLocation", + "Cert:\\" + pfx_certificate_store_location, + "-Password", + "$mypwd"] import_pfx_run = subprocess.run(args=import_pfx_arguments, shell=True, stdout=subprocess.PIPE) if (import_pfx_run.returncode != 0): print ("ERROR: Could not import PFX certificate into Windows store!") @@ -216,7 +228,8 @@ def cleanup_runnable(): global config_json_arguments_list for argument in config_json['arguments']: - config_json_arguments_list.append(argument['name']) + if( 'name' in argument): + config_json_arguments_list.append(argument['name']) # Based on the data present, we need to process and add the data differently try: @@ -242,7 +255,7 @@ def cleanup_runnable(): return -1 -def launch_runnable(): +def launch_runnable(runnable_dir): global config_json global config_json_arguments_list @@ -250,92 +263,125 @@ def launch_runnable(): print("No configuration JSON file data found!") return -1 + # Prepare data for runnable's STDIN + subprocess_stdin = None + if "stdin_file" in config_json: + stdin_file = os.path.join(runnable_dir, config_json['stdin_file']) + with open(stdin_file, "rb") as file: + subprocess_stdin = file.read() + exit_code = 0 + runnable_timeout = None + if ('timeout' in config_json): + runnable_timeout = config_json['timeout'] + print("Launching runnable...") - # Java - if (config_json['language'] == "Java"): - - # Flatten arguments down into a single string - arguments_as_string = "" - for i in range(0, len(config_json_arguments_list)): - arguments_as_string += str(config_json_arguments_list[i]) - if (i+1 < len(config_json_arguments_list)): - arguments_as_string += " " - - arguments = ["mvn", "compile", "exec:java"] - arguments.append("-pl") - arguments.append(config_json['runnable_file']) - arguments.append("-Dexec.mainClass=" + config_json['runnable_main_class']) - arguments.append("-Daws.crt.ci=True") - - # We have to do this as a string, unfortunately, due to how -Dexec.args= works... - argument_string = subprocess.list2cmdline(arguments) + " -Dexec.args=\"" + arguments_as_string + "\"" - print(f"Running cmd: {argument_string}") - runnable_return = subprocess.run(argument_string, shell=True) - exit_code = runnable_return.returncode - - # C++ - elif (config_json['language'] == "CPP"): - runnable_return = subprocess.run( - args=config_json_arguments_list, executable=config_json['runnable_file']) - exit_code = runnable_return.returncode - - elif (config_json['language'] == "Python"): - config_json_arguments_list.append("--is_ci") - config_json_arguments_list.append("True") - - runnable_return = subprocess.run( - args=[sys.executable, config_json['runnable_file']] + config_json_arguments_list) - exit_code = runnable_return.returncode - - elif (config_json['language'] == "Javascript"): - os.chdir(config_json['runnable_file']) - - config_json_arguments_list.append("--is_ci") - config_json_arguments_list.append("true") - - runnable_return_one = None - if sys.platform == "win32" or sys.platform == "cygwin": - runnable_return_one = subprocess.run(args=["npm", "install"], shell=True) - else: - runnable_return_one = subprocess.run(args=["npm", "install"]) + try: + # Java + if (config_json['language'] == "Java"): + # Flatten arguments down into a single string + arguments_as_string = "" + for i in range(0, len(config_json_arguments_list)): + arguments_as_string += str(config_json_arguments_list[i]) + if (i+1 < len(config_json_arguments_list)): + arguments_as_string += " " + + arguments = ["mvn", "compile", "exec:java"] + arguments.append("-pl") + arguments.append(config_json['runnable_file']) + arguments.append("-Dexec.mainClass=" + config_json['runnable_main_class']) + arguments.append("-Daws.crt.ci=True") + + # We have to do this as a string, unfortunately, due to how -Dexec.args= works... + argument_string = subprocess.list2cmdline(arguments) + " -Dexec.args=\"" + arguments_as_string + "\"" + print(f"Running cmd: {argument_string}") + runnable_return = subprocess.run(argument_string, input=subprocess_stdin, timeout=runnable_timeout, shell=True) + exit_code = runnable_return.returncode + + elif (config_json['language'] == "Java JAR"): + # Flatten arguments down into a single string + arguments_as_string = "" + for i in range(0, len(config_json_arguments_list)): + arguments_as_string += str(config_json_arguments_list[i]) + if (i+1 < len(config_json_arguments_list)): + arguments_as_string += " " + + runnable_file = os.path.join(runnable_dir, config_json['runnable_file']) + + arguments = ["java"] + arguments.append("-Daws.crt.ci=True") + arguments.append("-jar") + arguments.append(runnable_file) + + argument_string = subprocess.list2cmdline(arguments) + " " + arguments_as_string + print(f"Running cmd: {argument_string}") + runnable_return = subprocess.run(argument_string, input=subprocess_stdin, timeout=runnable_timeout, shell=True) + exit_code = runnable_return.returncode + + # C++ + elif (config_json['language'] == "CPP"): + runnable_file = os.path.join(runnable_dir, config_json['runnable_file']) + runnable_return = subprocess.run(args=config_json_arguments_list, input=subprocess_stdin, timeout=runnable_timeout, executable=runnable_file) + exit_code = runnable_return.returncode + + elif (config_json['language'] == "Python"): + runnable_file = os.path.join(runnable_dir, config_json['runnable_file']) + runnable_return = subprocess.run( + args=[sys.executable, runnable_file] + config_json_arguments_list, input=subprocess_stdin, timeout=runnable_timeout) + exit_code = runnable_return.returncode + + elif (config_json['language'] == "Javascript"): + os.chdir(config_json['runnable_file']) + + config_json_arguments_list.append("--is_ci") + config_json_arguments_list.append("true") + + runnable_return_one = None + if not 'skip_install' in config_json: + if sys.platform == "win32" or sys.platform == "cygwin": + runnable_return_one = subprocess.run(args=["npm", "install"], shell=True, timeout=runnable_timeout) + else: + runnable_return_one = subprocess.run(args=["npm", "install"], timeout=runnable_timeout) - if (runnable_return_one == None or runnable_return_one.returncode != 0): - exit_code = runnable_return_one.returncode - else: - runnable_return_two = None - arguments = [] - if 'node_cmd' in config_json: - arguments = config_json['node_cmd'].split(" ") + if not 'skip_install' in config_json and (runnable_return_one == None or runnable_return_one.returncode != 0): + exit_code = runnable_return_one.returncode else: - arguments = ["node", "dist/index.js"] + runnable_return_two = None + arguments = [] + if 'node_cmd' in config_json: + arguments = config_json['node_cmd'].split(" ") + else: + arguments = ["node", "dist/index.js"] - if sys.platform == "win32" or sys.platform == "cygwin": - runnable_return_two = subprocess.run( - args=arguments + config_json_arguments_list, shell=True) - else: - runnable_return_two = subprocess.run( - args=arguments + config_json_arguments_list) + if sys.platform == "win32" or sys.platform == "cygwin": + runnable_return_two = subprocess.run( + args=arguments + config_json_arguments_list, shell=True, check=True, timeout=runnable_timeout) + else: + runnable_return_two = subprocess.run( + args=arguments + config_json_arguments_list, input=subprocess_stdin, timeout=runnable_timeout) - if (runnable_return_two != None): - exit_code = runnable_return_two.returncode - else: - exit_code = 1 + if (runnable_return_two != None): + exit_code = runnable_return_two.returncode + else: + exit_code = 1 + except subprocess.CalledProcessError as e: + print(e.output) + exit_code = 1 cleanup_runnable() return exit_code -def setup_and_launch(file, input_uuid=None): +def setup_and_launch(file, input_uuid=None, runnable_dir=''): setup_result = setup_runnable(file, input_uuid) if setup_result != 0: print("Setting up runnable failed") return setup_result print("About to launch runnable...") - return launch_runnable() + return launch_runnable(runnable_dir) def main(): @@ -344,13 +390,16 @@ def main(): argument_parser.add_argument("--file", required=True, help="Configuration file to pull CI data from") argument_parser.add_argument("--input_uuid", required=False, help="UUID data to replace '$INPUT_UUID' with. Only works in Data field") + argument_parser.add_argument("--runnable_dir", required=False, default='', + help="Directory where runnable_file is located") parsed_commands = argument_parser.parse_args() file = parsed_commands.file input_uuid = parsed_commands.input_uuid + runnable_dir = parsed_commands.runnable_dir print(f"Starting to launch runnable: config {file}; input UUID: {input_uuid}") - test_result = setup_and_launch(file, input_uuid) + test_result = setup_and_launch(file, input_uuid, runnable_dir) sys.exit(test_result)