diff --git a/.github/workflows/test_and_docs.yml b/.github/workflows/test_and_docs.yml index 8d790e9fd..ffd609a12 100644 --- a/.github/workflows/test_and_docs.yml +++ b/.github/workflows/test_and_docs.yml @@ -44,7 +44,7 @@ jobs: contents: read pull-requests: read with: - bazel-target: 'test --lockfile_mode=error //score/... //tests/... --config=x86_64-linux' + bazel-target: 'test --lockfile_mode=error //score/... //tests/... //scripts/... --config=x86_64-linux' upload-name: 'bazel-testlogs' packages: 'fakechroot' build-docs: diff --git a/patches/BUILD b/patches/BUILD index 42ef274e6..35f6e0696 100644 --- a/patches/BUILD +++ b/patches/BUILD @@ -1,5 +1,14 @@ -exports_files(["hedron_compile_commands_env_vars.patch"]) +# ******************************************************************************* +# 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 +# ******************************************************************************* -exports_files(["score_bazel_tools.patch"]) - -exports_files(["score_bazel_tools_python.patch"]) +exports_files(glob(["*.patch"])) diff --git a/score/launch_manager/docs/product_documentation/known_limitations.rst b/score/launch_manager/docs/product_documentation/known_limitations.rst index 4cfc8e935..6fbcca996 100644 --- a/score/launch_manager/docs/product_documentation/known_limitations.rst +++ b/score/launch_manager/docs/product_documentation/known_limitations.rst @@ -33,8 +33,7 @@ Component ``run_target`` set to ``fallback_run_target``. * The ``ready_timeout`` is used as the timeout until process state Running is reached, even in case the ReadyCondition is ``process_state:Terminated``. -* The parameter ``deployment_config/working_dir`` is currently not supported - and is ignored. + Run target ---------- diff --git a/score/launch_manager/src/daemon/src/configuration/config_schema/lm_flatcfg.fbs b/score/launch_manager/src/daemon/src/configuration/config_schema/lm_flatcfg.fbs index e7f6fad15..ae687008d 100644 --- a/score/launch_manager/src/daemon/src/configuration/config_schema/lm_flatcfg.fbs +++ b/score/launch_manager/src/daemon/src/configuration/config_schema/lm_flatcfg.fbs @@ -43,6 +43,7 @@ table Process { uid: uint (id:4); gid: uint (id:5); path: string (id:6); + working_dir: string (id:11); security_policy_details: string (id:7); executable_reporting_behavior: ExecutionStateReportingBehaviorEnum (id:8); startup_config: [ProcessStartupConfig] (id:9); diff --git a/score/launch_manager/src/daemon/src/configuration/configuration_adapter.cpp b/score/launch_manager/src/daemon/src/configuration/configuration_adapter.cpp index 4b2ecd3c3..5f9ce161c 100644 --- a/score/launch_manager/src/daemon/src/configuration/configuration_adapter.cpp +++ b/score/launch_manager/src/daemon/src/configuration/configuration_adapter.cpp @@ -77,6 +77,7 @@ void ConfigurationAdapter::fillStartupConfigFromDeployment( const auto& props = comp.component_properties; startup.executable_path_ = deploy.bin_dir + "/" + props.binary_name; + startup.working_dir_ = deploy.working_dir; startup.short_name_ = comp.name; startup.uid_ = deploy.sandbox.uid; diff --git a/score/launch_manager/src/daemon/src/configuration/configuration_manager.cpp b/score/launch_manager/src/daemon/src/configuration/configuration_manager.cpp index bc538918c..4ce25de8b 100644 --- a/score/launch_manager/src/daemon/src/configuration/configuration_manager.cpp +++ b/score/launch_manager/src/daemon/src/configuration/configuration_manager.cpp @@ -521,8 +521,10 @@ bool ConfigurationManager::parseProcessConfigurations(const Process* node) setSchedulingParameters(*node, *startup_config_node, instance); // Set executable path from node's path instance.startup_config_.executable_path_ = getStringFromFlatBuffer(node->path()); + instance.startup_config_.working_dir_ = getStringFromFlatBuffer(node->working_dir()); LM_LOG_DEBUG() << "parseProcessConfigurations: Process index:" << instance.process_number_ - << "executable_path_:" << instance.startup_config_.executable_path_; + << "executable_path_:" << instance.startup_config_.executable_path_ + << " working_dir_:" << instance.startup_config_.working_dir_; instance.startup_config_.short_name_ = node->identifier() ? node->identifier()->c_str() : "Unknown"; instance.startup_config_.uid_ = node->uid() & 0x7FFFFFFFU; diff --git a/score/launch_manager/src/daemon/src/process_group_manager/details/process_launcher.cpp b/score/launch_manager/src/daemon/src/process_group_manager/details/process_launcher.cpp index 1b7369578..1c000b120 100644 --- a/score/launch_manager/src/daemon/src/process_group_manager/details/process_launcher.cpp +++ b/score/launch_manager/src/daemon/src/process_group_manager/details/process_launcher.cpp @@ -22,14 +22,14 @@ #include #include -#include "score/mw/launch_manager/process_group_manager/iprocess.hpp" -#include "score/mw/launch_manager/control/control_client_channel.hpp" #include "score/mw/launch_manager/common/log.hpp" +#include "score/mw/launch_manager/control/control_client_channel.hpp" #include "score/mw/launch_manager/osal/ipc_comms.hpp" #include "score/mw/launch_manager/osal/security_policy.hpp" #include "score/mw/launch_manager/osal/set_affinity.hpp" #include "score/mw/launch_manager/osal/set_groups.hpp" #include "score/mw/launch_manager/osal/sys_exit.hpp" +#include "score/mw/launch_manager/process_group_manager/iprocess.hpp" #include #include #include @@ -133,22 +133,11 @@ void handleComms(score::lcm::internal::osal::ChildProcessConfig& param) void changeCurrentWorkingDirectory(const score::lcm::internal::osal::OsalConfig& config) { - // Change current working directory to the same as the executable - constexpr size_t string_size = static_cast(PATH_MAX); - // Notice that this next static variable is duplicated by the fork() and so does not need - // any protection by a mutex although at first sight you may think it could need one. - static char path_copy[string_size + 1U] = {0}; - - if (config.executable_path_.size() >= string_size) - { - LM_LOG_ERROR() << "[New process] executable path longer than" << string_size - << "chars:" << config.executable_path_; - sysexit(EXIT_FAILURE); - } - - if (-1 == chdir(dirname(strncpy(path_copy, config.executable_path_.c_str(), string_size)))) + // working_dir_ is set by python configuration generator in lifecycle_config.py, so it should always be valid. + // If not, chdir will fail anyway and we will log an error and exit. + if (-1 == chdir(config.working_dir_.c_str())) { - LM_LOG_ERROR() << "[New process] chdir(" << config.executable_path_ + LM_LOG_ERROR() << "[New process] chdir(" << config.working_dir_ << ") failed:" << score::lcm::internal::errno_message(errno); sysexit(EXIT_FAILURE); } diff --git a/score/launch_manager/src/daemon/src/process_group_manager/iprocess.hpp b/score/launch_manager/src/daemon/src/process_group_manager/iprocess.hpp index e50738951..be7a9f5fd 100644 --- a/score/launch_manager/src/daemon/src/process_group_manager/iprocess.hpp +++ b/score/launch_manager/src/daemon/src/process_group_manager/iprocess.hpp @@ -67,6 +67,7 @@ struct OsalConfig int32_t scheduling_policy_; ///< Scheduling policy defined for this process int32_t scheduling_priority_; ///< Scheduling priority for this process OsalLimits resource_limits_; ///< Resource limits for this process + std::string working_dir_{}; ///< Working directory for the process }; /// @brief Struct to hold configuration parameters for the child process. diff --git a/scripts/config_mapping/integration_tests.py b/scripts/config_mapping/integration_tests.py index b393a6ac1..0e147a56f 100644 --- a/scripts/config_mapping/integration_tests.py +++ b/scripts/config_mapping/integration_tests.py @@ -28,6 +28,7 @@ script_dir.parent.parent / "score" / "launch_manager" + / "src" / "daemon" / "src" / "configuration" diff --git a/scripts/config_mapping/lifecycle_config.py b/scripts/config_mapping/lifecycle_config.py index 680ac4b1f..a5f9deeab 100644 --- a/scripts/config_mapping/lifecycle_config.py +++ b/scripts/config_mapping/lifecycle_config.py @@ -27,7 +27,6 @@ "shutdown_timeout": 0.5, "environmental_variables": {}, "bin_dir": "/opt", - "working_dir": "/tmp", "ready_recovery_action": { "restart": { "number_of_attempts": 0, @@ -108,6 +107,14 @@ def get_recovery_process_group_state(config): def sec_to_ms(sec: float) -> int: return int(sec * 1000) +def get_working_dir(deployment_config): + """ + Get the working directory for a component. If not specified, default to the bin_dir. + """ + return deployment_config.get( + "working_dir", deployment_config["bin_dir"] + ) + def preprocess_defaults(global_defaults, config): """ @@ -185,9 +192,13 @@ def dict_merge_recursive(dict_a, dict_b): merged_defaults["watchdog"], config.get("watchdog", {}) ) - new_config["initial_run_target"] = config.get( - "initial_run_target", merged_defaults["initial_run_target"] - ) + # Only emit initial_run_target when it can be derived from the input config + # or the defaults. This keeps preprocess_defaults usable with minimal + # defaults that don't define run-target keys. + if "initial_run_target" in config or "initial_run_target" in merged_defaults: + new_config["initial_run_target"] = config.get( + "initial_run_target", merged_defaults.get("initial_run_target") + ) if "fallback_run_target" in config: fallback_defaults = { @@ -289,7 +300,9 @@ def gen_config(output_dir, config, input_filename, schema_version=None): "ready_timeout": depl_cfg["ready_timeout"], "shutdown_timeout": depl_cfg["shutdown_timeout"], "bin_dir": depl_cfg["bin_dir"], - "working_dir": depl_cfg["working_dir"], + # Default the working directory to bin_dir (the directory the + # executable lives in) when not set explicitly. + "working_dir": get_working_dir(depl_cfg), "sandbox": sandbox_out, } @@ -668,6 +681,7 @@ def get_terminating_behavior(component_config): process["number_of_restart_attempts"] = component_config["deployment_config"][ "ready_recovery_action" ]["restart"]["number_of_attempts"] + process["working_dir"] = get_working_dir(component_config["deployment_config"]) match component_config["component_properties"]["application_profile"][ "application_type" diff --git a/scripts/config_mapping/tests/BUILD b/scripts/config_mapping/tests/BUILD index 9bf775d3c..825fd6e40 100644 --- a/scripts/config_mapping/tests/BUILD +++ b/scripts/config_mapping/tests/BUILD @@ -32,7 +32,6 @@ score_py_pytest( "//score/launch_manager/src/daemon/src/configuration/config_schema:launch_manager.schema.json", "//scripts/config_mapping:lifecycle_config.py", ], - tags = ["manual"], deps = [ requirement("pytest"), requirement("jsonschema"), diff --git a/scripts/config_mapping/tests/basic_test/expected_output/lm_demo.json b/scripts/config_mapping/tests/basic_test/expected_output/lm_demo.json index 660a30feb..1878015f6 100644 --- a/scripts/config_mapping/tests/basic_test/expected_output/lm_demo.json +++ b/scripts/config_mapping/tests/basic_test/expected_output/lm_demo.json @@ -20,6 +20,7 @@ ], "security_policy_details": "policy_name", "number_of_restart_attempts": 1, + "working_dir": "/opt/scripts", "executable_reporting_behavior": "DoesNotReportExecutionState", "startup_config": [ { @@ -84,6 +85,7 @@ ], "security_policy_details": "policy_name", "number_of_restart_attempts": 1, + "working_dir": "/opt/apps/dlt-daemon", "executable_reporting_behavior": "DoesNotReportExecutionState", "startup_config": [ { @@ -152,6 +154,7 @@ ], "security_policy_details": "policy_name", "number_of_restart_attempts": 1, + "working_dir": "/opt/apps/someip", "executable_reporting_behavior": "ReportsExecutionState", "startup_config": [ { @@ -219,6 +222,7 @@ ], "security_policy_details": "policy_name", "number_of_restart_attempts": 1, + "working_dir": "/opt/apps/test_app1", "executable_reporting_behavior": "ReportsExecutionState", "startup_config": [ { @@ -295,6 +299,7 @@ ], "security_policy_details": "policy_name", "number_of_restart_attempts": 1, + "working_dir": "/opt/apps/state_manager", "executable_reporting_behavior": "ReportsExecutionState", "function_cluster_affiliation": "STATE_MANAGEMENT", "startup_config": [ diff --git a/scripts/config_mapping/tests/basic_test/input/lm_config.json b/scripts/config_mapping/tests/basic_test/input/lm_config.json index b35597f9e..ab8402597 100644 --- a/scripts/config_mapping/tests/basic_test/input/lm_config.json +++ b/scripts/config_mapping/tests/basic_test/input/lm_config.json @@ -10,7 +10,6 @@ "EMPTY_GLOBAL_ENV_VAR": "" }, "bin_dir": "/opt", - "working_dir": "/tmp", "ready_recovery_action": { "restart": { "number_of_attempts": 1, diff --git a/scripts/config_mapping/tests/lm_config_test/expected_output/lm_demo.json b/scripts/config_mapping/tests/lm_config_test/expected_output/lm_demo.json index c90079231..9966f9104 100644 --- a/scripts/config_mapping/tests/lm_config_test/expected_output/lm_demo.json +++ b/scripts/config_mapping/tests/lm_config_test/expected_output/lm_demo.json @@ -17,6 +17,7 @@ ], "security_policy_details": "policy_name_2", "number_of_restart_attempts": 1, + "working_dir": "/opt/apps/test_app2", "executable_reporting_behavior": "ReportsExecutionState", "startup_config": [ { @@ -92,6 +93,7 @@ ], "security_policy_details": "policy_name", "number_of_restart_attempts": 1, + "working_dir": "/tmp", "executable_reporting_behavior": "DoesNotReportExecutionState", "startup_config": [ { @@ -161,6 +163,7 @@ ], "security_policy_details": "policy_name", "number_of_restart_attempts": 1, + "working_dir": "/tmp", "executable_reporting_behavior": "DoesNotReportExecutionState", "startup_config": [ { @@ -229,6 +232,7 @@ ], "security_policy_details": "policy_name", "number_of_restart_attempts": 1, + "working_dir": "/tmp", "executable_reporting_behavior": "ReportsExecutionState", "startup_config": [ { @@ -296,6 +300,7 @@ ], "security_policy_details": "policy_name", "number_of_restart_attempts": 1, + "working_dir": "/tmp", "executable_reporting_behavior": "ReportsExecutionState", "startup_config": [ { @@ -372,6 +377,7 @@ ], "security_policy_details": "policy_name", "number_of_restart_attempts": 1, + "working_dir": "/tmp", "executable_reporting_behavior": "ReportsExecutionState", "function_cluster_affiliation": "STATE_MANAGEMENT", "startup_config": [ diff --git a/scripts/config_mapping/tests/lm_config_test/input/lm_config.json b/scripts/config_mapping/tests/lm_config_test/input/lm_config.json index f825876f8..90007280a 100644 --- a/scripts/config_mapping/tests/lm_config_test/input/lm_config.json +++ b/scripts/config_mapping/tests/lm_config_test/input/lm_config.json @@ -85,7 +85,8 @@ "scheduling_priority": 99, "max_memory_usage": 2048, "max_cpu_usage": 50 - } + }, + "working_dir": "/opt/apps/test_app2" } }, "setup_filesystem_sh": { diff --git a/tests/integration/sandbox_options/BUILD b/tests/integration/sandbox_options/BUILD new file mode 100644 index 000000000..018c71319 --- /dev/null +++ b/tests/integration/sandbox_options/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("@rules_cc//cc:defs.bzl", "cc_binary") +load("//tests/utils/bazel:integration.bzl", "integration_test") + +cc_binary( + name = "control_daemon_mock", + srcs = ["control_daemon_mock.cpp"], + data = [], + deps = [ + "//score/launch_manager:control_cc", + "//score/launch_manager:lifecycle_cc", + "//tests/utils/test_helper", + "@googletest//:gtest_main", + ], +) + +# Two binaries are built from the same source. Each verifies the sandbox options passed to it on +# the command line (see sandbox_options.json), and derives its GTest XML result file name from its +# own executable name so the results do not collide. +cc_binary( + name = "sandbox_options_process_a", + srcs = ["sandbox_options_process.cpp"], + deps = [ + "//score/launch_manager:control_cc", + "//score/launch_manager:lifecycle_cc", + "//tests/utils/test_helper", + "@googletest//:gtest_main", + ], +) + +cc_binary( + name = "sandbox_options_process_b", + srcs = ["sandbox_options_process.cpp"], + deps = [ + "//score/launch_manager:control_cc", + "//score/launch_manager:lifecycle_cc", + "//tests/utils/test_helper", + "@googletest//:gtest_main", + ], +) + +integration_test( + name = "sandbox_options", + srcs = ["sandbox_options.py"], + binaries = [ + ":control_daemon_mock", + ":sandbox_options_process_a", + ":sandbox_options_process_b", + "//score/launch_manager", + ], + config = ":sandbox_options.json", +) diff --git a/tests/integration/sandbox_options/control_daemon_mock.cpp b/tests/integration/sandbox_options/control_daemon_mock.cpp new file mode 100644 index 000000000..00a7e9dd8 --- /dev/null +++ b/tests/integration/sandbox_options/control_daemon_mock.cpp @@ -0,0 +1,52 @@ +/******************************************************************************** + * 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 "tests/utils/test_helper/test_helper.hpp" +#include +#include + +TEST(SandboxOptions, Daemon) +{ + score::mw::lifecycle::ControlClient client{}; + ASSERT_TRUE(check_clean({test_end_location})); + TEST_STEP("Control daemon report running") + { + score::mw::lifecycle::report_running(); + } + + TEST_STEP("Activate RunTarget Running") + { + score::cpp::stop_token stop_token; + auto result = client.ActivateRunTarget("Running").Get(stop_token); + EXPECT_TRUE(result.has_value()) << "Activating target Running failed: " << result.error().Message(); + } + TEST_STEP("Activate RunTarget Startup") + { + score::cpp::stop_token stop_token; + auto result = client.ActivateRunTarget("Startup").Get(stop_token); + EXPECT_TRUE(result.has_value()); + } + TEST_STEP("Activate RunTarget Off") + { + client.ActivateRunTarget("Off"); + } +} + +int main() +{ + return TestRunner(__FILE__, TerminationBehavior::kWait, TerminationNotification::kTestEnd).RunTests(); +} diff --git a/tests/integration/sandbox_options/sandbox_options.json b/tests/integration/sandbox_options/sandbox_options.json new file mode 100644 index 000000000..5b22e9127 --- /dev/null +++ b/tests/integration/sandbox_options/sandbox_options.json @@ -0,0 +1,158 @@ +{ + "schema_version": 1, + "defaults": { + "deployment_config": { + "bin_dir": "/tmp/tests/sandbox_options", + "ready_timeout": 1.0, + "shutdown_timeout": 1.0, + "ready_recovery_action": { + "restart": { + "number_of_attempts": 0 + } + }, + "recovery_action": { + "switch_run_target": { + "run_target": "fallback_run_target" + } + }, + "environmental_variables": { + "LD_LIBRARY_PATH": "/opt/lib" + }, + "sandbox": { + "uid": 0, + "gid": 0, + "scheduling_policy": "SCHED_OTHER", + "scheduling_priority": 0 + } + }, + "component_properties": { + "application_profile": { + "application_type": "Reporting", + "is_self_terminating": false, + "alive_supervision": { + "reporting_cycle": 0.1, + "min_indications": 1, + "max_indications": 3, + "failed_cycles_tolerance": 1 + } + }, + "ready_condition": { + "process_state": "Running" + } + } + }, + "components": { + "control_daemon": { + "component_properties": { + "binary_name": "control_daemon_mock", + "application_profile": { + "application_type": "State_Manager", + "alive_supervision": { + "min_indications": 0 + } + } + }, + "deployment_config": { + "ready_timeout": 1.0, + "shutdown_timeout": 1.0, + "environmental_variables": { + "PROCESSIDENTIFIER": "control_daemon" + } + } + }, + "sandbox_options_process_a": { + "component_properties": { + "binary_name": "sandbox_options_process_a", + "application_profile": { + "application_type": "Reporting" + }, + "process_arguments": [ + "--uid=666", + "--gid=666", + "--supplementary-groups=123,321,456", + "--scheduling-policy=SCHED_FIFO", + "--scheduling-priority=10", + "--working-dir=/tmp" + ] + }, + "deployment_config": { + "environmental_variables": { + "PROCESSIDENTIFIER": "DefaultPG_app0" + }, + "sandbox": { + "uid": 666, + "gid": 666, + "supplementary_group_ids": [123, 321, 456], + "scheduling_policy": "SCHED_FIFO", + "scheduling_priority": 10 + }, + "working_dir": "/tmp" + } + }, + "sandbox_options_process_b": { + "component_properties": { + "binary_name": "sandbox_options_process_b", + "application_profile": { + "application_type": "Reporting" + }, + "process_arguments": [ + "--uid=0", + "--gid=0", + "--supplementary-groups=", + "--scheduling-policy=SCHED_RR" + ] + }, + "deployment_config": { + "environmental_variables": { + "PROCESSIDENTIFIER": "DefaultPG_app1" + }, + "sandbox": { + "uid": 0, + "gid": 0, + "supplementary_group_ids": [], + "scheduling_policy": "SCHED_RR" + } + } + } + }, + "run_targets": { + "Startup": { + "depends_on": [ + "control_daemon" + ], + "recovery_action": { + "switch_run_target": { + "run_target": "fallback_run_target" + } + } + }, + "Running": { + "depends_on": [ + "control_daemon", + "sandbox_options_process_a", + "sandbox_options_process_b" + ], + "recovery_action": { + "switch_run_target": { + "run_target": "fallback_run_target" + } + } + }, + "Off": { + "depends_on": [], + "recovery_action": { + "switch_run_target": { + "run_target": "fallback_run_target" + + } + } + } + }, + "initial_run_target": "Startup", + "alive_supervision": { + "evaluation_cycle": 0.05 + }, + "fallback_run_target": { + "depends_on": [] + } +} diff --git a/tests/integration/sandbox_options/sandbox_options.py b/tests/integration/sandbox_options/sandbox_options.py new file mode 100644 index 000000000..2a00c7bf6 --- /dev/null +++ b/tests/integration/sandbox_options/sandbox_options.py @@ -0,0 +1,75 @@ +# ******************************************************************************* +# 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 logging +import pytest +from score.itf.plugins.core import determine_target_scope +from tests.utils.testing_utils.run_until_file_deployed import run_until_file_deployed +from tests.utils.testing_utils.setup_test import setup_test +from tests.utils.testing_utils.test_results import assert_test_results +from attribute_plugin import add_test_properties + + +@pytest.fixture(scope=determine_target_scope) +def docker_configuration(): + """Grant the container CAP_SYS_NICE so the launch manager can apply the + SCHED_FIFO real-time scheduling policy configured in sandbox_options.json. + Without it, sched_setscheduler() fails with 'Operation not permitted'.""" + return {"cap_add": ["SYS_NICE"]} + + +@add_test_properties( + fully_verifies=[ + "feat_req__lifecycle__uid_gid_support", + "feat_req__lifecycle__launch_priority_support", + "feat_req__lifecycle__scheduling_policy", + "feat_req__lifecycle__cwd_support", + "feat_req__lifecycle__supplementary_groups", + ], + partially_verifies=[], + test_type="interface-test", + derivation_technique="explorative-testing", +) +def test_sandbox_options(target, setup_test, assert_test_results, remote_test_dir): + """ + Objective: Verifies the effectiveness of sandbox-options as gid, uid, supplementary groups, and scheduling policy. + + The launch manager starts with an initial run target. The control daemon activates the "Running" run target (starting the managed process with the sandbox options applied), then transitions back to "Startup", and finally activates "Off". + Expected Behaviour: All run target transitions complete successfully and all processes report running. + """ + + # launch manager will simply ignore the arguments if run with --//config:use_new_configuration=False. + # the old configuration will be used, which is the default behavior. + # The new configuration will be used if run with --//config:use_new_configuration=True + new_config_path = str(remote_test_dir / "etc/sandbox_options.bin") + + run_until_file_deployed( + target=target, + binary_path=str(remote_test_dir / "launch_manager"), + file_path=remote_test_dir.parent / "test_end", + cwd=str(remote_test_dir), + args=["-c", new_config_path], + timeout_s=3.0, + ) + + # sandbox_options_process_a is configured with a custom working directory ("working_dir": "/tmp" + # in sandbox_options.json), so its XML result file is written there rather than into + # remote_test_dir. sandbox_options_process_b has no working directory configured and therefore + # writes into remote_test_dir. Search all relevant directories. + assert_test_results( + { + "control_daemon_mock.xml", + "sandbox_options_process_a.xml", + "sandbox_options_process_b.xml", + }, + additional_search_dirs=["/tmp/tests/sandbox_options", "/tmp"], + ) diff --git a/tests/integration/sandbox_options/sandbox_options_process.cpp b/tests/integration/sandbox_options/sandbox_options_process.cpp new file mode 100644 index 000000000..bae967e7c --- /dev/null +++ b/tests/integration/sandbox_options/sandbox_options_process.cpp @@ -0,0 +1,342 @@ +/******************************************************************************** + * 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "tests/utils/test_helper/test_helper.hpp" +#include + +namespace +{ + +/// @brief Expected sandbox option values, supplied to this process on the command line. +/// +/// The launch manager applies the sandbox options from sandbox_options.json and passes the same +/// values to the managed process via 'process_arguments', so the test verifies the applied state +/// against the configured expectation without duplicating any literals here. +/// +/// Every value is optional: a value that is not passed on the command line is not verified. This +/// lets a component leave an option unset (e.g. no working directory) without the test flagging it. +struct ExpectedValues +{ + std::optional policy; + std::optional priority; + std::optional uid; + std::optional gid; + std::optional> supplementary_groups; + std::optional working_dir; +}; + +// Populated from argv in main() before the tests run. +ExpectedValues expected; + +const char* policy_name(const int policy) +{ + switch (policy) + { + case SCHED_FIFO: + return "SCHED_FIFO"; + case SCHED_RR: + return "SCHED_RR"; + case SCHED_OTHER: + return "SCHED_OTHER"; + default: + return "UNKNOWN"; + } +} + +int parse_policy(const std::string& name) +{ + if (name == "SCHED_FIFO") + { + return SCHED_FIFO; + } + if (name == "SCHED_RR") + { + return SCHED_RR; + } + if (name == "SCHED_OTHER") + { + return SCHED_OTHER; + } + return -1; +} + +// Parse a comma separated list of group ids, e.g. "123,321,456". +std::vector parse_groups(const std::string& csv) +{ + std::vector groups; + std::stringstream stream(csv); + std::string token; + while (std::getline(stream, token, ',')) + { + if (!token.empty()) + { + groups.push_back(static_cast(std::stoul(token))); + } + } + return groups; +} + +// If 'arg' has the form "--=", store the value in 'value' and return true. +bool match_option(const std::string& arg, const std::string_view key, std::string& value) +{ + const std::string prefix = "--" + std::string(key) + "="; + if (arg.rfind(prefix, 0) == 0) + { + value = arg.substr(prefix.size()); + return true; + } + return false; +} + +// Populate 'out' from the command line arguments. Only the options that are actually present are +// set; any option left out stays unset and is therefore not verified. Returns false if an +// unrecognized option is encountered. +bool parse_arguments(int argc, char** argv, ExpectedValues& out) +{ + bool all_recognized = true; + + std::string value; + for (int i = 1; i < argc; ++i) + { + const std::string arg = argv[i]; + if (match_option(arg, "uid", value)) + { + out.uid = static_cast(std::stoul(value)); + } + else if (match_option(arg, "gid", value)) + { + out.gid = static_cast(std::stoul(value)); + } + else if (match_option(arg, "supplementary-groups", value)) + { + out.supplementary_groups = parse_groups(value); + } + else if (match_option(arg, "scheduling-policy", value)) + { + out.policy = parse_policy(value); + } + else if (match_option(arg, "scheduling-priority", value)) + { + out.priority = std::stoi(value); + } + else if (match_option(arg, "working-dir", value)) + { + out.working_dir = value; + } + else + { + std::cerr << "Unrecognized argument: " << arg << std::endl; + all_recognized = false; + } + } + + return all_recognized; +} + +/// @brief Verify that the calling thread runs with the expected scheduling policy and priority. +/// @param[in] context Human readable name of the thread, used in failure messages. +/// @return true if the policy (when provided) and the priority match. +bool verify_scheduling(const std::string& context) +{ + int policy = -1; + sched_param param{}; + const int result = pthread_getschedparam(pthread_self(), &policy, ¶m); + EXPECT_EQ(result, 0) << context << ": pthread_getschedparam failed rc=" << result; + if (result != 0) + { + // 'policy' was not written, so it is still the sentinel -1. Bail out before it can reach + // sched_get_priority_min() below (which would return -1 and set errno=EINVAL). + return false; + } + + bool pass = true; + + if (expected.policy.has_value()) + { + EXPECT_EQ(policy, *expected.policy) + << context << ": Expected scheduling policy=" << policy_name(*expected.policy) << " but got " + << policy_name(policy); + if (policy != *expected.policy) + { + pass = false; + } + } + + // The priority is always verified. When no explicit priority is provided on the command line, + // verify against the default the launch manager ends up applying: its configured default (0) + // clamped up to the policy minimum, i.e. sched_get_priority_min() for the effective policy + // (1 for SCHED_FIFO/SCHED_RR, 0 for SCHED_OTHER). 'policy' is the value retrieved above and is + // guaranteed valid here (the rc != 0 case returned early). + const int effective_policy = expected.policy.value_or(policy); + const int expected_priority = expected.priority.value_or(sched_get_priority_min(effective_policy)); + EXPECT_EQ(param.sched_priority, expected_priority) + << context << ": Expected scheduling priority=" << expected_priority + << (expected.priority.has_value() ? "" : " (default)") << " but got " << param.sched_priority; + if (param.sched_priority != expected_priority) + { + pass = false; + } + + return pass; +} + +bool verify_sandbox_options() +{ + bool all_pass = true; + + if (expected.uid.has_value() || expected.gid.has_value()) + { + TEST_STEP("Verify uid and gid") + { + if (expected.uid.has_value()) + { + const uid_t current_uid = getuid(); + EXPECT_EQ(current_uid, *expected.uid) + << "Expected uid=" << *expected.uid << " but got uid=" << current_uid; + if (current_uid != *expected.uid) + { + all_pass = false; + } + } + + if (expected.gid.has_value()) + { + const gid_t current_gid = getgid(); + EXPECT_EQ(current_gid, *expected.gid) + << "Expected gid=" << *expected.gid << " but got gid=" << current_gid; + if (current_gid != *expected.gid) + { + all_pass = false; + } + } + } + } + + if (expected.supplementary_groups.has_value()) + { + TEST_STEP("Verify supplementary groups") + { + std::vector groups(256); + const int count = getgroups(static_cast(groups.size()), groups.data()); + EXPECT_GE(count, 0) << "Failed to get supplementary groups"; + if (count >= 0) + { + groups.resize(static_cast(count)); + } + + for (const gid_t expected_group : *expected.supplementary_groups) + { + const bool found = std::find(groups.begin(), groups.end(), expected_group) != groups.end(); + EXPECT_TRUE(found) << "Expected supplementary group " << expected_group << " not found (groups: [" + << count << " total)]"; + if (!found) + { + all_pass = false; + } + } + } + } + + if (expected.working_dir.has_value()) + { + TEST_STEP("Verify working directory") + { + std::array buf{}; + char* result = getcwd(buf.data(), buf.size()); + EXPECT_NE(result, nullptr) << "Failed to get current working directory"; + + if (result) + { + EXPECT_EQ(std::string(result), *expected.working_dir) + << "Expected working_dir=" << *expected.working_dir << " but got cwd=" << result; + + if (std::string(result) != *expected.working_dir) + { + all_pass = false; + } + } + } + } + + if (expected.policy.has_value() || expected.priority.has_value()) + { + TEST_STEP("Verify scheduling policy and priority in the main thread") + { + if (!verify_scheduling("main thread")) + { + all_pass = false; + } + } + + TEST_STEP("Verify scheduling policy and priority in a spawned thread") + { + // A thread created with default attributes inherits the schedulng policy and + // priority of its creating thread, so the configured real-time settings must + // apply here as well. + std::atomic thread_pass{false}; + std::thread worker([&thread_pass]() { + thread_pass = verify_scheduling("spawned thread"); + }); + worker.join(); + + if (!thread_pass) + { + all_pass = false; + } + } + } + + return all_pass; +} + +} // namespace + +TEST(SandboxOptions, RunAndVerify) +{ + ASSERT_TRUE(verify_sandbox_options()) << "Sandbox options verification failed"; + score::mw::lifecycle::report_running(); +} + +int main(int argc, char** argv) +{ + if (!parse_arguments(argc, argv, expected)) + { + std::cerr << "Recognized sandbox options: --uid, --gid, --supplementary-groups, " + "--scheduling-policy, --scheduling-priority, --working-dir" + << std::endl; + return 1; + } + // Derive the GTest XML result file name from the executable name (argv[0]) rather than the + // shared source file, so that the two binaries built from this source (sandbox_options_process_a + // and sandbox_options_process_b) write distinct result files. Dereference rather than index to + // avoid the no-pointer-arithmetic lint rule. + const char* const executable_path = (argc > 0) ? *argv : __FILE__; + return TestRunner(executable_path).RunTests(); +} \ No newline at end of file diff --git a/tests/integration/smoke/BUILD b/tests/integration/smoke/BUILD index 506497c00..127c143a4 100644 --- a/tests/integration/smoke/BUILD +++ b/tests/integration/smoke/BUILD @@ -13,11 +13,6 @@ load("@rules_cc//cc:defs.bzl", "cc_binary") load("//tests/utils/bazel:integration.bzl", "integration_test") -exports_files( - ["lm_demo.json"], - visibility = ["//examples:__subpackages__"], -) - cc_binary( name = "control_daemon_mock", srcs = ["control_daemon_mock.cpp"], diff --git a/tests/utils/testing_utils/run_until_file_deployed.py b/tests/utils/testing_utils/run_until_file_deployed.py index eb7d87f29..8c23111ca 100644 --- a/tests/utils/testing_utils/run_until_file_deployed.py +++ b/tests/utils/testing_utils/run_until_file_deployed.py @@ -25,6 +25,7 @@ def run_until_file_deployed( file_path: str, timeout_s: float = 30.0, poll_interval_s: float = 0.5, + stop_timeout_s: float = 2.0, args=None, cwd: str = "/", ) -> AsyncProcess: @@ -34,7 +35,9 @@ def run_until_file_deployed( :param binary_path: path to the binary to execute on the target. :param file_path: path of the file to wait for on the target. :param timeout_s: maximum seconds to wait for the file (default: 30). - :param poll_interval_s: seconds between file-existence checks (default: 0.5). + :param poll_interval_s: seconds between file and process checks (default: 0.5). + :param stop_timeout_s: maximum seconds to wait for the process to terminate + after SIGTERM (default: 2). :param args: optional list of arguments to pass to the binary. :param cwd: working directory on the target (default: "/"). :return: the stopped :class:`AsyncProcess` handle. @@ -64,12 +67,27 @@ def run_until_file_deployed( # run their cleanup code before exiting. kill_cmd = f"kill -15 -{proc.pid()}" res, _ = target.execute(kill_cmd) - time.sleep(0.5) - assert proc.is_running() == False, "LCM still running" + assert res == 0, "Couldn't kill process with SIGTERM" + + # Poll for the process to actually terminate instead of assuming a + # single fixed grace period is enough. Termination timing varies + # with system load, so a fixed sleep is flaky. + stop_deadline = time.monotonic() + stop_timeout_s + while proc.is_running() and time.monotonic() < stop_deadline: + time.sleep(poll_interval_s) + + if proc.is_running(): + kill_cmd = f"kill -9 -{proc.pid()}" + res, _ = target.execute(kill_cmd) + assert res == 0, "Couldn't kill process with SIGKILL" + # Let this test fail so we can see the logs and figure out why the process didn't exit cleanly. + assert False, ( + f"Process still running {stop_timeout_s}s after SIGTERM" + ) + assert proc.get_exit_code() == 0, ( - f"LCM did not exit cleanly, it died with code {proc.get_exit_code()}" + f"Process did not exit cleanly, it died with code {proc.get_exit_code()}" ) - assert res == 0, "Couldn't kill lcm" return proc logger.debug(f"Waiting for {file_path}") diff --git a/tests/utils/testing_utils/test_results.py b/tests/utils/testing_utils/test_results.py index 15934db40..083a7aea0 100644 --- a/tests/utils/testing_utils/test_results.py +++ b/tests/utils/testing_utils/test_results.py @@ -11,29 +11,45 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* from pathlib import Path -from typing import Set +from typing import Iterable, Sequence, Set, Union import pytest from junitparser import JUnitXml -def download_xml_results(target, remote_dir: Path, local_dir: Path): - """Glob all .xml files in remote_dir, download them to local_dir, and delete - them from the remote. +def download_xml_results( + target, remote_dirs: Union[Path, str, Sequence[Union[Path, str]]], local_dir: Path +): + """Glob all .xml files in each of `remote_dirs`, download them to `local_dir`, + and delete them from the remote. + + `remote_dirs` may be a single directory or an iterable of directories. Each + directory is searched at most once, even if listed multiple times. """ - res, stdout = target.execute(f"ls {remote_dir}/*.xml") - assert res == 0, "Couldn't get list of files" - remote_xml_files = stdout.decode().strip().splitlines() - for remote_path in remote_xml_files: - remote_path = remote_path.strip() - if not remote_path: + if isinstance(remote_dirs, (str, Path)): + remote_dirs = [remote_dirs] + + seen: Set[str] = set() + for remote_dir in remote_dirs: + remote_dir = str(remote_dir) + if remote_dir in seen: continue - xml_name = Path(remote_path).name - try: - target.download(remote_path, str(local_dir / xml_name)) - target.execute(f"rm {remote_path}") - except Exception: - pass + seen.add(remote_dir) + + res, stdout = target.execute(f"ls {remote_dir}/*.xml 2>/dev/null") + if res != 0: + continue + remote_xml_files = stdout.decode().strip().splitlines() + for remote_path in remote_xml_files: + remote_path = remote_path.strip() + if not remote_path: + continue + xml_name = Path(remote_path).name + try: + target.download(remote_path, str(local_dir / xml_name)) + target.execute(f"rm {remote_path}") + except Exception: + pass def get_failing_files(path: Path): @@ -55,17 +71,26 @@ def assert_test_results(target, remote_test_dir, test_output_dir): count with no failures. Takes `target`, `remote_test_dir`, and `test_output_dir` from fixtures automatically. + By default only `remote_test_dir` is searched for XML result files. Tests + that configure processes with a different working directory can pass + `additional_search_dirs` so those locations are searched as well. + Usage:: def test_foo(assert_test_results, ...): ... - assert_test_results(expected_count=2) + assert_test_results({"foo.xml"}) + assert_test_results({"foo.xml"}, additional_search_dirs=["/tmp"]) """ - def _assert(expected_xml_files: Set[str]): + def _assert( + expected_xml_files: Set[str], + additional_search_dirs: Iterable[Union[Path, str]] = (), + ): # Show the error as coming from the call in the test rather than here __tracebackhide__ = True - download_xml_results(target, remote_test_dir, test_output_dir) + search_dirs = [remote_test_dir, *additional_search_dirs] + download_xml_results(target, search_dirs, test_output_dir) all_files, failing_files = get_failing_files(test_output_dir) assert all_files == expected_xml_files, ( # Set equality f"Didn't find the expected files, found: {all_files}"