From d1cddfa8e4eeee1c203dd58935af190413329c63 Mon Sep 17 00:00:00 2001 From: Saumya-R Date: Thu, 16 Jul 2026 22:44:46 +0530 Subject: [PATCH] added test cases for conditional launching Copyright fixes added test case to reject unknown conditions fixed the readme added launch manager processes adding additional test cases for lifecycle removing the changes in pyproject and requirements txt 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 Adding review comments added the following fixes : - The scenario now actually polls and checks each condition instead of printing it - Replaced plain std::cout text with the same structured JSON log shape the Rust tracing subscriber emits - TestConditionalLaunchingScenario now creates a real flag file, sets a real env var, and spawns a real sleep process - Added TestConditionalLaunchingScenarioTimesOutOnUnmetConditions, a negative test where none of the conditions are ever satisfied - removed test_startup_launches_supervised_apps and test_dependency_gates_rust_startup from test_process_launching_with_daemon.py - TestConditionalLaunchingBlocksOnMissingDependency spins up its own launch_manager with cpp withheld to prove real gating added TestConditionalLaunchingScenarioRejectsUnsupportedPrefix --- feature_integration_tests/README.md | 61 +++ feature_integration_tests/configs/BUILD | 10 + .../configs/lifecycle_daemon_config.json | 103 ++++ feature_integration_tests/test_cases/BUILD | 124 ++++- .../test_cases/conftest.py | 94 +++- .../test_cases/daemon_helpers.py | 397 ++++++++++++++ .../test_cases/lifecycle_scenario.py | 50 ++ .../lifecycle/test_conditional_launching.py | 237 +++++++++ .../test_conditional_launching_scenario.py | 226 ++++++++ .../test_process_launching_with_daemon.py | 498 ++++++++++++++++++ .../lifecycle/conditional_launching.cpp | 280 ++++++++++ .../lifecycle/conditional_launching.h | 17 + .../test_scenarios/cpp/src/scenarios/mod.cpp | 13 +- .../test_scenarios/rust/BUILD | 25 + .../test_scenarios/rust/src/main.rs | 28 + .../lifecycle/conditional_launching.rs | 155 ++++++ .../rust/src/scenarios/lifecycle/mod.rs | 25 + .../test_scenarios/rust/src/scenarios/mod.rs | 4 +- pyproject.toml | 2 + 19 files changed, 2334 insertions(+), 15 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/lifecycle_scenario.py create mode 100644 feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.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 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..f74c737a635 100644 --- a/feature_integration_tests/README.md +++ b/feature_integration_tests/README.md @@ -36,6 +36,66 @@ bazel test //feature_integration_tests/test_cases:fit_rust bazel test --config=linux-x86_64 //feature_integration_tests/test_cases:fit_cpp ``` +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. + +#### 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: @@ -50,6 +110,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/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 dcf8ab0542b..b8a8b67148b 100644 --- a/feature_integration_tests/test_cases/BUILD +++ b/feature_integration_tests/test_cases/BUILD @@ -35,9 +35,11 @@ compile_pip_requirements( ) # Tests targets +# score_py_pytest( - name = "fit_rust", - srcs = glob(["tests/**/*.py"]), + name = "fit_rust_orch", + timeout = "long", + srcs = glob(["tests/basic/**/*.py"]), args = [ "-m rust", "--traces=all", @@ -58,8 +60,78 @@ score_py_pytest( ) score_py_pytest( - name = "fit_cpp", - srcs = glob(["tests/**/*.py"]), + name = "fit_rust_persistency", + timeout = "long", + srcs = glob(["tests/persistency/**/*.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_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", + "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", + }, + 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"], + deps = all_requirements, +) + +test_suite( + name = "fit_rust", + tests = [ + ":fit_rust_lifecycle", + ":fit_rust_orch", + ":fit_rust_persistency", + ], +) + +score_py_pytest( + name = "fit_cpp_persistency", + timeout = "long", + srcs = glob(["tests/persistency/**/*.py"]), args = [ "-m cpp", "--traces=all", @@ -76,6 +148,50 @@ score_py_pytest( deps = all_requirements, ) +score_py_pytest( + name = "fit_cpp_lifecycle", + timeout = "long", + srcs = glob(["tests/lifecycle/**/*.py"]), + args = [ + "-m cpp", + "--traces=all", + "--cpp-target-path=$(rootpath //feature_integration_tests/test_scenarios/cpp:cpp_test_scenarios)", + ], + data = [ + "conftest.py", + "daemon_helpers.py", + "fit_scenario.py", + "lifecycle_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)", + }, + 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"], + 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/conftest.py b/feature_integration_tests/test_cases/conftest.py index 662b7210943..dd828aed439 100644 --- a/feature_integration_tests/test_cases/conftest.py +++ b/feature_integration_tests/test_cases/conftest.py @@ -13,9 +13,82 @@ 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. + + 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 "" + 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 + + +# [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( @@ -31,7 +104,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( @@ -88,18 +161,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 = _selected_rust_target_name(session) + 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/daemon_helpers.py b/feature_integration_tests/test_cases/daemon_helpers.py new file mode 100644 index 00000000000..49c685c6bed --- /dev/null +++ b/feature_integration_tests/test_cases/daemon_helpers.py @@ -0,0 +1,397 @@ +# ******************************************************************************* +# 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 + + +_SETCAP_CAPS = "cap_setuid,cap_setgid,cap_sys_nice+ep" + + +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 `(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`, 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, "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")) + + 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: + 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: + 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) + + +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) + + 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) + sandbox_privileged, sandbox_privileged_reason = _grant_sandbox_capabilities(lm_dst) + + for key, src in (("rust", rust_supervised), ("cpp", cpp_supervised)): + dst = bin_dir / src.name + shutil.copy2(src, dst) + dst.chmod(0o000 if key in blocked_apps else 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() + 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 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) + 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}" + ) + + 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_info + finally: + stop_launch_manager_daemon(daemon_info) 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..5a757e53ec7 --- /dev/null +++ b/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching.py @@ -0,0 +1,237 @@ +# ******************************************************************************* +# 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 against a real Launch Manager. + +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 daemon_helpers import launch_manager_daemon, start_launch_manager_daemon, stop_launch_manager_daemon +from test_properties import add_test_properties + +pytestmark = [ + pytest.mark.daemon, + pytest.mark.parametrize("version", ["rust", "cpp"], scope="class"), +] + + +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 + + @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 + 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" + + @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], + version: str, + ) -> None: + """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})" + ) + + @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], + version: str, + ) -> None: + """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" + ) + + +@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_conditional_launching_scenario.py b/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching_scenario.py new file mode 100644 index 00000000000..f42033c807e --- /dev/null +++ b/feature_integration_tests/test_cases/tests/lifecycle/test_conditional_launching_scenario.py @@ -0,0 +1,226 @@ +# ******************************************************************************* +# 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. + +Unlike scenario logging smoke tests, these exercise the scenario binary's actual +wait-condition evaluation: preconditions (a path, an env var, a running process) are +really established or really withheld, so the assertions verify that the scenario +observes and enforces them, not merely that it echoes back what was configured. +""" + +import os +import subprocess +from collections.abc import Generator +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 ScenarioResult + +pytestmark = [pytest.mark.parametrize("version", ["rust", "cpp"], scope="class")] + +_CONDITION_ENV_VAR = "LM_CONDITION_READY" +_CONDITION_PROCESS_NAME = "sleep" + + +@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): + """Verify the scenario actually waits for and detects satisfied conditions.""" + + @pytest.fixture(scope="class") + def scenario_name(self) -> str: + return "lifecycle.conditional_launching" + + @pytest.fixture(scope="class") + def flag_path(self, temp_dir: Path) -> Path: + return temp_dir / "lifecycle_launch_ready.flag" + + @pytest.fixture(scope="class", autouse=True) + def satisfied_preconditions(self, flag_path: Path) -> Generator[None, None, None]: + """Really establish the preconditions the scenario is told to wait for. + + The flag file is created up front (path condition already met), the env var is + set in this process (inherited by the scenario subprocess), and a real `sleep` + process is kept alive for the duration of the scenario run (process condition). + Torn down afterwards so this class does not leak state into later tests. + """ + flag_path.write_text("ready", encoding="utf-8") + os.environ[_CONDITION_ENV_VAR] = "1" + process = subprocess.Popen([_CONDITION_PROCESS_NAME, "30"]) + try: + yield + finally: + process.kill() + process.wait() + del os.environ[_CONDITION_ENV_VAR] + flag_path.unlink(missing_ok=True) + + @pytest.fixture(scope="class") + def test_config(self, flag_path: Path, satisfied_preconditions: None) -> dict[str, Any]: + # Depends on `satisfied_preconditions` explicitly (rather than relying on autouse + # ordering) so preconditions are guaranteed established before `results` executes. + return { + "test": { + "wait_conditions": [ + f"path:{flag_path}", + f"env:{_CONDITION_ENV_VAR}", + f"process:{_CONDITION_PROCESS_NAME}", + ], + "polling_interval_ms": 50, + "timeout_ms": 2000, + }, + } + + def test_conditions_already_satisfied_allow_immediate_success( + self, + results: ScenarioResult, + version: str, + ) -> None: + """Verify the scenario succeeds once path/env/process conditions are all really met.""" + assert results.return_code == ResultCode.SUCCESS, ( + f"Expected success with satisfied preconditions, got: {results}" + ) + + def test_each_condition_is_individually_confirmed_satisfied( + self, + logs_info_level: Any, + flag_path: Path, + version: str, + ) -> None: + """Verify the scenario reports each condition as satisfied, not just configured.""" + expected_messages = [ + f"Condition satisfied: path:{flag_path}", + f"Condition satisfied: env:{_CONDITION_ENV_VAR}", + f"Condition satisfied: process:{_CONDITION_PROCESS_NAME}", + "All dependencies satisfied", + ] + for expected in expected_messages: + log = logs_info_level.find_log("message", value=expected) + assert log is not None, f"Expected scenario to log: {expected}" + + def test_timeout_and_polling_interval_are_honored( + self, + logs_info_level: Any, + version: str, + ) -> None: + """Verify the scenario logs the configured wait timing values.""" + assert logs_info_level.find_log("message", value="Polling interval: 50ms") is not None + assert logs_info_level.find_log("message", value="Condition timeout: 2000ms") is not None + + +@add_test_properties( + partially_verifies=[ + "feat_req__lifecycle__total_wait_time_support", + "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 TestConditionalLaunchingScenarioTimesOutOnUnmetConditions(LifecycleScenario): + """Verify the scenario fails when its wait conditions are never satisfied. + + Without this, an implementation that always reports success regardless of whether + a path exists, an env var is set, or a process is running would still pass the + happy-path test above. + """ + + @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]: + missing_path = temp_dir / "never_created.flag" + return { + "test": { + "wait_conditions": [ + f"path:{missing_path}", + "env:LM_CONDITION_NEVER_SET", + "process:process_that_does_not_exist_anywhere", + ], + "polling_interval_ms": 20, + "timeout_ms": 200, + }, + } + + def expect_command_failure(self) -> bool: + return True + + def capture_stderr(self) -> bool: + return True + + def test_scenario_fails_when_conditions_stay_unmet(self, results: ScenarioResult, version: str) -> None: + """Verify the scenario reports failure - and specifically a wait-condition timeout, + not merely any nonzero exit - when conditions are never satisfied.""" + assert results.return_code != ResultCode.SUCCESS, ( + f"Expected failure when wait conditions are never satisfied, got: {results}" + ) + assert results.stderr is not None + assert "Timed out" in results.stderr and "condition" in results.stderr, ( + f"Expected a wait-condition timeout error on stderr, got: {results.stderr}" + ) + + +@add_test_properties( + partially_verifies=["feat_req__lifecycle__validate_conditions"], + test_type="requirements-based", + derivation_technique="requirements-analysis", +) +class TestConditionalLaunchingScenarioRejectsUnsupportedPrefix(LifecycleScenario): + """Verify an unsupported wait-condition prefix is rejected as invalid configuration, + distinct from a legitimate condition that simply times out unmet.""" + + @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": ["badprefix:value"], + "polling_interval_ms": 20, + "timeout_ms": 200, + }, + } + + def expect_command_failure(self) -> bool: + return True + + def capture_stderr(self) -> bool: + return True + + def test_unsupported_prefix_is_rejected_immediately(self, results: ScenarioResult, version: str) -> None: + """Verify validation rejects the condition outright rather than waiting out the timeout.""" + assert results.return_code != ResultCode.SUCCESS, ( + f"Expected failure for an unsupported wait-condition prefix, got: {results}" + ) + assert results.stderr is not None + assert "Unsupported wait condition prefix" in results.stderr, ( + f"Expected an unsupported-prefix validation error on stderr, got: {results.stderr}" + ) 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..7f289bdb70c --- /dev/null +++ b/feature_integration_tests/test_cases/tests/lifecycle/test_process_launching_with_daemon.py @@ -0,0 +1,498 @@ +# ******************************************************************************* +# 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 +""" + +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 +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_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() + # e.g. "pid 400132's current scheduling policy: SCHED_OTHER" + if "scheduling policy" in lower: + policy = line.split(":", 1)[1].strip() + elif "scheduling priority" in lower: + 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 + + # NOTE: launch-order/dependency-gating coverage for rust-on-cpp startup lives in + # test_conditional_launching.py (that is the feature this repo dedicates to conditional + # launching); duplicating it here as test_startup_launches_supervised_apps / + # test_dependency_gates_rust_startup was removed per review feedback. + + @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], + 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 + daemon = daemon_info["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" + assert daemon.is_running(), "Launch Manager daemon stopped unexpectedly" + + @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], + 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')}" + ) + + @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: + """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" + + @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], + version: str, + ) -> 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; " + 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" + 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}" + ) + + @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], + version: str, + ) -> 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; " + f"scheduling policy cannot be applied. Reason: {daemon_info['sandbox_privileged_reason']}" + ) + + 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" + + # 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}" + 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.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}" + ) + + @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], + 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" + + @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. + + 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 new file mode 100644 index 00000000000..01e6235e80f --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.cpp @@ -0,0 +1,280 @@ +/******************************************************************************** + * 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 +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +constexpr const char* kTarget = "cpp_test_scenarios::scenarios::lifecycle::conditional_launching"; + +std::string unix_seconds_string() { + const auto now = std::chrono::system_clock::now(); + const auto secs = std::chrono::duration_cast(now.time_since_epoch()).count(); + return std::to_string(secs); +} + +// Emits the same structured JSON shape the Rust `tracing` subscriber produces, so FIT's +// LogContainer (which filters on the "target"/"level" fields) can find these messages instead +// of falling back to a raw stdout substring match. +void log_info(const std::string& message) { + std::cout << "{\"timestamp\":\"" << unix_seconds_string() << "\",\"level\":\"INFO\",\"fields\":{\"message\":\"" + << message << "\"},\"target\":\"" << kTarget << "\",\"threadId\":\"ThreadId(1)\"}" << std::endl; +} + +bool path_condition_met(const std::string& path) { + std::error_code ec; + return std::filesystem::exists(path, ec) && !ec; +} + +bool env_condition_met(const std::string& name) { + return std::getenv(name.c_str()) != nullptr; +} + +// Best-effort check whether a process matching `process_name` is currently running, by scanning +// /proc//comm (the kernel-truncated 15-char command name) and /proc//cmdline (the full +// argv[0], which covers names comm truncates). +bool process_condition_met(const std::string& process_name) { + // Iterating /proc races with processes exiting mid-scan (ENOENT on a just-vanished pid's + // subdirectory); std::filesystem surfaces that as filesystem_error even with the + // non-throwing error_code constructor, since only construction/increment on the top-level + // directory is covered, not opening files underneath. Treat it as "not found this pass" + // rather than letting a race abort the whole wait loop. + try { + std::error_code ec; + for (const auto& entry : std::filesystem::directory_iterator( + "/proc", std::filesystem::directory_options::skip_permission_denied, ec)) { + const std::string pid = entry.path().filename().string(); + if (pid.empty() || + !std::all_of(pid.begin(), pid.end(), [](unsigned char c) { return std::isdigit(c); })) { + continue; + } + + std::ifstream comm(entry.path() / "comm"); + std::string comm_value; + if (comm && std::getline(comm, comm_value) && comm_value == process_name) { + return true; + } + + std::ifstream cmdline(entry.path() / "cmdline"); + std::stringstream cmdline_buffer; + cmdline_buffer << cmdline.rdbuf(); + const std::string argv0 = cmdline_buffer.str(); + if (!argv0.empty()) { + const auto argv0_end = argv0.find('\0'); + const std::string first_arg = argv0.substr(0, argv0_end); + if (first_arg.size() >= process_name.size() && + first_arg.compare(first_arg.size() - process_name.size(), process_name.size(), process_name) == + 0) { + return true; + } + } + } + } catch (const std::filesystem::filesystem_error&) { + return false; + } + return false; +} + +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()) { + throw std::invalid_argument("Wait condition entries must be strings"); + } + 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 or empty 'test.wait_conditions' in scenario input"); + } + + log_info("Testing conditional launching"); + + for (const auto& condition : wait_conditions) { + if (condition.rfind("path:", 0) != 0U && condition.rfind("env:", 0) != 0U && + condition.rfind("process:", 0) != 0U) { + throw std::runtime_error("Unsupported wait condition prefix: " + condition); + } + } + + log_info("Polling interval: " + std::to_string(polling_interval) + "ms"); + log_info("Condition timeout: " + std::to_string(timeout) + "ms"); + + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout); + std::vector satisfied(wait_conditions.size(), false); + + while (true) { + bool all_satisfied = true; + for (std::size_t i = 0; i < wait_conditions.size(); ++i) { + if (satisfied[i]) { + continue; + } + const auto& condition = wait_conditions[i]; + bool met = false; + if (condition.rfind("path:", 0) == 0U) { + met = path_condition_met(condition.substr(5)); + } else if (condition.rfind("env:", 0) == 0U) { + met = env_condition_met(condition.substr(4)); + } else { + met = process_condition_met(condition.substr(8)); + } + + if (met) { + satisfied[i] = true; + log_info("Condition satisfied: " + condition); + } else { + all_satisfied = false; + } + } + + if (all_satisfied) { + break; + } + + if (std::chrono::steady_clock::now() >= deadline) { + std::string unmet; + for (std::size_t i = 0; i < wait_conditions.size(); ++i) { + if (!satisfied[i]) { + if (!unmet.empty()) { + unmet += ", "; + } + unmet += wait_conditions[i]; + } + } + throw std::runtime_error("Timed out after " + std::to_string(timeout) + + "ms waiting for condition(s): " + unmet); + } + + std::this_thread::sleep_for(std::chrono::milliseconds(polling_interval)); + } + + log_info("All dependencies satisfied"); + } +}; + +} // 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..95a4a6a1797 --- /dev/null +++ b/feature_integration_tests/test_scenarios/cpp/src/scenarios/lifecycle/conditional_launching.h @@ -0,0 +1,17 @@ +/******************************************************************************** + * 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..4f53916af86 --- /dev/null +++ b/feature_integration_tests/test_scenarios/rust/src/scenarios/lifecycle/conditional_launching.rs @@ -0,0 +1,155 @@ +// ******************************************************************************* +// 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 std::fs; +use std::path::Path; +use std::time::{Duration, Instant}; +use test_scenarios_rust::scenario::Scenario; +use tracing::info; + +pub struct ConditionalLaunching; + +fn path_condition_met(path: &str) -> bool { + Path::new(path).exists() +} + +fn env_condition_met(name: &str) -> bool { + std::env::var_os(name).is_some() +} + +/// Best-effort check whether a process matching `process_name` is currently running, by +/// scanning /proc//comm (kernel-truncated to 15 chars) and /proc//cmdline (full +/// argv[0], which covers names `comm` truncates). +fn process_condition_met(process_name: &str) -> bool { + let Ok(entries) = fs::read_dir("/proc") else { + return false; + }; + + for entry in entries.flatten() { + let pid = entry.file_name(); + let Some(pid) = pid.to_str() else { continue }; + if !pid.chars().all(|c| c.is_ascii_digit()) { + continue; + } + + if let Ok(comm) = fs::read_to_string(entry.path().join("comm")) { + if comm.trim_end() == process_name { + return true; + } + } + + if let Ok(cmdline) = fs::read(entry.path().join("cmdline")) { + let argv0 = cmdline.split(|&b| b == 0).next().unwrap_or(&[]); + if let Ok(argv0) = std::str::from_utf8(argv0) { + if argv0.ends_with(process_name) { + return true; + } + } + } + } + false +} + +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"); + + let conditions: Vec<&str> = conditions + .iter() + .map(|condition| { + condition + .as_str() + .ok_or_else(|| "Wait condition entries must be strings".to_string()) + }) + .collect::>()?; + + for condition in &conditions { + if !condition.starts_with("path:") && !condition.starts_with("env:") && !condition.starts_with("process:") { + return Err(format!("Unsupported wait condition prefix: {condition}")); + } + } + + info!("Polling interval: {polling_interval}ms"); + info!("Condition timeout: {timeout}ms"); + + let deadline = Instant::now() + Duration::from_millis(timeout); + let mut satisfied = vec![false; conditions.len()]; + + loop { + let mut all_satisfied = true; + for (index, condition) in conditions.iter().enumerate() { + if satisfied[index] { + continue; + } + + let met = if let Some(path) = condition.strip_prefix("path:") { + path_condition_met(path) + } else if let Some(name) = condition.strip_prefix("env:") { + env_condition_met(name) + } else { + process_condition_met(condition.strip_prefix("process:").expect("checked above")) + }; + + if met { + satisfied[index] = true; + info!("Condition satisfied: {condition}"); + } else { + all_satisfied = false; + } + } + + if all_satisfied { + break; + } + + if Instant::now() >= deadline { + let unmet = conditions + .iter() + .zip(&satisfied) + .filter(|(_, met)| !**met) + .map(|(condition, _)| *condition) + .collect::>() + .join(", "); + return Err(format!("Timed out after {timeout}ms waiting for condition(s): {unmet}")); + } + + std::thread::sleep(Duration::from_millis(polling_interval)); + } + + 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..42ad93bd713 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',