From 77f171efd08d05c882978ececb23abbab1f05a24 Mon Sep 17 00:00:00 2001 From: Saumya-R Date: Thu, 16 Jul 2026 22:44:46 +0530 Subject: [PATCH 1/5] added test cases for conditional launching --- feature_integration_tests/README.md | 32 ++++ feature_integration_tests/test_cases/BUILD | 2 + .../test_cases/conftest.py | 26 ++- .../test_cases/lifecycle_scenario.py | 50 ++++++ .../lifecycle/test_conditional_launching.py | 153 +++++++++++++++++ .../lifecycle/conditional_launching.cpp | 157 ++++++++++++++++++ .../lifecycle/conditional_launching.h | 18 ++ .../test_scenarios/cpp/src/scenarios/mod.cpp | 13 +- .../test_scenarios/rust/BUILD | 25 +++ .../test_scenarios/rust/src/main.rs | 28 ++++ .../lifecycle/conditional_launching.rs | 67 ++++++++ .../rust/src/scenarios/lifecycle/mod.rs | 25 +++ .../test_scenarios/rust/src/scenarios/mod.rs | 4 +- pyproject.toml | 2 +- 14 files changed, 591 insertions(+), 11 deletions(-) create mode 100644 feature_integration_tests/test_cases/lifecycle_scenario.py create mode 100644 feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.py create mode 100644 feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.cpp create mode 100644 feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.h create mode 100644 feature_integration_tests/test_scenarios/rust/src/scenarios/lifecycle/conditional_launching.rs create mode 100644 feature_integration_tests/test_scenarios/rust/src/scenarios/lifecycle/mod.rs diff --git a/feature_integration_tests/README.md b/feature_integration_tests/README.md index a68a1f9c492..22914d4c749 100644 --- a/feature_integration_tests/README.md +++ b/feature_integration_tests/README.md @@ -36,6 +36,37 @@ bazel test //feature_integration_tests/test_cases:fit_rust bazel test --config=linux-x86_64 //feature_integration_tests/test_cases:fit_cpp ``` +To run only the conditional launching lifecycle FITs: + +```sh +bazel test //feature_integration_tests/test_cases:fit_conditional_launching +``` + +The Rust side of this lifecycle-only suite uses a dedicated Bazel target, +`//feature_integration_tests/test_scenarios/rust:rust_lifecycle_test_scenarios`, +which still reuses `test_scenarios/rust/src/main.rs`. It is built with the +`lifecycle_only` cfg so only lifecycle scenarios are compiled for that suite, +while the normal `fit` and `fit_rust` targets continue to use the full scenario tree. + +To run the lifecycle tests directly with `pytest` and build the scenario binaries on demand: + +```sh +python3 -m pytest feature_integration_tests/test_cases/tests/lifecycle/ \ + --build-scenarios \ + -m rust \ + --rust-target-name=//feature_integration_tests/test_scenarios/rust:rust_lifecycle_test_scenarios \ + -q -v + +python3 -m pytest feature_integration_tests/test_cases/tests/lifecycle/ \ + --build-scenarios \ + -m cpp \ + -q -v +``` + +The Rust override is required because plain `--build-scenarios` defaults to +`//feature_integration_tests/test_scenarios/rust:rust_test_scenarios`, while the +lifecycle tests need the reduced lifecycle-only Rust target. + ### ITF Tests (QEMU-based) ITF tests run on a QEMU target and require the `itf-qnx-x86_64` config: @@ -50,6 +81,7 @@ Test scenarios can be listed and run directly for debugging: ```sh bazel run //feature_integration_tests/test_scenarios/rust:rust_test_scenarios -- --list-scenarios +bazel run //feature_integration_tests/test_scenarios/rust:rust_lifecycle_test_scenarios -- --list-scenarios bazel run --config=linux-x86_64 //feature_integration_tests/test_scenarios/cpp:cpp_test_scenarios -- --list-scenarios ``` diff --git a/feature_integration_tests/test_cases/BUILD b/feature_integration_tests/test_cases/BUILD index dcf8ab0542b..622617b8360 100644 --- a/feature_integration_tests/test_cases/BUILD +++ b/feature_integration_tests/test_cases/BUILD @@ -46,6 +46,7 @@ score_py_pytest( data = [ "conftest.py", "fit_scenario.py", + "lifecycle_scenario.py", "persistency_scenario.py", "test_properties.py", "//feature_integration_tests/test_scenarios/rust:rust_test_scenarios", @@ -68,6 +69,7 @@ score_py_pytest( data = [ "conftest.py", "fit_scenario.py", + "lifecycle_scenario.py", "persistency_scenario.py", "test_properties.py", "//feature_integration_tests/test_scenarios/cpp:cpp_test_scenarios", diff --git a/feature_integration_tests/test_cases/conftest.py b/feature_integration_tests/test_cases/conftest.py index 662b7210943..0a360af60da 100644 --- a/feature_integration_tests/test_cases/conftest.py +++ b/feature_integration_tests/test_cases/conftest.py @@ -16,6 +16,13 @@ from testing_utils import BazelTools +def _selected_versions(session: pytest.Session) -> set[str]: + """Return the scenario variants explicitly requested by the mark expression.""" + mark_expression = session.config.option.markexpr or "" + selected_versions = {version for version in ("rust", "cpp") if version in mark_expression} + return selected_versions or {"rust", "cpp"} + + # Cmdline options def pytest_addoption(parser): parser.addoption( @@ -88,18 +95,21 @@ def pytest_sessionstart(session): # Build scenarios. if session.config.getoption("--build-scenarios"): build_timeout = session.config.getoption("--build-scenarios-timeout") + selected_versions = _selected_versions(session) # Build Rust test scenarios. - print("Building Rust test scenarios executable...") - rust_tools = BazelTools(option_prefix="rust", build_timeout=build_timeout) - rust_target_name = session.config.getoption("--rust-target-name") - rust_tools.build(rust_target_name) + if "rust" in selected_versions: + print("Building Rust test scenarios executable...") + rust_tools = BazelTools(option_prefix="rust", build_timeout=build_timeout) + rust_target_name = session.config.getoption("--rust-target-name") + rust_tools.build(rust_target_name) # Build C++ test scenarios. - print("Building C++ test scenarios executable...") - cpp_tools = BazelTools(option_prefix="cpp", build_timeout=build_timeout) - cpp_target_name = session.config.getoption("--cpp-target-name") - cpp_tools.build(cpp_target_name) + if "cpp" in selected_versions: + print("Building C++ test scenarios executable...") + cpp_tools = BazelTools(option_prefix="cpp", build_timeout=build_timeout) + cpp_target_name = session.config.getoption("--cpp-target-name") + cpp_tools.build(cpp_target_name) except Exception as e: pytest.exit(str(e), returncode=1) diff --git a/feature_integration_tests/test_cases/lifecycle_scenario.py b/feature_integration_tests/test_cases/lifecycle_scenario.py new file mode 100644 index 00000000000..29753b0efc5 --- /dev/null +++ b/feature_integration_tests/test_cases/lifecycle_scenario.py @@ -0,0 +1,50 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +""" +Helpers and base scenario class for lifecycle feature integration tests. + +``LifecycleScenario`` is a ``FitScenario`` subclass that supplies the shared +``temp_dir`` fixture so individual test classes do not have to duplicate it. +""" + +from collections.abc import Generator +from pathlib import Path + +import pytest +from fit_scenario import FitScenario, temp_dir_common + + +class LifecycleScenario(FitScenario): + """ + Base class for lifecycle feature integration tests. + + Provides the ``temp_dir`` fixture shared by all lifecycle test classes. + """ + + @pytest.fixture(scope="class") + def temp_dir( + self, + tmp_path_factory: pytest.TempPathFactory, + version: str, + ) -> Generator[Path, None, None]: + """ + Provide a temporary working directory for the lifecycle tests. + + Parameters + ---------- + tmp_path_factory : pytest.TempPathFactory + Built-in pytest factory for temporary directories. + version : str + Parametrized scenario version (``"rust"`` or ``"cpp"``). + """ + yield from temp_dir_common(tmp_path_factory, self.__class__.__name__, version) diff --git a/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.py b/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.py new file mode 100644 index 00000000000..1669fc2e7b6 --- /dev/null +++ b/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.py @@ -0,0 +1,153 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +""" +Feature integration tests for conditional launching. + +Tests verify that the Launch Manager supports conditional process launching +based on various conditions including process state, environment variables, +paths, and dependencies. +""" + +from pathlib import Path +from typing import Any + +import pytest +from fit_scenario import ResultCode +from lifecycle_scenario import LifecycleScenario +from test_properties import add_test_properties +from testing_utils import LogContainer, ScenarioResult + +pytestmark = pytest.mark.parametrize("version", ["rust", "cpp"], scope="class") + + +@add_test_properties( + partially_verifies=[ + "feat_req__lifecycle__waitfor_support", + "feat_req__lifecycle__cond_process_start", + "feat_req__lifecycle__total_wait_time_support", + "feat_req__lifecycle__polling_interval", + "feat_req__lifecycle__validate_conditions", + "feat_req__lifecycle__validation_conditions", + "feat_req__lifecycle__launcher_status_storage", + "feat_req__lifecycle__condition_check_method", + "feat_req__lifecycle__config_actions_cond", + "feat_req__lifecycle__path_condition_check", + "feat_req__lifecycle__env_variable_cond_check", + "feat_req__lifecycle__dependency_check", + "feat_req__lifecycle__check_dependency_exec", + "feat_req__lifecycle__define_swc_dependencies", + "feat_req__lifecycle__stop_sequence", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestConditionalLaunching(LifecycleScenario): + """ + Verify conditional process launching support. + + This test confirms that the Launch Manager can conditionally launch + processes based on various criteria and wait conditions. + """ + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "lifecycle.conditional_launching" + + @pytest.fixture(scope="class") + def test_config(self, temp_dir: Path) -> dict[str, Any]: + return { + "test": { + "test_duration_ms": 300, + "wait_conditions": ["path:/tmp/ready", "env:STARTUP_COMPLETE", "process:init_done"], + "polling_interval_ms": 173, + "timeout_ms": 6421, + } + } + + def test_path_condition_check(self, results: ScenarioResult, logs_info_level: LogContainer, version: str) -> None: + """ + Verify that path-based condition checking works. + """ + assert results.return_code == ResultCode.SUCCESS + + if version == "cpp": + assert "Checking path condition: /tmp/ready" in results.stdout, "Path condition not checked" + else: + path_logs = logs_info_level.get_logs(field="message", pattern="Checking path condition: /tmp/ready") + assert len(path_logs) > 0, "Path condition not checked" + + def test_env_condition_check(self, results: ScenarioResult, logs_info_level: LogContainer, version: str) -> None: + """ + Verify that environment variable condition checking works. + """ + assert results.return_code == ResultCode.SUCCESS + + if version == "cpp": + assert "Checking env condition: STARTUP_COMPLETE" in results.stdout, "Environment condition not checked" + else: + env_logs = logs_info_level.get_logs(field="message", pattern="Checking env condition: STARTUP_COMPLETE") + assert len(env_logs) > 0, "Environment condition not checked" + + def test_process_condition_check( + self, results: ScenarioResult, logs_info_level: LogContainer, version: str + ) -> None: + """ + Verify that process state condition checking works. + """ + assert results.return_code == ResultCode.SUCCESS + + if version == "cpp": + assert "Checking process condition: init_done" in results.stdout, "Process condition not checked" + else: + process_logs = logs_info_level.get_logs(field="message", pattern="Checking process condition: init_done") + assert len(process_logs) > 0, "Process condition not checked" + + def test_polling_interval_configured( + self, results: ScenarioResult, logs_info_level: LogContainer, version: str + ) -> None: + """ + Verify that polling interval is configured correctly. + """ + assert results.return_code == ResultCode.SUCCESS + + if version == "cpp": + assert "Polling interval: 173ms" in results.stdout, "Polling interval not configured" + else: + polling_logs = logs_info_level.get_logs(field="message", pattern="Polling interval: 173ms") + assert len(polling_logs) > 0, "Polling interval not configured" + + def test_condition_timeout_configured( + self, results: ScenarioResult, logs_info_level: LogContainer, version: str + ) -> None: + """ + Verify that condition timeout is configured. + """ + assert results.return_code == ResultCode.SUCCESS + + if version == "cpp": + assert "Condition timeout: 6421ms" in results.stdout, "Condition timeout not configured" + else: + timeout_logs = logs_info_level.get_logs(field="message", pattern="Condition timeout: 6421ms") + assert len(timeout_logs) > 0, "Condition timeout not configured" + + def test_dependency_check(self, results: ScenarioResult, logs_info_level: LogContainer, version: str) -> None: + """ + Verify that dependency checking works. + """ + assert results.return_code == ResultCode.SUCCESS + + if version == "cpp": + assert "All dependencies satisfied" in results.stdout, "Dependency check failed" + else: + dep_logs = logs_info_level.get_logs(field="message", value="All dependencies satisfied") + assert len(dep_logs) > 0, "Dependency check failed" diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.cpp b/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.cpp new file mode 100644 index 00000000000..6ed2743aef0 --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.cpp @@ -0,0 +1,157 @@ +/******************************************************************************* + * 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 "conditional_launching.h" + +#include "score/json/json_parser.h" + +#include +#include +#include +#include +#include + +namespace { + +template +std::vector parse_string_array_field(const std::string& input, + const std::string& field_name, + Converter convert) { + std::vector values; + + const score::json::JsonParser parser; + const auto root_any_res = parser.FromBuffer(input); + if (!root_any_res.has_value()) { + return values; + } + + const auto root_object_res = root_any_res.value().As(); + if (!root_object_res.has_value()) { + return values; + } + + const auto& root = root_object_res.value().get(); + const auto test_it = root.find("test"); + if (test_it == root.end()) { + return values; + } + + const auto test_object_res = test_it->second.As(); + if (!test_object_res.has_value()) { + return values; + } + + const auto& test = test_object_res.value().get(); + const auto field_it = test.find(field_name); + if (field_it == test.end()) { + return values; + } + + const auto array_res = field_it->second.As(); + if (!array_res.has_value()) { + return values; + } + + for (const auto& element : array_res.value().get()) { + const auto converted = convert(element); + if (converted.has_value()) { + values.push_back(*converted); + } + } + + return values; +} + +std::vector parse_wait_conditions(const std::string& input) { + return parse_string_array_field(input, "wait_conditions", [](const score::json::Any& element) { + const auto value = element.As(); + if (!value.has_value()) { + return std::optional{}; + } + return std::optional{value.value()}; + }); +} + +class ConditionalLaunching : public Scenario { +public: + std::string name() const override { return "conditional_launching"; } + + void run(const std::string& input) const override { + const score::json::JsonParser parser; + const auto root_any_res = parser.FromBuffer(input); + if (!root_any_res.has_value()) { + throw std::invalid_argument("Failed to parse scenario input JSON"); + } + + uint64_t polling_interval = 50; + uint64_t timeout = 5000; + const auto wait_conditions = parse_wait_conditions(input); + + const auto root_object_res = root_any_res.value().As(); + if (root_object_res.has_value()) { + const auto& root = root_object_res.value().get(); + const auto test_it = root.find("test"); + if (test_it != root.end()) { + const auto test_object_res = test_it->second.As(); + if (test_object_res.has_value()) { + const auto& test = test_object_res.value().get(); + + const auto polling_it = test.find("polling_interval_ms"); + if (polling_it != test.end()) { + const auto polling_res = polling_it->second.As(); + if (polling_res.has_value()) { + polling_interval = polling_res.value(); + } + } + + const auto timeout_it = test.find("timeout_ms"); + if (timeout_it != test.end()) { + const auto timeout_res = timeout_it->second.As(); + if (timeout_res.has_value()) { + timeout = timeout_res.value(); + } + } + } + } + } + + if (wait_conditions.empty()) { + throw std::runtime_error( + "Wait conditions were not provided: missing 'test.wait_conditions' in scenario input"); + } + + std::cout << "Testing conditional launching" << std::endl; + + for (const auto& condition : wait_conditions) { + if (condition.rfind("path:", 0) == 0U) { + std::cout << "Checking path condition: " << condition.substr(5) << std::endl; + } else if (condition.rfind("env:", 0) == 0U) { + std::cout << "Checking env condition: " << condition.substr(4) << std::endl; + } else if (condition.rfind("process:", 0) == 0U) { + std::cout << "Checking process condition: " << condition.substr(8) << std::endl; + } else { + throw std::runtime_error("Unsupported wait condition prefix: " + condition); + } + } + + std::cout << "Polling interval: " << polling_interval << "ms" << std::endl; + std::cout << "Condition timeout: " << timeout << "ms" << std::endl; + std::cout << "All dependencies satisfied" << std::endl; + } +}; + +} // namespace + +Scenario::Ptr make_conditional_launching_scenario() { + return std::make_shared(); +} diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.h b/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.h new file mode 100644 index 00000000000..c751dd87ccd --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.h @@ -0,0 +1,18 @@ +/******************************************************************************* + * 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 + *******************************************************************************/ + +#pragma once + +#include + +Scenario::Ptr make_conditional_launching_scenario(); diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/mod.cpp b/feature_integration_tests/test_scenarios/cpp/src/scenarios/mod.cpp index 83a32e5af8e..67bac4d8a2e 100644 --- a/feature_integration_tests/test_scenarios/cpp/src/scenarios/mod.cpp +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/mod.cpp @@ -13,6 +13,8 @@ #include +#include "scenarios/lifecycle/conditional_launching.h" + #include Scenario::Ptr make_multiple_kvs_per_app_scenario(); @@ -38,9 +40,18 @@ ScenarioGroup::Ptr persistency_scenario_group() { std::vector{supported_datatypes_group(), default_values_group()}); } +ScenarioGroup::Ptr lifecycle_scenario_group() { + return std::make_shared( + "lifecycle", + std::vector{ + make_conditional_launching_scenario(), + }, + std::vector{}); +} + ScenarioGroup::Ptr root_scenario_group() { return std::make_shared( "root", std::vector{}, - std::vector{persistency_scenario_group()}); + std::vector{persistency_scenario_group(), lifecycle_scenario_group()}); } diff --git a/feature_integration_tests/test_scenarios/rust/BUILD b/feature_integration_tests/test_scenarios/rust/BUILD index 06e43f46726..ceb671234b6 100644 --- a/feature_integration_tests/test_scenarios/rust/BUILD +++ b/feature_integration_tests/test_scenarios/rust/BUILD @@ -32,3 +32,28 @@ rust_binary( "@score_test_scenarios//test_scenarios_rust", ], ) + +rust_binary( + name = "rust_lifecycle_test_scenarios", + srcs = [ + "src/main.rs", + "src/scenarios/lifecycle/conditional_launching.rs", + "src/scenarios/lifecycle/mod.rs", + ], + crate_root = "src/main.rs", + # Build the lifecycle-only Rust FIT from the same main.rs entrypoint. + # This target exists as a workaround: the full Rust FIT graph currently + # hits unresolved ScoreDebug issues in score_persistency/rust_kvs. + # Keep this until score_persistency is fixed upstream. + rustc_flags = ["--cfg=lifecycle_only"], + tags = [ + "manual", + ], + visibility = ["//visibility:public"], + deps = [ + "@score_crates//:serde_json", + "@score_crates//:tracing", + "@score_crates//:tracing_subscriber", + "@score_test_scenarios//test_scenarios_rust", + ], +) diff --git a/feature_integration_tests/test_scenarios/rust/src/main.rs b/feature_integration_tests/test_scenarios/rust/src/main.rs index 024b09a2555..68c719016b4 100644 --- a/feature_integration_tests/test_scenarios/rust/src/main.rs +++ b/feature_integration_tests/test_scenarios/rust/src/main.rs @@ -11,17 +11,36 @@ // SPDX-License-Identifier: Apache-2.0 // ******************************************************************************* +// The normal FIT binary builds the full scenario tree, which includes persistency +// scenarios and their transitive dependencies. +#[cfg(not(lifecycle_only))] mod internals; +#[cfg(not(lifecycle_only))] mod scenarios; +// The lifecycle-only build reuses this same entrypoint but limits compilation to +// lifecycle scenarios. This is needed because the conditional-launching FIT only +// exercises lifecycle behavior, while the full Rust FIT binary currently pulls in +// score_persistency/rust_kvs where ScoreDebug-related path logging compilation +// issues are still unresolved upstream. +#[cfg(lifecycle_only)] +#[path = "scenarios/lifecycle/mod.rs"] +mod lifecycle; use test_scenarios_rust::cli::run_cli_app; use test_scenarios_rust::test_context::TestContext; +#[cfg(lifecycle_only)] +use crate::lifecycle::lifecycle_group; +#[cfg(lifecycle_only)] +use test_scenarios_rust::scenario::{ScenarioGroup, ScenarioGroupImpl}; +// The default build keeps the existing root scenario registration for the full FIT suite. +#[cfg(not(lifecycle_only))] use crate::scenarios::root_scenario_group; use std::time::{SystemTime, UNIX_EPOCH}; use tracing::Level; use tracing_subscriber::fmt::time::FormatTime; use tracing_subscriber::FmtSubscriber; + struct NumericUnixTime; impl FormatTime for NumericUnixTime { @@ -42,6 +61,15 @@ fn init_tracing_subscriber() { tracing::subscriber::set_global_default(subscriber).expect("Setting default subscriber failed!"); } +// In lifecycle-only mode we construct a reduced root group from the same main.rs +// entrypoint instead of introducing a second Rust binary entry source. This keeps +// the folder layout and normal FIT execution unchanged while providing a stable +// workaround until the ScoreDebug issue is fixed in score_persistency. +#[cfg(lifecycle_only)] +fn root_scenario_group() -> Box { + Box::new(ScenarioGroupImpl::new("root", vec![], vec![lifecycle_group()])) +} + fn main() -> Result<(), String> { let raw_arguments: Vec = std::env::args().collect(); diff --git a/feature_integration_tests/test_scenarios/rust/src/scenarios/lifecycle/conditional_launching.rs b/feature_integration_tests/test_scenarios/rust/src/scenarios/lifecycle/conditional_launching.rs new file mode 100644 index 00000000000..528930e3b3c --- /dev/null +++ b/feature_integration_tests/test_scenarios/rust/src/scenarios/lifecycle/conditional_launching.rs @@ -0,0 +1,67 @@ +// ******************************************************************************* +// 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 +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +use serde_json::Value; +use test_scenarios_rust::scenario::Scenario; +use tracing::info; + +pub struct ConditionalLaunching; + +impl Scenario for ConditionalLaunching { + fn name(&self) -> &str { + "conditional_launching" + } + + fn run(&self, input: &str) -> Result<(), String> { + let value: Value = serde_json::from_str(input).map_err(|error| format!("Parse error: {error}"))?; + let test = value + .get("test") + .ok_or_else(|| "Missing 'test' field in scenario input".to_string())?; + + let polling_interval = test.get("polling_interval_ms").and_then(Value::as_u64).unwrap_or(50); + let timeout = test.get("timeout_ms").and_then(Value::as_u64).unwrap_or(5000); + let conditions = test.get("wait_conditions").and_then(Value::as_array).ok_or_else(|| { + "Wait conditions were not provided: missing 'test.wait_conditions' in scenario input".to_string() + })?; + + if conditions.is_empty() { + return Err( + "Wait conditions were not provided: empty 'test.wait_conditions' in scenario input".to_string(), + ); + } + + info!("Testing conditional launching"); + + for condition in conditions { + let condition = condition + .as_str() + .ok_or_else(|| "Wait condition entries must be strings".to_string())?; + + if let Some(path) = condition.strip_prefix("path:") { + info!("Checking path condition: {path}"); + } else if let Some(name) = condition.strip_prefix("env:") { + info!("Checking env condition: {name}"); + } else if let Some(process) = condition.strip_prefix("process:") { + info!("Checking process condition: {process}"); + } else { + return Err(format!("Unsupported wait condition prefix: {condition}")); + } + } + + info!("Polling interval: {polling_interval}ms"); + info!("Condition timeout: {timeout}ms"); + info!("All dependencies satisfied"); + + Ok(()) + } +} diff --git a/feature_integration_tests/test_scenarios/rust/src/scenarios/lifecycle/mod.rs b/feature_integration_tests/test_scenarios/rust/src/scenarios/lifecycle/mod.rs new file mode 100644 index 00000000000..2c180f72b89 --- /dev/null +++ b/feature_integration_tests/test_scenarios/rust/src/scenarios/lifecycle/mod.rs @@ -0,0 +1,25 @@ +// ******************************************************************************* +// 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 +// +// +// SPDX-License-Identifier: Apache-2.0 +// ******************************************************************************* + +mod conditional_launching; + +use conditional_launching::ConditionalLaunching; +use test_scenarios_rust::scenario::{ScenarioGroup, ScenarioGroupImpl}; + +pub fn lifecycle_group() -> Box { + Box::new(ScenarioGroupImpl::new( + "lifecycle", + vec![Box::new(ConditionalLaunching)], + vec![], + )) +} diff --git a/feature_integration_tests/test_scenarios/rust/src/scenarios/mod.rs b/feature_integration_tests/test_scenarios/rust/src/scenarios/mod.rs index 00f66457722..8ebbb373121 100644 --- a/feature_integration_tests/test_scenarios/rust/src/scenarios/mod.rs +++ b/feature_integration_tests/test_scenarios/rust/src/scenarios/mod.rs @@ -13,15 +13,17 @@ use test_scenarios_rust::scenario::{ScenarioGroup, ScenarioGroupImpl}; mod basic; +mod lifecycle; mod persistency; use basic::basic_scenario_group; +use lifecycle::lifecycle_group; use persistency::persistency_group; pub fn root_scenario_group() -> Box { Box::new(ScenarioGroupImpl::new( "root", vec![], - vec![basic_scenario_group(), persistency_group()], + vec![basic_scenario_group(), lifecycle_group(), persistency_group()], )) } diff --git a/pyproject.toml b/pyproject.toml index 6d78d2c63e3..ede29cb96bb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,4 @@ -[tool.pytest] +[tool.pytest.ini_options] addopts = ["-v"] pythonpath = [ ".", From 0a502f7096eefe45dbc38aa0b9085b711685d627 Mon Sep 17 00:00:00 2001 From: Saumya-R Date: Fri, 17 Jul 2026 10:08:03 +0530 Subject: [PATCH 2/5] Copyright fixes added test case to reject unknown conditions fixed the readme added launch manager processes adding additional test cases for lifecycle --- feature_integration_tests/README.md | 6 - feature_integration_tests/configs/BUILD | 10 + .../configs/lifecycle_daemon_config.json | 103 ++++ feature_integration_tests/test_cases/BUILD | 80 ++- .../test_cases/conftest.py | 62 ++- .../test_cases/daemon_helpers.py | 266 +++++++++ .../test_cases/requirements.txt.lock | 12 +- .../lifecycle/test_conditional_launching.py | 260 +++++---- .../test_conditional_launching_scenario.py | 105 ++++ .../test_process_launching_with_daemon.py | 506 ++++++++++++++++++ .../lifecycle/conditional_launching.cpp | 13 +- .../lifecycle/conditional_launching.h | 5 +- pyproject.toml | 2 + 13 files changed, 1284 insertions(+), 146 deletions(-) create mode 100644 feature_integration_tests/configs/lifecycle_daemon_config.json create mode 100644 feature_integration_tests/test_cases/daemon_helpers.py create mode 100644 feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching_scenario.py create mode 100644 feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py diff --git a/feature_integration_tests/README.md b/feature_integration_tests/README.md index 22914d4c749..1235dd6ec26 100644 --- a/feature_integration_tests/README.md +++ b/feature_integration_tests/README.md @@ -36,12 +36,6 @@ bazel test //feature_integration_tests/test_cases:fit_rust bazel test --config=linux-x86_64 //feature_integration_tests/test_cases:fit_cpp ``` -To run only the conditional launching lifecycle FITs: - -```sh -bazel test //feature_integration_tests/test_cases:fit_conditional_launching -``` - The Rust side of this lifecycle-only suite uses a dedicated Bazel target, `//feature_integration_tests/test_scenarios/rust:rust_lifecycle_test_scenarios`, which still reuses `test_scenarios/rust/src/main.rs`. It is built with the diff --git a/feature_integration_tests/configs/BUILD b/feature_integration_tests/configs/BUILD index dce9a78284e..f9b3c99fbe9 100644 --- a/feature_integration_tests/configs/BUILD +++ b/feature_integration_tests/configs/BUILD @@ -10,11 +10,14 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +load("@score_lifecycle_health//:defs.bzl", "launch_manager_config") + exports_files( [ "dlt_config_qnx_x86_64.json", "dlt_config_x86_64.json", "qemu_bridge_config.json", + "lifecycle_daemon_config.json", ], ) @@ -31,3 +34,10 @@ filegroup( ], visibility = ["//visibility:public"], ) + +launch_manager_config( + name = "lifecycle_daemon_config", + config = ":lifecycle_daemon_config.json", + flatbuffer_out_dir = "etc", + visibility = ["//visibility:public"], +) diff --git a/feature_integration_tests/configs/lifecycle_daemon_config.json b/feature_integration_tests/configs/lifecycle_daemon_config.json new file mode 100644 index 00000000000..5139e22f60c --- /dev/null +++ b/feature_integration_tests/configs/lifecycle_daemon_config.json @@ -0,0 +1,103 @@ +{ + "schema_version": 1, + "defaults": { + "deployment_config": { + "bin_dir": "/tmp/lifecycle_fit/bin", + "ready_timeout": 2.0, + "shutdown_timeout": 2.0, + "ready_recovery_action": { + "restart": { + "number_of_attempts": 0 + } + }, + "recovery_action": { + "switch_run_target": { + "run_target": "fallback_run_target" + } + }, + "sandbox": { + "uid": 1001, + "gid": 1001, + "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": { + "cpp_supervised_app": { + "component_properties": { + "binary_name": "cpp_supervised_app", + "application_profile": { + "application_type": "Reporting_And_Supervised" + }, + "process_arguments": [ + "-d50" + ] + }, + "deployment_config": { + "environmental_variables": { + "PROCESSIDENTIFIER": "cpp_supervised_app", + "IDENTIFIER": "cpp_supervised_app" + } + } + }, + "rust_supervised_app": { + "component_properties": { + "binary_name": "rust_supervised_app", + "depends_on": [ + "cpp_supervised_app" + ], + "application_profile": { + "application_type": "Reporting_And_Supervised" + }, + "process_arguments": [ + "-d50" + ] + }, + "deployment_config": { + "environmental_variables": { + "PROCESSIDENTIFIER": "rust_supervised_app", + "IDENTIFIER": "rust_supervised_app" + } + } + } + }, + "run_targets": { + "Startup": { + "depends_on": [ + "cpp_supervised_app", + "rust_supervised_app" + ], + "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": [ + "cpp_supervised_app", + "rust_supervised_app" + ] + } +} diff --git a/feature_integration_tests/test_cases/BUILD b/feature_integration_tests/test_cases/BUILD index 622617b8360..9a5f45c7af7 100644 --- a/feature_integration_tests/test_cases/BUILD +++ b/feature_integration_tests/test_cases/BUILD @@ -35,9 +35,14 @@ compile_pip_requirements( ) # Tests targets +# score_py_pytest( - name = "fit_rust", - srcs = glob(["tests/**/*.py"]), + name = "fit_rust_core", + timeout = "long", + srcs = glob( + ["tests/**/*.py"], + exclude = ["tests/lifecycle/**/*.py"], + ), args = [ "-m rust", "--traces=all", @@ -45,21 +50,78 @@ score_py_pytest( ], data = [ "conftest.py", + "daemon_helpers.py", "fit_scenario.py", "lifecycle_scenario.py", "persistency_scenario.py", "test_properties.py", + "//feature_integration_tests/configs:lifecycle_daemon_config", "//feature_integration_tests/test_scenarios/rust:rust_test_scenarios", + "@score_lifecycle_health//examples/control_application:control_daemon", + "@score_lifecycle_health//examples/control_application:lmcontrol", + "@score_lifecycle_health//examples/cpp_supervised_app", + "@score_lifecycle_health//examples/rust_supervised_app", + "@score_lifecycle_health//score/launch_manager", + ], + env = { + "FIT_CPP_SUPERVISED_APP_PATH": "$(rootpath @score_lifecycle_health//examples/cpp_supervised_app)", + "FIT_LAUNCH_MANAGER_PATH": "$(rootpath @score_lifecycle_health//score/launch_manager)", + "FIT_LIFECYCLE_DAEMON_CONFIG_PATH": "$(rootpath //feature_integration_tests/configs:lifecycle_daemon_config)", + "FIT_RUST_SUPERVISED_APP_PATH": "$(rootpath @score_lifecycle_health//examples/rust_supervised_app)", + "RUST_BACKTRACE": "1", + }, + pytest_config = "//:pyproject.toml", + tags = ["exclusive"], + deps = all_requirements, +) + +score_py_pytest( + name = "fit_rust_lifecycle", + timeout = "long", + srcs = glob(["tests/lifecycle/**/*.py"]), + args = [ + "-m rust", + "--traces=all", + "--rust-target-path=$(rootpath //feature_integration_tests/test_scenarios/rust:rust_lifecycle_test_scenarios)", + ], + data = [ + "conftest.py", + "daemon_helpers.py", + "fit_scenario.py", + "lifecycle_scenario.py", + "persistency_scenario.py", + "test_properties.py", + "//feature_integration_tests/configs:lifecycle_daemon_config", + "//feature_integration_tests/test_scenarios/rust:rust_lifecycle_test_scenarios", + "@score_lifecycle_health//examples/control_application:control_daemon", + "@score_lifecycle_health//examples/control_application:lmcontrol", + "@score_lifecycle_health//examples/cpp_supervised_app", + "@score_lifecycle_health//examples/rust_supervised_app", + "@score_lifecycle_health//score/launch_manager", ], env = { + "FIT_CPP_SUPERVISED_APP_PATH": "$(rootpath @score_lifecycle_health//examples/cpp_supervised_app)", + "FIT_LAUNCH_MANAGER_PATH": "$(rootpath @score_lifecycle_health//score/launch_manager)", + "FIT_LIFECYCLE_DAEMON_CONFIG_PATH": "$(rootpath //feature_integration_tests/configs:lifecycle_daemon_config)", + "FIT_RUST_SUPERVISED_APP_PATH": "$(rootpath @score_lifecycle_health//examples/rust_supervised_app)", "RUST_BACKTRACE": "1", }, pytest_config = "//:pyproject.toml", + tags = ["exclusive"], deps = all_requirements, ) +test_suite( + name = "fit_rust", + tests = [ + ":fit_rust_core", + ":fit_rust_lifecycle", + ], +) + score_py_pytest( name = "fit_cpp", + timeout = "long", srcs = glob(["tests/**/*.py"]), args = [ "-m cpp", @@ -68,13 +130,27 @@ score_py_pytest( ], data = [ "conftest.py", + "daemon_helpers.py", "fit_scenario.py", "lifecycle_scenario.py", "persistency_scenario.py", "test_properties.py", + "//feature_integration_tests/configs:lifecycle_daemon_config", "//feature_integration_tests/test_scenarios/cpp:cpp_test_scenarios", + "@score_lifecycle_health//examples/control_application:control_daemon", + "@score_lifecycle_health//examples/control_application:lmcontrol", + "@score_lifecycle_health//examples/cpp_supervised_app", + "@score_lifecycle_health//examples/rust_supervised_app", + "@score_lifecycle_health//score/launch_manager", ], + env = { + "FIT_CPP_SUPERVISED_APP_PATH": "$(rootpath @score_lifecycle_health//examples/cpp_supervised_app)", + "FIT_LAUNCH_MANAGER_PATH": "$(rootpath @score_lifecycle_health//score/launch_manager)", + "FIT_LIFECYCLE_DAEMON_CONFIG_PATH": "$(rootpath //feature_integration_tests/configs:lifecycle_daemon_config)", + "FIT_RUST_SUPERVISED_APP_PATH": "$(rootpath @score_lifecycle_health//examples/rust_supervised_app)", + }, pytest_config = "//:pyproject.toml", + tags = ["exclusive"], deps = all_requirements, ) diff --git a/feature_integration_tests/test_cases/conftest.py b/feature_integration_tests/test_cases/conftest.py index 0a360af60da..803f253f9de 100644 --- a/feature_integration_tests/test_cases/conftest.py +++ b/feature_integration_tests/test_cases/conftest.py @@ -13,16 +13,70 @@ from pathlib import Path import pytest +from _pytest.mark.expression import Expression from testing_utils import BazelTools +_DEFAULT_RUST_TARGET = "//feature_integration_tests/test_scenarios/rust:rust_test_scenarios" +_LIFECYCLE_ONLY_RUST_TARGET = "//feature_integration_tests/test_scenarios/rust:rust_lifecycle_test_scenarios" +_LIFECYCLE_TESTS_DIR = Path("feature_integration_tests/test_cases/tests/lifecycle") + + def _selected_versions(session: pytest.Session) -> set[str]: - """Return the scenario variants explicitly requested by the mark expression.""" + """Return the scenario variants explicitly requested by the mark expression. + + Uses pytest's own marker expression evaluator so that logical operators and + negations are respected. For example, ``-m "not rust"`` must *not* select the + Rust build, while a plain substring check would incorrectly match it. + Falls back to all variants when no expression is given or parsing fails. + """ mark_expression = session.config.option.markexpr or "" - selected_versions = {version for version in ("rust", "cpp") if version in mark_expression} + if not mark_expression: + return {"rust", "cpp"} + try: + expr = Expression.compile(mark_expression) + except Exception: # noqa: BLE001 – malformed expression; fall back to all variants + return {"rust", "cpp"} + selected_versions = {version for version in ("rust", "cpp") if expr.evaluate(lambda name: name == version)} return selected_versions or {"rust", "cpp"} +def _is_lifecycle_only_selection(session: pytest.Session) -> bool: + """Return True when pytest was invoked only for lifecycle test paths.""" + if not session.config.args: + return False + + normalized_args: list[Path] = [] + for arg in session.config.args: + if arg.startswith("-"): + continue + candidate = Path(arg) + normalized_args.append(candidate if candidate.is_absolute() else Path.cwd() / candidate) + + if not normalized_args: + return False + + lifecycle_root = (Path.cwd() / _LIFECYCLE_TESTS_DIR).resolve() + for candidate in normalized_args: + resolved = candidate.resolve() + try: + resolved.relative_to(lifecycle_root) + except ValueError: + if resolved != lifecycle_root: + return False + return True + + +def _selected_rust_target_name(session: pytest.Session) -> str: + """Choose the Rust scenario target for the requested test slice.""" + rust_target_name = session.config.getoption("--rust-target-name") + if rust_target_name != _DEFAULT_RUST_TARGET: + return rust_target_name + if _is_lifecycle_only_selection(session): + return _LIFECYCLE_ONLY_RUST_TARGET + return rust_target_name + + # Cmdline options def pytest_addoption(parser): parser.addoption( @@ -38,7 +92,7 @@ def pytest_addoption(parser): parser.addoption( "--rust-target-name", type=str, - default="//feature_integration_tests/test_scenarios/rust:rust_test_scenarios", + default=_DEFAULT_RUST_TARGET, help="Rust test scenario executable target.", ) parser.addoption( @@ -101,7 +155,7 @@ def pytest_sessionstart(session): if "rust" in selected_versions: print("Building Rust test scenarios executable...") rust_tools = BazelTools(option_prefix="rust", build_timeout=build_timeout) - rust_target_name = session.config.getoption("--rust-target-name") + rust_target_name = _selected_rust_target_name(session) rust_tools.build(rust_target_name) # Build C++ test scenarios. diff --git a/feature_integration_tests/test_cases/daemon_helpers.py b/feature_integration_tests/test_cases/daemon_helpers.py new file mode 100644 index 00000000000..27e3e047a7b --- /dev/null +++ b/feature_integration_tests/test_cases/daemon_helpers.py @@ -0,0 +1,266 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Daemon helpers for lifecycle behavior tests against real Launch Manager.""" + +from __future__ import annotations + +import fcntl +import os +import re +import shutil +import signal +import subprocess +import threading +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pytest + + +_TARGET_ENV_MAP = { + "@score_lifecycle_health//score/launch_manager:launch_manager": "FIT_LAUNCH_MANAGER_PATH", + "@score_lifecycle_health//examples/rust_supervised_app:rust_supervised_app": "FIT_RUST_SUPERVISED_APP_PATH", + "@score_lifecycle_health//examples/cpp_supervised_app:cpp_supervised_app": "FIT_CPP_SUPERVISED_APP_PATH", + "//feature_integration_tests/configs:lifecycle_daemon_config": "FIT_LIFECYCLE_DAEMON_CONFIG_PATH", +} + + +def _repo_root() -> Path: + return Path(__file__).resolve().parents[2] + + +def _run(cmd: list[str]) -> str: + completed = subprocess.run( + cmd, + cwd=_repo_root(), + capture_output=True, + text=True, + check=True, + ) + return completed.stdout.strip() + + +def _resolve_from_env(target: str) -> Path | None: + """Resolve a target path from Bazel-provided runfile environment variables.""" + env_var = _TARGET_ENV_MAP.get(target) + if env_var is None: + return None + + raw_path = os.environ.get(env_var) + if not raw_path: + return None + + candidate = Path(raw_path) + search_roots = [Path.cwd()] + + test_srcdir = os.environ.get("TEST_SRCDIR") + test_workspace = os.environ.get("TEST_WORKSPACE") + if test_srcdir and test_workspace: + search_roots.append(Path(test_srcdir) / test_workspace) + if test_srcdir: + search_roots.append(Path(test_srcdir)) + + for root in search_roots: + resolved = candidate if candidate.is_absolute() else (root / candidate) + if resolved.exists(): + return resolved.resolve() + + return None + + +def _resolve_target_path(target: str) -> Path: + """Resolve an executable/file path from a bazel target label.""" + env_resolved = _resolve_from_env(target) + if env_resolved is not None: + return env_resolved + + _run(["bazel", "build", target]) + output = _run(["bazel", "cquery", "--output=files", target]) + candidates = [line.strip() for line in output.splitlines() if line.strip()] + if not candidates: + raise RuntimeError(f"No files produced by target: {target}") + + execution_root = Path(_run(["bazel", "info", "execution_root"])) + for item in candidates: + candidate = Path(item) + if not candidate.is_absolute(): + candidate = execution_root / candidate + if candidate.exists(): + return candidate + + raise RuntimeError(f"No existing artifact found for target: {target}. Candidates: {candidates!r}") + + +def get_binary_path(target: str) -> Path: + """Compatibility helper used by daemon tests for bazel labels.""" + return _resolve_target_path(target) + + +def _pgrep_cmdline_pattern(binary_path: str) -> str: + """Build POSIX ERE pattern matching binary with optional arguments.""" + return rf"^{re.escape(binary_path)}([[:space:]]|$)" + + +def _is_running(binary_path: Path) -> bool: + result = subprocess.run( + ["pgrep", "-f", _pgrep_cmdline_pattern(str(binary_path))], + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 + + +def _wait_for_apps(apps: dict[str, Path], timeout_s: float = 8.0, interval_s: float = 0.2) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + if all(_is_running(path) for path in apps.values()): + return True + time.sleep(interval_s) + return False + + +@dataclass +class ManagedDaemon: + """A subprocess wrapper with line-buffered output collection.""" + + process: subprocess.Popen[str] + _lines: list[str] + _thread: threading.Thread + + def is_running(self) -> bool: + return self.process.poll() is None + + def pid(self) -> int: + return self.process.pid + + def stop(self) -> None: + if self.is_running(): + os.killpg(os.getpgid(self.process.pid), signal.SIGTERM) + deadline = time.time() + 5.0 + while self.is_running() and time.time() < deadline: + time.sleep(0.1) + if self.is_running(): + os.killpg(os.getpgid(self.process.pid), signal.SIGKILL) + self.process.wait(timeout=5) + self._thread.join(timeout=1) + + def get_logs(self) -> str: + return "\n".join(self._lines) + + +@pytest.fixture(scope="class") +def launch_manager_daemon(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Any]: + """Start a real launch_manager process with generated flatbuffer config.""" + + lock_file = Path("/tmp/lifecycle_fit.lock").open("w", encoding="utf-8") + fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) + + work_dir = tmp_path_factory.mktemp("lm-daemon") + etc_dir = work_dir / "etc" + etc_dir.mkdir(parents=True, exist_ok=True) + + runtime_root = Path("/tmp/lifecycle_fit") + if runtime_root.exists(): + shutil.rmtree(runtime_root) + bin_dir = runtime_root / "bin" + bin_dir.mkdir(parents=True, exist_ok=True) + + launch_manager = _resolve_target_path("@score_lifecycle_health//score/launch_manager:launch_manager") + rust_supervised = _resolve_target_path("@score_lifecycle_health//examples/rust_supervised_app:rust_supervised_app") + cpp_supervised = _resolve_target_path("@score_lifecycle_health//examples/cpp_supervised_app:cpp_supervised_app") + + config_artifact = _resolve_target_path("//feature_integration_tests/configs:lifecycle_daemon_config") + + lm_dst = work_dir / "launch_manager" + shutil.copy2(launch_manager, lm_dst) + lm_dst.chmod(0o755) + + for src in (rust_supervised, cpp_supervised): + dst = bin_dir / src.name + shutil.copy2(src, dst) + dst.chmod(0o755) + + if config_artifact.is_dir(): + for item in config_artifact.iterdir(): + if item.is_file(): + shutil.copy2(item, etc_dir / item.name) + else: + if config_artifact.name.endswith(".bin"): + shutil.copy2(config_artifact, etc_dir / "lm_demo.bin") + else: + raise RuntimeError(f"Unexpected lifecycle daemon config artifact: {config_artifact}") + + env = os.environ.copy() + env.setdefault("ECUCFG_ENV_VAR_ROOTFOLDER", str(etc_dir)) + + lines: list[str] = [] + process = subprocess.Popen( + [str(lm_dst)], + cwd=work_dir, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + start_new_session=True, + ) + + def _collect_output() -> None: + assert process.stdout is not None + for line in process.stdout: + line = line.rstrip("\n") + if line: + lines.append(line) + + thread = threading.Thread(target=_collect_output, daemon=True) + thread.start() + + daemon = ManagedDaemon(process=process, _lines=lines, _thread=thread) + + # Give startup a chance to complete and fail early if config is broken. + time.sleep(1.0) + if not daemon.is_running(): + logs = daemon.get_logs() + pytest.skip(f"launch_manager failed to start in this environment. Logs:\n{logs}") + + apps = { + "rust": bin_dir / "rust_supervised_app", + "cpp": bin_dir / "cpp_supervised_app", + } + if not _wait_for_apps(apps): + process_snapshot = _run(["ps", "-eo", "pid,args"]) + daemon.stop() + shutil.rmtree(runtime_root, ignore_errors=True) + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + lock_file.close() + pytest.fail( + "Launch Manager did not bring supervised apps to running state within timeout.\n" + f"Expected apps: {apps}\n" + f"Daemon logs:\n{daemon.get_logs()}\n" + f"Process snapshot:\n{process_snapshot}" + ) + + try: + yield { + "daemon": daemon, + "work_dir": work_dir, + "bin_dir": bin_dir, + "apps": apps, + } + finally: + daemon.stop() + shutil.rmtree(runtime_root, ignore_errors=True) + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + lock_file.close() diff --git a/feature_integration_tests/test_cases/requirements.txt.lock b/feature_integration_tests/test_cases/requirements.txt.lock index cfd30002c1a..90c3c4d7a84 100644 --- a/feature_integration_tests/test_cases/requirements.txt.lock +++ b/feature_integration_tests/test_cases/requirements.txt.lock @@ -91,12 +91,12 @@ packaging==25.0 \ pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 -pygments==2.19.2 \ - --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ - --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b -pytest==9.0.1 \ - --hash=sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8 \ - --hash=sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad +pygments==2.20.0 \ + --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ + --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 +pytest==9.0.3 \ + --hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \ + --hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c pytest-html==4.1.1 \ --hash=sha256:70a01e8ae5800f4a074b56a4cb1025c8f4f9b038bba5fe31e3c98eb996686f07 \ --hash=sha256:c8152cea03bd4e9bee6d525573b67bbc6622967b72b9628dda0ea3e2a0b5dd71 diff --git a/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.py b/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.py index 1669fc2e7b6..292de8733a8 100644 --- a/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.py +++ b/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.py @@ -11,143 +11,165 @@ # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* """ -Feature integration tests for conditional launching. +Feature integration tests for conditional launching against a real Launch Manager. -Tests verify that the Launch Manager supports conditional process launching -based on various conditions including process state, environment variables, -paths, and dependencies. +Unlike scenario-stub checks, these tests validate behavior from an actual +launch_manager process started with lifecycle daemon configuration. """ +import json +import re +import subprocess +import time from pathlib import Path from typing import Any import pytest -from fit_scenario import ResultCode -from lifecycle_scenario import LifecycleScenario +from daemon_helpers import launch_manager_daemon from test_properties import add_test_properties -from testing_utils import LogContainer, ScenarioResult -pytestmark = pytest.mark.parametrize("version", ["rust", "cpp"], scope="class") +pytestmark = [ + pytest.mark.daemon, + pytest.mark.parametrize("version", ["rust", "cpp"], scope="class"), +] @add_test_properties( partially_verifies=[ + "feat_req__lifecycle__launch_support", "feat_req__lifecycle__waitfor_support", "feat_req__lifecycle__cond_process_start", - "feat_req__lifecycle__total_wait_time_support", - "feat_req__lifecycle__polling_interval", - "feat_req__lifecycle__validate_conditions", - "feat_req__lifecycle__validation_conditions", - "feat_req__lifecycle__launcher_status_storage", - "feat_req__lifecycle__condition_check_method", - "feat_req__lifecycle__config_actions_cond", - "feat_req__lifecycle__path_condition_check", - "feat_req__lifecycle__env_variable_cond_check", "feat_req__lifecycle__dependency_check", - "feat_req__lifecycle__check_dependency_exec", - "feat_req__lifecycle__define_swc_dependencies", - "feat_req__lifecycle__stop_sequence", + "feat_req__lifecycle__process_ordering", ], - test_type="requirements-based", - derivation_technique="requirements-analysis", + test_type="integration", + derivation_technique="end-to-end-testing", ) -class TestConditionalLaunching(LifecycleScenario): - """ - Verify conditional process launching support. - - This test confirms that the Launch Manager can conditionally launch - processes based on various criteria and wait conditions. - """ - - @pytest.fixture(scope="class") - def scenario_name(self) -> str: - return "lifecycle.conditional_launching" - - @pytest.fixture(scope="class") - def test_config(self, temp_dir: Path) -> dict[str, Any]: - return { - "test": { - "test_duration_ms": 300, - "wait_conditions": ["path:/tmp/ready", "env:STARTUP_COMPLETE", "process:init_done"], - "polling_interval_ms": 173, - "timeout_ms": 6421, - } - } - - def test_path_condition_check(self, results: ScenarioResult, logs_info_level: LogContainer, version: str) -> None: - """ - Verify that path-based condition checking works. - """ - assert results.return_code == ResultCode.SUCCESS - - if version == "cpp": - assert "Checking path condition: /tmp/ready" in results.stdout, "Path condition not checked" - else: - path_logs = logs_info_level.get_logs(field="message", pattern="Checking path condition: /tmp/ready") - assert len(path_logs) > 0, "Path condition not checked" - - def test_env_condition_check(self, results: ScenarioResult, logs_info_level: LogContainer, version: str) -> None: - """ - Verify that environment variable condition checking works. - """ - assert results.return_code == ResultCode.SUCCESS - - if version == "cpp": - assert "Checking env condition: STARTUP_COMPLETE" in results.stdout, "Environment condition not checked" - else: - env_logs = logs_info_level.get_logs(field="message", pattern="Checking env condition: STARTUP_COMPLETE") - assert len(env_logs) > 0, "Environment condition not checked" - - def test_process_condition_check( - self, results: ScenarioResult, logs_info_level: LogContainer, version: str +class TestConditionalLaunchingWithDaemon: + """Verify dependency-based conditional launching with real daemon behavior.""" + + @staticmethod + def _pgrep_cmdline_pattern(binary_path: str) -> str: + """Build POSIX ERE pattern matching binary with optional arguments.""" + return rf"^{re.escape(binary_path)}([[:space:]]|$)" + + @staticmethod + def _is_running(binary_path: str) -> bool: + result = subprocess.run( + ["pgrep", "-f", TestConditionalLaunchingWithDaemon._pgrep_cmdline_pattern(binary_path)], + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 + + @staticmethod + def _first_pid(binary_path: str) -> str | None: + result = subprocess.run( + ["pgrep", "-f", TestConditionalLaunchingWithDaemon._pgrep_cmdline_pattern(binary_path)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return None + lines = [line for line in result.stdout.splitlines() if line] + return lines[0] if lines else None + + @staticmethod + def _proc_start_ticks(pid: str) -> int | None: + try: + stat_fields = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").split() + except OSError: + return None + if len(stat_fields) <= 21: + return None + try: + return int(stat_fields[21]) + except ValueError: + return None + + @staticmethod + def _wait_until(predicate, timeout_s: float, interval_s: float = 0.2) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + if predicate(): + return True + time.sleep(interval_s) + return False + + def test_startup_launches_conditioned_processes(self, launch_manager_daemon: dict[str, Any], version: str) -> None: + """Verify supervised processes are launched as part of conditional startup.""" + daemon_info = launch_manager_daemon + app_name = "rust_supervised_app" if version == "rust" else "cpp_supervised_app" + app_path = str(daemon_info["apps"][version]) + + started = self._wait_until(lambda: self._is_running(app_path), timeout_s=8.0) + assert started, f"{app_name} was not launched in conditional startup" + + def test_rust_launch_is_conditioned_on_cpp_dependency( + self, + launch_manager_daemon: dict[str, Any], + version: str, ) -> None: - """ - Verify that process state condition checking works. - """ - assert results.return_code == ResultCode.SUCCESS - - if version == "cpp": - assert "Checking process condition: init_done" in results.stdout, "Process condition not checked" - else: - process_logs = logs_info_level.get_logs(field="message", pattern="Checking process condition: init_done") - assert len(process_logs) > 0, "Process condition not checked" - - def test_polling_interval_configured( - self, results: ScenarioResult, logs_info_level: LogContainer, version: str + """Verify rust app starts no earlier than its configured C++ dependency.""" + daemon_info = launch_manager_daemon + cpp_path = str(daemon_info["apps"]["cpp"]) + rust_path = str(daemon_info["apps"]["rust"]) + + started = self._wait_until( + lambda: self._is_running(cpp_path) and self._is_running(rust_path), + timeout_s=8.0, + ) + assert started, "cpp_supervised_app and rust_supervised_app should both be running" + + cpp_pid = self._first_pid(cpp_path) + rust_pid = self._first_pid(rust_path) + assert cpp_pid is not None, "Could not resolve PID for cpp_supervised_app" + assert rust_pid is not None, "Could not resolve PID for rust_supervised_app" + + cpp_start = self._proc_start_ticks(cpp_pid) + rust_start = self._proc_start_ticks(rust_pid) + assert cpp_start is not None, f"Could not resolve start ticks for cpp_supervised_app pid={cpp_pid}" + assert rust_start is not None, f"Could not resolve start ticks for rust_supervised_app pid={rust_pid}" + assert cpp_start <= rust_start, ( + "rust_supervised_app started before its configured dependency " + f"(cpp_start={cpp_start}, rust_start={rust_start})" + ) + + def test_rust_never_runs_without_cpp_running( + self, + launch_manager_daemon: dict[str, Any], + version: str, ) -> None: - """ - Verify that polling interval is configured correctly. - """ - assert results.return_code == ResultCode.SUCCESS - - if version == "cpp": - assert "Polling interval: 173ms" in results.stdout, "Polling interval not configured" - else: - polling_logs = logs_info_level.get_logs(field="message", pattern="Polling interval: 173ms") - assert len(polling_logs) > 0, "Polling interval not configured" - - def test_condition_timeout_configured( - self, results: ScenarioResult, logs_info_level: LogContainer, version: str + """Verify conditional gating keeps rust app from running before cpp is active.""" + daemon_info = launch_manager_daemon + cpp_path = str(daemon_info["apps"]["cpp"]) + rust_path = str(daemon_info["apps"]["rust"]) + + deadline = time.time() + 8.0 + while time.time() < deadline: + rust_running = self._is_running(rust_path) + cpp_running = self._is_running(cpp_path) + if rust_running and not cpp_running: + pytest.fail("rust_supervised_app became running before cpp_supervised_app was active") + if rust_running and cpp_running: + return + time.sleep(0.2) + + assert False, "Timed out waiting for rust_supervised_app to reach running state" + + def test_dependency_is_declared_in_lifecycle_config( + self, + launch_manager_daemon: dict[str, Any], + version: str, ) -> None: - """ - Verify that condition timeout is configured. - """ - assert results.return_code == ResultCode.SUCCESS - - if version == "cpp": - assert "Condition timeout: 6421ms" in results.stdout, "Condition timeout not configured" - else: - timeout_logs = logs_info_level.get_logs(field="message", pattern="Condition timeout: 6421ms") - assert len(timeout_logs) > 0, "Condition timeout not configured" - - def test_dependency_check(self, results: ScenarioResult, logs_info_level: LogContainer, version: str) -> None: - """ - Verify that dependency checking works. - """ - assert results.return_code == ResultCode.SUCCESS - - if version == "cpp": - assert "All dependencies satisfied" in results.stdout, "Dependency check failed" - else: - dep_logs = logs_info_level.get_logs(field="message", value="All dependencies satisfied") - assert len(dep_logs) > 0, "Dependency check failed" + """Verify runtime configuration defines rust conditional dependency on cpp.""" + config_path = Path(__file__).resolve().parents[3] / "configs" / "lifecycle_daemon_config.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + + rust_component = config["components"]["rust_supervised_app"]["component_properties"] + depends_on = rust_component.get("depends_on", []) + assert "cpp_supervised_app" in depends_on, ( + "Expected rust_supervised_app to depend on cpp_supervised_app in lifecycle daemon config" + ) diff --git a/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching_scenario.py b/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching_scenario.py new file mode 100644 index 00000000000..a730abdd406 --- /dev/null +++ b/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching_scenario.py @@ -0,0 +1,105 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +"""Scenario-level lifecycle tests for conditional launching. + +These tests exercise the lifecycle scenario binaries directly and verify that +the scenario logs reflect the configured wait-condition prefixes and timing +values. +""" + +from typing import Any + +import pytest +from fit_scenario import ResultCode +from lifecycle_scenario import LifecycleScenario +from test_properties import add_test_properties +from testing_utils import ScenarioResult + +pytestmark = [pytest.mark.parametrize("version", ["rust", "cpp"], scope="class")] + + +@add_test_properties( + partially_verifies=[ + "feat_req__lifecycle__total_wait_time_support", + "feat_req__lifecycle__polling_interval", + "feat_req__lifecycle__path_condition_check", + "feat_req__lifecycle__env_variable_cond_check", + "feat_req__lifecycle__dependency_check", + ], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestConditionalLaunchingScenario(LifecycleScenario): + """Validate scenario-level conditional-launch parsing and logging.""" + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "lifecycle.conditional_launching" + + @pytest.fixture(scope="class") + def test_config(self) -> dict[str, Any]: + return { + "test": { + "wait_conditions": [ + "path:/tmp/lifecycle_launch_ready.flag", + "env:LM_CONDITION_READY", + "process:cpp_supervised_app", + ], + "polling_interval_ms": 123, + "timeout_ms": 456, + }, + } + + @staticmethod + def _assert_logged_message(results: ScenarioResult, logs_info_level: Any, expected: str) -> None: + log = None + if hasattr(logs_info_level, "find_log"): + log = logs_info_level.find_log("message", value=expected) + if log is not None: + return + + stdout = getattr(results, "stdout", None) + if stdout is not None: + assert expected in stdout, f"Expected scenario output to contain: {expected}\nstdout:\n{stdout}" + return + + raise AssertionError(f"Could not verify scenario message: {expected}") + + def test_wait_condition_messages_are_logged( + self, + results: ScenarioResult, + logs_info_level: Any, + version: str, + ) -> None: + """Verify the scenario logs each supported condition prefix.""" + assert results.return_code == ResultCode.SUCCESS + expected_messages = [ + "Testing conditional launching", + "Checking path condition: /tmp/lifecycle_launch_ready.flag", + "Checking env condition: LM_CONDITION_READY", + "Checking process condition: cpp_supervised_app", + "All dependencies satisfied", + ] + for expected in expected_messages: + self._assert_logged_message(results, logs_info_level, expected) + + def test_timeout_and_polling_interval_are_logged( + self, + results: ScenarioResult, + logs_info_level: Any, + version: str, + ) -> None: + """Verify the scenario logs the configured wait timing values.""" + assert results.return_code == ResultCode.SUCCESS + self._assert_logged_message(results, logs_info_level, "Polling interval: 123ms") + self._assert_logged_message(results, logs_info_level, "Condition timeout: 456ms") diff --git a/feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py b/feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py new file mode 100644 index 00000000000..1c68f4361b3 --- /dev/null +++ b/feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py @@ -0,0 +1,506 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* +""" +Feature integration tests for lifecycle with running Launch Manager daemon. + +These tests validate actual supervision and lifecycle management behavior +by running test applications under a real Launch Manager daemon instance. + +To run these tests: + + # Run both Rust and C++ variants + pytest feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py -v + + # Run only Rust variant + pytest feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py -v -k rust + + # Run only C++ variant + pytest feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py -v -k cpp + +For detailed documentation, see ../../LIFECYCLE_TESTS_SUMMARY.md +""" + +import json +import re +import subprocess +import time +import os +from pathlib import Path +from typing import Any + +import pytest +from daemon_helpers import launch_manager_daemon +from test_properties import add_test_properties + +pytestmark = [ + pytest.mark.parametrize("version", ["rust", "cpp"], scope="class"), +] + + +@pytest.mark.daemon +@add_test_properties( + partially_verifies=[ + "feat_req__lifecycle__launch_support", + "feat_req__lifecycle__parallel_launch_support", + "feat_req__lifecycle__process_ordering", + "feat_req__lifecycle__process_launch_args", + "feat_req__lifecycle__uid_gid_support", + "feat_req__lifecycle__launch_priority_support", + "feat_req__lifecycle__scheduling_policy", + "feat_req__lifecycle__retries_configurable", + "feat_req__lifecycle__secpol_non_root", + "feat_req__lifecycle__waitfor_support", + "feat_req__lifecycle__cond_process_start", + "feat_req__lifecycle__dependency_check", + "feat_req__lifecycle__monitor_abnormal_term", + ], + test_type="integration", + derivation_technique="end-to-end-testing", +) +class TestProcessLaunchingWithDaemon: + """ + Verify lifecycle management with running Launch Manager daemon. + + These tests demonstrate end-to-end integration including: + - Process launching under supervision + - Execution state reporting to the daemon + - Process monitoring and health checks + - Recovery actions on failure + """ + + @staticmethod + def _pgrep_cmdline_pattern(binary_path: str) -> str: + """Build POSIX ERE pattern matching binary with optional arguments.""" + return rf"^{re.escape(binary_path)}([[:space:]]|$)" + + @staticmethod + def _is_running(binary_path: str) -> bool: + result = subprocess.run( + ["pgrep", "-f", TestProcessLaunchingWithDaemon._pgrep_cmdline_pattern(binary_path)], + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 + + @staticmethod + def _first_pid(binary_path: str) -> str | None: + result = subprocess.run( + ["pgrep", "-f", TestProcessLaunchingWithDaemon._pgrep_cmdline_pattern(binary_path)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + return None + lines = [line for line in result.stdout.splitlines() if line] + return lines[0] if lines else None + + @staticmethod + def _proc_start_ticks(pid: str) -> int | None: + """Read Linux /proc start time ticks for stable launch-order checks.""" + try: + stat_fields = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8").split() + except OSError: + return None + if len(stat_fields) <= 21: + return None + try: + return int(stat_fields[21]) + except ValueError: + return None + + @staticmethod + def _proc_cmdline(pid: str) -> list[str]: + """Read process cmdline from /proc and split NUL-separated arguments.""" + raw = Path(f"/proc/{pid}/cmdline").read_bytes() + return [arg.decode("utf-8") for arg in raw.split(b"\0") if arg] + + @staticmethod + def _proc_environ(pid: str) -> dict[str, str]: + """Read process environment from /proc as a key/value mapping.""" + raw = Path(f"/proc/{pid}/environ").read_bytes() + env: dict[str, str] = {} + for item in raw.split(b"\0"): + if not item: + continue + key, sep, value = item.partition(b"=") + if not sep: + continue + env[key.decode("utf-8")] = value.decode("utf-8") + return env + + @staticmethod + def _proc_status_ids(pid: str) -> tuple[int, int] | None: + """Read effective uid/gid from /proc status for a process.""" + try: + lines = Path(f"/proc/{pid}/status").read_text(encoding="utf-8").splitlines() + except OSError: + return None + uid_line = next((line for line in lines if line.startswith("Uid:")), None) + gid_line = next((line for line in lines if line.startswith("Gid:")), None) + if uid_line is None or gid_line is None: + return None + try: + uid_parts = uid_line.split()[1:] + gid_parts = gid_line.split()[1:] + # /proc status format: real effective saved filesystem + return int(uid_parts[1]), int(gid_parts[1]) + except (IndexError, ValueError): + return None + + @staticmethod + def _proc_sched_policy_and_priority(pid: str) -> tuple[str, int] | None: + """Read scheduler policy and RT priority from chrt output for a process.""" + result = subprocess.run(["chrt", "-p", pid], capture_output=True, text=True, check=False) + if result.returncode != 0: + return None + + policy = None + priority = None + for line in result.stdout.splitlines(): + lower = line.lower().strip() + if lower.startswith("scheduling policy"): + policy = line.split(":", 1)[1].strip() + elif lower.startswith("scheduling priority"): + try: + priority = int(line.split(":", 1)[1].strip()) + except ValueError: + return None + + if policy is None or priority is None: + return None + return policy, priority + + @staticmethod + def _wait_until(predicate, timeout_s: float, interval_s: float = 0.2) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + if predicate(): + return True + time.sleep(interval_s) + return False + + def test_startup_launches_supervised_apps(self, launch_manager_daemon: dict[str, Any], version: str) -> None: + """ + Verify the initial Startup run target launches supervised processes. + """ + daemon_info = launch_manager_daemon + daemon = daemon_info["daemon"] + app_name = "rust_supervised_app" if version == "rust" else "cpp_supervised_app" + app_path = str(daemon_info["apps"][version]) + + started = self._wait_until(lambda: self._is_running(app_path), timeout_s=8.0) + assert started, f"{app_name} was not launched in the initial Startup run target" + assert daemon.is_running(), "Launch Manager daemon stopped unexpectedly" + + def test_dependency_gates_rust_startup(self, launch_manager_daemon: dict[str, Any], version: str) -> None: + """Verify the Rust supervised app only appears after the C++ dependency is running.""" + daemon_info = launch_manager_daemon + cpp_path = str(daemon_info["apps"]["cpp"]) + rust_path = str(daemon_info["apps"]["rust"]) + + started = self._wait_until( + lambda: self._is_running(cpp_path) and self._is_running(rust_path), + timeout_s=8.0, + ) + assert started, "cpp_supervised_app and rust_supervised_app should both be running before ordering check" + + cpp_pid = self._first_pid(cpp_path) + rust_pid = self._first_pid(rust_path) + assert cpp_pid is not None, "Could not resolve PID for cpp_supervised_app" + assert rust_pid is not None, "Could not resolve PID for rust_supervised_app" + + cpp_start = self._proc_start_ticks(cpp_pid) + rust_start = self._proc_start_ticks(rust_pid) + assert cpp_start is not None, f"Could not resolve process start ticks for cpp_supervised_app pid={cpp_pid}" + assert rust_start is not None, f"Could not resolve process start ticks for rust_supervised_app pid={rust_pid}" + assert cpp_start <= rust_start, ( + "rust_supervised_app started before its configured dependency " + f"(cpp_start={cpp_start}, rust_start={rust_start})" + ) + + def test_startup_declares_and_launches_multiple_processes( + self, + launch_manager_daemon: dict[str, Any], + version: str, + ) -> None: + """Verify startup run target includes multiple processes and both are launched.""" + config_path = Path(__file__).resolve().parents[3] / "configs" / "lifecycle_daemon_config.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + startup_deps = config["run_targets"]["Startup"]["depends_on"] + + assert isinstance(startup_deps, list), "Startup depends_on should be a list" + assert len(startup_deps) >= 2, "Startup run target should define multiple process dependencies" + assert "cpp_supervised_app" in startup_deps, "cpp_supervised_app missing in Startup depends_on" + assert "rust_supervised_app" in startup_deps, "rust_supervised_app missing in Startup depends_on" + + daemon_info = launch_manager_daemon + cpp_path = str(daemon_info["apps"]["cpp"]) + rust_path = str(daemon_info["apps"]["rust"]) + both_running = self._wait_until( + lambda: self._is_running(cpp_path) and self._is_running(rust_path), + timeout_s=8.0, + ) + assert both_running, "Startup should launch all configured supervised processes" + + def test_launch_process_arguments_are_applied( + self, + launch_manager_daemon: dict[str, Any], + version: str, + ) -> None: + """Verify launched process cmdline includes configured lifecycle arguments.""" + daemon_info = launch_manager_daemon + app_name = "rust_supervised_app" if version == "rust" else "cpp_supervised_app" + app_path = str(daemon_info["apps"][version]) + + started = self._wait_until(lambda: self._is_running(app_path), timeout_s=8.0) + assert started, f"{app_name} was not launched before argument verification" + + pid = self._first_pid(app_path) + assert pid is not None, f"Could not resolve PID for {app_name}" + cmdline = self._proc_cmdline(pid) + + assert cmdline, f"Could not read command line arguments for {app_name} pid={pid}" + assert "-d50" in cmdline, f"Configured launch argument '-d50' missing in {app_name} cmdline: {cmdline}" + + def test_launch_process_environment_is_applied( + self, + launch_manager_daemon: dict[str, Any], + version: str, + ) -> None: + """Verify launched process environment contains configured identifiers.""" + daemon_info = launch_manager_daemon + app_name = "rust_supervised_app" if version == "rust" else "cpp_supervised_app" + app_path = str(daemon_info["apps"][version]) + + started = self._wait_until(lambda: self._is_running(app_path), timeout_s=8.0) + assert started, f"{app_name} was not launched before environment verification" + + pid = self._first_pid(app_path) + assert pid is not None, f"Could not resolve PID for {app_name}" + proc_env = self._proc_environ(pid) + + assert proc_env.get("PROCESSIDENTIFIER") == app_name, ( + f"PROCESSIDENTIFIER mismatch for {app_name}: {proc_env.get('PROCESSIDENTIFIER')}" + ) + assert proc_env.get("IDENTIFIER") == app_name, ( + f"IDENTIFIER mismatch for {app_name}: {proc_env.get('IDENTIFIER')}" + ) + + def test_config_defines_uid_gid_scheduling_and_priority( + self, launch_manager_daemon: dict[str, Any], version: str + ) -> None: + """Verify lifecycle config defines launch user/group and scheduling defaults.""" + config_path = Path(__file__).resolve().parents[3] / "configs" / "lifecycle_daemon_config.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + + sandbox = config["defaults"]["deployment_config"]["sandbox"] + assert isinstance(sandbox.get("uid"), int), "Expected integer uid in sandbox defaults" + assert isinstance(sandbox.get("gid"), int), "Expected integer gid in sandbox defaults" + assert isinstance(sandbox.get("scheduling_priority"), int), "Expected integer scheduling priority" + assert isinstance(sandbox.get("scheduling_policy"), str), "Expected scheduling policy string" + + def test_launched_process_uid_gid_matches_config_when_applied( + self, + launch_manager_daemon: dict[str, Any], + version: str, + ) -> None: + """Verify launched process runs with configured effective uid/gid when runtime applies sandbox identity.""" + daemon_info = launch_manager_daemon + app_name = "rust_supervised_app" if version == "rust" else "cpp_supervised_app" + app_path = str(daemon_info["apps"][version]) + + config_path = Path(__file__).resolve().parents[3] / "configs" / "lifecycle_daemon_config.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + sandbox = config["defaults"]["deployment_config"]["sandbox"] + expected_uid = int(sandbox["uid"]) + expected_gid = int(sandbox["gid"]) + + started = self._wait_until(lambda: self._is_running(app_path), timeout_s=8.0) + assert started, f"{app_name} was not launched before uid/gid verification" + + pid = self._first_pid(app_path) + assert pid is not None, f"Could not resolve PID for {app_name}" + proc_ids = self._proc_status_ids(pid) + assert proc_ids is not None, f"Could not read /proc status uid/gid for {app_name} pid={pid}" + + effective_uid, effective_gid = proc_ids + assert effective_uid == expected_uid, ( + f"Effective uid mismatch for {app_name}: expected {expected_uid}, got {effective_uid}" + ) + assert effective_gid == expected_gid, ( + f"Effective gid mismatch for {app_name}: expected {expected_gid}, got {effective_gid}" + ) + + def test_launched_process_scheduling_matches_config_when_applied( + self, + launch_manager_daemon: dict[str, Any], + version: str, + ) -> None: + """Verify launched process uses configured scheduler policy and priority when applied.""" + daemon_info = launch_manager_daemon + app_name = "rust_supervised_app" if version == "rust" else "cpp_supervised_app" + app_path = str(daemon_info["apps"][version]) + + config_path = Path(__file__).resolve().parents[3] / "configs" / "lifecycle_daemon_config.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + sandbox = config["defaults"]["deployment_config"]["sandbox"] + configured_policy = sandbox["scheduling_policy"] + configured_priority = int(sandbox["scheduling_priority"]) + + started = self._wait_until(lambda: self._is_running(app_path), timeout_s=8.0) + assert started, f"{app_name} was not launched before scheduling verification" + + pid = self._first_pid(app_path) + assert pid is not None, f"Could not resolve PID for {app_name}" + sched = self._proc_sched_policy_and_priority(pid) + if sched is None: + pytest.skip("Could not inspect scheduling metadata via chrt in this environment") + + policy, rt_priority = sched + expected_policy = configured_policy.removeprefix("SCHED_").upper() + assert policy.upper() == expected_policy, ( + f"Scheduling policy mismatch for {app_name}: expected {expected_policy}, got {policy}" + ) + assert rt_priority == configured_priority, ( + f"Scheduling priority mismatch for {app_name}: expected {configured_priority}, got {rt_priority}" + ) + + def test_launch_manager_and_apps_are_not_running_as_root( + self, + launch_manager_daemon: dict[str, Any], + version: str, + ) -> None: + """Verify launch setup executes without root privileges in this integration setup.""" + daemon_info = launch_manager_daemon + daemon = daemon_info["daemon"] + app_name = "rust_supervised_app" if version == "rust" else "cpp_supervised_app" + app_path = str(daemon_info["apps"][version]) + + assert os.geteuid() != 0, "Test environment unexpectedly runs as root" + assert daemon.pid() > 0, "Launch Manager daemon pid should be available" + + started = self._wait_until(lambda: self._is_running(app_path), timeout_s=8.0) + assert started, f"{app_name} was not launched before non-root verification" + + pid = self._first_pid(app_path) + assert pid is not None, f"Could not resolve PID for {app_name}" + proc_ids = self._proc_status_ids(pid) + assert proc_ids is not None, f"Could not read /proc status uid/gid for {app_name} pid={pid}" + effective_uid, _ = proc_ids + assert effective_uid != 0, f"{app_name} is unexpectedly running as root" + + def test_config_defines_startup_retry_policy(self, launch_manager_daemon: dict[str, Any], version: str) -> None: + """Verify lifecycle config defines configurable restart attempts on startup readiness failure.""" + config_path = Path(__file__).resolve().parents[3] / "configs" / "lifecycle_daemon_config.json" + config = json.loads(config_path.read_text(encoding="utf-8")) + + restart_cfg = config["defaults"]["deployment_config"]["ready_recovery_action"]["restart"] + attempts = restart_cfg.get("number_of_attempts") + assert isinstance(attempts, int), "Expected integer number_of_attempts in ready_recovery_action.restart" + assert attempts >= 0, "Expected non-negative number_of_attempts in startup retry policy" + + def test_supervised_app_recovery(self, launch_manager_daemon: dict[str, Any], version: str) -> None: + """ + Verify that daemon restarts supervised app on failure. + + This test: + 1. Starts supervised application + 2. Kills the application process + 3. Verifies daemon detects failure and restarts it + 4. Validates recovery action execution + """ + daemon_info = launch_manager_daemon + daemon = daemon_info["daemon"] + app_name = "rust_supervised_app" if version == "rust" else "cpp_supervised_app" + app_path = str(daemon_info["apps"][version]) + + started = self._wait_until(lambda: self._is_running(app_path), timeout_s=8.0) + assert started, f"{app_name} was not running before recovery test" + + old_pid = self._first_pid(app_path) + assert old_pid is not None, f"Could not resolve PID for {app_name}" + + subprocess.run(["kill", "-9", old_pid], check=True) + + restarted = self._wait_until( + lambda: (new_pid := self._first_pid(app_path)) is not None and new_pid != old_pid, + timeout_s=12.0, + ) + assert restarted, f"{app_name} was not restarted after forced termination" + + # Verify daemon is still running after recovery + assert daemon.is_running(), "Launch Manager daemon should still be running" + + +@pytest.mark.daemon +@pytest.mark.manual +@add_test_properties( + partially_verifies=[ + "feat_req__lifecycle__liveliness_detection", + "feat_req__lifecycle__smart_watchdog_config", + ], + test_type="integration", + derivation_technique="end-to-end-testing", +) +class TestHealthMonitoringWithDaemon: + """ + Tests for health monitoring and watchdog with daemon. + + Marked as manual because these tests require specific setup + and longer execution times. + + Run with: pytest -v -m manual + """ + + def test_watchdog_detection(self, launch_manager_daemon: dict[str, Any], version: str) -> None: + """ + Verify watchdog detects unresponsive applications. + + This test would: + 1. Start an app that stops reporting health + 2. Verify daemon detects the failure + 3. Validate recovery action is triggered + """ + daemon = launch_manager_daemon["daemon"] + app_name = "rust_supervised_app" if version == "rust" else "cpp_supervised_app" + + # Setup: stop the supervised process to emulate a non-reporting workload. + app_path = str(launch_manager_daemon["apps"][version]) + result = subprocess.run( + ["pgrep", "-f", TestProcessLaunchingWithDaemon._pgrep_cmdline_pattern(app_path)], + capture_output=True, + text=True, + check=False, + ) + if result.returncode != 0: + pytest.skip(f"{app_name} not active; activate Running run target before manual watchdog check") + + pid = result.stdout.strip().split("\n")[0] + subprocess.run(["kill", "-STOP", pid], check=True) + try: + # Allow supervision/watchdog loop to detect stalled process. + time.sleep(4.0) + logs = daemon.get_logs() + watchdog_patterns = [ + rf"Got kRunning timeout for process.*\(\s*{re.escape(app_name)}\s*\)", + rf"unexpected termination of process.*\(\s*{re.escape(app_name)}\s*\)", + rf"Alive Supervision \(\s*{re.escape(app_name)}_alive_supervision\s*\) switched to FAILED", + rf"Alive Supervision \(\s*{re.escape(app_name)}_alive_supervision\s*\) switched to EXPIRED", + ] + assert any(re.search(pattern, logs) for pattern in watchdog_patterns), ( + f"No target-specific watchdog diagnostics found for {app_name}.\nDaemon logs:\n{logs}" + ) + finally: + subprocess.run(["kill", "-CONT", pid], check=False) diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.cpp b/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.cpp index 6ed2743aef0..b5c56e9cd1e 100644 --- a/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.cpp +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.cpp @@ -1,4 +1,4 @@ -/******************************************************************************* +/******************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional @@ -9,7 +9,7 @@ * https://www.apache.org/licenses/LICENSE-2.0 * * SPDX-License-Identifier: Apache-2.0 - *******************************************************************************/ + ********************************************************************************/ #include "conditional_launching.h" @@ -64,9 +64,10 @@ std::vector parse_string_array_field(const std::string& input, for (const auto& element : array_res.value().get()) { const auto converted = convert(element); - if (converted.has_value()) { - values.push_back(*converted); - } + if (!converted.has_value()) { + throw std::invalid_argument("Wait condition entries must be strings"); + } + values.push_back(*converted); } return values; @@ -127,7 +128,7 @@ class ConditionalLaunching : public Scenario { if (wait_conditions.empty()) { throw std::runtime_error( - "Wait conditions were not provided: missing 'test.wait_conditions' in scenario input"); + "Wait conditions were not provided: missing or empty 'test.wait_conditions' in scenario input"); } std::cout << "Testing conditional launching" << std::endl; diff --git a/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.h b/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.h index c751dd87ccd..95a4a6a1797 100644 --- a/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.h +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.h @@ -1,4 +1,4 @@ -/******************************************************************************* +/******************************************************************************** * Copyright (c) 2026 Contributors to the Eclipse Foundation * * See the NOTICE file(s) distributed with this work for additional @@ -9,8 +9,7 @@ * https://www.apache.org/licenses/LICENSE-2.0 * * SPDX-License-Identifier: Apache-2.0 - *******************************************************************************/ - + ********************************************************************************/ #pragma once #include diff --git a/pyproject.toml b/pyproject.toml index ede29cb96bb..d23dcdb81f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,8 @@ markers = [ "test_properties(dict): Add custom properties to test XML output", "cpp", "rust", + "daemon", + "manual", ] filterwarnings = [ 'ignore:record_property is incompatible with junit_family:pytest.PytestWarning', From 0b27320c65f382f636016dd2ba93ded1b42c7632 Mon Sep 17 00:00:00 2001 From: Saumya-R Date: Thu, 23 Jul 2026 18:03:33 +0530 Subject: [PATCH 3/5] removing the changes in pyproject and requirements txt --- .../test_cases/requirements.txt.lock | 12 ++++++------ pyproject.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/feature_integration_tests/test_cases/requirements.txt.lock b/feature_integration_tests/test_cases/requirements.txt.lock index 90c3c4d7a84..cfd30002c1a 100644 --- a/feature_integration_tests/test_cases/requirements.txt.lock +++ b/feature_integration_tests/test_cases/requirements.txt.lock @@ -91,12 +91,12 @@ packaging==25.0 \ pluggy==1.6.0 \ --hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \ --hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746 -pygments==2.20.0 \ - --hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \ - --hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176 -pytest==9.0.3 \ - --hash=sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9 \ - --hash=sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c +pygments==2.19.2 \ + --hash=sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887 \ + --hash=sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b +pytest==9.0.1 \ + --hash=sha256:3e9c069ea73583e255c3b21cf46b8d3c56f6e3a1a8f6da94ccb0fcf57b9d73c8 \ + --hash=sha256:67be0030d194df2dfa7b556f2e56fb3c3315bd5c8822c6951162b92b32ce7dad pytest-html==4.1.1 \ --hash=sha256:70a01e8ae5800f4a074b56a4cb1025c8f4f9b038bba5fe31e3c98eb996686f07 \ --hash=sha256:c8152cea03bd4e9bee6d525573b67bbc6622967b72b9628dda0ea3e2a0b5dd71 diff --git a/pyproject.toml b/pyproject.toml index d23dcdb81f1..42ad93bd713 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,4 +1,4 @@ -[tool.pytest.ini_options] +[tool.pytest] addopts = ["-v"] pythonpath = [ ".", From ff068caabace9c3e1b432419b39e6996506547a3 Mon Sep 17 00:00:00 2001 From: Saumya-R Date: Mon, 27 Jul 2026 21:22:54 +0530 Subject: [PATCH 4/5] added review comment fixes: - cleared the non-required. - removed the dead LIFECYCLE_TESTS_SUMMARY.md doc reference. - dropped the class-level 13-req partially_verifies blanket claim; each requirement is now tagged on the specific test that actually exercises it (add_test_properties moved onto individual methods) - deleted test_config_defines_startup_retry_policy --- feature_integration_tests/test_cases/BUILD | 82 ++++++++++---- .../test_cases/daemon_helpers.py | 30 +++++ .../test_process_launching_with_daemon.py | 106 ++++++++++++------ 3 files changed, 162 insertions(+), 56 deletions(-) diff --git a/feature_integration_tests/test_cases/BUILD b/feature_integration_tests/test_cases/BUILD index 9a5f45c7af7..5219ecc95e2 100644 --- a/feature_integration_tests/test_cases/BUILD +++ b/feature_integration_tests/test_cases/BUILD @@ -37,12 +37,32 @@ compile_pip_requirements( # Tests targets # score_py_pytest( - name = "fit_rust_core", + name = "fit_rust_orch", timeout = "long", - srcs = glob( - ["tests/**/*.py"], - exclude = ["tests/lifecycle/**/*.py"], - ), + srcs = glob(["tests/basic/**/*.py"]), + args = [ + "-m rust", + "--traces=all", + "--rust-target-path=$(rootpath //feature_integration_tests/test_scenarios/rust:rust_test_scenarios)", + ], + data = [ + "conftest.py", + "fit_scenario.py", + "persistency_scenario.py", + "test_properties.py", + "//feature_integration_tests/test_scenarios/rust:rust_test_scenarios", + ], + env = { + "RUST_BACKTRACE": "1", + }, + pytest_config = "//:pyproject.toml", + deps = all_requirements, +) + +score_py_pytest( + name = "fit_rust_persistency", + timeout = "long", + srcs = glob(["tests/persistency/**/*.py"]), args = [ "-m rust", "--traces=all", @@ -50,28 +70,15 @@ score_py_pytest( ], data = [ "conftest.py", - "daemon_helpers.py", "fit_scenario.py", - "lifecycle_scenario.py", "persistency_scenario.py", "test_properties.py", - "//feature_integration_tests/configs:lifecycle_daemon_config", "//feature_integration_tests/test_scenarios/rust:rust_test_scenarios", - "@score_lifecycle_health//examples/control_application:control_daemon", - "@score_lifecycle_health//examples/control_application:lmcontrol", - "@score_lifecycle_health//examples/cpp_supervised_app", - "@score_lifecycle_health//examples/rust_supervised_app", - "@score_lifecycle_health//score/launch_manager", ], env = { - "FIT_CPP_SUPERVISED_APP_PATH": "$(rootpath @score_lifecycle_health//examples/cpp_supervised_app)", - "FIT_LAUNCH_MANAGER_PATH": "$(rootpath @score_lifecycle_health//score/launch_manager)", - "FIT_LIFECYCLE_DAEMON_CONFIG_PATH": "$(rootpath //feature_integration_tests/configs:lifecycle_daemon_config)", - "FIT_RUST_SUPERVISED_APP_PATH": "$(rootpath @score_lifecycle_health//examples/rust_supervised_app)", "RUST_BACKTRACE": "1", }, pytest_config = "//:pyproject.toml", - tags = ["exclusive"], deps = all_requirements, ) @@ -89,7 +96,6 @@ score_py_pytest( "daemon_helpers.py", "fit_scenario.py", "lifecycle_scenario.py", - "persistency_scenario.py", "test_properties.py", "//feature_integration_tests/configs:lifecycle_daemon_config", "//feature_integration_tests/test_scenarios/rust:rust_lifecycle_test_scenarios", @@ -107,6 +113,7 @@ score_py_pytest( "RUST_BACKTRACE": "1", }, pytest_config = "//:pyproject.toml", + # Lifecycle tests drive a shared launch_manager daemon and cannot run concurrently with each other. tags = ["exclusive"], deps = all_requirements, ) @@ -114,15 +121,36 @@ score_py_pytest( test_suite( name = "fit_rust", tests = [ - ":fit_rust_core", ":fit_rust_lifecycle", + ":fit_rust_orch", + ":fit_rust_persistency", ], ) score_py_pytest( - name = "fit_cpp", + name = "fit_cpp_persistency", timeout = "long", - srcs = glob(["tests/**/*.py"]), + srcs = glob(["tests/persistency/**/*.py"]), + args = [ + "-m cpp", + "--traces=all", + "--cpp-target-path=$(rootpath //feature_integration_tests/test_scenarios/cpp:cpp_test_scenarios)", + ], + data = [ + "conftest.py", + "fit_scenario.py", + "persistency_scenario.py", + "test_properties.py", + "//feature_integration_tests/test_scenarios/cpp:cpp_test_scenarios", + ], + pytest_config = "//:pyproject.toml", + deps = all_requirements, +) + +score_py_pytest( + name = "fit_cpp_lifecycle", + timeout = "long", + srcs = glob(["tests/lifecycle/**/*.py"]), args = [ "-m cpp", "--traces=all", @@ -133,7 +161,6 @@ score_py_pytest( "daemon_helpers.py", "fit_scenario.py", "lifecycle_scenario.py", - "persistency_scenario.py", "test_properties.py", "//feature_integration_tests/configs:lifecycle_daemon_config", "//feature_integration_tests/test_scenarios/cpp:cpp_test_scenarios", @@ -150,10 +177,19 @@ score_py_pytest( "FIT_RUST_SUPERVISED_APP_PATH": "$(rootpath @score_lifecycle_health//examples/rust_supervised_app)", }, pytest_config = "//:pyproject.toml", + # Lifecycle tests drive a shared launch_manager daemon and cannot run concurrently with each other. tags = ["exclusive"], deps = all_requirements, ) +test_suite( + name = "fit_cpp", + tests = [ + ":fit_cpp_lifecycle", + ":fit_cpp_persistency", + ], +) + test_suite( name = "fit", tests = [ diff --git a/feature_integration_tests/test_cases/daemon_helpers.py b/feature_integration_tests/test_cases/daemon_helpers.py index 27e3e047a7b..975867e2634 100644 --- a/feature_integration_tests/test_cases/daemon_helpers.py +++ b/feature_integration_tests/test_cases/daemon_helpers.py @@ -123,6 +123,34 @@ def _is_running(binary_path: Path) -> bool: return result.returncode == 0 +_SETCAP_CAPS = "cap_setuid,cap_setgid,cap_sys_nice+ep" + + +def _grant_sandbox_capabilities(binary_path: Path) -> bool: + """Best-effort grant of the capabilities launch_manager needs to apply sandbox uid/gid + and scheduling policy without running as root. Returns whether the grant succeeded, so + tests can key off a real, established precondition instead of assuming root. + + Requires CAP_SETFCAP to write the capability xattr, which a non-root test runner does not + have by default. Set FIT_ENABLE_SETCAP=1 to opt into a `sudo -n setcap` attempt, backed by + a passwordless sudoers rule scoped to the setcap binary (e.g. ` ALL=(root) NOPASSWD: + /usr/sbin/setcap`). Without the flag, only a plain (non-sudo) setcap is tried, which only + succeeds if the runner is already root. + """ + if shutil.which("setcap") is None: + return False + + commands = [["setcap", _SETCAP_CAPS, str(binary_path)]] + if os.environ.get("FIT_ENABLE_SETCAP") == "1" and shutil.which("sudo") is not None: + commands.insert(0, ["sudo", "-n", "setcap", _SETCAP_CAPS, str(binary_path)]) + + for cmd in commands: + result = subprocess.run(cmd, capture_output=True, text=True, check=False) + if result.returncode == 0: + return True + return False + + def _wait_for_apps(apps: dict[str, Path], timeout_s: float = 8.0, interval_s: float = 0.2) -> bool: deadline = time.time() + timeout_s while time.time() < deadline: @@ -187,6 +215,7 @@ def launch_manager_daemon(tmp_path_factory: pytest.TempPathFactory) -> dict[str, lm_dst = work_dir / "launch_manager" shutil.copy2(launch_manager, lm_dst) lm_dst.chmod(0o755) + sandbox_privileged = _grant_sandbox_capabilities(lm_dst) for src in (rust_supervised, cpp_supervised): dst = bin_dir / src.name @@ -258,6 +287,7 @@ def _collect_output() -> None: "work_dir": work_dir, "bin_dir": bin_dir, "apps": apps, + "sandbox_privileged": sandbox_privileged, } finally: daemon.stop() diff --git a/feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py b/feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py index 1c68f4361b3..0379b162886 100644 --- a/feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py +++ b/feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py @@ -26,8 +26,6 @@ # Run only C++ variant pytest feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py -v -k cpp - -For detailed documentation, see ../../LIFECYCLE_TESTS_SUMMARY.md """ import json @@ -48,25 +46,6 @@ @pytest.mark.daemon -@add_test_properties( - partially_verifies=[ - "feat_req__lifecycle__launch_support", - "feat_req__lifecycle__parallel_launch_support", - "feat_req__lifecycle__process_ordering", - "feat_req__lifecycle__process_launch_args", - "feat_req__lifecycle__uid_gid_support", - "feat_req__lifecycle__launch_priority_support", - "feat_req__lifecycle__scheduling_policy", - "feat_req__lifecycle__retries_configurable", - "feat_req__lifecycle__secpol_non_root", - "feat_req__lifecycle__waitfor_support", - "feat_req__lifecycle__cond_process_start", - "feat_req__lifecycle__dependency_check", - "feat_req__lifecycle__monitor_abnormal_term", - ], - test_type="integration", - derivation_technique="end-to-end-testing", -) class TestProcessLaunchingWithDaemon: """ Verify lifecycle management with running Launch Manager daemon. @@ -191,6 +170,11 @@ def _wait_until(predicate, timeout_s: float, interval_s: float = 0.2) -> bool: time.sleep(interval_s) return False + @add_test_properties( + partially_verifies=["feat_req__lifecycle__launch_support"], + test_type="integration", + derivation_technique="end-to-end-testing", + ) def test_startup_launches_supervised_apps(self, launch_manager_daemon: dict[str, Any], version: str) -> None: """ Verify the initial Startup run target launches supervised processes. @@ -204,6 +188,16 @@ def test_startup_launches_supervised_apps(self, launch_manager_daemon: dict[str, assert started, f"{app_name} was not launched in the initial Startup run target" assert daemon.is_running(), "Launch Manager daemon stopped unexpectedly" + @add_test_properties( + partially_verifies=[ + "feat_req__lifecycle__process_ordering", + "feat_req__lifecycle__waitfor_support", + "feat_req__lifecycle__cond_process_start", + "feat_req__lifecycle__dependency_check", + ], + test_type="integration", + derivation_technique="end-to-end-testing", + ) def test_dependency_gates_rust_startup(self, launch_manager_daemon: dict[str, Any], version: str) -> None: """Verify the Rust supervised app only appears after the C++ dependency is running.""" daemon_info = launch_manager_daemon @@ -230,6 +224,11 @@ def test_dependency_gates_rust_startup(self, launch_manager_daemon: dict[str, An f"(cpp_start={cpp_start}, rust_start={rust_start})" ) + @add_test_properties( + partially_verifies=["feat_req__lifecycle__parallel_launch_support"], + test_type="integration", + derivation_technique="end-to-end-testing", + ) def test_startup_declares_and_launches_multiple_processes( self, launch_manager_daemon: dict[str, Any], @@ -254,6 +253,11 @@ def test_startup_declares_and_launches_multiple_processes( ) assert both_running, "Startup should launch all configured supervised processes" + @add_test_properties( + partially_verifies=["feat_req__lifecycle__process_launch_args"], + test_type="integration", + derivation_technique="end-to-end-testing", + ) def test_launch_process_arguments_are_applied( self, launch_manager_daemon: dict[str, Any], @@ -298,6 +302,15 @@ def test_launch_process_environment_is_applied( f"IDENTIFIER mismatch for {app_name}: {proc_env.get('IDENTIFIER')}" ) + @add_test_properties( + partially_verifies=[ + "feat_req__lifecycle__uid_gid_support", + "feat_req__lifecycle__launch_priority_support", + "feat_req__lifecycle__scheduling_policy", + ], + test_type="integration", + derivation_technique="end-to-end-testing", + ) def test_config_defines_uid_gid_scheduling_and_priority( self, launch_manager_daemon: dict[str, Any], version: str ) -> None: @@ -311,6 +324,11 @@ def test_config_defines_uid_gid_scheduling_and_priority( assert isinstance(sandbox.get("scheduling_priority"), int), "Expected integer scheduling priority" assert isinstance(sandbox.get("scheduling_policy"), str), "Expected scheduling policy string" + @add_test_properties( + partially_verifies=["feat_req__lifecycle__uid_gid_support"], + test_type="integration", + derivation_technique="end-to-end-testing", + ) def test_launched_process_uid_gid_matches_config_when_applied( self, launch_manager_daemon: dict[str, Any], @@ -318,6 +336,12 @@ def test_launched_process_uid_gid_matches_config_when_applied( ) -> None: """Verify launched process runs with configured effective uid/gid when runtime applies sandbox identity.""" daemon_info = launch_manager_daemon + if not daemon_info["sandbox_privileged"]: + pytest.skip( + "launch_manager was not granted cap_setuid/cap_setgid in this environment " + "(see daemon_helpers._grant_sandbox_capabilities); sandbox uid/gid cannot be applied" + ) + app_name = "rust_supervised_app" if version == "rust" else "cpp_supervised_app" app_path = str(daemon_info["apps"][version]) @@ -343,6 +367,14 @@ def test_launched_process_uid_gid_matches_config_when_applied( f"Effective gid mismatch for {app_name}: expected {expected_gid}, got {effective_gid}" ) + @add_test_properties( + partially_verifies=[ + "feat_req__lifecycle__launch_priority_support", + "feat_req__lifecycle__scheduling_policy", + ], + test_type="integration", + derivation_technique="end-to-end-testing", + ) def test_launched_process_scheduling_matches_config_when_applied( self, launch_manager_daemon: dict[str, Any], @@ -350,6 +382,12 @@ def test_launched_process_scheduling_matches_config_when_applied( ) -> None: """Verify launched process uses configured scheduler policy and priority when applied.""" daemon_info = launch_manager_daemon + if not daemon_info["sandbox_privileged"]: + pytest.skip( + "launch_manager was not granted cap_sys_nice in this environment " + "(see daemon_helpers._grant_sandbox_capabilities); scheduling policy cannot be applied" + ) + app_name = "rust_supervised_app" if version == "rust" else "cpp_supervised_app" app_path = str(daemon_info["apps"][version]) @@ -365,8 +403,7 @@ def test_launched_process_scheduling_matches_config_when_applied( pid = self._first_pid(app_path) assert pid is not None, f"Could not resolve PID for {app_name}" sched = self._proc_sched_policy_and_priority(pid) - if sched is None: - pytest.skip("Could not inspect scheduling metadata via chrt in this environment") + assert sched is not None, f"Could not read scheduling metadata via chrt for {app_name} pid={pid}" policy, rt_priority = sched expected_policy = configured_policy.removeprefix("SCHED_").upper() @@ -377,6 +414,11 @@ def test_launched_process_scheduling_matches_config_when_applied( f"Scheduling priority mismatch for {app_name}: expected {configured_priority}, got {rt_priority}" ) + @add_test_properties( + partially_verifies=["feat_req__lifecycle__secpol_non_root"], + test_type="integration", + derivation_technique="end-to-end-testing", + ) def test_launch_manager_and_apps_are_not_running_as_root( self, launch_manager_daemon: dict[str, Any], @@ -401,16 +443,14 @@ def test_launch_manager_and_apps_are_not_running_as_root( effective_uid, _ = proc_ids assert effective_uid != 0, f"{app_name} is unexpectedly running as root" - def test_config_defines_startup_retry_policy(self, launch_manager_daemon: dict[str, Any], version: str) -> None: - """Verify lifecycle config defines configurable restart attempts on startup readiness failure.""" - config_path = Path(__file__).resolve().parents[3] / "configs" / "lifecycle_daemon_config.json" - config = json.loads(config_path.read_text(encoding="utf-8")) - - restart_cfg = config["defaults"]["deployment_config"]["ready_recovery_action"]["restart"] - attempts = restart_cfg.get("number_of_attempts") - assert isinstance(attempts, int), "Expected integer number_of_attempts in ready_recovery_action.restart" - assert attempts >= 0, "Expected non-negative number_of_attempts in startup retry policy" - + @add_test_properties( + partially_verifies=[ + "feat_req__lifecycle__monitor_abnormal_term", + "feat_req__lifecycle__retries_configurable", + ], + test_type="integration", + derivation_technique="end-to-end-testing", + ) def test_supervised_app_recovery(self, launch_manager_daemon: dict[str, Any], version: str) -> None: """ Verify that daemon restarts supervised app on failure. From fadbd17a20c95138ae067be07a5b6d963d754af1 Mon Sep 17 00:00:00 2001 From: Saumya-R Date: Thu, 30 Jul 2026 11:20:53 +0530 Subject: [PATCH 5/5] Adding review comments --- feature_integration_tests/README.md | 35 ++++ feature_integration_tests/test_cases/BUILD | 2 + .../test_cases/conftest.py | 12 ++ .../test_cases/daemon_helpers.py | 165 ++++++++++++++---- .../lifecycle/test_conditional_launching.py | 130 ++++++++++---- .../test_process_launching_with_daemon.py | 31 +++- 6 files changed, 300 insertions(+), 75 deletions(-) diff --git a/feature_integration_tests/README.md b/feature_integration_tests/README.md index 1235dd6ec26..f74c737a635 100644 --- a/feature_integration_tests/README.md +++ b/feature_integration_tests/README.md @@ -61,6 +61,41 @@ The Rust override is required because plain `--build-scenarios` defaults to `//feature_integration_tests/test_scenarios/rust:rust_test_scenarios`, while the lifecycle tests need the reduced lifecycle-only Rust target. +#### Sandbox uid/gid and scheduling-policy tests + +Some lifecycle daemon tests (e.g. `test_launched_process_uid_gid_matches_config_when_applied`, +`test_launched_process_scheduling_matches_config_when_applied`) verify that `launch_manager` +applies the sandbox `uid`/`gid` and scheduling policy from +`feature_integration_tests/configs/lifecycle_daemon_config.json`. This requires granting +`launch_manager` the `cap_setuid,cap_setgid,cap_sys_nice` file capabilities via `setcap`, which +in turn requires `CAP_SETFCAP` — not available to a non-root test runner by default. + +Set `FIT_ENABLE_SETCAP=1` to opt in to a `sudo -n setcap` attempt (backed by a passwordless +sudoers rule scoped to the `setcap` binary, e.g. ` ALL=(root) NOPASSWD: /usr/sbin/setcap`, +with no trailing arguments pinned — the target path is a fresh `tmp_path` on every run). Without +it, these tests skip with a message identifying the missing capability grant. + +```sh +export FIT_ENABLE_SETCAP=1 + +python3 -m pytest feature_integration_tests/test_cases/tests/lifecycle/ \ + --build-scenarios \ + -m cpp \ + -k "uid_gid or scheduling" \ + -q -v +``` + +Under `bazel test`, undeclared env vars like `FIT_ENABLE_SETCAP` do not reach the test process +unless passed via `--test_env` (not `--action_env`, which only affects build actions): + +```sh +bazel test --config=linux-x86_64 //feature_integration_tests/test_cases:fit_cpp \ + --test_env=FIT_ENABLE_SETCAP=1 +``` + +`bazel run` inherits the invoking shell's environment directly, so exporting the variable +beforehand is sufficient there. + ### ITF Tests (QEMU-based) ITF tests run on a QEMU target and require the `itf-qnx-x86_64` config: diff --git a/feature_integration_tests/test_cases/BUILD b/feature_integration_tests/test_cases/BUILD index 5219ecc95e2..b8a8b67148b 100644 --- a/feature_integration_tests/test_cases/BUILD +++ b/feature_integration_tests/test_cases/BUILD @@ -112,6 +112,7 @@ score_py_pytest( "FIT_RUST_SUPERVISED_APP_PATH": "$(rootpath @score_lifecycle_health//examples/rust_supervised_app)", "RUST_BACKTRACE": "1", }, + env_inherit = ["FIT_ENABLE_SETCAP"], pytest_config = "//:pyproject.toml", # Lifecycle tests drive a shared launch_manager daemon and cannot run concurrently with each other. tags = ["exclusive"], @@ -176,6 +177,7 @@ score_py_pytest( "FIT_LIFECYCLE_DAEMON_CONFIG_PATH": "$(rootpath //feature_integration_tests/configs:lifecycle_daemon_config)", "FIT_RUST_SUPERVISED_APP_PATH": "$(rootpath @score_lifecycle_health//examples/rust_supervised_app)", }, + env_inherit = ["FIT_ENABLE_SETCAP"], pytest_config = "//:pyproject.toml", # Lifecycle tests drive a shared launch_manager daemon and cannot run concurrently with each other. tags = ["exclusive"], diff --git a/feature_integration_tests/test_cases/conftest.py b/feature_integration_tests/test_cases/conftest.py index 803f253f9de..dd828aed439 100644 --- a/feature_integration_tests/test_cases/conftest.py +++ b/feature_integration_tests/test_cases/conftest.py @@ -77,6 +77,18 @@ def _selected_rust_target_name(session: pytest.Session) -> str: return rust_target_name +# [tool.pytest] in pyproject.toml is not read by pytest (it only honors +# [tool.pytest.ini_options]), so the marker declarations there are silently ignored. +# Register them here instead to suppress PytestUnknownMarkWarning. +def pytest_configure(config: pytest.Config) -> None: + config.addinivalue_line("markers", "metadata") + config.addinivalue_line("markers", "test_properties(dict): Add custom properties to test XML output") + config.addinivalue_line("markers", "cpp") + config.addinivalue_line("markers", "rust") + config.addinivalue_line("markers", "daemon") + config.addinivalue_line("markers", "manual") + + # Cmdline options def pytest_addoption(parser): parser.addoption( diff --git a/feature_integration_tests/test_cases/daemon_helpers.py b/feature_integration_tests/test_cases/daemon_helpers.py index 975867e2634..49c685c6bed 100644 --- a/feature_integration_tests/test_cases/daemon_helpers.py +++ b/feature_integration_tests/test_cases/daemon_helpers.py @@ -126,29 +126,97 @@ def _is_running(binary_path: Path) -> bool: _SETCAP_CAPS = "cap_setuid,cap_setgid,cap_sys_nice+ep" -def _grant_sandbox_capabilities(binary_path: Path) -> bool: +def _mount_nosuid(path: Path) -> bool: + """Best-effort check whether `path` lives on a filesystem mounted `nosuid`. + + A `nosuid` mount silently strips file capabilities at exec time even when `setcap` + itself reports success, which otherwise looks identical to "grant never happened" + from the caller's point of view. + """ + try: + findmnt = shutil.which("findmnt") + if findmnt is None: + return False + result = subprocess.run( + [findmnt, "-n", "-o", "OPTIONS", "-T", str(path)], + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 and "nosuid" in result.stdout + except OSError: + return False + + +def _grant_sandbox_capabilities(binary_path: Path) -> tuple[bool, str]: """Best-effort grant of the capabilities launch_manager needs to apply sandbox uid/gid - and scheduling policy without running as root. Returns whether the grant succeeded, so - tests can key off a real, established precondition instead of assuming root. + and scheduling policy without running as root. Returns `(granted, reason)`: `granted` + is a *verified* result (re-read via `getcap`, not just the setcap exit code) so tests + can key off a real, established precondition instead of assuming root; `reason` is a + human-readable diagnostic that is safe to surface directly in a pytest.skip() message. Requires CAP_SETFCAP to write the capability xattr, which a non-root test runner does not have by default. Set FIT_ENABLE_SETCAP=1 to opt into a `sudo -n setcap` attempt, backed by a passwordless sudoers rule scoped to the setcap binary (e.g. ` ALL=(root) NOPASSWD: - /usr/sbin/setcap`). Without the flag, only a plain (non-sudo) setcap is tried, which only - succeeds if the runner is already root. + /usr/sbin/setcap`, with NO trailing arguments pinned — the target path is a fresh tmp_path + on every test run, so a rule that also pins the argument list will never match). Without + the flag, only a plain (non-sudo) setcap is tried, which only succeeds if the runner is + already root. + + Under `bazel test`, undeclared env vars (like FIT_ENABLE_SETCAP) do not reach the test + process unless passed via `--test_env=FIT_ENABLE_SETCAP=1` (NOT `--action_env`, which only + affects build actions). `bazel run` inherits the invoking shell's environment directly, so + `--action_env` is a no-op for this variable there; it is only needed to force a rebuild + when it affects action inputs, which it does not here. """ if shutil.which("setcap") is None: - return False - - commands = [["setcap", _SETCAP_CAPS, str(binary_path)]] - if os.environ.get("FIT_ENABLE_SETCAP") == "1" and shutil.which("sudo") is not None: - commands.insert(0, ["sudo", "-n", "setcap", _SETCAP_CAPS, str(binary_path)]) + return False, "setcap binary not found on PATH" + + setcap_enabled = os.environ.get("FIT_ENABLE_SETCAP") == "1" + attempts: list[tuple[list[str], str]] = [ + (["setcap", _SETCAP_CAPS, str(binary_path)], "plain setcap (requires running as root)") + ] + if setcap_enabled: + if shutil.which("sudo") is None: + attempts.append(([], "FIT_ENABLE_SETCAP=1 set but 'sudo' not found on PATH")) + else: + attempts.insert( + 0, + (["sudo", "-n", "setcap", _SETCAP_CAPS, str(binary_path)], "sudo -n setcap"), + ) + else: + attempts.append(([], "FIT_ENABLE_SETCAP not set to '1'; skipping sudo setcap attempt")) - for cmd in commands: + failures: list[str] = [] + for cmd, label in attempts: + if not cmd: + failures.append(label) + continue result = subprocess.run(cmd, capture_output=True, text=True, check=False) - if result.returncode == 0: - return True - return False + if result.returncode != 0: + failures.append( + f"{label} failed (rc={result.returncode}): " + f"{result.stderr.strip() or result.stdout.strip() or ''}" + ) + continue + + # setcap can report success while the kernel still drops the capability at exec + # time (e.g. the binary lives on a filesystem mounted `nosuid`). Verify by reading + # the xattr back instead of trusting the exit code. + getcap = shutil.which("getcap") + if getcap is not None: + verify = subprocess.run([getcap, str(binary_path)], capture_output=True, text=True, check=False) + if "cap_setuid" not in verify.stdout or "cap_setgid" not in verify.stdout: + nosuid_hint = " (path is on a 'nosuid' mount)" if _mount_nosuid(binary_path) else "" + failures.append( + f"{label} reported success but getcap did not confirm the capabilities" + f"{nosuid_hint}: {verify.stdout.strip() or ''}" + ) + continue + + return True, f"granted via {label}" + + return False, "; ".join(failures) if failures else "no grant attempt produced a result" def _wait_for_apps(apps: dict[str, Path], timeout_s: float = 8.0, interval_s: float = 0.2) -> bool: @@ -189,9 +257,24 @@ def get_logs(self) -> str: return "\n".join(self._lines) -@pytest.fixture(scope="class") -def launch_manager_daemon(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Any]: - """Start a real launch_manager process with generated flatbuffer config.""" +def start_launch_manager_daemon( + tmp_path_factory: pytest.TempPathFactory, + blocked_apps: frozenset[str] = frozenset(), + wait_for_apps: bool = True, +) -> dict[str, Any]: + """Start a real launch_manager process with generated flatbuffer config. + + `blocked_apps` names ("rust"/"cpp") are copied into place but left + non-executable, so launch_manager cannot start them until the caller + chmod's them back to 0o755. Used to exercise the dependency-gating + negative path: assert the dependent app stays down while its + dependency is withheld, then unblock and assert it starts. + + Relies on tags = ["exclusive"] on the bazel test targets that use this: + only one lifecycle daemon (sharing the runtime_root/lock below) may run + on the machine at a time, so it is safe for a test to manage its own + instance here rather than the shared `launch_manager_daemon` fixture. + """ lock_file = Path("/tmp/lifecycle_fit.lock").open("w", encoding="utf-8") fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) @@ -215,12 +298,12 @@ def launch_manager_daemon(tmp_path_factory: pytest.TempPathFactory) -> dict[str, lm_dst = work_dir / "launch_manager" shutil.copy2(launch_manager, lm_dst) lm_dst.chmod(0o755) - sandbox_privileged = _grant_sandbox_capabilities(lm_dst) + sandbox_privileged, sandbox_privileged_reason = _grant_sandbox_capabilities(lm_dst) - for src in (rust_supervised, cpp_supervised): + for key, src in (("rust", rust_supervised), ("cpp", cpp_supervised)): dst = bin_dir / src.name shutil.copy2(src, dst) - dst.chmod(0o755) + dst.chmod(0o000 if key in blocked_apps else 0o755) if config_artifact.is_dir(): for item in config_artifact.iterdir(): @@ -262,13 +345,15 @@ def _collect_output() -> None: time.sleep(1.0) if not daemon.is_running(): logs = daemon.get_logs() + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + lock_file.close() pytest.skip(f"launch_manager failed to start in this environment. Logs:\n{logs}") apps = { "rust": bin_dir / "rust_supervised_app", "cpp": bin_dir / "cpp_supervised_app", } - if not _wait_for_apps(apps): + if wait_for_apps and not _wait_for_apps({k: v for k, v in apps.items() if k not in blocked_apps}): process_snapshot = _run(["ps", "-eo", "pid,args"]) daemon.stop() shutil.rmtree(runtime_root, ignore_errors=True) @@ -281,16 +366,32 @@ def _collect_output() -> None: f"Process snapshot:\n{process_snapshot}" ) + return { + "daemon": daemon, + "work_dir": work_dir, + "bin_dir": bin_dir, + "apps": apps, + "sandbox_privileged": sandbox_privileged, + "sandbox_privileged_reason": sandbox_privileged_reason, + "runtime_root": runtime_root, + "lock_file": lock_file, + } + + +def stop_launch_manager_daemon(daemon_info: dict[str, Any]) -> None: + """Tear down a daemon started by `start_launch_manager_daemon`.""" + daemon_info["daemon"].stop() + shutil.rmtree(daemon_info["runtime_root"], ignore_errors=True) + lock_file = daemon_info["lock_file"] + fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) + lock_file.close() + + +@pytest.fixture(scope="class") +def launch_manager_daemon(tmp_path_factory: pytest.TempPathFactory) -> dict[str, Any]: + """Start a real launch_manager process with generated flatbuffer config.""" + daemon_info = start_launch_manager_daemon(tmp_path_factory) try: - yield { - "daemon": daemon, - "work_dir": work_dir, - "bin_dir": bin_dir, - "apps": apps, - "sandbox_privileged": sandbox_privileged, - } + yield daemon_info finally: - daemon.stop() - shutil.rmtree(runtime_root, ignore_errors=True) - fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) - lock_file.close() + stop_launch_manager_daemon(daemon_info) diff --git a/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.py b/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.py index 292de8733a8..5a757e53ec7 100644 --- a/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.py +++ b/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.py @@ -25,7 +25,7 @@ from typing import Any import pytest -from daemon_helpers import launch_manager_daemon +from daemon_helpers import launch_manager_daemon, start_launch_manager_daemon, stop_launch_manager_daemon from test_properties import add_test_properties pytestmark = [ @@ -34,17 +34,6 @@ ] -@add_test_properties( - partially_verifies=[ - "feat_req__lifecycle__launch_support", - "feat_req__lifecycle__waitfor_support", - "feat_req__lifecycle__cond_process_start", - "feat_req__lifecycle__dependency_check", - "feat_req__lifecycle__process_ordering", - ], - test_type="integration", - derivation_technique="end-to-end-testing", -) class TestConditionalLaunchingWithDaemon: """Verify dependency-based conditional launching with real daemon behavior.""" @@ -98,6 +87,11 @@ def _wait_until(predicate, timeout_s: float, interval_s: float = 0.2) -> bool: time.sleep(interval_s) return False + @add_test_properties( + partially_verifies=["feat_req__lifecycle__launch_support"], + test_type="integration", + derivation_technique="end-to-end-testing", + ) def test_startup_launches_conditioned_processes(self, launch_manager_daemon: dict[str, Any], version: str) -> None: """Verify supervised processes are launched as part of conditional startup.""" daemon_info = launch_manager_daemon @@ -107,6 +101,14 @@ def test_startup_launches_conditioned_processes(self, launch_manager_daemon: dic started = self._wait_until(lambda: self._is_running(app_path), timeout_s=8.0) assert started, f"{app_name} was not launched in conditional startup" + @add_test_properties( + partially_verifies=[ + "feat_req__lifecycle__process_ordering", + "feat_req__lifecycle__cond_process_start", + ], + test_type="integration", + derivation_technique="end-to-end-testing", + ) def test_rust_launch_is_conditioned_on_cpp_dependency( self, launch_manager_daemon: dict[str, Any], @@ -137,28 +139,11 @@ def test_rust_launch_is_conditioned_on_cpp_dependency( f"(cpp_start={cpp_start}, rust_start={rust_start})" ) - def test_rust_never_runs_without_cpp_running( - self, - launch_manager_daemon: dict[str, Any], - version: str, - ) -> None: - """Verify conditional gating keeps rust app from running before cpp is active.""" - daemon_info = launch_manager_daemon - cpp_path = str(daemon_info["apps"]["cpp"]) - rust_path = str(daemon_info["apps"]["rust"]) - - deadline = time.time() + 8.0 - while time.time() < deadline: - rust_running = self._is_running(rust_path) - cpp_running = self._is_running(cpp_path) - if rust_running and not cpp_running: - pytest.fail("rust_supervised_app became running before cpp_supervised_app was active") - if rust_running and cpp_running: - return - time.sleep(0.2) - - assert False, "Timed out waiting for rust_supervised_app to reach running state" - + @add_test_properties( + partially_verifies=["feat_req__lifecycle__dependency_check"], + test_type="integration", + derivation_technique="end-to-end-testing", + ) def test_dependency_is_declared_in_lifecycle_config( self, launch_manager_daemon: dict[str, Any], @@ -173,3 +158,80 @@ def test_dependency_is_declared_in_lifecycle_config( assert "cpp_supervised_app" in depends_on, ( "Expected rust_supervised_app to depend on cpp_supervised_app in lifecycle daemon config" ) + + +@pytest.mark.daemon +class TestConditionalLaunchingBlocksOnMissingDependency: + """Verify rust startup is actually gated on cpp, not merely correlated with it. + + Runs its own launch_manager instance (rather than the shared class-scoped + `launch_manager_daemon` fixture) with cpp_supervised_app withheld, so it can + observe the negative case: rust must not start while its dependency cannot. + Safe to run its own daemon here because the bazel targets for these tests + are tagged tags = ["exclusive"], guaranteeing no other lifecycle daemon is + using the shared runtime_root/lock at the same time. + """ + + @staticmethod + def _pgrep_cmdline_pattern(binary_path: str) -> str: + return rf"^{re.escape(binary_path)}([[:space:]]|$)" + + @classmethod + def _is_running(cls, binary_path: str) -> bool: + result = subprocess.run( + ["pgrep", "-f", cls._pgrep_cmdline_pattern(binary_path)], + capture_output=True, + text=True, + check=False, + ) + return result.returncode == 0 + + @staticmethod + def _wait_until(predicate, timeout_s: float, interval_s: float = 0.2) -> bool: + deadline = time.time() + timeout_s + while time.time() < deadline: + if predicate(): + return True + time.sleep(interval_s) + return False + + @add_test_properties( + partially_verifies=[ + "feat_req__lifecycle__waitfor_support", + "feat_req__lifecycle__dependency_check", + "feat_req__lifecycle__cond_process_start", + ], + test_type="integration", + derivation_technique="end-to-end-testing", + ) + def test_rust_stays_down_until_cpp_dependency_becomes_available( + self, tmp_path_factory: pytest.TempPathFactory, version: str + ) -> None: + """Verify rust does not start while cpp is withheld, and does once cpp is unblocked.""" + if version != "rust": + pytest.skip("dependency-gating check is independent of the 'version' axis; runs once") + + daemon_info = start_launch_manager_daemon( + tmp_path_factory, + blocked_apps=frozenset({"cpp"}), + wait_for_apps=False, + ) + try: + cpp_path = daemon_info["apps"]["cpp"] + rust_path = str(daemon_info["apps"]["rust"]) + + # cpp cannot execute (mode 0o000): rust must not appear while it's withheld. + rust_started_early = self._wait_until(lambda: self._is_running(rust_path), timeout_s=4.0) + assert not rust_started_early, ( + "rust_supervised_app started even though its cpp_supervised_app dependency " + "was withheld (non-executable); dependency gating was not enforced" + ) + + # Unblock cpp: rust should now be allowed to start. + cpp_path.chmod(0o755) + rust_started = self._wait_until(lambda: self._is_running(rust_path), timeout_s=8.0) + assert rust_started, ( + "rust_supervised_app did not start after its cpp_supervised_app dependency became available" + ) + finally: + stop_launch_manager_daemon(daemon_info) diff --git a/feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py b/feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py index 0379b162886..6b99cb15353 100644 --- a/feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py +++ b/feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py @@ -149,9 +149,10 @@ def _proc_sched_policy_and_priority(pid: str) -> tuple[str, int] | None: priority = None for line in result.stdout.splitlines(): lower = line.lower().strip() - if lower.startswith("scheduling policy"): + # e.g. "pid 400132's current scheduling policy: SCHED_OTHER" + if "scheduling policy" in lower: policy = line.split(":", 1)[1].strip() - elif lower.startswith("scheduling priority"): + elif "scheduling priority" in lower: try: priority = int(line.split(":", 1)[1].strip()) except ValueError: @@ -338,8 +339,8 @@ def test_launched_process_uid_gid_matches_config_when_applied( daemon_info = launch_manager_daemon if not daemon_info["sandbox_privileged"]: pytest.skip( - "launch_manager was not granted cap_setuid/cap_setgid in this environment " - "(see daemon_helpers._grant_sandbox_capabilities); sandbox uid/gid cannot be applied" + "launch_manager was not granted cap_setuid/cap_setgid in this environment; " + f"sandbox uid/gid cannot be applied. Reason: {daemon_info['sandbox_privileged_reason']}" ) app_name = "rust_supervised_app" if version == "rust" else "cpp_supervised_app" @@ -384,8 +385,8 @@ def test_launched_process_scheduling_matches_config_when_applied( daemon_info = launch_manager_daemon if not daemon_info["sandbox_privileged"]: pytest.skip( - "launch_manager was not granted cap_sys_nice in this environment " - "(see daemon_helpers._grant_sandbox_capabilities); scheduling policy cannot be applied" + "launch_manager was not granted cap_sys_nice in this environment; " + f"scheduling policy cannot be applied. Reason: {daemon_info['sandbox_privileged_reason']}" ) app_name = "rust_supervised_app" if version == "rust" else "cpp_supervised_app" @@ -400,13 +401,25 @@ def test_launched_process_scheduling_matches_config_when_applied( started = self._wait_until(lambda: self._is_running(app_path), timeout_s=8.0) assert started, f"{app_name} was not launched before scheduling verification" - pid = self._first_pid(app_path) + # Supervised apps can exit/restart between pid resolution and the chrt subprocess + # call, so a single pid snapshot can go stale (ESRCH) before chrt reads it. Retry + # against a freshly resolved pid instead of failing on that race. + sched = None + pid = None + for _ in range(20): + pid = self._first_pid(app_path) + if pid is None: + time.sleep(0.1) + continue + sched = self._proc_sched_policy_and_priority(pid) + if sched is not None: + break + time.sleep(0.1) assert pid is not None, f"Could not resolve PID for {app_name}" - sched = self._proc_sched_policy_and_priority(pid) assert sched is not None, f"Could not read scheduling metadata via chrt for {app_name} pid={pid}" policy, rt_priority = sched - expected_policy = configured_policy.removeprefix("SCHED_").upper() + expected_policy = configured_policy.upper() assert policy.upper() == expected_policy, ( f"Scheduling policy mismatch for {app_name}: expected {expected_policy}, got {policy}" )