From 933ed6d78886b378df6a749d034d896e7dc6f353 Mon Sep 17 00:00:00 2001 From: Joel Guittet Date: Thu, 28 May 2026 22:57:27 +0200 Subject: [PATCH 1/3] project: new option to set custom server certificate --- app/CMakeLists.txt | 8 ++++++-- app/Kconfig | 11 +++++++++++ app/src/main.c | 18 ++++++++++++++++++ 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index fe30e09..e49aa9c 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -34,5 +34,9 @@ target_compile_definitions(app PRIVATE PROJECT_NAME="mender-ncs-example") target_compile_definitions(app PRIVATE _POSIX_C_SOURCE=200809L) # Required for strdup support # Generate Root CA include files -generate_inc_file_for_target(app "src/AmazonRootCA1.cer" "${ZEPHYR_BINARY_DIR}/include/generated/AmazonRootCA1.cer.inc") -generate_inc_file_for_target(app "src/GoogleTrustServicesR4.crt" "${ZEPHYR_BINARY_DIR}/include/generated/GoogleTrustServicesR4.crt.inc") +if (CONFIG_EXAMPLE_USE_CUSTOM_CERTIFICATE) + generate_inc_file_for_target(app "${CONFIG_EXAMPLE_CERTIFICATE_PATH}" "${ZEPHYR_BINARY_DIR}/include/generated/mender.der.inc") +else() + generate_inc_file_for_target(app "src/AmazonRootCA1.cer" "${ZEPHYR_BINARY_DIR}/include/generated/AmazonRootCA1.cer.inc") + generate_inc_file_for_target(app "src/GoogleTrustServicesR4.crt" "${ZEPHYR_BINARY_DIR}/include/generated/GoogleTrustServicesR4.crt.inc") +endif() diff --git a/app/Kconfig b/app/Kconfig index 0863d8e..1bfbc3b 100644 --- a/app/Kconfig +++ b/app/Kconfig @@ -23,6 +23,17 @@ endmenu menu "Application" + config EXAMPLE_USE_CUSTOM_CERTIFICATE + bool "Use custom certificate path" + help + Should be set to use custom certificate path instead of the default certificates. + + config EXAMPLE_CERTIFICATE_PATH + string "Certificate path" + depends on EXAMPLE_USE_CUSTOM_CERTIFICATE + help + Custom certificate path to be integrated in the application instead of the default certificates. + config EXAMPLE_ARTIFACT_NAME_PREFIX string "Artifact name" help diff --git a/app/src/main.c b/app/src/main.c index 7d67589..947b53f 100644 --- a/app/src/main.c +++ b/app/src/main.c @@ -49,6 +49,22 @@ LOG_MODULE_REGISTER(mender_ncs_example, LOG_LEVEL_INF); #ifdef CONFIG_NET_SOCKETS_SOCKOPT_TLS #include +#ifdef CONFIG_EXAMPLE_USE_CUSTOM_CERTIFICATE + +/* + * Certificate must be retrieved in DER format. + * It is converted to include file in application CMakeLists.txt. + */ +#ifdef CONFIG_TLS_CREDENTIAL_FILENAMES +static const unsigned char ca_certificate_primary[] = "mender.der"; +#else +static const unsigned char ca_certificate_primary[] = { +#include "mender.der.inc" +}; +#endif /* CONFIG_TLS_CREDENTIAL_FILENAMES */ + +#else + /* * Amazon Root CA 1 certificate, retrieved from https://www.amazontrust.com/repository in DER format. * It is converted to include file in application CMakeLists.txt. @@ -76,6 +92,8 @@ static const unsigned char ca_certificate_secondary[] = { #endif /* CONFIG_TLS_CREDENTIAL_FILENAMES */ #endif /* (0 != CONFIG_MENDER_NET_CA_CERTIFICATE_TAG_SECONDARY) */ +#endif /* CONFIG_EXAMPLE_USE_CUSTOM_CERTIFICATE */ + #endif /* CONFIG_NET_SOCKETS_SOCKOPT_TLS */ #include "mender-client.h" From 88e33e3f4f294e65500cdfdeaab271a2d75c455c Mon Sep 17 00:00:00 2001 From: Joel Guittet Date: Thu, 28 May 2026 23:00:57 +0200 Subject: [PATCH 2/3] project: new option to read firmware version using mcumgr --- app/CMakeLists.txt | 5 +++++ app/mcumgr.conf | 21 +++++++++++++++++++++ app/src/main.c | 26 ++++++++++++++++++++++++++ west.yml | 1 + 4 files changed, 53 insertions(+) create mode 100644 app/mcumgr.conf diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt index e49aa9c..676fcc7 100644 --- a/app/CMakeLists.txt +++ b/app/CMakeLists.txt @@ -33,6 +33,11 @@ target_sources(app PRIVATE "src/main.c") target_compile_definitions(app PRIVATE PROJECT_NAME="mender-ncs-example") target_compile_definitions(app PRIVATE _POSIX_C_SOURCE=200809L) # Required for strdup support +# Link application to bootutil library if mcumgr is used +if (CONFIG_MCUMGR_GRP_IMG) + zephyr_link_libraries(MCUBOOT_BOOTUTIL) +endif() + # Generate Root CA include files if (CONFIG_EXAMPLE_USE_CUSTOM_CERTIFICATE) generate_inc_file_for_target(app "${CONFIG_EXAMPLE_CERTIFICATE_PATH}" "${ZEPHYR_BINARY_DIR}/include/generated/mender.der.inc") diff --git a/app/mcumgr.conf b/app/mcumgr.conf new file mode 100644 index 0000000..3d9e5b4 --- /dev/null +++ b/app/mcumgr.conf @@ -0,0 +1,21 @@ +# @file mcumgr.conf +# @brief mender-ncs-example mcumgr configuration file +# +# Copyright joelguittet and mender-mcu-client contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# mcumgr +CONFIG_MCUMGR=y +CONFIG_MCUMGR_GRP_IMG=y +CONFIG_ZCBOR=y diff --git a/app/src/main.c b/app/src/main.c index 947b53f..3b07c11 100644 --- a/app/src/main.c +++ b/app/src/main.c @@ -29,6 +29,9 @@ LOG_MODULE_REGISTER(mender_ncs_example, LOG_LEVEL_INF); #include #include +#ifdef CONFIG_MCUMGR_GRP_IMG +#include +#endif /* CONFIG_MCUMGR_GRP_IMG */ #include #include #ifdef CONFIG_WIFI @@ -630,12 +633,35 @@ main(void) { linkaddr->addr[5]); LOG_INF("MAC address of the device '%s'", mac_address); +#ifdef CONFIG_MCUMGR_GRP_IMG /* Retrieve running version of the device */ + struct image_version image_version; + img_mgmt_my_version(&image_version); + LOG_INF("Running project '%s' version '%d.%d.%d+%d'", + PROJECT_NAME, + image_version.iv_major, + image_version.iv_minor, + image_version.iv_revision, + image_version.iv_build_num); +#else + /* Print version of the device */ LOG_INF("Running project '%s' version '%s'", PROJECT_NAME, APP_VERSION_STRING); +#endif /* CONFIG_MCUMGR_GRP_IMG */ /* Compute artifact name */ char artifact_name[128]; +#ifdef CONFIG_MCUMGR_GRP_IMG + snprintf(artifact_name, + sizeof(artifact_name), + "%s-v%d.%d.%d+%d", + CONFIG_EXAMPLE_ARTIFACT_NAME_PREFIX, + image_version.iv_major, + image_version.iv_minor, + image_version.iv_revision, + image_version.iv_build_num); +#else snprintf(artifact_name, sizeof(artifact_name), "%s-v%s", CONFIG_EXAMPLE_ARTIFACT_NAME_PREFIX, APP_VERSION_STRING); +#endif /* CONFIG_MCUMGR_GRP_IMG */ /* Retrieve device type */ char *device_type = CONFIG_EXAMPLE_DEVICE_TYPE; diff --git a/west.yml b/west.yml index 9aff3f5..c6d4d1e 100644 --- a/west.yml +++ b/west.yml @@ -55,6 +55,7 @@ manifest: - nrf_hw_models - nrf_wifi - picolibc + - zcbor # required in case mcumgr is enabled - name: mender-mcu-client remote: joelguittet revision: 0.12.4 From 880af24d4afec15c95d02c476ce83e418ca0c7d9 Mon Sep 17 00:00:00 2001 From: Joel Guittet Date: Thu, 28 May 2026 23:21:18 +0200 Subject: [PATCH 3/3] workflows: add device testing and related scripts --- .github/workflows/device-testing.yml | 254 +++++++++++++++++++++++++++ .github/workflows/hardware-map.yml | 108 ++++++++++++ .gitignore | 2 + README.md | 31 +++- app/pytest/helpers.py | 132 ++++++++++++++ app/pytest/mender-cli-terminal.sh | 71 ++++++++ app/pytest/mender_cli.py | 56 ++++++ app/pytest/mender_management.py | 230 ++++++++++++++++++++++++ app/pytest/test_mender_mcu_client.py | 231 ++++++++++++++++++++++++ app/testcase.yaml | 40 +++++ 10 files changed, 1154 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/device-testing.yml create mode 100644 .github/workflows/hardware-map.yml create mode 100644 app/pytest/helpers.py create mode 100755 app/pytest/mender-cli-terminal.sh create mode 100644 app/pytest/mender_cli.py create mode 100644 app/pytest/mender_management.py create mode 100644 app/pytest/test_mender_mcu_client.py create mode 100644 app/testcase.yaml diff --git a/.github/workflows/device-testing.yml b/.github/workflows/device-testing.yml new file mode 100644 index 0000000..155547a --- /dev/null +++ b/.github/workflows/device-testing.yml @@ -0,0 +1,254 @@ +# @file device-testing.yml +# @brief Used to perform device testing using twister +# +# Copyright joelguittet and mender-mcu-client contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: device-testing + +on: + push: + branches: + - '**' + pull_request: + types: [opened, synchronize, reopened] + schedule: + - cron: '0 0 * * 1' + workflow_dispatch: + +# Define concurrency so that the jobs of the workflow are not mixed between +# several executions in case it is launched again while one is still running +concurrency: + group: device-testing + cancel-in-progress: false + +jobs: + device-testing: + name: Test execution + runs-on: self-hosted + timeout-minutes: 120 + # Twister is executed inside ghcr.io/zephyrproject-rtos/ci container + # The container is executed with privileged option to have full access to the host + # The following are bounded in the container: + # - /dev: to allow access to the devices + # - local.conf: contains configuration of the test bench such as Mender server and Wi-Fi settings + # - map.yml: the twister hardware map corresponding to the devices physically connected to the test bench + # - mender.crt/mender.der: mender-server certificate to allow device and tests connecting to the server + container: + image: ghcr.io/zephyrproject-rtos/ci:v0.29.0 + volumes: + - /dev:/dev + - /home/testbench/mender-ncs-example/local.conf:/__w/mender-ncs-example/mender-ncs-example/local.conf + - /home/testbench/mender-ncs-example/map.yml:/__w/mender-ncs-example/mender-ncs-example/map.yml + - /home/testbench/mender-server/compose/certs/mender.crt:/__w/mender-ncs-example/mender-ncs-example/mender.crt + - /home/testbench/mender-server/compose/certs/mender.der:/__w/mender-ncs-example/mender-ncs-example/mender.der + options: --privileged + # Permissions + # - contents: read, actions: read and checks: write are required to use dorny/test-reporter@v3 + permissions: + contents: read + actions: read + checks: write + + steps: + # Install mender-artifact and mender-cli + # mender-artifact is used to generate artifact for firmware update + # mender-cli is used to connect to the troubleshoot terminal of the device + - name: Install mender-artifact and mender-cli + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + apt-transport-https ca-certificates curl gnupg jq + curl -fsSL https://downloads.mender.io/repos/debian/gpg | tee /etc/apt/trusted.gpg.d/mender.asc + sed -i.bak -e "\,https://downloads.mender.io/repos/workstation-tools,d" /etc/apt/sources.list + echo "deb [arch=$(dpkg --print-architecture)] https://downloads.mender.io/repos/workstation-tools debian/trixie/stable main" | tee /etc/apt/sources.list.d/mender.list + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + mender-artifact mender-cli + + # Install J-Link (used to flash nrf7002-dk board) + - name: Install J-Link + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + libxcb-render-util0 libxcb-shape0 libxcb-icccm4 libxcb-keysyms1 libxcb-image0 libxkbcommon-x11-0 udev curl + /lib/systemd/systemd-udevd --daemon + curl -d "accept_license_agreement=accepted&submit=Download+software" -X POST -O "https://www.segger.com/downloads/jlink/JLink_Linux_${JLINK_VERSION}_x86_64.deb" + dpkg -i JLink_Linux_${JLINK_VERSION}_x86_64.deb + rm JLink_Linux_${JLINK_VERSION}_x86_64.deb + env: + JLINK_VERSION: V890 + + # Checkout mender-ncs-example + - name: Checkout mender-ncs-example + uses: actions/checkout@v6 + with: + submodules: recursive + path: mender-ncs-example + + # Setup Python + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + env: + PIP_ROOT_USER_ACTION: ignore + + # Setup Zephyr project + # Toolchains are installed to build the application for all supported boards: + # - arm-zephyr-eabi: to support nrf7002-dk board + - name: Setup Zephyr project + uses: zephyrproject-rtos/action-zephyr-setup@v1 + with: + app-path: mender-ncs-example + toolchains: arm-zephyr-eabi + env: + PIP_ROOT_USER_ACTION: ignore + + # Apply Zephyr patches + - name: Apply Zephyr patches + working-directory: mender-ncs-example + run: | + if [ -f "zephyr/patches.yml" ]; then + west patch apply + fi + + # Fetch blobs + # The following blobs are fetch: + # - nrf_wifi: to support nrf7002-dk board + - name: Fetch blobs + working-directory: mender-ncs-example + run: | + west blobs fetch nrf_wifi + + # Execute tests using twister + # The following options are used: + # --allow-installed-plugin: allow using pytest-dependency + # --enable-slow: execute test with "slow: true" in the testcase.yml file + # --inline-logs: print logs to stdout + # --device-testing: test on devices + # --hardware-map: Hardware map file, specific to the testbench machine + # The following environment variables are defined: + # - MENDER_SERVER: mender-server URL "https://docker.mender.io" + # - MENDER_USER: mender-server username previsouly defined + # - MENDER_PASS: mender-server password previsouly defined + # - MENDER_CA: mender-server certificate path to connect to the server + - name: Execute tests + working-directory: mender-ncs-example + id: execute-tests + run: | + west twister -T app -v --allow-installed-plugin --enable-slow --inline-logs --device-testing --hardware-map /__w/mender-ncs-example/mender-ncs-example/map.yml + env: + MENDER_SERVER: "https://docker.mender.io" + MENDER_USERNAME: ${{ secrets.MENDER_USERNAME }} + MENDER_PASSWORD: ${{ secrets.MENDER_PASSWORD }} + MENDER_CA: "/__w/mender-ncs-example/mender-ncs-example/mender.crt" + + # Publish test results + - name: Publish test results + if: ${{ always() && steps.execute-tests.conclusion != 'skipped' && steps.execute-tests.conclusion != 'cancelled' }} + uses: dorny/test-reporter@v3 + with: + working-directory: mender-ncs-example + name: πŸ§ͺ Zephyr Twister Test Report + path: twister-out/twister_report.xml + reporter: java-junit + report-title: πŸ§ͺ Zephyr Twister Test Report + + # Upload test-results artifact + # This step is always executed, so that we can get logs even in case of failure during the execution of the tests + - name: Upload artifact + if: ${{ always() && steps.execute-tests.conclusion != 'skipped' && steps.execute-tests.conclusion != 'cancelled' }} + uses: actions/upload-artifact@v7 + with: + name: test-results + if-no-files-found: error + retention-days: 7 + path: | + mender-ncs-example/twister-out/testplan.json + mender-ncs-example/twister-out/twister.json + mender-ncs-example/twister-out/twister.log + mender-ncs-example/twister-out/twister.xml + mender-ncs-example/twister-out/twister_report.xml + mender-ncs-example/twister-out/twister_suite_report.xml + mender-ncs-example/twister-out/**/build.log + mender-ncs-example/twister-out/**/device.log + mender-ncs-example/twister-out/**/handler.log + mender-ncs-example/twister-out/**/report.xml + mender-ncs-example/twister-out/**/twister_harness.log + + # Cleanup dangling devices and artifacts on mender-server + - name: Cleanup dangling devices and artifacts on mender-server + if: ${{ always() }} + shell: bash + run: | + # Login to mender-server + TOKEN=$(curl -s -X POST https://docker.mender.io/api/management/v1/useradm/auth/login --cacert /__w/mender-ncs-example/mender-ncs-example/mender.crt -H "Content-Type: application/json" -H "Accept: application/jwt" -H "Authorization: Basic $(printf "%s" "${{ secrets.MENDER_USERNAME }}:${{ secrets.MENDER_PASSWORD }}" | base64)") + DEVICE_TYPES=(mender-nrf7002dk-ncs-example) + for device_type in "${DEVICE_TYPES[@]}"; do + # Abort all the active and pending deployments + curl -s https://docker.mender.io/api/management/v1/inventory/devices?inventory/device_type=${device_type} --cacert /__w/mender-ncs-example/mender-ncs-example/mender.crt -H "Authorization: Bearer $TOKEN" | jq -r '.[] | .id' | while read id; do echo "Abort deployment for device type '$device_type' with id '$id'"; curl -s -X DELETE https://docker.mender.io/api/management/v1/deployments/deployments/devices/$id --cacert /__w/mender-ncs-example/mender-ncs-example/mender.crt -H "Authorization: Bearer $TOKEN"; done + # Remove artifacts + curl -s https://docker.mender.io/api/management/v2/deployments/artifacts?device_type=${device_type} --cacert /__w/mender-ncs-example/mender-ncs-example/mender.crt -H "Authorization: Bearer $TOKEN" | jq -r '.[] | .id' | while read id; do echo "Removing artifact for device type '$device_type' with id '$id'"; curl -s -X DELETE https://docker.mender.io/api/management/v1/deployments/artifacts/$id --cacert /__w/mender-ncs-example/mender-ncs-example/mender.crt -H "Authorization: Bearer $TOKEN"; done + # Remove devices + curl -s https://docker.mender.io/api/management/v1/inventory/devices?inventory/device_type=${device_type} --cacert /__w/mender-ncs-example/mender-ncs-example/mender.crt -H "Authorization: Bearer $TOKEN" | jq -r '.[] | .id' | while read id; do echo "Removing device type '$device_type' with id '$id'"; curl -s -X DELETE https://docker.mender.io/api/management/v2/devauth/devices/$id --cacert /__w/mender-ncs-example/mender-ncs-example/mender.crt -H "Authorization: Bearer $TOKEN"; done + done + + # Cleanup twister results + - name: Cleanup twister results + if: ${{ always() }} + working-directory: mender-ncs-example + run: | + rm -rf twister-out + + # Cleanup Zephyr patches + - name: Cleanup Zephyr patches + if: ${{ always() }} + working-directory: mender-ncs-example + run: | + if [ -f "zephyr/patches.yml" ]; then + west patch clean + fi + + # Cleanup Zephyr environment + - name: Cleanup Zephyr environment + if: ${{ always() }} + run: | + rm -rf .west + + publish-test-results: + name: Publish test results + runs-on: ubuntu-24.04 + if: ${{ always() && !contains(needs.device-testing.result, 'skipped') && !contains(needs.device-testing.result, 'cancelled') }} + needs: device-testing + # Permissions + # - contents: read, issues: read checks: write and pull-requests: write are required to use EnricoMi/publish-unit-test-result-action/linux@v2 + permissions: + contents: read + issues: read + checks: write + pull-requests: write + + steps: + # Download artifacts + # This step is used to retrieve test results so they can be used to publish report + - uses: actions/download-artifact@v8 + with: + path: artifacts + + # Publish test results + - name: Publish test results + uses: EnricoMi/publish-unit-test-result-action/linux@v2 + with: + files: artifacts/twister_report.xml diff --git a/.github/workflows/hardware-map.yml b/.github/workflows/hardware-map.yml new file mode 100644 index 0000000..995f015 --- /dev/null +++ b/.github/workflows/hardware-map.yml @@ -0,0 +1,108 @@ +# @file hardware-map.yml +# @brief Used to generate map on the test machine +# +# Copyright joelguittet and mender-mcu-client contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +name: hardware-map + +on: + workflow_dispatch: + +jobs: + generate-hardware-map: + name: Generate hardware map + runs-on: self-hosted + # Twister is executed inside zephyrproject-rtos/ci container + # The container is executed with privileged option to have full access to the host + # The following are bounded in the container: + # - /dev: to allow access to the devices + container: + image: ghcr.io/zephyrproject-rtos/ci:v0.29.0 + volumes: + - /dev:/dev + options: --privileged + + steps: + # Checkout mender-ncs-example + - name: Checkout mender-ncs-example + uses: actions/checkout@v6 + with: + submodules: recursive + path: mender-ncs-example + + # Setup Python + - name: Setup Python + uses: actions/setup-python@v6 + with: + python-version: "3.12" + env: + PIP_ROOT_USER_ACTION: ignore + + # Setup Zephyr project + - name: Setup Zephyr project + uses: zephyrproject-rtos/action-zephyr-setup@v1 + with: + app-path: mender-ncs-example + env: + PIP_ROOT_USER_ACTION: ignore + + # Apply Zephyr patches + - name: Apply Zephyr patches + working-directory: mender-ncs-example + run: | + if [ -f "zephyr/patches.yml" ]; then + west patch apply + fi + + # Generate hardware map using twister + # The following options is used: + # --generate-hardware-map: generate hardware map + # --persistent-hardware-map: tries to use persistent names for serial devices + - name: Generate hardware map + working-directory: mender-ncs-example + run: | + west twister --persistent-hardware-map --generate-hardware-map twister-out/map.yml + + # Upload hardware-map artifact + - name: Upload artifact + uses: actions/upload-artifact@v7 + with: + name: hardware-map + if-no-files-found: error + retention-days: 1 + path: | + mender-ncs-example/twister-out/map.yml + + # Cleanup twister results + - name: Cleanup twister results + if: ${{ always() }} + working-directory: mender-ncs-example + run: | + rm -rf twister-out + + # Cleanup Zephyr patches + - name: Cleanup Zephyr patches + if: ${{ always() }} + working-directory: mender-ncs-example + run: | + if [ -f "zephyr/patches.yml" ]; then + west patch clean + fi + + # Cleanup Zephyr environment + - name: Cleanup Zephyr environment + if: ${{ always() }} + run: | + rm -rf .west diff --git a/.gitignore b/.gitignore index 378eac2..33ae8d1 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ build +twister-out +__pycache__ diff --git a/README.md b/README.md index 0e7dbaa..4373ea0 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,7 @@ Congratulation! You have updated the device. Mender server displays the success In case of failure to connect and authenticate to the server the current example application performs a rollback to the previous release. You can customize the behavior of the example application to add your own checks and perform the rollback in case the tests fail. -### Building vscode nRF Connect SDK environment +### Building using vscode nRF Connect SDK environment As an alternative to using the command line, it is possible to build the application using vscode nRF Connect SDK environment. @@ -345,6 +345,35 @@ Those configurations are also available using the Kconfig fragment `trial.conf` west build -b nrf7002dk/nrf5340/cpuapp/ns app -- -DEXTRA_CONF_FILE="trial.conf" ``` + +## Testing + +[Twister](https://docs.zephyrproject.org/latest/develop/test/twister.html) and [Pytest](https://docs.pytest.org/en/stable) are used in Github actions workflows to execute tests on real target. + +For such purpose, Github action runner is installed on a Debian machine running the workflows. The Debian machine uses `ghcr.io/zephyrproject-rtos/ci` container which provides a good environment to build Zephyr RTOS projects in such context. + +When testing is executed, a dedicated mender-server instance is used. mender-server certificate to allow device and tests connecting to the server are provided. mender-server username and password are provided throw the Github Actions secrets. + +The Debian machine has a local configuration `local.conf` file used to build the firmware which contains the following settings: + +``` +CONFIG_MENDER_SERVER_HOST="https://docker.mender.io" +CONFIG_MENDER_SERVER_TENANT_TOKEN="" +CONFIG_EXAMPLE_USE_CUSTOM_CERTIFICATE=y +CONFIG_EXAMPLE_CERTIFICATE_PATH="/__w/mender-ncs-example/mender-ncs-example/mender.der" +CONFIG_EXAMPLE_WIFI_SSID="" +CONFIG_EXAMPLE_WIFI_PSK="" +``` + +The Debian machine has also a `map.yml` file to provide board settings for twister to run testing. + +Tests cover device registration, inventory, configuration, troubleshooting shell, firmware update and decomissioning. + +Tests are executed for each pull-request opened on the repository. Results are reported in the pull-request. + +The workflows and test files can be reused for your own projects. + + ## License Copyright joelguittet and mender-mcu-client contributors diff --git a/app/pytest/helpers.py b/app/pytest/helpers.py new file mode 100644 index 0000000..b81312a --- /dev/null +++ b/app/pytest/helpers.py @@ -0,0 +1,132 @@ +# @file helpers.py +# @brief Helpers +# +# Copyright joelguittet and mender-mcu-client contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import re +import subprocess +import tempfile +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path +from twister_harness import DeviceAdapter +from twister_harness.exceptions import TwisterHarnessTimeoutException +from twister_harness.helpers.utils import match_no_lines + + +class Helpers: + """ + Provides helper methods for device management. + """ + + def get_device_mac_address(self, dut: DeviceAdapter) -> str: + try: + output = dut.readlines_until(regex="MAC address of the device '[0-9A-Za-z:]+'", timeout=60) + except (TwisterHarnessTimeoutException, AssertionError): + raise AssertionError("πŸ”΄β€‹β€‹β€‹ MAC address of the device not found") + m = re.search("MAC address of the device '(?P[0-9A-Za-z:]+)'", output[-1]) + if m: + mac_address = m.group('mac') + else: + raise AssertionError("πŸ”΄β€‹β€‹β€‹ MAC address of the device not found") + print(f"πŸ”΅β€‹ MAC address of the device is '{mac_address}'") + return mac_address + + def get_device_firmware_version(self, dut: DeviceAdapter) -> str: + try: + output = dut.readlines_until(regex="Running project 'mender-ncs-example' version '[0-9.+]+'", timeout=60) + except (TwisterHarnessTimeoutException, AssertionError): + raise AssertionError("πŸ”΄β€‹β€‹β€‹ Firmware version of the device not found") + m = re.search("Running project 'mender-ncs-example' version '(?P[0-9.+]+)'", output[-1]) + if m: + fw_version = m.group('version') + else: + raise AssertionError("πŸ”΄β€‹β€‹β€‹ Firmware version of the device not found") + print(f"πŸ”΅β€‹ Firmware version of the device is '{fw_version}'") + return fw_version + + def assert_device_not_authenticated(self, dut: DeviceAdapter): + try: + output = dut.readlines_until(regex=r".*.*\[401\] Unauthorized.*|.*.*Mender client authenticated.*", timeout=600) + except (TwisterHarnessTimeoutException, AssertionError): + raise AssertionError("πŸ”΄β€‹β€‹β€‹ Device is not communicating with the server") + try: + match_no_lines(output[-1:], ["Mender client authenticated"]) + except (AssertionError): + raise AssertionError("πŸ”΄β€‹β€‹β€‹ Device is authenticated to the server but it should not") + print("βœ… Device is not authenticated") + + def assert_device_authenticated(self, dut: DeviceAdapter): + try: + output = dut.readlines_until(regex=r".*.*\[401\] Unauthorized.*|.*.*Mender client authenticated.*", timeout=600) + except (TwisterHarnessTimeoutException, AssertionError): + raise AssertionError("πŸ”΄β€‹β€‹β€‹ Device is not communicating with the server") + try: + match_no_lines(output[-1:], [r"\[401\] Unauthorized"]) + except (AssertionError): + raise AssertionError("πŸ”΄β€‹β€‹β€‹ Device is not authenticated to the server but it should") + print("βœ… Device is authenticated") + + def assert_device_troubleshoot_connected(self, dut: DeviceAdapter): + try: + dut.readlines_until(regex="Troubleshoot client connected", timeout=600) + except (TwisterHarnessTimeoutException, AssertionError): + raise AssertionError("πŸ”΄β€‹β€‹β€‹ Device troubleshoot doesn't appear to be connected but it should") + print("βœ… Device troubleshoot connected") + + def create_mender_artifact(self, build_dir: Path, key_file: Path, device_type: str, artifact_name_prefix: str, version: str, mcuboot_pad_size: str, mcuboot_primary_size: str) -> Path: + # zephyr.signed.file path + zephyr_signed_bin = Path(build_dir) / "zephyr" / "zephyr.signed.bin" + #Β Work in a temporary directory + with tempfile.TemporaryDirectory() as tmpdir: + # Retrieve image information from zephyr.signed.bin file + cmd=f"imgtool dumpinfo {zephyr_signed_bin}" + result = subprocess.run(cmd, cwd=Path(tmpdir), shell=True, capture_output=True, text=True, check=True) + header = dict(re.findall(r"^\s*(\w+):\s*(0x[0-9a-fA-F]+)", result.stdout, re.MULTILINE)) + #Β Extract image payload from zephyr.signed.bin file + cmd=f"dd if={zephyr_signed_bin} of=zephyr.bin bs=1 skip={int(header["hdr_size"], 16)} count={int(header["img_size"], 16)}" + subprocess.run(cmd, cwd=Path(tmpdir), shell=True, check=True) + #Β Overwrite zephyr.signed.bin file updating the version (key file is optional) + cmd=f"imgtool sign --version {version} --align 4 --pad-header --header-size {mcuboot_pad_size} --slot-size {mcuboot_primary_size}" + if os.path.exists(key_file): + cmd += f" --key {key_file}" + cmd += f" zephyr.bin {zephyr_signed_bin}" + subprocess.run(cmd, cwd=Path(tmpdir), shell=True, check=True) + # Create artifact using mender-artifact + artifact_file = Path(build_dir) / "zephyr" / f"{artifact_name_prefix}-v{version}.mender" + cmd = f"mender-artifact write rootfs-image --compression none --device-type {device_type} --artifact-name {artifact_name_prefix}-v{version} --output-path {artifact_file} --file {zephyr_signed_bin}" + subprocess.run(cmd, shell=True, check=True) + return artifact_file + + def run_until_data_refreshed(self, f, timeout=600, interval=30): + start_time = time.time() + while time.time() - start_time < timeout: + data, updated_ts = f() + if data is not None and updated_ts is not None: + delta = interval * 2 + if datetime.fromisoformat(updated_ts) > datetime.now(timezone.utc) - timedelta(seconds=delta): + return data + time.sleep(interval) + return None + + def run_until_data_available(self, f, timeout=600, interval=30): + start_time = time.time() + while time.time() - start_time < timeout: + data = f() + if data is not None: + return data + time.sleep(interval) + return None diff --git a/app/pytest/mender-cli-terminal.sh b/app/pytest/mender-cli-terminal.sh new file mode 100755 index 0000000..7e8cd02 --- /dev/null +++ b/app/pytest/mender-cli-terminal.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# @file mender-cli-terminal.sh +# @brief mender-cli terminal wrapper +# +# Copyright joelguittet and mender-mcu-client contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +server=$1 +device_id=$2 +command=$3 + +# Create temporary directory for PTYs +temp_dir=$(mktemp -d /tmp/mender-cli-XXXXXX) + +# Start socat with explicit PTY links +socat -d -d pty,raw,echo=0,link=$temp_dir/pty1 pty,raw,echo=0,link=$temp_dir/pty2 > /dev/null 2>&1 & +SOCAT_PID=$! +sleep 3 + +# Start mender-cli terminal +mender-cli terminal $device_id --server $server --skip-verify < $temp_dir/pty1 > $temp_dir/pty1 2>&1 & +MENDER_CLI_PID=$! +sleep 10 + +# Check if prompt is received +output=$(timeout 30s dd if=$temp_dir/pty2 iflag=nonblock status=none 2>/dev/null) +if [[ "$output" != *"\$"* ]]; then + # Disconnect + printf "\x1d" > $temp_dir/pty2 + sleep 3 + # Stop mender-cli if still executing + kill -TERM $MENDER_CLI_PID > /dev/null 2>&1 + # Stop socat + kill -TERM $SOCAT_PID > /dev/null 2>&1 + # Cleaning + rm -rf $temp_dir + exit 1 +fi + +# Print expected command to the terminal +printf "$command\n" > $temp_dir/pty2 +sleep 3 + +# Disconnect +printf "\x1d" > $temp_dir/pty2 +sleep 3 + +# Read result +timeout 30s dd if=$temp_dir/pty2 iflag=nonblock status=none 2>/dev/null | tail -n +2 + +# Stop mender-cli if still executing +kill -TERM $MENDER_CLI_PID > /dev/null 2>&1 + +# Stop socat +kill -TERM $SOCAT_PID > /dev/null 2>&1 + +# Cleaning +rm -rf $temp_dir + +exit 0 diff --git a/app/pytest/mender_cli.py b/app/pytest/mender_cli.py new file mode 100644 index 0000000..8053b67 --- /dev/null +++ b/app/pytest/mender_cli.py @@ -0,0 +1,56 @@ +# @file mender_cli.py +# @brief mender-cli wrapper +# +# Copyright joelguittet and mender-mcu-client contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import os +import subprocess + + +class MenderCli: + """ + Interface on top of mender-cli. + Provides methods to accesss remote terminal. + """ + + def __init__(self, + server=None, + username=None, + password=None, + ca_cert=None): + self.server = server or os.getenv("MENDER_SERVER", "https://mender.mycompany.local") + self.username = username or os.getenv("MENDER_USERNAME", "admin@mycompany.local") + self.password = password or os.getenv("MENDER_PASSWORD", "MySecretPassword") + self.ca_cert = ca_cert or os.getenv("MENDER_CA", "/etc/ssl/certs/my-ca.crt") + + self.login() + + # ============================================================ + # AUTH + # ============================================================ + def login(self, timeout=60): + """Authenticate to Mender CLI using Basic Auth.""" + cmd = f"mender-cli login --skip-verify --server {self.server} --username {self.username} --password {self.password}" + subprocess.run(cmd, shell=True, check=True, timeout=timeout) + print("βœ… Mender CLI logged in with Basic Auth") + + # ============================================================ + # SHELL + # ============================================================ + def shell_exec(self, device, cmd, timeout=60): + """Execute a shell command using Mender CLI interface.""" + cmd = f"./mender-cli-terminal.sh {self.server} {device['id']} {cmd}" + result = subprocess.run(cmd, cwd=os.path.dirname(os.path.abspath(__file__)), shell=True, capture_output=True, text=True, check=True, timeout=timeout) + return result diff --git a/app/pytest/mender_management.py b/app/pytest/mender_management.py new file mode 100644 index 0000000..d677058 --- /dev/null +++ b/app/pytest/mender_management.py @@ -0,0 +1,230 @@ +# @file mender_management.py +# @brief Mender management API wrapper +# +# Copyright joelguittet and mender-mcu-client contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import json +import os +import requests +import time +from datetime import datetime, timezone +from requests.auth import HTTPBasicAuth + + +class MenderManagement: + """ + Wrapper around Mender Management API (v1/v2). + Provides methods for device management, inventory, configuration and deployments. + """ + + def __init__(self, + server=None, + username=None, + password=None, + ca_cert=None): + self.server = server or os.getenv("MENDER_SERVER", "https://mender.mycompany.local") + self.username = username or os.getenv("MENDER_USERNAME", "admin@mycompany.local") + self.password = password or os.getenv("MENDER_PASSWORD", "MySecretPassword") + self.ca_cert = ca_cert or os.getenv("MENDER_CA", "/etc/ssl/certs/my-ca.crt") + + self.token = None + self.headers = None + self.login() + + def _request(self, method, url, **kwargs): + try: + r = requests.request(method=method, url=url, headers=self.headers, verify=self.ca_cert, timeout=10, **kwargs) + r.raise_for_status() + except requests.exceptions.HTTPError as e: + print(f"πŸ”΄ HTTP error occurred: {e}") + try: + print("πŸ”΄ Server response:", r.json()) + except ValueError: + print("πŸ”΄ Server response:", r.text) + raise + except requests.exceptions.RequestException as e: + print(f"πŸ”΄ Network error occurred: {e}") + raise + return r + + # ============================================================ + # AUTH + # ============================================================ + def login(self): + """Authenticate to Mender Management API using Basic Auth or JSON body.""" + url = f"{self.server}/api/management/v1/useradm/auth/login" + + try: + resp = requests.post(url, auth=HTTPBasicAuth(self.username, self.password), verify=self.ca_cert, timeout=10) + if resp.status_code == 200 and resp.text.strip(): + self.token = resp.text.strip() + self.headers = {"Authorization": f"Bearer {self.token}"} + print("βœ… Mender Management logged in with Basic Auth") + return + except requests.RequestException: + pass + + try: + resp = requests.post(url, json={"username": self.username, "password": self.password}, verify=self.ca_cert, timeout=10) + resp.raise_for_status() + if resp.text.strip(): + self.token = resp.text.strip() + self.headers = {"Authorization": f"Bearer {self.token}"} + print("βœ… Mender Management logged in with JSON body") + return + except requests.RequestException as e: + raise RuntimeError(f"πŸ”΄β€‹ Login failed (Basic + JSON): {e}") from e + + # ============================================================ + # DEVICES + # ============================================================ + def list_devices(self): + """Return all devices known by Mender.""" + url = f"{self.server}/api/management/v2/devauth/devices" + r = self._request("GET", url) + return r.json() if r.text else {} + + def get_device_by_mac(self, mac_address): + """Return a single device and auth_set by MAC address.""" + devices = self.list_devices() + for device in devices: + try: + identity_data = device.get('identity_data') + # Handle both JSON string and dict cases + if isinstance(identity_data, str): + identity = json.loads(identity_data) + elif isinstance(identity_data, dict): + identity = identity_data + else: + continue + if identity.get('mac') == mac_address: + return device + except (KeyError, json.JSONDecodeError): + continue + return None + + def accept_device(self, device): + """Accept device.""" + auth_sets = device.get('auth_sets', []) + auth = auth_sets[0] if auth_sets else None + url = f"{self.server}/api/management/v2/devauth/devices/{device['id']}/auth/{auth['id']}/status" + payload = {"status": "accepted"} + self._request("PUT", url, json=payload) + return True + + def delete_device(self, device): + """Delete device.""" + url = f"{self.server}/api/management/v2/devauth/devices/{device['id']}" + self._request("DELETE", url) + return True + + # ============================================================ + # INVENTORY + # ============================================================ + def get_device_inventory(self, device): + """Retrieve current inventory data for a device.""" + url = f"{self.server}/api/management/v1/inventory/devices/{device['id']}" + r = self._request("GET", url) + return r.json() if r.text else {} + + # ============================================================ + # CONFIGURATION + # ============================================================ + def get_device_configuration(self, device): + """Retrieve current configuration data for a device.""" + url = f"{self.server}/api/management/v1/deviceconfig/configurations/device/{device['id']}" + r = self._request("GET", url) + return r.json() if r.text else {} + + def set_device_configuration(self, device, configuration): + """Set configuration key/values for a device.""" + url = f"{self.server}/api/management/v1/deviceconfig/configurations/device/{device['id']}" + self._request("PUT", url, json=configuration) + return True + + def deploy_device_configuration(self, device): + """Deploy configuration key/values for a device.""" + url = f"{self.server}/api/management/v1/deviceconfig/configurations/device/{device['id']}/deploy" + payload = {"retries": 0} + r = self._request("POST", url, json=payload) + return r.json() if r.text else {} + + # ============================================================ + # DEPLOYMENTS + # ============================================================ + def upload_artifact(self, filepath, description=None): + """Upload a new artifact (deployment package).""" + url = f"{self.server}/api/management/v1/deployments/artifacts" + with open(filepath, "rb") as file: + files = {"artifact": file} + data = {"description": description} if description else {} + r = self._request("POST", url, files=files, data=data) + return r.json() if r.text else {} + + def list_artifacts(self, name=None): + """List artifacts.""" + url = f"{self.server}/api/management/v1/deployments/artifacts/list" + params = {} + if name is not None: + params['name'] = name + r = self._request("GET", url, params=params) + return r.json() if r.text else {} + + def delete_artifact(self, name): + """Delete artifact.""" + artifacts = self.list_artifacts(name) + for artifact in artifacts: + if artifact.get('name') == name: + break + url = f"{self.server}/api/management/v1/deployments/artifacts/{artifact['id']}" + self._request("DELETE", url) + return True + + def deploy_device_firmware(self, name, artifact_name, devices): + """Create a deployment for one or more devices.""" + url = f"{self.server}/api/management/v1/deployments/deployments" + payload = {"name": name, "artifact_name": artifact_name, "devices": [device['id'] for device in devices]} + r = self._request("POST", url, json=payload) + return r.json() if r.text else {} + + def get_deployment_status(self, name=None, deployment=None): + """Get status of a deployment.""" + url = f"{self.server}/api/management/v2/deployments/deployments" + params = {} + if name is not None: + params['name'] = name + if deployment is not None and deployment.get('deployment_id') is not None: + params['id'] = deployment['deployment_id'] + r = self._request("GET", url, params=params) + for item in r.json(): + if name is not None: + if item.get('name') == name: + return item + if deployment is not None and deployment.get('deployment_id') is not None: + if item.get('id') == deployment['deployment_id']: + return item + return None + + def wait_for_deployment(self, name=None, deployment=None, timeout=1200, interval=30): + """Wait until deployment completes or fails.""" + deployment_status = None + start_time = time.time() + while time.time() - start_time < timeout: + deployment_status = self.get_deployment_status(name, deployment) + print(f"πŸ”΅ Device deployment status at {datetime.now(timezone.utc)} is: {deployment_status}") + if deployment_status is not None and deployment_status.get('status') == 'finished': + return deployment_status + time.sleep(interval) + return deployment_status diff --git a/app/pytest/test_mender_mcu_client.py b/app/pytest/test_mender_mcu_client.py new file mode 100644 index 0000000..baf8099 --- /dev/null +++ b/app/pytest/test_mender_mcu_client.py @@ -0,0 +1,231 @@ +# @file test_mender_mcu_client.py +# @brief Mender MCU client test file +# +# Copyright joelguittet and mender-mcu-client contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + + +import logging +import pytest +import random +import re +import uuid +from datetime import datetime, timezone +from pathlib import Path +from twister_harness import DeviceAdapter +from twister_harness.helpers.utils import find_in_config +from mender_cli import MenderCli +from mender_management import MenderManagement +from helpers import Helpers + +logger = logging.getLogger(__name__) + +mender_cli = MenderCli() +mender_management = MenderManagement() +helpers = Helpers() + +@pytest.mark.order('first') +@pytest.mark.dependency() +def test_device_registration(dut: DeviceAdapter): + """Get the MAC address of the device.""" + mac_address = helpers.get_device_mac_address(dut) + """Ensure the device is not authorized to the mender server.""" + helpers.assert_device_not_authenticated(dut) + device = mender_management.get_device_by_mac(mac_address) + assert device is not None, f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' not found on mender server" + assert device['status'] == "pending", f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' has unexpected status '{device['status']}'" + print("βœ… Device has status 'pending' on the mender server") + """Accept the device on the mender server.""" + mender_management.accept_device(device) + """Ensure the device is authorized on the mender server.""" + helpers.assert_device_authenticated(dut) + device = mender_management.get_device_by_mac(mac_address) + assert device is not None, f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' not found on mender server" + assert device['status'] == "accepted", f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' has unexpected status '{device['status']}'" + print("βœ… Device has status 'accepted' on the mender server") + print("βœ… Device registration done successfully on the mender server") + + +@pytest.mark.dependency(depends=['test_device_registration']) +def test_device_inventory(dut: DeviceAdapter): + """Check if inventory add-on is built-in.""" + if not find_in_config(Path(dut.device_config.app_build_dir) / 'zephyr' / '.config', 'CONFIG_MENDER_CLIENT_ADD_ON_INVENTORY'): + pytest.skip("Inventory add-on is not built-in") + """Get the MAC address of the device.""" + mac_address = helpers.get_device_mac_address(dut) + """Ensure the device is authorized on the mender server.""" + helpers.assert_device_authenticated(dut) + device = mender_management.get_device_by_mac(mac_address) + assert device is not None, f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' not found on mender server" + assert device['status'] == "accepted", f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' has unexpected status '{device['status']}'" + print("βœ… Device has status 'accepted' on the mender server") + """Wait until the device inventory is updated.""" + def get_device_inventory(): + inventory = mender_management.get_device_inventory(device) + print(f"πŸ”΅ Device inventory at {datetime.now(timezone.utc)} is: {inventory}") + if inventory is not None and inventory.get('updated_ts'): + return inventory, inventory.get('updated_ts') + return None, None + inventory = helpers.run_until_data_refreshed(get_device_inventory) + assert inventory is not None, "πŸ”΄β€‹β€‹β€‹ Device inventory not found" + """Ensure of the device inventory content.""" + inventory = {item['name']: item['value'] for item in inventory.get('attributes', [])} + print(f"Device reported inventory: '{inventory}'") + expected_inventory = {} + expected_inventory['artifact_name'] = f"{find_in_config(Path(dut.device_config.app_build_dir) / 'zephyr' / '.config', 'CONFIG_EXAMPLE_ARTIFACT_NAME_PREFIX')}-v0.1.0" + expected_inventory['rootfs-image.version'] = f"{find_in_config(Path(dut.device_config.app_build_dir) / 'zephyr' / '.config', 'CONFIG_EXAMPLE_ARTIFACT_NAME_PREFIX')}-v0.1.0" + expected_inventory['device_type'] = find_in_config(Path(dut.device_config.app_build_dir) / 'zephyr' / '.config', 'CONFIG_EXAMPLE_DEVICE_TYPE') + expected_inventory['latitude'] = "45.8325" + expected_inventory['longitude'] = "6.864722" + expected_inventory['mender-mcu-client'] = "[0-9]+.[0-9]+.[0-9]+" + expected_inventory['zephyr-rtos'] = "[0-9]+.[0-9]+.[0-9]+" + print(f"Device expected inventory: '{expected_inventory}'") + for key, val in expected_inventory.items(): + assert inventory.get(key) is not None, "πŸ”΄β€‹β€‹β€‹ Device inventory is invalid" + m = re.search(val, inventory.get(key)) + assert m is not None, "πŸ”΄β€‹β€‹β€‹ Device inventory is invalid" + print("βœ… Device inventory contains expected inventory items") + + +@pytest.mark.dependency(depends=['test_device_registration']) +def test_device_configure(dut: DeviceAdapter): + """Check if configure add-on is built-in.""" + if not find_in_config(Path(dut.device_config.app_build_dir) / 'zephyr' / '.config', 'CONFIG_MENDER_CLIENT_ADD_ON_CONFIGURE'): + pytest.skip("Configure add-on is not built-in") + """Get the MAC address of the device.""" + mac_address = helpers.get_device_mac_address(dut) + """Ensure the device is authorized on the mender server.""" + helpers.assert_device_authenticated(dut) + device = mender_management.get_device_by_mac(mac_address) + assert device is not None, f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' not found on mender server" + assert device['status'] == "accepted", f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' has unexpected status '{device['status']}'" + print("βœ… Device has status 'accepted' on the mender server") + """Generate a random configuration content with several uuids.""" + """Generating such configuration ensure very easily the previous one was different.""" + expected_configuration = {f"uuid{index+1}": str(uuid.uuid4()) for index in range(random.randint(4, 8))} + """Set device configuration.""" + mender_management.set_device_configuration(device, expected_configuration) + deployment = mender_management.deploy_device_configuration(device) + assert deployment is not None, "πŸ”΄β€‹β€‹β€‹ Deployment not found" + """"Wait for deployment completes or fails.""" + deployment_status = mender_management.wait_for_deployment(deployment=deployment) + assert deployment_status is not None, "πŸ”΄β€‹β€‹β€‹ Deployment status not found" + if deployment_status['status'] != 'finished': + raise AssertionError(f"πŸ”΄β€‹β€‹β€‹ Deployment did not complete successfully, it has unexpected status '{deployment_status}'") + if deployment_status.get('statistics').get('status').get('success') != 1: + raise AssertionError(f"πŸ”΄β€‹β€‹β€‹ Deployment did not complete successfully, it has unexpected status '{deployment_status}'") + print("βœ… Device configuration deployment done successfuly") + """Wait until the device configuration is updated.""" + def get_device_configuration(): + configuration = mender_management.get_device_configuration(device) + print(f"πŸ”΅ Device configuration at {datetime.now(timezone.utc)} is: {configuration}") + if configuration is not None and configuration.get('reported_ts') and datetime.fromisoformat(configuration.get('reported_ts')) >= datetime.fromisoformat(deployment_status.get('finished')): + return configuration, configuration.get('reported_ts') + return None, None + configuration = helpers.run_until_data_refreshed(get_device_configuration) + assert configuration is not None, "πŸ”΄β€‹β€‹β€‹ Device configuration not found" + """Ensure of the device configuration content.""" + print(f"Device reported configuration: '{configuration['reported']}'") + print(f"Device expected configuration: '{expected_configuration}'") + if configuration['reported'] != expected_configuration: + raise AssertionError("πŸ”΄β€‹β€‹β€‹ Device configuration is invalid") + print("βœ… Device configuration contains expected configuration items") + + +@pytest.mark.dependency(depends=['test_device_registration']) +def test_device_troubleshoot_shell(dut: DeviceAdapter): + """Check if troubleshoot add-on shell is built-in.""" + if not find_in_config(Path(dut.device_config.app_build_dir) / 'zephyr' / '.config', 'CONFIG_MENDER_CLIENT_TROUBLESHOOT_SHELL'): + pytest.skip("Troubleshoot add-on shell is not built-in") + """Get the MAC address of the device.""" + mac_address = helpers.get_device_mac_address(dut) + """Ensure the device is authorized on the mender server.""" + helpers.assert_device_authenticated(dut) + device = mender_management.get_device_by_mac(mac_address) + assert device is not None, f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' not found on mender server" + assert device['status'] == "accepted", f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' has unexpected status '{device['status']}'" + print("βœ… Device has status 'accepted' on the mender server") + """Ensure the device troubleshoot is connected to the mender server.""" + helpers.assert_device_troubleshoot_connected(dut) + """Execute 'help' command on troubleshoot shell.""" + result = mender_cli.shell_exec(device, "help") + m = re.search(".*Please refer to shell documentation for more details.*", result.stdout) + assert m is not None, "πŸ”΄β€‹β€‹β€‹ Device troubleshoot shell result is invalid" + print("βœ… Device troubleshoot shell working as expected") + + +@pytest.mark.dependency(depends=['test_device_registration']) +def test_device_valid_firmware_update(dut: DeviceAdapter): + """Get the MAC address of the device.""" + mac_address = helpers.get_device_mac_address(dut) + """Get the firmware version of the device.""" + fw_version = helpers.get_device_firmware_version(dut) + assert fw_version == "0.1.0+0", f"πŸ”΄β€‹β€‹β€‹ Device has invalid firmware version '{fw_version}'" + print("βœ… Device has expected firmware version") + """Ensure the device is authorized on the mender server.""" + helpers.assert_device_authenticated(dut) + device = mender_management.get_device_by_mac(mac_address) + assert device is not None, f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' not found on mender server" + assert device['status'] == "accepted", f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' has unexpected status '{device['status']}'" + print("βœ… Device has status 'accepted' on the mender server") + """Create a new firmware update.""" + # Key file is optional and will be empty in case signature is not enabled + # This is the case on esp32s3-devkitc board for which only the Espressif port of mcuboot supports signature verification + expected_version = "0.2.0+0" + mcuboot_pad_size = find_in_config(Path(dut.device_config.build_dir) / 'pm.config', 'PM_MCUBOOT_PAD_SIZE') + mcuboot_primary_size = find_in_config(Path(dut.device_config.build_dir) / 'pm.config', 'PM_MCUBOOT_PRIMARY_SIZE') + key_file = find_in_config(Path(dut.device_config.build_dir) / 'mcuboot' / 'zephyr' / '.config', 'CONFIG_BOOT_SIGNATURE_KEY_FILE') + device_type = find_in_config(Path(dut.device_config.app_build_dir) / 'zephyr' / '.config', 'CONFIG_EXAMPLE_DEVICE_TYPE') + artifact_name_prefix = find_in_config(Path(dut.device_config.app_build_dir) / 'zephyr' / '.config', 'CONFIG_EXAMPLE_ARTIFACT_NAME_PREFIX') + artifact = helpers.create_mender_artifact(dut.device_config.app_build_dir, key_file, device_type, artifact_name_prefix, expected_version, mcuboot_pad_size, mcuboot_primary_size) + deployment_name = f"{artifact_name_prefix}-v{expected_version}" + artifact_name = f"{artifact_name_prefix}-v{expected_version}" + """Upload artifact to the server.""" + mender_management.upload_artifact(artifact) + try: + """Deploy artifact to the device.""" + mender_management.deploy_device_firmware(deployment_name, artifact_name, [device]) + """"Wait for deployment completes or fails.""" + deployment_status = mender_management.wait_for_deployment(name=deployment_name) + assert deployment_status is not None, "πŸ”΄β€‹β€‹β€‹ Deployment status not found" + if deployment_status['status'] != 'finished': + raise AssertionError(f"πŸ”΄β€‹β€‹β€‹ Deployment did not complete successfully, it has unexpected status '{deployment_status}'") + if deployment_status.get('statistics').get('status').get('success') != 1: + raise AssertionError(f"πŸ”΄β€‹β€‹β€‹ Deployment did not complete successfully, it has unexpected status '{deployment_status}'") + finally: + """Remove artifact from the server (done even in case the above deployment fails).""" + mender_management.delete_artifact(artifact_name) + print("βœ… Device firmware deployment done successfuly") + """Get the firmware version of the device.""" + fw_version = helpers.get_device_firmware_version(dut) + assert fw_version == expected_version, f"πŸ”΄β€‹β€‹β€‹ Device has invalid firmware version '{fw_version}'" + print("βœ… Device has expected firmware version") + + +@pytest.mark.order('last') +@pytest.mark.dependency(depends=['test_device_registration']) +def test_device_decomissioning(dut: DeviceAdapter): + """Get the MAC address of the device.""" + mac_address = helpers.get_device_mac_address(dut) + """Ensure the device is authorized on the mender server.""" + helpers.assert_device_authenticated(dut) + device = mender_management.get_device_by_mac(mac_address) + assert device is not None, f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' not found on mender server" + assert device['status'] == "accepted", f"πŸ”΄β€‹β€‹β€‹ Device with MAC '{mac_address}' has unexpected status '{device['status']}'" + print("βœ… Device has status 'accepted' on the mender server") + """Decomissioning of the device on the server.""" + mender_management.delete_device(device) + """Ensure the device is not authorized to the mender server.""" + helpers.assert_device_not_authenticated(dut) + print("βœ… Device decomissioning done successfully on the mender server") diff --git a/app/testcase.yaml b/app/testcase.yaml new file mode 100644 index 0000000..fa416e8 --- /dev/null +++ b/app/testcase.yaml @@ -0,0 +1,40 @@ +# @file testcase.yaml +# @brief twister test file used to execute tests on the targets +# +# Copyright joelguittet and mender-mcu-client contributors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +common: + sysbuild: True + timeout: 3600 + slow: true + harness: pytest + harness_config: + pytest_dut_scope: function + pytest_root: + - "pytest/test_mender_mcu_client.py" + tags: + - testing + - pytest + - shell + +tests: + mender.mcu.client.default: + extra_args: EXTRA_CONF_FILE="${WEST_TOPDIR}/local.conf;mcumgr.conf" + platform_allow: + - nrf7002dk/nrf5340/cpuapp/ns + mender.mcu.client.troubleshoot: + extra_args: EXTRA_CONF_FILE="${WEST_TOPDIR}/local.conf;mcumgr.conf;troubleshoot.conf" + platform_allow: + - nrf7002dk/nrf5340/cpuapp/ns