From d2a13cb5f222b0b066dd7da26f7632ee92cb4c72 Mon Sep 17 00:00:00 2001 From: Wang Qixiang <43193572+wqx6@users.noreply.github.com> Date: Thu, 11 Jan 2024 14:04:34 +0800 Subject: [PATCH] Follow-up Persistent Subscription tests (#31310) * Add an option to set subscription capacity for linux examples * Add basic subscription resumption cirque test and subscription resumption capacity cirque test * Change the test app for subscription resumption test to lit-icd-app * Restyled by clang-format * Restyled by gn * Restyled by autopep8 * Restyled by isort * Fix Subscriptions capacity option type cast * lints changes * review changes * typo changes * Restyled by autopep8 * Restyled by isort * ci fixes --------- Co-authored-by: Restyled.io --- BUILD.gn | 13 ++ .../lit-icd-common/lit-icd-server-app.matter | 1 + .../lit-icd-common/lit-icd-server-app.zap | 8 + examples/platform/linux/AppMain.cpp | 7 + examples/platform/linux/Options.cpp | 15 ++ examples/platform/linux/Options.h | 3 + .../chip-cirque-device-base/Dockerfile | 9 +- scripts/build/gn_gen_cirque.sh | 2 +- scripts/tests/cirque_tests.sh | 2 + src/controller/python/chip/ChipDeviceCtrl.py | 6 +- .../python/test/test_scripts/base.py | 161 +++++++++++++++++ .../cirque_restart_remote_device.py | 82 +++++++++ ...cription_resumption_capacity_test_ctrl1.py | 133 ++++++++++++++ ...cription_resumption_capacity_test_ctrl2.py | 146 +++++++++++++++ .../subscription_resumption_test.py | 135 ++++++++++++++ .../SubscriptionResumptionCapacityTest.py | 167 ++++++++++++++++++ .../SubscriptionResumptionTest.py | 132 ++++++++++++++ 17 files changed, 1018 insertions(+), 4 deletions(-) create mode 100644 src/controller/python/test/test_scripts/cirque_restart_remote_device.py create mode 100755 src/controller/python/test/test_scripts/subscription_resumption_capacity_test_ctrl1.py create mode 100755 src/controller/python/test/test_scripts/subscription_resumption_capacity_test_ctrl2.py create mode 100755 src/controller/python/test/test_scripts/subscription_resumption_test.py create mode 100755 src/test_driver/linux-cirque/SubscriptionResumptionCapacityTest.py create mode 100755 src/test_driver/linux-cirque/SubscriptionResumptionTest.py diff --git a/BUILD.gn b/BUILD.gn index 587d3df973c7a9..b51ab7238414e9 100644 --- a/BUILD.gn +++ b/BUILD.gn @@ -357,6 +357,10 @@ if (current_toolchain != "${dir_pw_toolchain}/default:default") { enable_linux_lock_app_build = enable_default_builds && (host_os == "linux" || host_os == "mac") + # Build the Linux LIT ICD example. + enable_linux_lit_icd_app_build = + enable_default_builds && (host_os == "linux" || host_os == "mac") + # Build the cc13x2x7_26x2x7 lock app example. enable_cc13x2x7_26x2x7_lock_app_build = enable_ti_simplelink_builds @@ -610,6 +614,15 @@ if (current_toolchain != "${dir_pw_toolchain}/default:default") { extra_build_deps += [ ":linux_lock_app" ] } + if (enable_linux_lit_icd_app_build) { + group("linux_lit_icd_app") { + deps = + [ "${chip_root}/examples/lit-icd-app/linux(${standalone_toolchain})" ] + } + + extra_build_deps += [ ":linux_lit_icd_app" ] + } + if (enable_efr32_lock_app_build) { group("efr32_lock_app") { deps = [ "${chip_root}/examples/lock-app/efr32(${chip_root}/config/efr32/toolchain:efr32_lock_app)" ] diff --git a/examples/lit-icd-app/lit-icd-common/lit-icd-server-app.matter b/examples/lit-icd-app/lit-icd-common/lit-icd-server-app.matter index 1af37adcc789a7..79623008b20092 100644 --- a/examples/lit-icd-app/lit-icd-common/lit-icd-server-app.matter +++ b/examples/lit-icd-app/lit-icd-common/lit-icd-server-app.matter @@ -1673,6 +1673,7 @@ endpoint 0 { ram attribute clusterRevision default = 1; handle command OpenCommissioningWindow; + handle command OpenBasicCommissioningWindow; handle command RevokeCommissioning; } diff --git a/examples/lit-icd-app/lit-icd-common/lit-icd-server-app.zap b/examples/lit-icd-app/lit-icd-common/lit-icd-server-app.zap index 4d8eb20df73e06..69c099d561d37c 100644 --- a/examples/lit-icd-app/lit-icd-common/lit-icd-server-app.zap +++ b/examples/lit-icd-app/lit-icd-common/lit-icd-server-app.zap @@ -2549,6 +2549,14 @@ "isIncoming": 1, "isEnabled": 1 }, + { + "name": "OpenBasicCommissioningWindow", + "code": 1, + "mfgCode": null, + "source": "client", + "isIncoming": 1, + "isEnabled": 1 + }, { "name": "RevokeCommissioning", "code": 2, diff --git a/examples/platform/linux/AppMain.cpp b/examples/platform/linux/AppMain.cpp index 79e3e08edce718..04ed5ad74fa8cc 100644 --- a/examples/platform/linux/AppMain.cpp +++ b/examples/platform/linux/AppMain.cpp @@ -568,6 +568,13 @@ void ChipLinuxAppMainLoop(AppMainLoopImplementation * impl) // Init ZCL Data Model and CHIP App Server Server::GetInstance().Init(initParams); +#if CONFIG_BUILD_FOR_HOST_UNIT_TEST + // Set ReadHandler Capacity for Subscriptions + chip::app::InteractionModelEngine::GetInstance()->SetHandlerCapacityForSubscriptions( + LinuxDeviceOptions::GetInstance().subscriptionCapacity); + chip::app::InteractionModelEngine::GetInstance()->SetForceHandlerQuota(true); +#endif + // Now that the server has started and we are done with our startup logging, // log our discovery/onboarding information again so it's not lost in the // noise. diff --git a/examples/platform/linux/Options.cpp b/examples/platform/linux/Options.cpp index c340f662df3269..4c6791a178e48e 100644 --- a/examples/platform/linux/Options.cpp +++ b/examples/platform/linux/Options.cpp @@ -87,6 +87,9 @@ enum #if defined(PW_RPC_ENABLED) kOptionRpcServerPort = 0x1023, #endif +#if CONFIG_BUILD_FOR_HOST_UNIT_TEST + kDeviceOption_SubscriptionCapacity = 0x1024, +#endif }; constexpr unsigned kAppUsageLength = 64; @@ -143,6 +146,9 @@ OptionDef sDeviceOptionDefs[] = { { "simulate-no-internal-time", kNoArgument, kOptionSimulateNoInternalTime }, #if defined(PW_RPC_ENABLED) { "rpc-server-port", kArgumentRequired, kOptionRpcServerPort }, +#endif +#if CONFIG_BUILD_FOR_HOST_UNIT_TEST + { "subscription-capacity", kArgumentRequired, kDeviceOption_SubscriptionCapacity }, #endif {} }; @@ -263,6 +269,10 @@ const char * sDeviceOptionHelp = #if defined(PW_RPC_ENABLED) " --rpc-server-port\n" " Start RPC server on specified port\n" +#endif +#if CONFIG_BUILD_FOR_HOST_UNIT_TEST + " --subscription-capacity\n" + " Max number of subscriptions the device will allow\n" #endif "\n"; @@ -521,6 +531,11 @@ bool HandleOption(const char * aProgram, OptionSet * aOptions, int aIdentifier, case kOptionRpcServerPort: LinuxDeviceOptions::GetInstance().rpcServerPort = static_cast(atoi(aValue)); break; +#endif +#if CONFIG_BUILD_FOR_HOST_UNIT_TEST + case kDeviceOption_SubscriptionCapacity: + LinuxDeviceOptions::GetInstance().subscriptionCapacity = static_cast(atoi(aValue)); + break; #endif default: PrintArgError("%s: INTERNAL ERROR: Unhandled option: %s\n", aProgram, aName); diff --git a/examples/platform/linux/Options.h b/examples/platform/linux/Options.h index 0aedffb57dc76e..784c8d11ef1231 100644 --- a/examples/platform/linux/Options.h +++ b/examples/platform/linux/Options.h @@ -70,6 +70,9 @@ struct LinuxDeviceOptions bool mSimulateNoInternalTime = false; #if defined(PW_RPC_ENABLED) uint16_t rpcServerPort = 33000; +#endif +#if CONFIG_BUILD_FOR_HOST_UNIT_TEST + int32_t subscriptionCapacity = CHIP_IM_MAX_NUM_SUBSCRIPTIONS; #endif static LinuxDeviceOptions & GetInstance(); }; diff --git a/integrations/docker/images/stage-2/chip-cirque-device-base/Dockerfile b/integrations/docker/images/stage-2/chip-cirque-device-base/Dockerfile index d7f7538e484e40..8d79b306c1a327 100644 --- a/integrations/docker/images/stage-2/chip-cirque-device-base/Dockerfile +++ b/integrations/docker/images/stage-2/chip-cirque-device-base/Dockerfile @@ -30,6 +30,7 @@ RUN apt-get update \ libgirepository1.0-dev \ libglib2.0-dev \ libjpeg-dev \ + openssh-server \ psmisc \ python3-dev \ python3-pip \ @@ -55,7 +56,12 @@ RUN apt-get update \ && echo "ctrl_interface=/run/wpa_supplicant" >> /etc/wpa_supplicant/wpa_supplicant.conf \ && echo "update_config=1" >> /etc/wpa_supplicant/wpa_supplicant.conf \ && rm -rf /var/lib/apt/lists/* \ - && pip3 install --no-cache-dir click==8.0.3 + && pip3 install --no-cache-dir click==8.0.3 paramiko \ + && mkdir /var/run/sshd \ + && echo 'root:admin' | chpasswd \ + && sed -i 's/#Port 22/Port 2222/' /etc/ssh/sshd_config \ + && sed -i 's/#PermitRootLogin prohibit-password/PermitRootLogin yes/' /etc/ssh/sshd_config \ + && sed 's@session\s*required\s*pam_loginuid.so@session optional pam_loginuid.so@g' -i /etc/pam.d/sshd COPY CHIPCirqueDaemon.py /bin/CHIPCirqueDaemon.py COPY entrypoint.sh /opt/entrypoint.sh @@ -65,3 +71,4 @@ WORKDIR / ENTRYPOINT ["/opt/entrypoint.sh"] EXPOSE 80 +EXPOSE 2222 diff --git a/scripts/build/gn_gen_cirque.sh b/scripts/build/gn_gen_cirque.sh index 061f89932023ee..d6f6bd86905a0e 100755 --- a/scripts/build/gn_gen_cirque.sh +++ b/scripts/build/gn_gen_cirque.sh @@ -36,7 +36,7 @@ echo "Setup build environment" source "./scripts/activate.sh" echo "Build: GN configure" -gn --root="$CHIP_ROOT" gen --check --fail-on-unused-args out/debug --args='target_os="all"'"chip_build_tests=false chip_enable_wifi=false chip_im_force_fabric_quota_check=true enable_default_builds=false enable_host_gcc_build=true enable_standalone_chip_tool_build=true enable_linux_all_clusters_app_build=true enable_linux_lighting_app_build=true" +gn --root="$CHIP_ROOT" gen --check --fail-on-unused-args out/debug --args='target_os="all"'"chip_build_tests=false chip_enable_wifi=false chip_im_force_fabric_quota_check=true enable_default_builds=false enable_host_gcc_build=true enable_standalone_chip_tool_build=true enable_linux_all_clusters_app_build=true enable_linux_lighting_app_build=true enable_linux_lit_icd_app_build=true" echo "Build: Ninja build" time ninja -C out/debug all check diff --git a/scripts/tests/cirque_tests.sh b/scripts/tests/cirque_tests.sh index 2670a2e69eb0b3..0f9fe22e9cdde0 100755 --- a/scripts/tests/cirque_tests.sh +++ b/scripts/tests/cirque_tests.sh @@ -49,6 +49,8 @@ CIRQUE_TESTS=( "CommissioningFailureOnReportTest" "PythonCommissioningTest" "CommissioningWindowTest" + "SubscriptionResumptionTest" + "SubscriptionResumptionCapacityTest" ) BOLD_GREEN_TEXT="\033[1;32m" diff --git a/src/controller/python/chip/ChipDeviceCtrl.py b/src/controller/python/chip/ChipDeviceCtrl.py index 72a1f7aaa681fd..c21cc5388f81af 100644 --- a/src/controller/python/chip/ChipDeviceCtrl.py +++ b/src/controller/python/chip/ChipDeviceCtrl.py @@ -1417,7 +1417,8 @@ def ZCLWriteAttribute(self, cluster: str, attribute: str, nodeid, endpoint, grou return asyncio.run(self.WriteAttribute(nodeid, [(endpoint, req, dataVersion)])) - def ZCLSubscribeAttribute(self, cluster, attribute, nodeid, endpoint, minInterval, maxInterval, blocking=True): + def ZCLSubscribeAttribute(self, cluster, attribute, nodeid, endpoint, minInterval, maxInterval, blocking=True, + keepSubscriptions=False, autoResubscribe=True): ''' Wrapper over ReadAttribute for a single attribute Returns a SubscriptionTransaction. See ReadAttribute for more information. ''' @@ -1428,7 +1429,8 @@ def ZCLSubscribeAttribute(self, cluster, attribute, nodeid, endpoint, minInterva req = eval(f"GeneratedObjects.{cluster}.Attributes.{attribute}") except BaseException: raise UnknownAttribute(cluster, attribute) - return asyncio.run(self.ReadAttribute(nodeid, [(endpoint, req)], None, False, reportInterval=(minInterval, maxInterval))) + return asyncio.run(self.ReadAttribute(nodeid, [(endpoint, req)], None, False, reportInterval=(minInterval, maxInterval), + keepSubscriptions=keepSubscriptions, autoResubscribe=autoResubscribe)) def ZCLCommandList(self): self.CheckIsActive() diff --git a/src/controller/python/test/test_scripts/base.py b/src/controller/python/test/test_scripts/base.py index 8ab30f8f6f5686..8552e8f5062e2d 100644 --- a/src/controller/python/test/test_scripts/base.py +++ b/src/controller/python/test/test_scripts/base.py @@ -40,6 +40,7 @@ from chip.ChipStack import ChipStack from chip.crypto import p256keypair from chip.utils import CommissioningBuildingBlocks +from cirque_restart_remote_device import restartRemoteDevice logger = logging.getLogger('PythonMatterControllerTEST') logger.setLevel(logging.INFO) @@ -336,6 +337,21 @@ def TestCommissioningWithSetupPayload(self, setupPayload: str, nodeid: int): self.logger.info("Commissioning finished.") return True + def TestOnNetworkCommissioning(self, discriminator: int, setuppin: int, nodeid: int, ip_override: str = None): + self.logger.info("Testing discovery") + device = self.TestDiscovery(discriminator=discriminator) + if not device: + self.logger.info("Failed to discover any devices.") + return False + address = device.addresses[0] + if ip_override: + address = ip_override + self.logger.info("Testing commissioning") + if not self.TestCommissioning(address, setuppin, nodeid): + self.logger.info("Failed to finish commissioning") + return False + return True + def TestUsedTestCommissioner(self): return self.devCtrl.GetTestCommissionerUsed() @@ -1316,3 +1332,148 @@ def TestFabricScopedCommandDuringPase(self, nodeid: int): status = ex.status return status == IM.Status.UnsupportedAccess + + def TestSubscriptionResumption(self, nodeid: int, endpoint: int, remote_ip: str, ssh_port: int, remote_server_app: str): + ''' + This test validates that the device can resume the subscriptions after restarting. + It is executed in Linux Cirque tests and the steps of this test are: + 1. Subscription the NodeLable attribute on BasicInformation cluster with the controller + 2. Restart the remote server app + 3. Validate that the controller can receive a report from the remote server app + ''' + desiredPath = None + receivedUpdate = False + updateLock = threading.Lock() + updateCv = threading.Condition(updateLock) + + def OnValueReport(path: Attribute.TypedAttributePath, transaction: Attribute.SubscriptionTransaction) -> None: + nonlocal desiredPath, updateCv, updateLock, receivedUpdate + if path.Path != desiredPath: + return + + data = transaction.GetAttribute(path) + logger.info( + f"Received report from server: path: {path.Path}, value: {data}") + with updateLock: + receivedUpdate = True + updateCv.notify_all() + + try: + desiredPath = Clusters.Attribute.AttributePath( + EndpointId=0, ClusterId=0x28, AttributeId=5) + # BasicInformation Cluster, NodeLabel Attribute + subscription = self.devCtrl.ZCLSubscribeAttribute( + "BasicInformation", "NodeLabel", nodeid, endpoint, 1, 50, keepSubscriptions=True, autoResubscribe=False) + subscription.SetAttributeUpdateCallback(OnValueReport) + + self.logger.info("Restart remote deivce") + restartRemoteThread = restartRemoteDevice( + remote_ip, ssh_port, "root", "admin", remote_server_app, "--thread --discriminator 3840") + restartRemoteThread.start() + # After device restarts, the attribute will be set dirty so the subscription can receive + # the update + with updateCv: + while receivedUpdate is False: + if not updateCv.wait(10.0): + self.logger.error( + "Failed to receive subscription resumption report") + break + + restartRemoteThread.join(10.0) + + # + # Clean-up by shutting down the sub. Otherwise, we're going to get callbacks through + # OnValueChange on what will soon become an invalid execution context above. + # + subscription.Shutdown() + + if restartRemoteThread.is_alive(): + # Thread join timed out + self.logger.error("Failed to join change thread") + return False + + return receivedUpdate + + except Exception as ex: + self.logger.exception(f"Failed to finish API test: {ex}") + return False + + return True + + ''' + The SubscriptionResumptionCapacity Cirque Test is to verify that the device can still handle new subscription + requests when resuming the maximum subscriptions. The steps for this test are: + 1. Commission the server app to the first fabric and send maximum subscription requests from the controller in + the first fabric to establish maximum subscriptions. + 2. Open the commissioning window to make the server app can be commissioned to the second fabric. + 3. Shutdown the controller in the first fabric to extend the time of resuming subscriptions. The server app will + keep resolving the address of the first controller for a while after rebooting. + 4. Commission the server app to the second fabric. + 5. Restart the server app and the server app will start resuming subscriptions. Since the first controller is + shutdown, the server app will keep resolving the address of the first controller for a while and the subscription + resumption will not fail so quickly. + 6. When the server app is resuming subscriptions, send a new subscription request from the second controller. + Verify that the device can still handle this subscription. + + BaseTestHelper provides two controllers. However, if using the two controller (devCtrl and devCtrl2) in one + MobileDevice to execute this Cirque test, the CHIPEndDevice can still resolve the address for first controller + even if the first controller is shutdown by 'self.devCtrl.Shutdown()'. And the server will fail to establish the + subscriptions immediately, which makes it hard to send the new subscription request from the second controller + at the time of server app resuming maximum subscriptions. + So we will use two controller containers for this test and divide the test to two steps. The Step1 is executed in + controller 1 in container 1 while the Step2 is executed in controller 2 in container 2 + ''' + + def TestSubscriptionResumptionCapacityStep1(self, nodeid: int, endpoint: int, subscription_capacity: int): + try: + # BasicInformation Cluster, NodeLabel Attribute + for i in range(subscription_capacity): + self.devCtrl.ZCLSubscribeAttribute( + "BasicInformation", "NodeLabel", nodeid, endpoint, 1, 50, keepSubscriptions=True, autoResubscribe=False) + + logger.info("Send OpenBasicCommissioningWindow command on fist controller") + asyncio.run( + self.devCtrl.SendCommand( + nodeid, + 0, + Clusters.AdministratorCommissioning.Commands.OpenBasicCommissioningWindow(180), + timedRequestTimeoutMs=10000 + )) + return True + + except Exception as ex: + self.logger.exception(f"Failed to finish API test: {ex}") + return False + + return True + + def TestSubscriptionResumptionCapacityStep2(self, nodeid: int, endpoint: int, remote_ip: str, ssh_port: int, + remote_server_app: str, subscription_capacity: int): + try: + self.logger.info("Restart remote deivce") + extra_agrs = f"--thread --discriminator 3840 --subscription-capacity {subscription_capacity}" + restartRemoteThread = restartRemoteDevice(remote_ip, ssh_port, "root", "admin", remote_server_app, extra_agrs) + restartRemoteThread.start() + + # Wait for some time so that the device will be resolving the address of the first controller after restarting + time.sleep(8) + restartRemoteThread.join(10.0) + + self.logger.info("Send a new subscription request from the second controller") + # Close previous session so that the second controller will res-establish the session with the remote device + self.devCtrl.CloseSession(nodeid) + self.devCtrl.ZCLSubscribeAttribute( + "BasicInformation", "NodeLabel", nodeid, endpoint, 1, 50, keepSubscriptions=True, autoResubscribe=False) + + if restartRemoteThread.is_alive(): + # Thread join timed out + self.logger.error("Failed to join change thread") + return False + + return True + + except Exception as ex: + self.logger.exception(f"Failed to finish API test: {ex}") + return False + + return True diff --git a/src/controller/python/test/test_scripts/cirque_restart_remote_device.py b/src/controller/python/test/test_scripts/cirque_restart_remote_device.py new file mode 100644 index 00000000000000..4cc773d52a1d35 --- /dev/null +++ b/src/controller/python/test/test_scripts/cirque_restart_remote_device.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 + +# +# Copyright (c) 2024 Project CHIP Authors +# All rights reserved. +# +# 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. +# + +# This is used to restart the remote device in cirque test + +import logging +import os +import sys +import threading +import time + +try: + import paramiko +except ImportError: + pass + +CHIP_REPO = os.path.join(os.path.abspath( + os.path.dirname(__file__)), "..", "..", "..", "..", "..") + +logger = logging.getLogger("CirqueRestartRemoteDevice") +logger.setLevel(logging.INFO) + +sh = logging.StreamHandler() +sh.setFormatter( + logging.Formatter( + '%(asctime)s [%(name)s] %(levelname)s %(message)s')) +sh.setStream(sys.stdout) +logger.addHandler(sh) + + +class restartRemoteDevice(threading.Thread): + def __init__(self, remote_ip: str, ssh_port: int, user: str, password: str, remote_server_app: str, + extra_args: str): + super(restartRemoteDevice, self).__init__() + self.remote_ip = remote_ip + self.ssh_port = ssh_port + self.user = user + self.password = password + self.remote_server_app = remote_server_app + self.extra_args = extra_args + + def run(self): + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + client.connect(self.remote_ip, self.ssh_port, self.user, self.password) + client.exec_command( + ("kill \"$(ps aux | grep -E \'out/debug/standalone/{}\' | grep -v grep | grep -v gdb | " + "awk \'{{print $2}}\')\"").format(self.remote_server_app)) + time.sleep(1) + stdin, stdout, stderr = client.exec_command( + ("ps aux | grep -E \'out/debug/standalone/{}\' | grep -v grep | grep -v gdb | " + "awk \'{{print $2}}\'").format(self.remote_server_app)) + if not stdout.read().decode().strip(): + logger.info(f"Succeed to kill remote process {self.remote_server_app}") + else: + logger.error(f"Failed to kill remote process {self.remote_server_app}") + + restart_remote_device_command = ( + "CHIPCirqueDaemon.py -- run gdb -batch -return-child-result -q -ex \"set pagination off\" " + "-ex run -ex \"thread apply all bt\" --args {} {}").format( + os.path.join(CHIP_REPO, "out/debug/standalone", self.remote_server_app), self.extra_args) + client.exec_command(restart_remote_device_command) + + finally: + client.close() diff --git a/src/controller/python/test/test_scripts/subscription_resumption_capacity_test_ctrl1.py b/src/controller/python/test/test_scripts/subscription_resumption_capacity_test_ctrl1.py new file mode 100755 index 00000000000000..470ff5aea53da1 --- /dev/null +++ b/src/controller/python/test/test_scripts/subscription_resumption_capacity_test_ctrl1.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 + +# +# Copyright (c) 2024 Project CHIP Authors +# All rights reserved. +# +# 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. +# + +# Commissioning test. + +import os +import sys +from optparse import OptionParser + +from base import BaseTestHelper, FailIfNot, TestFail, TestTimeout, logger + +TEST_DISCRIMINATOR = 3840 +TEST_SETUPPIN = 20202021 + +TEST_ENDPOINT_ID = 0 + + +def main(): + optParser = OptionParser() + optParser.add_option( + "-t", + "--timeout", + action="store", + dest="testTimeout", + default=90, + type='int', + help="The program will return with timeout after specified seconds.", + metavar="", + ) + optParser.add_option( + "-a", + "--address", + action="store", + dest="deviceAddress", + default='', + type='str', + help="Address of the device", + metavar="", + ) + optParser.add_option( + "--nodeid", + action="store", + dest="nodeid", + default=1, + type=int, + help="The Node ID issued to the device", + metavar="" + ) + optParser.add_option( + "--discriminator", + action="store", + dest="discriminator", + default=TEST_DISCRIMINATOR, + type=int, + help="Discriminator of the device", + metavar="" + ) + optParser.add_option( + "--setuppin", + action="store", + dest="setuppin", + default=TEST_SETUPPIN, + type=int, + help="Setup PIN of the device", + metavar="" + ) + optParser.add_option( + "-p", + "--paa-trust-store-path", + action="store", + dest="paaTrustStorePath", + default='', + type='str', + help="Path that contains valid and trusted PAA Root Certificates.", + metavar="" + ) + optParser.add_option( + "--subscription-capacity", + action="store", + dest="subscriptionCapacity", + default=3, + type=int, + help="Subscription resumption capacity", + metavar="" + ) + + (options, remainingArgs) = optParser.parse_args(sys.argv[1:]) + + timeoutTicker = TestTimeout(options.testTimeout) + timeoutTicker.start() + + test = BaseTestHelper( + nodeid=112233, paaTrustStorePath=options.paaTrustStorePath, testCommissioner=True) + + FailIfNot( + test.TestOnNetworkCommissioning(options.discriminator, options.setuppin, options.nodeid, options.deviceAddress), + "Failed on on-network commissioing") + + FailIfNot( + test.TestSubscriptionResumptionCapacityStep1(options.nodeid, TEST_ENDPOINT_ID, options.subscriptionCapacity), + "Failed on step 1 of testing subscription resumption capacity") + + timeoutTicker.stop() + + logger.info("Test finished") + + # TODO: Python device controller cannot be shutdown clean sometimes and will block on AsyncDNSResolverSockets shutdown. + # Call os._exit(0) to force close it. + os._exit(0) + + +if __name__ == "__main__": + try: + main() + except Exception as ex: + logger.exception(ex) + TestFail("Exception occurred when running tests.") diff --git a/src/controller/python/test/test_scripts/subscription_resumption_capacity_test_ctrl2.py b/src/controller/python/test/test_scripts/subscription_resumption_capacity_test_ctrl2.py new file mode 100755 index 00000000000000..2f3058afcd3bca --- /dev/null +++ b/src/controller/python/test/test_scripts/subscription_resumption_capacity_test_ctrl2.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 + +# +# Copyright (c) 2024 Project CHIP Authors +# All rights reserved. +# +# 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. +# + +# Commissioning test. + +import os +import sys +from optparse import OptionParser + +from base import BaseTestHelper, FailIfNot, TestFail, TestTimeout, logger + +TEST_DISCRIMINATOR = 3840 +TEST_SETUPPIN = 20202021 + +TEST_ENDPOINT_ID = 0 + +TEST_SSH_PORT = 2222 + + +def main(): + optParser = OptionParser() + optParser.add_option( + "-t", + "--timeout", + action="store", + dest="testTimeout", + default=90, + type='int', + help="The program will return with timeout after specified seconds.", + metavar="", + ) + optParser.add_option( + "-a", + "--address", + action="store", + dest="deviceAddress", + default='', + type='str', + help="Address of the device", + metavar="", + ) + optParser.add_option( + "--nodeid", + action="store", + dest="nodeid", + default=1, + type=int, + help="The Node ID issued to the device", + metavar="" + ) + optParser.add_option( + "--discriminator", + action="store", + dest="discriminator", + default=TEST_DISCRIMINATOR, + type=int, + help="Discriminator of the device", + metavar="" + ) + optParser.add_option( + "--setuppin", + action="store", + dest="setuppin", + default=TEST_SETUPPIN, + type=int, + help="Setup PIN of the device", + metavar="" + ) + optParser.add_option( + "-p", + "--paa-trust-store-path", + action="store", + dest="paaTrustStorePath", + default='', + type='str', + help="Path that contains valid and trusted PAA Root Certificates.", + metavar="" + ) + optParser.add_option( + "--remote-server-app", + action="store", + dest="remoteServerApp", + default='', + type='str', + help="Remote Server App", + metavar="" + ) + optParser.add_option( + "--subscription-capacity", + action="store", + dest="subscriptionCapacity", + default=3, + type=int, + help="Subscription resumption capacity", + metavar="" + ) + + (options, remainingArgs) = optParser.parse_args(sys.argv[1:]) + + timeoutTicker = TestTimeout(options.testTimeout) + timeoutTicker.start() + + # Use a different node ID for the second controller + test = BaseTestHelper( + nodeid=112244, paaTrustStorePath=options.paaTrustStorePath, testCommissioner=True) + + FailIfNot( + test.TestOnNetworkCommissioning(options.discriminator, options.setuppin, options.nodeid, options.deviceAddress), + "Failed on on-network commissioing") + + FailIfNot( + test.TestSubscriptionResumptionCapacityStep2(options.nodeid, TEST_ENDPOINT_ID, options.deviceAddress, + TEST_SSH_PORT, options.remoteServerApp, options.subscriptionCapacity), + "Failed on testing subscription resumption capacity") + + timeoutTicker.stop() + + logger.info("Test finished") + + # TODO: Python device controller cannot be shutdown clean sometimes and will block on AsyncDNSResolverSockets shutdown. + # Call os._exit(0) to force close it. + os._exit(0) + + +if __name__ == "__main__": + try: + main() + except Exception as ex: + logger.exception(ex) + TestFail("Exception occurred when running tests.") diff --git a/src/controller/python/test/test_scripts/subscription_resumption_test.py b/src/controller/python/test/test_scripts/subscription_resumption_test.py new file mode 100755 index 00000000000000..8b2000fb070cd7 --- /dev/null +++ b/src/controller/python/test/test_scripts/subscription_resumption_test.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 + +# +# Copyright (c) 2024 Project CHIP Authors +# All rights reserved. +# +# 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. +# + +# Commissioning test. + +import os +import sys +from optparse import OptionParser + +from base import BaseTestHelper, FailIfNot, TestFail, TestTimeout, logger + +TEST_DISCRIMINATOR = 3840 +TEST_SETUPPIN = 20202021 + +TEST_ENDPOINT_ID = 0 + +TEST_SSH_PORT = 2222 + + +def main(): + optParser = OptionParser() + optParser.add_option( + "-t", + "--timeout", + action="store", + dest="testTimeout", + default=90, + type='int', + help="The program will return with timeout after specified seconds.", + metavar="", + ) + optParser.add_option( + "-a", + "--address", + action="store", + dest="deviceAddress", + default='', + type='str', + help="Address of the device", + metavar="", + ) + optParser.add_option( + "--nodeid", + action="store", + dest="nodeid", + default=1, + type=int, + help="The Node ID issued to the device", + metavar="" + ) + optParser.add_option( + "--discriminator", + action="store", + dest="discriminator", + default=TEST_DISCRIMINATOR, + type=int, + help="Discriminator of the device", + metavar="" + ) + optParser.add_option( + "--setuppin", + action="store", + dest="setuppin", + default=TEST_SETUPPIN, + type=int, + help="Setup PIN of the device", + metavar="" + ) + optParser.add_option( + "-p", + "--paa-trust-store-path", + action="store", + dest="paaTrustStorePath", + default='', + type='str', + help="Path that contains valid and trusted PAA Root Certificates.", + metavar="" + ) + optParser.add_option( + "--remote-server-app", + action="store", + dest="remoteServerApp", + default='', + type='str', + help="Remote Server App", + metavar="" + ) + + (options, remainingArgs) = optParser.parse_args(sys.argv[1:]) + + timeoutTicker = TestTimeout(options.testTimeout) + timeoutTicker.start() + + test = BaseTestHelper( + nodeid=112233, paaTrustStorePath=options.paaTrustStorePath, testCommissioner=True) + + FailIfNot( + test.TestOnNetworkCommissioning(options.discriminator, options.setuppin, options.nodeid, options.deviceAddress), + "Failed on on-network commissioing") + + FailIfNot( + test.TestSubscriptionResumption(options.nodeid, TEST_ENDPOINT_ID, options.deviceAddress, + TEST_SSH_PORT, options.remoteServerApp), "Failed to resume subscription") + + timeoutTicker.stop() + + logger.info("Test finished") + + # TODO: Python device controller cannot be shutdown clean sometimes and will block on AsyncDNSResolverSockets shutdown. + # Call os._exit(0) to force close it. + os._exit(0) + + +if __name__ == "__main__": + try: + main() + except Exception as ex: + logger.exception(ex) + TestFail("Exception occurred when running tests.") diff --git a/src/test_driver/linux-cirque/SubscriptionResumptionCapacityTest.py b/src/test_driver/linux-cirque/SubscriptionResumptionCapacityTest.py new file mode 100755 index 00000000000000..d99d25e5d2b8b2 --- /dev/null +++ b/src/test_driver/linux-cirque/SubscriptionResumptionCapacityTest.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2024 Project CHIP Authors + +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 os +import sys + +from helper.CHIPTestBase import CHIPVirtualHome + +""" +Test to verify that the device can still handle new subscription requests when resuming the maximum subscriptions. +Steps for this test: + 1. Commission the server app to the first fabric and send maximum subscription requests from the controller in + the first fabric to establish maximum subscriptions. + 2. Open the commissioning window to make the server app can be commissioned to the second fabric. + 3. Shutdown the controller in the first fabric to extend the time of resuming subscriptions. The server app will + keep resolving the address of the first controller for a while after rebooting. + 4. Commission the server app to the second fabric. + 5. Restart the server app and the server app will start resuming subscriptions. + 6. When the server app is resuming subscriptions, send a new subscription request from the second controller. + 7. Verify that the device can still handle this subscription request. +""" + +logger = logging.getLogger('SubscriptionResumptionCapacityTest') +logger.setLevel(logging.INFO) + +sh = logging.StreamHandler() +sh.setFormatter( + logging.Formatter( + '%(asctime)s [%(name)s] %(levelname)s %(message)s')) +logger.addHandler(sh) + +CHIP_PORT = 5540 + +CIRQUE_URL = "http://localhost:5000" +CHIP_REPO = os.path.join(os.path.abspath( + os.path.dirname(__file__)), "..", "..", "..") +TEST_EXTPANID = "fedcba9876543210" +TEST_DISCRIMINATOR = 3840 +MATTER_DEVELOPMENT_PAA_ROOT_CERTS = "credentials/development/paa-root-certs" +TEST_END_DEVICE_APP = "lit-icd-app" +TEST_SUBSCRIPTION_CAPACITY = 3 + + +# TODO: If using one Mobile Device, the CHIPEndDevice can still resolve the address for first controller +# even if it is shutdown by 'devCtrl.Shutdown()'. And the server will fail to estalish the subscriptions +# immediately, which makes it hard to send the new subscription request from the second controller. + +# Use two containers for two controller in two different fabrics. +DEVICE_CONFIG = { + 'device0': { + 'type': 'MobileDevice', + 'base_image': '@default', + 'capability': ['TrafficControl', 'Mount'], + 'rcp_mode': True, + 'docker_network': 'Ipv6', + 'traffic_control': {'latencyMs': 25}, + "mount_pairs": [[CHIP_REPO, CHIP_REPO]], + }, + 'device1': { + 'type': 'MobileDevice', + 'base_image': '@default', + 'capability': ['TrafficControl', 'Mount'], + 'rcp_mode': True, + 'docker_network': 'Ipv6', + 'traffic_control': {'latencyMs': 25}, + "mount_pairs": [[CHIP_REPO, CHIP_REPO]], + }, + 'device2': { + 'type': 'CHIPEndDevice', + 'base_image': '@default', + 'capability': ['Thread', 'TrafficControl', 'Mount'], + 'rcp_mode': True, + 'docker_network': 'Ipv6', + 'traffic_control': {'latencyMs': 25}, + "mount_pairs": [[CHIP_REPO, CHIP_REPO]], + } +} + + +class TestSubscriptionResumptionCapacity(CHIPVirtualHome): + def __init__(self, device_config): + super().__init__(CIRQUE_URL, device_config) + self.logger = logger + + def setup(self): + self.initialize_home() + + def test_routine(self): + self.run_subscription_resumption_capacity_test() + + def run_subscription_resumption_capacity_test(self): + ethernet_ip = [device['description']['ipv6_addr'] for device in self.non_ap_devices + if device['type'] == 'CHIPEndDevice'][0] + server_ids = [device['id'] for device in self.non_ap_devices + if device['type'] == 'CHIPEndDevice'] + req_ids = [device['id'] for device in self.non_ap_devices + if device['type'] == 'MobileDevice'] + + server_device_id = server_ids[0] + # Start SSH server + self.execute_device_cmd(server_device_id, "service ssh start") + self.execute_device_cmd( + server_device_id, + ("CHIPCirqueDaemon.py -- run gdb -batch -return-child-result -q -ex \"set pagination off\" " + "-ex run -ex \"thread apply all bt\" --args {} --thread --discriminator {} " + "--subscription-capacity {}").format( + os.path.join(CHIP_REPO, "out/debug/standalone", TEST_END_DEVICE_APP), TEST_DISCRIMINATOR, + TEST_SUBSCRIPTION_CAPACITY)) + + self.reset_thread_devices(server_ids) + + for req_device_id in req_ids: + self.execute_device_cmd(req_device_id, "pip3 install {}".format(os.path.join( + CHIP_REPO, "out/debug/linux_x64_gcc/controller/python/chip_clusters-0.0-py3-none-any.whl"))) + self.execute_device_cmd(req_device_id, "pip3 install {}".format(os.path.join( + CHIP_REPO, "out/debug/linux_x64_gcc/controller/python/chip_core-0.0-cp37-abi3-linux_x86_64.whl"))) + self.execute_device_cmd(req_device_id, "pip3 install {}".format(os.path.join( + CHIP_REPO, "out/debug/linux_x64_gcc/controller/python/chip_repl-0.0-py3-none-any.whl"))) + + command1 = ("gdb -batch -return-child-result -q -ex run -ex \"thread apply all bt\" " + "--args python3 {} -t 300 -a {} --paa-trust-store-path {} --subscription-capacity {}").format( + os.path.join(CHIP_REPO, "src/controller/python/test/test_scripts", + "subscription_resumption_capacity_test_ctrl1.py"), + ethernet_ip, os.path.join(CHIP_REPO, MATTER_DEVELOPMENT_PAA_ROOT_CERTS), + TEST_SUBSCRIPTION_CAPACITY) + ret1 = self.execute_device_cmd(req_ids[0], command1) + + self.assertEqual(ret1['return_code'], '0', + "Test failed: non-zero return code") + + command2 = ("gdb -batch -return-child-result -q -ex run -ex \"thread apply all bt\" " + "--args python3 {} -t 300 -a {} --paa-trust-store-path {} --remote-server-app {} " + "--subscription-capacity {}").format( + os.path.join(CHIP_REPO, "src/controller/python/test/test_scripts", + "subscription_resumption_capacity_test_ctrl2.py"), + ethernet_ip, os.path.join(CHIP_REPO, MATTER_DEVELOPMENT_PAA_ROOT_CERTS), + TEST_END_DEVICE_APP, TEST_SUBSCRIPTION_CAPACITY) + ret2 = self.execute_device_cmd(req_ids[1], command2) + + self.assertEqual(ret2['return_code'], '0', + "Test failed: non-zero return code") + + # Check the device can evit existing subscriptions + self.logger.info("checking device log for {}".format( + self.get_device_pretty_id(server_device_id))) + self.assertFalse(self.sequenceMatch(self.get_device_log(server_device_id).decode('utf-8'), [ + "Failed to get required resources by evicting existing subscriptions"]), + "SubscriptionResumptionCapacity test failed: find abort log from device {}".format(server_device_id)) + + +if __name__ == "__main__": + sys.exit(TestSubscriptionResumptionCapacity(DEVICE_CONFIG).run_test()) diff --git a/src/test_driver/linux-cirque/SubscriptionResumptionTest.py b/src/test_driver/linux-cirque/SubscriptionResumptionTest.py new file mode 100755 index 00000000000000..47de0f4f4a1d79 --- /dev/null +++ b/src/test_driver/linux-cirque/SubscriptionResumptionTest.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +""" +Copyright (c) 2024 Project CHIP Authors + +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 os +import sys + +from helper.CHIPTestBase import CHIPVirtualHome + +""" +Basic Subscription Resumption Test to validate that the device can resume subscriptions after restarting. +Steps for this test: + 1. Subcription an attribute on the controller + 2. Restart the server app + 3. Verify that the server app with resume the subscription and send a report to the controller +""" + +logger = logging.getLogger('SubscriptionResumptionTest') +logger.setLevel(logging.INFO) + +sh = logging.StreamHandler() +sh.setFormatter( + logging.Formatter( + '%(asctime)s [%(name)s] %(levelname)s %(message)s')) +logger.addHandler(sh) + +CHIP_PORT = 5540 + +CIRQUE_URL = "http://localhost:5000" +CHIP_REPO = os.path.join(os.path.abspath( + os.path.dirname(__file__)), "..", "..", "..") +TEST_EXTPANID = "fedcba9876543210" +TEST_DISCRIMINATOR = 3840 +MATTER_DEVELOPMENT_PAA_ROOT_CERTS = "credentials/development/paa-root-certs" +TEST_END_DEVICE_APP = "lit-icd-app" + +DEVICE_CONFIG = { + 'device0': { + 'type': 'MobileDevice', + 'base_image': '@default', + 'capability': ['TrafficControl', 'Mount'], + 'rcp_mode': True, + 'docker_network': 'Ipv6', + 'traffic_control': {'latencyMs': 25}, + "mount_pairs": [[CHIP_REPO, CHIP_REPO]], + }, + 'device1': { + 'type': 'CHIPEndDevice', + 'base_image': '@default', + 'capability': ['Thread', 'TrafficControl', 'Mount'], + 'rcp_mode': True, + 'docker_network': 'Ipv6', + 'traffic_control': {'latencyMs': 25}, + "mount_pairs": [[CHIP_REPO, CHIP_REPO]], + } +} + + +class TestSubscriptionResumption(CHIPVirtualHome): + def __init__(self, device_config): + super().__init__(CIRQUE_URL, device_config) + self.logger = logger + + def setup(self): + self.initialize_home() + + def test_routine(self): + self.run_subscription_resumption_test() + + def run_subscription_resumption_test(self): + ethernet_ip = [device['description']['ipv6_addr'] for device in self.non_ap_devices + if device['type'] == 'CHIPEndDevice'][0] + server_ids = [device['id'] for device in self.non_ap_devices + if device['type'] == 'CHIPEndDevice'] + req_ids = [device['id'] for device in self.non_ap_devices + if device['type'] == 'MobileDevice'] + + server_device_id = server_ids[0] + # Start SSH server + self.execute_device_cmd(server_device_id, "service ssh start") + self.execute_device_cmd( + server_device_id, + ("CHIPCirqueDaemon.py -- run gdb -batch -return-child-result -q -ex \"set pagination off\" " + "-ex run -ex \"thread apply all bt\" --args {} --thread --discriminator {}").format( + os.path.join(CHIP_REPO, "out/debug/standalone", TEST_END_DEVICE_APP), TEST_DISCRIMINATOR)) + + self.reset_thread_devices(server_ids) + + req_device_id = req_ids[0] + + self.execute_device_cmd(req_device_id, "pip3 install {}".format(os.path.join( + CHIP_REPO, "out/debug/linux_x64_gcc/controller/python/chip_clusters-0.0-py3-none-any.whl"))) + self.execute_device_cmd(req_device_id, "pip3 install {}".format(os.path.join( + CHIP_REPO, "out/debug/linux_x64_gcc/controller/python/chip_core-0.0-cp37-abi3-linux_x86_64.whl"))) + self.execute_device_cmd(req_device_id, "pip3 install {}".format(os.path.join( + CHIP_REPO, "out/debug/linux_x64_gcc/controller/python/chip_repl-0.0-py3-none-any.whl"))) + + command = ("gdb -batch -return-child-result -q -ex run -ex \"thread apply all bt\" " + "--args python3 {} -t 300 -a {} --paa-trust-store-path {} --remote-server-app {}").format( + os.path.join( + CHIP_REPO, "src/controller/python/test/test_scripts/subscription_resumption_test.py"), ethernet_ip, + os.path.join(CHIP_REPO, MATTER_DEVELOPMENT_PAA_ROOT_CERTS), TEST_END_DEVICE_APP) + ret = self.execute_device_cmd(req_device_id, command) + + self.assertEqual(ret['return_code'], '0', + "Test failed: non-zero return code") + + # Check the device can resume subscriptions + self.logger.info("checking device log for {}".format( + self.get_device_pretty_id(server_device_id))) + self.assertTrue(self.sequenceMatch(self.get_device_log(server_device_id).decode('utf-8'), [ + "Resuming 1 subscriptions in 1 seconds", + "Registered a ReadHandler that will schedule a report"]), + "SubscriptionResumption test failed: cannot find matching string from device {}".format(server_device_id)) + + +if __name__ == "__main__": + sys.exit(TestSubscriptionResumption(DEVICE_CONFIG).run_test())