diff --git a/score/test/component/datarouter/BUILD b/score/test/component/datarouter/BUILD index 50e3ffb9e..fab77b035 100644 --- a/score/test/component/datarouter/BUILD +++ b/score/test/component/datarouter/BUILD @@ -102,3 +102,82 @@ py_logging_itf_test( filesystem_pkg = "//score/test/component/filters_app:filtertest_filesystem_pkg", deps = ["@score_itf//score/itf/plugins/dlt"], ) + +# ============================================================================= +# dr_restart test +# ============================================================================= + +# No app-under-test needed: datarouter itself is already baked into every +# test image via //score/test/component:datarouter_pkg. Reused below by any +# other test that only needs datarouter itself, with no additional app. +pkg_filegroup( + name = "empty_filesystem_pkg", + srcs = [], +) + +pkg_tar( + name = "empty_filesystem", + srcs = [":empty_filesystem_pkg"], +) + +# ============================================================================= +# bandwidth quotas test +# ============================================================================= + +py_logging_itf_test( + name = "test_bandwidth_quotes_exists", + srcs = ["test_bandwidth_quotes_exists.py"], + filesystem = ":empty_filesystem", + filesystem_pkg = ":empty_filesystem_pkg", +) + +# ============================================================================= +# file_logging_test (kFile logging) +# ============================================================================= + +py_logging_itf_test( + name = "test_file_logging", + srcs = ["test_file_logging.py"], + filesystem = "//score/test/component/dlt_generator_app:dlt_generator_filesystem", + filesystem_pkg = "//score/test/component/dlt_generator_app:dlt_generator_filesystem_pkg", + deps = [ + "@score_itf//third_party/python_dlt", + ], +) + +# ============================================================================= +# quota_exceed test +# ============================================================================= + +py_logging_itf_test( + name = "test_quota_exceed", + timeout = "long", + srcs = ["test_quota_exceed.py"], + filesystem = "//score/test/component/dlt_generator_app:dlt_generator_filesystem", + filesystem_pkg = "//score/test/component/dlt_generator_app:dlt_generator_filesystem_pkg", + deps = ["@score_itf//score/itf/plugins/dlt"], +) + +# ============================================================================= +# logging_after_delayed_dr_start test +# ============================================================================= + +py_logging_itf_test( + name = "test_logging_after_delayed_dr_start", + srcs = ["test_logging_after_delayed_dr_start.py"], + filesystem = "//score/test/component/dlt_generator_app:dlt_generator_filesystem", + filesystem_pkg = "//score/test/component/dlt_generator_app:dlt_generator_filesystem_pkg", + deps = ["@score_itf//score/itf/plugins/dlt"], +) + +# ============================================================================= +# logging_detached_logs test +# ============================================================================= + +py_logging_itf_test( + name = "test_logging_detached_logs", + srcs = ["test_logging_detached_logs.py"], + filesystem = "//score/test/component/dlt_generator_app:dlt_generator_filesystem", + filesystem_pkg = "//score/test/component/dlt_generator_app:dlt_generator_filesystem_pkg", + deps = ["@score_itf//score/itf/plugins/dlt"], +) diff --git a/score/test/component/datarouter/etc/log-channels.json b/score/test/component/datarouter/etc/log-channels.json index 5571f4346..da392f260 100644 --- a/score/test/component/datarouter/etc/log-channels.json +++ b/score/test/component/datarouter/etc/log-channels.json @@ -41,6 +41,13 @@ ] } }, + "quotas": { + "quotaEnforcementEnabled": false, + "throughput": { + "overallMbps": 100, + "applicationsKbps": {} + } + }, "defaultChannel": "3493", "defaultThresold": "kVerbose", "messageThresholds": { diff --git a/score/test/component/datarouter/test_bandwidth_quotes_exists.py b/score/test/component/datarouter/test_bandwidth_quotes_exists.py new file mode 100644 index 000000000..976294599 --- /dev/null +++ b/score/test/component/datarouter/test_bandwidth_quotes_exists.py @@ -0,0 +1,56 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +"""Integration test verifying datarouter's log-channels.json declares DLT +bandwidth quotas. +""" + +import json +import logging +import os +import tempfile + +from attribute_plugin import add_test_properties + +LOGGER = logging.getLogger(__name__) + +_LINUX_LOG_CHANNELS_PATH = "/opt/datarouter/etc/log-channels.json" +_QNX_LOG_CHANNELS_PATH = "/usr/bin/datarouter/etc/log-channels.json" + + +def _is_qnx(target) -> bool: + exit_code, out = target.execute("uname -s") + output = out.decode() if isinstance(out, bytes) else out + return "QNX" in output + + +@add_test_properties( + fully_verifies=["comp_req__data_router__dlt_bw_quota_config"], + test_type="requirements-based", + derivation_technique="equivalence-classes", +) +def test_bandwidth_quotes_exists(target): + """Verify datarouter's log-channels.json declares a 'quotas' section.""" + remote_path = ( + _QNX_LOG_CHANNELS_PATH if _is_qnx(target) else _LINUX_LOG_CHANNELS_PATH + ) + + with tempfile.TemporaryDirectory() as tmpdir: + local_path = os.path.join(tmpdir, "log-channels.json") + target.download(remote_path, local_path) + + with open(local_path, "r") as f: + log_channel_content = json.load(f) + + LOGGER.info(f"log-channels.json keys: {list(log_channel_content.keys())}") + assert "quotas" in log_channel_content diff --git a/score/test/component/datarouter/test_file_logging.py b/score/test/component/datarouter/test_file_logging.py new file mode 100644 index 000000000..82de21d06 --- /dev/null +++ b/score/test/component/datarouter/test_file_logging.py @@ -0,0 +1,59 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +"""Integration test for DLT kFile logging via dlt_generator. + +Verifies that dlt_generator writes the expected number of DLT messages to a +local .dlt file when configured with kFile logging mode. +""" + +import logging +import os +import tempfile + +import dlt.dlt as python_dlt + +LOGGER = logging.getLogger(__name__) + +APP_ID = "LGGG" +ITERATIONS = 12 +DEFAULT_MESSAGE = "default message text for example log generating application" +# use_full_output defaults to true in dlt_generator: Fatal + Error + Warn + Info + Verbose + Debug +MESSAGES_PER_ITERATION = 6 + + +def test_dlt_kfile_logging(target, datarouter_on_target): + """Verify DLT kFile logging produces the expected number of messages.""" + expected = ITERATIONS * MESSAGES_PER_ITERATION + + target.execute( + f"cd /opt/test_apps/dlt_generator && ./bin/dlt_generator -i {ITERATIONS} -s 0" + ) + + exit_code, _ = target.execute("test -f /tmp/LGGG.dlt && echo EXISTS") + assert exit_code == 0, "DLT file /tmp/LGGG.dlt was not created on the target" + + with tempfile.TemporaryDirectory() as tmpdir: + local_dlt = os.path.join(tmpdir, "LGGG.dlt") + target.download("/tmp/LGGG.dlt", local_dlt) + LOGGER.info(f"Downloaded DLT file: {os.path.getsize(local_dlt)} bytes") + + dlt_messages = python_dlt.load(local_dlt, None) + occurrences = sum( + 1 + for m in dlt_messages + if getattr(m, "apid", None) == APP_ID + and DEFAULT_MESSAGE in str(getattr(m, "payload_decoded", "")) + ) + LOGGER.info(f"Expected {expected} occurrences, got {occurrences}") + assert occurrences == expected diff --git a/score/test/component/datarouter/test_logging_after_delayed_dr_start.py b/score/test/component/datarouter/test_logging_after_delayed_dr_start.py new file mode 100644 index 000000000..4f44dc616 --- /dev/null +++ b/score/test/component/datarouter/test_logging_after_delayed_dr_start.py @@ -0,0 +1,77 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +"""Integration test verifying DataRouter can pick up an already-running +logging app's queued messages after a delayed start. + +Uses the datarouter_manager fixture (manual start/stop) so the app can be +started before DataRouter, then checks for the app's regular messages +having been received once DataRouter connects. Needs kRemote + dlt_capture +(not kFile): a client's local file writer doesn't care whether DataRouter +is running, so kFile-mode counting wouldn't exercise the delayed-start +behavior being tested. + +NOTE: like other dlt_capture()-based tests in this suite, this depends on +multicast DLT reception and is subject to the local Docker/multicast +limitation -- build-verified locally, relies on CI for the actual pass/fail. +""" + +import logging +import time + +from attribute_plugin import add_test_properties +from logging_plugin import download_dlt + +LOGGER = logging.getLogger(__name__) + +APP_ID = "LGGG" +DEFAULT_MESSAGE = "default message text for example log generating application" + +_DR_START_DELAY_SEC = 2 +_GENERATION_ITERATIONS = 20 +_GENERATION_SLEEP_MS = 500 +_SHUTDOWN_WAIT_MS = 2000 + + +@add_test_properties( + fully_verifies=["comp_req__data_router__dlt_server"], + test_type="requirements-based", + derivation_technique="equivalence-classes", +) +def test_logging_after_delayed_dr_start(target, datarouter_manager, dlt_capture): + """Verify DataRouter connects to and drains a client started before it.""" + with dlt_capture() as receiver: + proc = target.execute_async( + "/opt/test_apps/dlt_generator/bin/dlt_generator", + args=[ + "-i", + str(_GENERATION_ITERATIONS), + "-s", + str(_GENERATION_SLEEP_MS), + "-w", + str(_SHUTDOWN_WAIT_MS), + ], + cwd="/opt/test_apps/dlt_generator", + ) + LOGGER.info( + f"dlt_generator started; delaying DataRouter start by {_DR_START_DELAY_SEC}s" + ) + time.sleep(_DR_START_DELAY_SEC) + datarouter_manager.start() + proc.wait(timeout_s=30) + + record = download_dlt(target, receiver.dlt_file) + messages = record.find(query=dict(apid=APP_ID)) + count = sum(1 for m in messages if DEFAULT_MESSAGE in str(m.payload)) + LOGGER.info(f"Received {count} messages after delayed DataRouter start") + assert count > 0, "No messages received after delayed DataRouter start" diff --git a/score/test/component/datarouter/test_logging_detached_logs.py b/score/test/component/datarouter/test_logging_detached_logs.py new file mode 100644 index 000000000..377d39c78 --- /dev/null +++ b/score/test/component/datarouter/test_logging_detached_logs.py @@ -0,0 +1,51 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +"""Integration test verifying DataRouter can retrieve a client's log data +after the client exits immediately (detached mode). + +The app is launched with -w 0 so it exits immediately after logging, without +waiting for DataRouter -- this forces the shared-memory writer into detached +state for DataRouter to discover. Uses kRemote + dlt_capture, since this +needs to exercise DataRouter's detached-read path specifically. + +NOTE: like other dlt_capture()-based tests in this suite, this depends on +multicast DLT reception and is subject to the local Docker/multicast +limitation -- build-verified locally, relies on CI for the actual pass/fail. +""" + +import logging +import time + +from logging_plugin import download_dlt + +LOGGER = logging.getLogger(__name__) + +APP_ID = "LGGG" +DEFAULT_MESSAGE = "default message text for example log generating application" + +_POST_EXIT_WAIT_SEC = 5 + + +def test_logging_detached_logs(target, datarouter_on_target, dlt_capture): + """Verify DataRouter retrieves logs from a client that exits immediately.""" + with dlt_capture() as receiver: + target.execute("cd /opt/test_apps/dlt_generator && ./bin/dlt_generator -w 0") + # Give DataRouter time to detect the detached writer and drain it. + time.sleep(_POST_EXIT_WAIT_SEC) + + record = download_dlt(target, receiver.dlt_file) + messages = record.find(query=dict(apid=APP_ID)) + count = sum(1 for m in messages if DEFAULT_MESSAGE in str(m.payload)) + LOGGER.info(f"Received {count} messages from the detached client") + assert count > 0, "Couldn't find logs from dlt_generator after detached exit" diff --git a/score/test/component/datarouter/test_quota_exceed.py b/score/test/component/datarouter/test_quota_exceed.py new file mode 100644 index 000000000..f2d0890cf --- /dev/null +++ b/score/test/component/datarouter/test_quota_exceed.py @@ -0,0 +1,179 @@ +# ******************************************************************************* +# 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 +# ******************************************************************************* + +"""Integration test for datarouter's per-application bandwidth quota +enforcement. + +Uses kRemote + dlt_capture rather than kFile: quota enforcement happens in +the DataRouter daemon's own read of the client's shared-memory ring buffer, +which kFile-mode writes bypass entirely. + +NOTE: like other dlt_capture()-based tests in this suite, this depends on +multicast DLT reception and is subject to the local Docker/multicast +limitation -- build-verified locally, relies on CI for the actual pass/fail. + +NOTE: on QNX, the default config path is baked into the read-only IFS boot +image, so each scenario's config is written to /tmp instead and datarouter +is started with --config pointing at it. +""" + +import copy +import json +import logging +import os +import tempfile + +from attribute_plugin import add_test_properties +from logging_plugin import download_dlt + +LOGGER = logging.getLogger(__name__) + +APP_ID = "LGGG" +DEFAULT_MESSAGE = "default message text for example log generating application" + +_LINUX_LOG_CHANNELS_PATH = "/opt/datarouter/etc/log-channels.json" +_QNX_LOG_CHANNELS_PATH = "/tmp/log-channels.json" + +# Sustained generation across ~30s (3 quota stats cycles @ 10s each): the +# first cycle establishes the observed rate, enforcement (if enabled) then +# applies from the second cycle onward. +_GENERATION_ITERATIONS = 3000 +_GENERATION_SLEEP_MS = 10 + +# CheckAndSetQuotaEnforcement (data_router.cpp) computes +# rate_k_bps = totalsize_bytes * 1000 / 1024 / elapsed_ms, i.e. KB/s (not +# Kbit/s despite the "Kbps" field name in log-channels.json). Empirically +# measured baseline for the generation params above (via kFile byte count, +# same message volume applies to kRemote): ~65 KB/s. The original +# implementation's 500/100 values are calibrated to different hardware +# throughput and don't translate +# directly; these limits are chosen to sit below the measured baseline here +# so enforcement is actually exercised. + +# Mirrors score/test/component/datarouter/etc/log-channels.json, varying +# only the "quotas" section per scenario. +_BASE_LOG_CHANNELS = { + "channels": { + "3491": { + "address": "0.0.0.0", + "channelThreshold": "kError", + "dstAddress": "239.255.42.99", + "dstPort": 3490, + "ecu": "TST1", + "port": 3491, + }, + "3492": { + "address": "0.0.0.0", + "channelThreshold": "kInfo", + "dstAddress": "239.255.42.99", + "dstPort": 3490, + "ecu": "TST2", + "port": 3492, + }, + "3493": { + "address": "0.0.0.0", + "channelThreshold": "kVerbose", + "dstAddress": "239.255.42.99", + "dstPort": 3490, + "ecu": "TST3", + "port": 3493, + }, + }, + "channelAssignments": { + "DR": {"": ["3492"], "CTX1": ["3492", "3493"]}, + "-NI-": {"": ["3491"]}, + }, + "defaultChannel": "3493", + "defaultThresold": "kVerbose", + "messageThresholds": { + "": {"vcip": "kInfo"}, + "DR": {"": "kVerbose", "CTX1": "kVerbose", "STAT": "kDebug"}, + "-NI-": {"": "kVerbose"}, + }, +} + +SCENARIOS = [ + {"name": "quota_disabled_500", "enabled": False, "limit_kbps": 500}, + {"name": "quota_enabled_500", "enabled": True, "limit_kbps": 500}, + {"name": "quota_enabled_15", "enabled": True, "limit_kbps": 15}, +] + + +def _is_qnx(target) -> bool: + exit_code, out = target.execute("uname -s") + output = out.decode() if isinstance(out, bytes) else out + return "QNX" in output + + +def _build_log_channels(enabled, limit_kbps): + config = copy.deepcopy(_BASE_LOG_CHANNELS) + config["quotas"] = { + "quotaEnforcementEnabled": enabled, + "throughput": { + "overallMbps": 100, + "applicationsKbps": {APP_ID: limit_kbps}, + }, + } + return config + + +def _apply_scenario_config(target, enabled, limit_kbps): + remote_path = ( + _QNX_LOG_CHANNELS_PATH if _is_qnx(target) else _LINUX_LOG_CHANNELS_PATH + ) + config = _build_log_channels(enabled, limit_kbps) + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump(config, f) + local_path = f.name + try: + target.upload(local_path, remote_path) + finally: + os.unlink(local_path) + + +@add_test_properties( + fully_verifies=["comp_req__data_router__dlt_quota_bw_reduction"], + test_type="requirements-based", + derivation_technique="equivalence-classes", +) +def test_quota_exceed(target, datarouter_manager, dlt_capture): + """Verify quota enforcement reduces observed throughput as configured.""" + results = {} + qnx_config_path = _QNX_LOG_CHANNELS_PATH if _is_qnx(target) else None + for scenario in SCENARIOS: + LOGGER.info(f"Scenario: {scenario['name']}") + datarouter_manager.stop() + _apply_scenario_config(target, scenario["enabled"], scenario["limit_kbps"]) + datarouter_manager.start(config_path=qnx_config_path) + + with dlt_capture() as receiver: + target.execute( + f"cd /opt/test_apps/dlt_generator && " + f"./bin/dlt_generator -i {_GENERATION_ITERATIONS} -s {_GENERATION_SLEEP_MS}" + ) + + record = download_dlt(target, receiver.dlt_file) + messages = record.find(query=dict(apid=APP_ID)) + count = sum(1 for m in messages if DEFAULT_MESSAGE in str(m.payload)) + LOGGER.info(f"{scenario['name']}: {count} messages received") + results[scenario["name"]] = count + + LOGGER.info(f"Results: {results}") + assert results["quota_enabled_500"] <= results["quota_disabled_500"], ( + f"500 enabled ({results['quota_enabled_500']}) should be <= disabled " + f"({results['quota_disabled_500']})" + ) + assert results["quota_enabled_15"] < results["quota_enabled_500"], ( + f"15 ({results['quota_enabled_15']}) should be < 500 " + f"({results['quota_enabled_500']})" + ) diff --git a/score/test/component/logging_plugin.py b/score/test/component/logging_plugin.py index 8d94dc445..6215c718c 100644 --- a/score/test/component/logging_plugin.py +++ b/score/test/component/logging_plugin.py @@ -131,6 +131,55 @@ def datarouter_on_target(target): proc.stop() +class _DatarouterManager: + """Manual start/stop control over the DataRouter process on the target.""" + + def __init__(self, target): + self._target = target + self._proc = None + self._started = False + + def start(self, config_path=None): + if self._started: + return + if _is_qnx(self._target): + cmd = _QNX_DR_CMD + if config_path: + cmd = cmd.replace( + "--no_adaptive_runtime ", + f"--no_adaptive_runtime --config {config_path} ", + ) + self._target.execute(cmd) + else: + self._proc = self._target.execute_async( + "/opt/datarouter/bin/datarouter", + args=["--no_adaptive_runtime"], + cwd="/opt/datarouter", + ) + _wait_for_datarouter(self._target) + self._started = True + + def stop(self): + if not self._started: + return + if _is_qnx(self._target): + self._target.execute(_QNX_DR_STOP_CMD) + else: + if self._proc is not None and self._proc.is_running(): + self._proc.stop() + self._started = False + + +@pytest.fixture(scope="function") +def datarouter_manager(target): + """Yield a _DatarouterManager for tests that need manual DR start/stop control.""" + manager = _DatarouterManager(target) + try: + yield manager + finally: + manager.stop() + + @pytest.fixture(scope="function") def dlt_capture(target, dlt_on_target, request): """Start a DLT receiver. On QNX, runs dlt-receive on the host; on Docker, on the target."""