From 905218a028fb393c86ccd62d4ae44aad9a453cdc Mon Sep 17 00:00:00 2001 From: WilliamRoebuck <244554584+WilliamRoebuck@users.noreply.github.com> Date: Thu, 5 Feb 2026 10:56:47 +0000 Subject: [PATCH] System test execution setup - Local execution - Launching LM daemon with a config - GTEST processes launched by LM with test results parsed - Test timeout - Scaffolding for platform independent python scripts - Running LM without root - setgroups() is not attempted if there are no supplementary group ids - Minor cleanup in ProcessGroupManager::initialize() --- .devcontainer/devcontainer.json | 2 +- .github/workflows/tests.yml | 17 +- MODULE.bazel | 8 + config/flatbuffers_rules.bzl | 77 +++++++ src/launch_manager_daemon/BUILD | 2 + .../health_monitor_lib/BUILD | 2 + .../health_monitor_thread.cpp | 2 +- .../processgroupmanager.cpp | 24 +-- .../process_group_manager/processlauncher.cpp | 3 +- tests/integration/BUILD | 63 ++++++ tests/integration/control_interface.py | 58 ++++++ tests/integration/readme.md | 12 ++ tests/integration/requirements.lock | 31 +++ tests/integration/requirements.txt | 1 + tests/integration/smoke/BUILD | 73 +++++++ .../integration/smoke/control_daemon_mock.cpp | 59 ++++++ tests/integration/smoke/gtest_process.cpp | 31 +++ tests/integration/smoke/hm_demo.json | 13 ++ tests/integration/smoke/lm_demo.json | 123 ++++++++++++ tests/integration/smoke/smoke.py | 29 +++ tests/integration/test_helper.hpp | 86 ++++++++ tests/integration/testing_utils.py | 189 ++++++++++++++++++ 22 files changed, 883 insertions(+), 22 deletions(-) create mode 100644 config/flatbuffers_rules.bzl create mode 100644 tests/integration/BUILD create mode 100644 tests/integration/control_interface.py create mode 100644 tests/integration/readme.md create mode 100644 tests/integration/requirements.lock create mode 100644 tests/integration/requirements.txt create mode 100644 tests/integration/smoke/BUILD create mode 100644 tests/integration/smoke/control_daemon_mock.cpp create mode 100644 tests/integration/smoke/gtest_process.cpp create mode 100644 tests/integration/smoke/hm_demo.json create mode 100644 tests/integration/smoke/lm_demo.json create mode 100644 tests/integration/smoke/smoke.py create mode 100644 tests/integration/test_helper.hpp create mode 100644 tests/integration/testing_utils.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 539b6e06..0fdf39a2 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -29,7 +29,7 @@ ], // Add your personal customizations "onCreateCommand": { - "update certificates & install dependencies": "sudo apt update && sudo apt install -y --no-install-recommends ca-certificates-java openjdk-17-jre-headless libacl1-dev tmux && sudo update-ca-certificates", + "update certificates & install dependencies": "sudo apt update && sudo apt install -y --no-install-recommends ca-certificates-java openjdk-17-jre-headless libacl1-dev tmux fakechroot && sudo update-ca-certificates", "bazel use system trust store": "echo 'startup --host_jvm_args=-Djavax.net.ssl.trustStore=/etc/ssl/certs/java/cacerts --host_jvm_args=-Djavax.net.ssl.trustStorePassword=changeit' | sudo tee --append /etc/bazel.bazelrc" }, diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index eb1459ba..5a4f81a8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -19,10 +19,13 @@ on: types: [checks_requested] jobs: test: - name: "Run tests" - uses: eclipse-score/cicd-workflows/.github/workflows/tests.yml@main - permissions: - contents: read - pull-requests: read - with: - bazel-target: 'test //src/...' + steps: + - name: Install fakechroot + run: sudo apt install -y fakechroot + - name: Run tests + uses: eclipse-score/cicd-workflows/.github/workflows/tests.yml@main + permissions: + contents: read + pull-requests: read + with: + bazel-target: 'test //src/... //tests/integration/...' diff --git a/MODULE.bazel b/MODULE.bazel index f44180e8..36a47b28 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -99,6 +99,14 @@ python.toolchain( ) use_repo(python) +pip = use_extension("@rules_python//python/extensions:pip.bzl", "pip", dev_dependency = True) +pip.parse( + hub_name = "pip_score_venv_test", + python_version = PYTHON_VERSION, + requirements_lock = "//tests/integration:requirements.lock", +) +use_repo(pip, "pip_score_venv_test") + use_repo(toolchains_qnx, "toolchains_qnx_sdp") use_repo(toolchains_qnx, "toolchains_qnx_qcc") use_repo(toolchains_qnx, "toolchains_qnx_ifs") diff --git a/config/flatbuffers_rules.bzl b/config/flatbuffers_rules.bzl new file mode 100644 index 00000000..366d96ef --- /dev/null +++ b/config/flatbuffers_rules.bzl @@ -0,0 +1,77 @@ +def _flatbuffer_json_to_bin_impl(ctx): + flatc = ctx.executable.flatc + json = ctx.file.json + schema = ctx.file.schema + + # flatc will name the file the same as the json (can't be changed) + out_name = json.basename[:-len(".json")] + ".bin" + out = ctx.actions.declare_file(out_name, sibling = json) + + # flatc args --------------------------------- + flatc_args = [ + "-b", + "-o", + out.dirname, + ] + + for inc in ctx.attr.includes: + flatc_args.extend(["-I", inc.path]) + + if ctx.attr.strict_json: + flatc_args.append("--strict-json") + + flatc_args.extend([schema.path, json.path]) + # -------------------------------------------- + + ctx.actions.run( + inputs = [json, schema] + list(ctx.files.includes), + outputs = [out], + executable = flatc, + arguments = flatc_args, + progress_message = "flatc generation {}".format(json.short_path), + mnemonic = "FlatcGeneration", + ) + + rf = ctx.runfiles( + files = [out], + root_symlinks = { + ("_main/" + ctx.attr.out_dir + "/" + out_name): out, + }, + ) + + return DefaultInfo(files = depset([out]), runfiles = rf) + +flatbuffer_json_to_bin = rule( + implementation = _flatbuffer_json_to_bin_impl, + attrs = { + "json": attr.label( + allow_single_file = [".json"], + mandatory = True, + doc = "Json file to convert. Note that the binary file will have the same name as the json (minus the suffix)", + ), + "schema": attr.label( + allow_single_file = [".fbs"], + mandatory = True, + doc = "FBS file to use", + ), + "out_dir": attr.string( + default = "etc", + doc = "Directory to copy the generated file to, sibling to 'src' and 'tests' dirs. Do not include a trailing '/'", + ), + "flatc": attr.label( + default = Label("@flatbuffers//:flatc"), + executable = True, + cfg = "exec", + doc = "Reference to the flatc binary", + ), + # flatc arguments + "includes": attr.label_list( + allow_files = True, + doc = "Flatc include paths", + ), + "strict_json": attr.bool( + default = False, + doc = "Require strict JSON (no trailing commas etc)", + ), + }, +) diff --git a/src/launch_manager_daemon/BUILD b/src/launch_manager_daemon/BUILD index a18d5545..ac84080a 100644 --- a/src/launch_manager_daemon/BUILD +++ b/src/launch_manager_daemon/BUILD @@ -12,6 +12,8 @@ # ******************************************************************************* load("//config:common_cc.bzl", "cc_binary_with_common_opts", "cc_library_with_common_opts") +exports_files(["config/lm_flatcfg.fbs"]) + cc_library( name = "config", hdrs = ["config/lm_flatcfg_generated.h"], diff --git a/src/launch_manager_daemon/health_monitor_lib/BUILD b/src/launch_manager_daemon/health_monitor_lib/BUILD index 3f699d3c..5fe4095b 100644 --- a/src/launch_manager_daemon/health_monitor_lib/BUILD +++ b/src/launch_manager_daemon/health_monitor_lib/BUILD @@ -13,6 +13,8 @@ load("//config:common_cc.bzl", "cc_binary_with_common_opts", "cc_library_with_common_opts") # flatcfg configuration +exports_files(["config/hm_flatcfg.fbs"]) + cc_library( name = "config", hdrs = [ diff --git a/src/launch_manager_daemon/src/process_group_manager/health_monitor_thread.cpp b/src/launch_manager_daemon/src/process_group_manager/health_monitor_thread.cpp index fb4173fe..01b5b3b4 100644 --- a/src/launch_manager_daemon/src/process_group_manager/health_monitor_thread.cpp +++ b/src/launch_manager_daemon/src/process_group_manager/health_monitor_thread.cpp @@ -37,7 +37,7 @@ bool HealthMonitorThread::start() { waitForInitializationCompleted(init_status); - return (init_status == score::lcm::saf::daemon::EInitCode::kNoError); + return init_status == saf::daemon::EInitCode::kNoError; } void HealthMonitorThread::stop() { diff --git a/src/launch_manager_daemon/src/process_group_manager/processgroupmanager.cpp b/src/launch_manager_daemon/src/process_group_manager/processgroupmanager.cpp index dde42d37..b6b5bddb 100644 --- a/src/launch_manager_daemon/src/process_group_manager/processgroupmanager.cpp +++ b/src/launch_manager_daemon/src/process_group_manager/processgroupmanager.cpp @@ -65,8 +65,6 @@ void ProcessGroupManager::setLaunchManagerConfiguration(const OsProcess* launch_ } bool ProcessGroupManager::initialize() { - bool success = false; - // setup signal handler em_cancelled.store(false); // RULECHECKER_comment(1, 1, check_union_object, "Union type defined in external library is used.", true) @@ -86,22 +84,24 @@ bool ProcessGroupManager::initialize() { sigaction(SIGUSR2, &action, NULL); sigaction(SIGVTALRM, &action, NULL); - success = initializeControlClientHandler() && initializeProcessGroups(); + if (!initializeControlClientHandler() || !initializeProcessGroups()) { + return false; + } - if (success) { - LM_LOG_DEBUG() << "Process Group initialization done"; - createProcessComponentsObjects(); - initializeGraphNodes(); - //success = ucm_polling_thread_.startPolling(); - success = health_monitor_thread_->start(); + LM_LOG_DEBUG() << "Process Group initialization done"; + createProcessComponentsObjects(); + initializeGraphNodes(); + if (!health_monitor_thread_->start()) { + LM_LOG_ERROR() << "Health monitor thread failed to start"; + return false; } - if (success && launch_manager_config_ && + if (launch_manager_config_ && OsalReturnType::kFail == IProcess::setSchedulingAndSecurity(launch_manager_config_->startup_config_)) { - success = false; + return false; } - return success; + return true; } void ProcessGroupManager::deinitialize() { diff --git a/src/launch_manager_daemon/src/process_group_manager/processlauncher.cpp b/src/launch_manager_daemon/src/process_group_manager/processlauncher.cpp index 8f3c33d7..8a59c36b 100644 --- a/src/launch_manager_daemon/src/process_group_manager/processlauncher.cpp +++ b/src/launch_manager_daemon/src/process_group_manager/processlauncher.cpp @@ -334,7 +334,7 @@ OsalReturnType IProcess::setSchedulingAndSecurity(const OsalConfig& config) { size_t supplementary_gids_number = config.supplementary_gids_.size(); // Note: the type of the first parameter of setgroups() differs in Linux and QNX, so we use osal - if (-1 == osal::setgroups(supplementary_gids_number, config.supplementary_gids_.data())) { + if (supplementary_gids_number > 0 && -1 == osal::setgroups(supplementary_gids_number, config.supplementary_gids_.data())) { LM_LOG_ERROR() << "setgroups() failed:" << std::strerror(errno); retval = OsalReturnType::kFail; } @@ -354,6 +354,7 @@ inline void IProcess::handleChildProcess(ChildProcessConfig& param) { if (OsalReturnType::kSuccess != setSchedulingAndSecurity(*param.config)) { sysexit(EXIT_FAILURE); } + changeCurrentWorkingDirectory(*param.config); implementMemoryResourceLimits(*param.config); changeSecurityPolicy(*param.config); diff --git a/tests/integration/BUILD b/tests/integration/BUILD new file mode 100644 index 00000000..b2ba66a2 --- /dev/null +++ b/tests/integration/BUILD @@ -0,0 +1,63 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("@pip_score_venv_test//:requirements.bzl", "all_requirements") +load("@rules_python//python:pip.bzl", "compile_pip_requirements") +load("@score_tooling//python_basics:defs.bzl", "score_py_pytest", "score_virtualenv") + +# In order to update the requirements, change the `requirements.txt` file and run: +# `bazel run //tests/integration:requirements.update`. +# This will update the `requirements.lock` file. +# To upgrade all dependencies to their latest versions, run: +# `bazel run //tests/integration:requirements.update -- --upgrade`. +compile_pip_requirements( + name = "requirements", + srcs = [ + "requirements.txt", + "@score_tooling//python_basics:requirements.txt", + ], + extra_args = [ + "--no-annotate", + ], + requirements_txt = "requirements.lock", + tags = [ + "manual", + ], +) + +score_virtualenv( + name = "python_tc_venv", + reqs = all_requirements, + venv_name = ".python_tc_venv", +) + +cc_library( + name = "test_helper", + hdrs = ["test_helper.hpp"], + visibility = ["//tests:__subpackages__"], + deps = [ + "@googletest//:gtest_main", + ], +) + +py_library( + name = "control_interface", + srcs = ["control_interface.py"], + visibility = ["//tests:__subpackages__"], +) + +py_library( + name = "testing_utils", + srcs = ["testing_utils.py"], + visibility = ["//tests:__subpackages__"], + deps = [":control_interface"], +) diff --git a/tests/integration/control_interface.py b/tests/integration/control_interface.py new file mode 100644 index 00000000..668ae76b --- /dev/null +++ b/tests/integration/control_interface.py @@ -0,0 +1,58 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +from typing import Tuple +from pathlib import Path +from abc import ABC, abstractmethod + + +class ControlInterface(ABC): + """Platform independent interface to execute commands on the target""" + + @abstractmethod + def exec_command_blocking( + *args: str, timeout=1, **env: str + ) -> Tuple[int, str, str]: + """Execute a command on the target + + Args: + *args (str): Command to run with arguments + timeout (int): Time in seconds to exit after, returning status -1 + **env (str): Environment vars to set + + Returns: + (int, str, str): exit_status, stdout, stderr + """ + raise NotImplementedError() + + @abstractmethod + def run_until_file_deployed( + *args, + timeout=1, + file_path=Path("tests/integration/test_end"), + poll_interval=0.05, + **env, + ) -> Tuple[int, str, str]: + """Launch a process and terminate it once a given file has been deployed + + Args: + + *args (str): Command to run with arguments + timeout (int): Time in seconds to exit after, returning status -1 + file_path (Path): File to wait for + poll_interval (float): How often, in seconds, to check if we should terminate the process + **env (str): Environment vars to set + + Returns: + (int, str, str): exit_status, stdout, stderr + """ + raise NotImplementedError() diff --git a/tests/integration/readme.md b/tests/integration/readme.md new file mode 100644 index 00000000..5180abfd --- /dev/null +++ b/tests/integration/readme.md @@ -0,0 +1,12 @@ +# Local integration testing + +## Prerequisites +- fakechroot must be installed to run these tests + - `sudo apt install fakechroot` + +## Running the tests + +To run all tests, simply run `bazel test //tests/integration/...` + +## Running a single test +You can run a single integration test locally using `bazel test //tests/integration/` \ No newline at end of file diff --git a/tests/integration/requirements.lock b/tests/integration/requirements.lock new file mode 100644 index 00000000..22a31765 --- /dev/null +++ b/tests/integration/requirements.lock @@ -0,0 +1,31 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# bazel run //tests/integration:requirements.update +# +basedpyright==1.29.2 \ + --hash=sha256:12c49186003b9f69a028615da883ef97035ea2119a9e3f93a00091b3a27088a6 \ + --hash=sha256:f389e2997de33d038c5065fd85bff351fbdc62fa6d6371c7b947fc3bce8d437d +iniconfig==2.1.0 \ + --hash=sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7 \ + --hash=sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760 +nodejs-wheel-binaries==22.16.0 \ + --hash=sha256:2728972d336d436d39ee45988978d8b5d963509e06f063e80fe41b203ee80b28 \ + --hash=sha256:2fffb4bf1066fb5f660da20819d754f1b424bca1b234ba0f4fa901c52e3975fb \ + --hash=sha256:447ad796850eb52ca20356ad39b2d296ed8fef3f214921f84a1ccdad49f2eba1 \ + --hash=sha256:4ae3cf22138891cb44c3ee952862a257ce082b098b29024d7175684a9a77b0c0 \ + --hash=sha256:71f2de4dc0b64ae43e146897ce811f80ac4f9acfbae6ccf814226282bf4ef174 \ + --hash=sha256:7f526ca6a132b0caf633566a2a78c6985fe92857e7bfdb37380f76205a10b808 \ + --hash=sha256:986b715a96ed703f8ce0c15712f76fc42895cf09067d72b6ef29e8b334eccf64 \ + --hash=sha256:d695832f026df3a0cf9a089d222225939de9d1b67f8f0a353b79f015aabbe7e2 \ + --hash=sha256:dbfccbcd558d2f142ccf66d8c3a098022bf4436db9525b5b8d32169ce185d99e +packaging==25.0 \ + --hash=sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484 \ + --hash=sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f +pluggy==1.6.0 \ + --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ + --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 +pytest==8.3.5 \ + --hash=sha256:c69214aa47deac29fad6c2a4f590b9c4a9fdb16a403176fe154b79c0b4d4d820 \ + --hash=sha256:f4efe70cc14e511565ac476b57c279e12a855b11f48f212af1080ef2263d3845 diff --git a/tests/integration/requirements.txt b/tests/integration/requirements.txt new file mode 100644 index 00000000..55b033e9 --- /dev/null +++ b/tests/integration/requirements.txt @@ -0,0 +1 @@ +pytest \ No newline at end of file diff --git a/tests/integration/smoke/BUILD b/tests/integration/smoke/BUILD new file mode 100644 index 00000000..1deb9a9f --- /dev/null +++ b/tests/integration/smoke/BUILD @@ -0,0 +1,73 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +load("@pip_score_venv_test//:requirements.bzl", "all_requirements") +load("@score_tooling//:defs.bzl", "score_py_pytest") +load("//config:flatbuffers_rules.bzl", "flatbuffer_json_to_bin") + +flatbuffer_json_to_bin( + name = "test_lm_cfg", + json = "lm_demo.json", + schema = "//src/launch_manager_daemon:config/lm_flatcfg.fbs", +) + +flatbuffer_json_to_bin( + name = "test_hm_cfg", + json = "hm_demo.json", + schema = "//src/launch_manager_daemon/health_monitor_lib:config/hm_flatcfg.fbs", +) + +cc_binary( + name = "control_daemon_mock", + srcs = ["control_daemon_mock.cpp"], + data = [ + ":test_hm_cfg", + ":test_lm_cfg", + ], + deps = [ + "//src/control_client_lib", + "//src/launch_manager_daemon/lifecycle_client_lib:lifecycle_client", + "//tests/integration:test_helper", + "@googletest//:gtest_main", + ], +) + +cc_binary( + name = "gtest_process", + srcs = ["gtest_process.cpp"], + deps = [ + "//src/control_client_lib", + "//src/launch_manager_daemon/lifecycle_client_lib:lifecycle_client", + "//tests/integration:test_helper", + "@googletest//:gtest_main", + ], +) + +score_py_pytest( + name = "smoke", + srcs = [ + "smoke.py", + ], + args = [ + ], + data = [ + ":control_daemon_mock", + ":gtest_process", + "//src/launch_manager_daemon:launch_manager", + ], + tags = [ + "integration", + ], + deps = all_requirements + [ + "//tests/integration:testing_utils", + ], +) diff --git a/tests/integration/smoke/control_daemon_mock.cpp b/tests/integration/smoke/control_daemon_mock.cpp new file mode 100644 index 00000000..aeea0ae4 --- /dev/null +++ b/tests/integration/smoke/control_daemon_mock.cpp @@ -0,0 +1,59 @@ +/******************************************************************************** + * Copyright (c) 2025 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ +#include +#include +#include +#include + +#include +#include +#include +#include "tests/integration/test_helper.hpp" + +score::lcm::ControlClient client([](const score::lcm::ExecutionErrorEvent& event) { + std::cerr << "Undefined state callback invoked for process group id: " << event.processGroup.data() << std::endl; +}); + +// create DefaultPG +const score::lcm::IdentifierHash defaultpg {"DefaultPG"}; +const score::lcm::IdentifierHash defaultpgOn {"DefaultPG/On"}; +const score::lcm::IdentifierHash defaultpgOff {"DefaultPG/Off"}; +// MainPG +const score::lcm::IdentifierHash mainpg {"MainPG"}; +const score::lcm::IdentifierHash mainpgOff {"MainPG/Off"}; + +TEST(Smoke, Daemon) { + TEST_STEP("Control daemon report kRunning") { + // report kRunning + auto result = score::lcm::LifecycleClient{}.ReportExecutionState(score::lcm::ExecutionState::kRunning); + + ASSERT_TRUE(result.has_value()) << "client.ReportExecutionState() failed"; + } + TEST_STEP("Turn default PG on") { + score::cpp::stop_token stop_token; + auto result = client.SetState(defaultpg, defaultpgOn).Get(stop_token); + EXPECT_TRUE(result.has_value()); + } + TEST_STEP("Turn default PG off") { + score::cpp::stop_token stop_token; + auto result = client.SetState(defaultpg, defaultpgOff).Get(stop_token); + EXPECT_TRUE(result.has_value()); + } + TEST_STEP("Turn main PG off") { + client.SetState(mainpg, mainpgOff); + } +} + +int main(int argc, char** argv) { + return TestRunner(__FILE__, true).RunTests(); +} diff --git a/tests/integration/smoke/gtest_process.cpp b/tests/integration/smoke/gtest_process.cpp new file mode 100644 index 00000000..702e51b9 --- /dev/null +++ b/tests/integration/smoke/gtest_process.cpp @@ -0,0 +1,31 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ + +#include +#include +#include +#include + +#include +#include "tests/integration/test_helper.hpp" + +TEST(Smoke, Process) { + // report kRunning + auto result = score::lcm::LifecycleClient{}.ReportExecutionState(score::lcm::ExecutionState::kRunning); + + ASSERT_TRUE(result.has_value()) << "client.ReportExecutionState() failed"; +} + +int main() { + return TestRunner(__FILE__).RunTests(); +} diff --git a/tests/integration/smoke/hm_demo.json b/tests/integration/smoke/hm_demo.json new file mode 100644 index 00000000..8e559853 --- /dev/null +++ b/tests/integration/smoke/hm_demo.json @@ -0,0 +1,13 @@ +{ + "versionMajor": 8, + "versionMinor": 0, + "process": [], + "hmMonitorInterface": [], + "hmSupervisionCheckpoint": [], + "hmAliveSupervision": [], + "hmDeadlineSupervision": [], + "hmLogicalSupervision": [], + "hmLocalSupervision": [], + "hmGlobalSupervision": [], + "hmRecoveryNotification": [] +} \ No newline at end of file diff --git a/tests/integration/smoke/lm_demo.json b/tests/integration/smoke/lm_demo.json new file mode 100644 index 00000000..10185fc8 --- /dev/null +++ b/tests/integration/smoke/lm_demo.json @@ -0,0 +1,123 @@ +{ + "versionMajor": 7, + "versionMinor": 0, + "Process": [ + { + "identifier": "control_daemon", + "uid": 0, + "gid": 0, + "path": "/tests/integration/smoke/control_daemon_mock", + "functionClusterAffiliation": "STATE_MANAGEMENT", + "numberOfRestartAttempts": 0, + "executable_reportingBehavior": "ReportsExecutionState", + "sgids": [], + "startupConfig": [ + { + "executionError": "1", + "schedulingPolicy": "SCHED_OTHER", + "schedulingPriority": "0", + "identifier": "control_daemon_startup_config", + "enterTimeoutValue": 1000, + "exitTimeoutValue": 1000, + "terminationBehavior": "ProcessIsNotSelfTerminating", + "executionDependency": [], + "processGroupStateDependency": [ + { + "stateMachine_name": "MainPG", + "stateName": "MainPG/Startup" + }, + { + "stateMachine_name": "MainPG", + "stateName": "MainPG/Recovery" + } + ], + "environmentVariable": [ + { + "key": "LD_LIBRARY_PATH", + "value": "/opt/lib" + }, + { + "key": "PROCESSIDENTIFIER", + "value": "control_daemon" + } + ], + "processArgument": [] + } + ] + }, + { + "identifier": "demo_app0_DefaultPG", + "uid": 0, + "gid": 0, + "path": "/tests/integration/smoke/gtest_process", + "numberOfRestartAttempts": 0, + "executable_reportingBehavior": "ReportsExecutionState", + "sgids": [], + "startupConfig": [ + { + "executionError": "1", + "schedulingPolicy": "SCHED_OTHER", + "schedulingPriority": "0", + "identifier": "demo_app_startup_config_0", + "enterTimeoutValue": 2000, + "exitTimeoutValue": 2000, + "terminationBehavior": "ProcessIsNotSelfTerminating", + "processGroupStateDependency": [ + { + "stateMachine_name": "DefaultPG", + "stateName": "DefaultPG/On" + } + ], + "environmentVariable": [ + { + "key": "LD_LIBRARY_PATH", + "value": "/opt/lib" + }, + { + "key": "PROCESSIDENTIFIER", + "value": "DefaultPG_app0" + }, + { + "key": "CONFIG_PATH", + "value": "/opt/supervision_demo/etc/health_monitor_process_cfg_0_MainPG.bin" + } + ] + } + ] + } + ], + "ModeGroup": [ + { + "identifier": "MainPG", + "initialMode_name": "Off", + "recoveryMode_name": "MainPG/Recovery", + "modeDeclaration": [ + { + "identifier": "MainPG/Off" + }, + { + "identifier": "MainPG/Startup" + }, + { + "identifier": "MainPG/Recovery" + } + ] + }, + { + "identifier": "DefaultPG", + "initialMode_name": "Off", + "recoveryMode_name": "DefaultPG/Recovery", + "modeDeclaration": [ + { + "identifier": "DefaultPG/Off" + }, + { + "identifier": "DefaultPG/On" + }, + { + "identifier": "DefaultPG/Recovery" + } + ] + } + ] +} \ No newline at end of file diff --git a/tests/integration/smoke/smoke.py b/tests/integration/smoke/smoke.py new file mode 100644 index 00000000..75fd6e5b --- /dev/null +++ b/tests/integration/smoke/smoke.py @@ -0,0 +1,29 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +from tests.integration.testing_utils import ( + get_common_interface, + check_for_failures, + format_logs, +) +from pathlib import Path + + +def test_smoke(): + code, stdout, stderr = get_common_interface().run_until_file_deployed( + "src/launch_manager_daemon/launch_manager" + ) + + print(format_logs(code, stdout, stderr)) + + check_for_failures(Path("tests/integration/smoke"), 2) + assert code == 0 diff --git a/tests/integration/test_helper.hpp b/tests/integration/test_helper.hpp new file mode 100644 index 00000000..b9fdf76f --- /dev/null +++ b/tests/integration/test_helper.hpp @@ -0,0 +1,86 @@ +/******************************************************************************** + * Copyright (c) 2026 Contributors to the Eclipse Foundation + * + * See the NOTICE file(s) distributed with this work for additional + * information regarding copyright ownership. + * + * This program and the accompanying materials are made available under the + * terms of the Apache License Version 2.0 which is available at + * https://www.apache.org/licenses/LICENSE-2.0 + * + * SPDX-License-Identifier: Apache-2.0 + ********************************************************************************/ +#include +#include +#include +#include + +/// @return File path to an xml adjacent to the input file path +std::string xmlPath(const std::string_view file) { + return std::filesystem::path{file}.filename().stem().string() + ".xml"; +} + +/// @brief Creates an empty file. +/// @return AssertionSuccess if the file is correctly created. +inline testing::AssertionResult touch_file(const std::string_view file_path) { + auto openRes = fopen(file_path.data(), "w+"); + if (!openRes) + return testing::AssertionFailure() + << "Could not touch file " << file_path << " errno: " << errno << " message: " << strerror(errno); + + if (fclose(openRes) != 0) + return testing::AssertionFailure() + << "Couldn't close opened file " << file_path << " errno: " << errno << " message: " << strerror(errno); + return testing::AssertionSuccess(); +} + +#define TEST_STEP(message) \ + for (bool once = \ + (std::cout << "[ STEP ] " << (message) << std::endl, \ + true); \ + once; \ + (std::cout << "[ END STEP ] " << (message) << std::endl), \ + once = false) + + +/// @brief Helper class to setup, run, and clean up GTEST tests +class TestRunner { + inline static std::atomic exitRequested = false; + + static void signalHandler(int) { + exitRequested = true; + } + + bool signal_completion; + +public: + /// @brief TestRunner constructor + /// @param[in] test_path location to write the GTEST xml file (usually __FILE__) + /// @param[in] do_signal_completion whether this test should deploy a file signaling the test has completed + /// Usually the control daemon should deploy this file. + TestRunner(std::string test_path, bool do_signal_completion=false) { + ::testing::GTEST_FLAG(output) = "xml:" + xmlPath(test_path); + testing::InitGoogleTest(); + + signal(SIGINT, signalHandler); + signal(SIGTERM, signalHandler); + signal_completion = do_signal_completion; + } + + ~TestRunner() { + if (!exitRequested) { + pause(); + } + + if (signal_completion) { + static_cast(touch_file("../test_end")); + } + } + + /// @brief Use this function in main() to run all tests. It returns 0 if all tests are successful, or 1 otherwise. + int RunTests() { + auto res = RUN_ALL_TESTS(); + + return res; + } +}; \ No newline at end of file diff --git a/tests/integration/testing_utils.py b/tests/integration/testing_utils.py new file mode 100644 index 00000000..fbfea165 --- /dev/null +++ b/tests/integration/testing_utils.py @@ -0,0 +1,189 @@ +# ******************************************************************************* +# Copyright (c) 2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import signal +import subprocess +import shutil +import threading +import time +from typing import List, Optional, Tuple, Literal +from pathlib import Path +import os +from tests.integration.control_interface import ControlInterface + +_TIMEOUT_CODE = -1 + + +class LinuxControl(ControlInterface): + def exec_command_blocking(*args, timeout=1, **env) -> Tuple[int, str, str]: + try: + res = subprocess.run( + args, env=env, capture_output=True, text=True, timeout=timeout + ) + return res.returncode, "".join(res.stdout), "".join(res.stderr) + except subprocess.TimeoutExpired as ex: + return _TIMEOUT_CODE, ex.output.decode("utf-8"), ex.stderr + + def _reader(stream, sink: List[str]): + """Read text lines from a stream until EOF and append to sink.""" + try: + for line in iter(stream.readline, ""): + if not line: + break + sink.append(line) + finally: + try: + stream.close() + except Exception: + pass + + def _terminate_process_group( + proc: subprocess.Popen, sigterm_timeout_seconds: float + ): + """Terminate all processes in a processgroup. Graceful termination is + attempted before SIGKILL is sent""" + if proc.poll() is not None: + return # already exited + + try: + os.killpg(proc.pid, signal.SIGTERM) + except Exception: + proc.terminate() + + deadline = time.time() + sigterm_timeout_seconds + while time.time() < deadline: + if proc.poll() is not None: + return + time.sleep(0.05) + + # Force kill + try: + os.killpg(proc.pid, signal.SIGKILL) + except Exception: + proc.kill() + + def run_until_file_deployed( + *args, + timeout=1, + file_path=Path("tests/integration/test_end"), + poll_interval=0.05, + **env, + ) -> Tuple[int, str, str]: + proc = subprocess.Popen( + ("/usr/bin/fakeroot", "/usr/bin/fakechroot", "-s", "chroot", ".", *args), + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + bufsize=1, + text=True, + preexec_fn=os.setsid, # start the process in its own process group so we can signal the whole group + ) + + # Start reader threads to capture stdout/stderr without blocking + stdout_lines: List[str] = [] + stderr_lines: List[str] = [] + t_out = threading.Thread( + target=LinuxControl._reader, args=(proc.stdout, stdout_lines), daemon=True + ) + t_err = threading.Thread( + target=LinuxControl._reader, args=(proc.stderr, stderr_lines), daemon=True + ) + t_out.start() + t_err.start() + + start = time.time() + deadline = start + timeout + + exit_code: Optional[int] = None + + try: + while True: + rc = proc.poll() + if rc is not None: # Exited already + exit_code = rc + break + + now = time.time() + + if file_path.exists(): + exit_code = 0 + LinuxControl._terminate_process_group(proc, timeout) + os.remove(file_path) + break + + if now >= deadline: + exit_code = _TIMEOUT_CODE + LinuxControl._terminate_process_group(proc, timeout) + break + + time.sleep(poll_interval) + except KeyboardInterrupt: + LinuxControl._terminate_process_group(proc, timeout) + + # Ensure readers finish + t_out.join(timeout=2.0) + t_err.join(timeout=2.0) + + return exit_code, "".join(stdout_lines), "".join(stderr_lines) + + +def get_common_interface() -> ControlInterface: + """Get a platform independent façade to execute commands on the target""" + match get_platform(): + case "linux": + return LinuxControl + case "qemu": + raise NotImplementedError("QEMU façade is not yet implemented") + case _: + raise KeyError("Platform not recognised") + + +def get_platform() -> Literal["linux", "qemu"]: + return "linux" + + +def get_bazel_out_dir() -> Path: + """Files written to this location are accessible from `bazel-out` when + `--remote_download_outputs=all` + """ + return Path(os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR")) + + +def check_for_failures(path: Path, expected_count: int): + """Check expected_count xml files for failures, raising an exception if + a failure is found or a different number of xml files are found. + """ + failing_files = [] + checked_files = [] + for file in path.iterdir(): + if file.suffix == ".xml": + gtest_xml = open(file).read() + query = 'failures="' + failure_number = gtest_xml[gtest_xml.find(query) + len(query)] + if failure_number != "0": + failing_files.append(file.name) + checked_files.append(file.name) + shutil.copy(file, get_bazel_out_dir()) + if len(failing_files) > 0: + raise RuntimeError( + f"Failures found in the following files:\n {'\n'.join(failing_files)}" + ) + if len(checked_files) != expected_count: + raise RuntimeError( + f"Expected to find {expected_count} xml files, instead found {len(checked_files)}:\n{'\n'.join(checked_files)}" + ) + + +def format_logs(exit_code: int, stdout: str, stderr: str) -> str: + """Human-readable format for exit code, stdout and stderr""" + extra_info = " (timeout)" if exit_code == _TIMEOUT_CODE else "" + return f"stdout:\n{stdout}\n\nstderr:\n{stderr}\n\nExit status = {exit_code}{extra_info}"