From 749e01e24c0616459860c40e1c985618463dcc21 Mon Sep 17 00:00:00 2001 From: Tejaswini-Janjale25 Date: Tue, 21 Jul 2026 15:53:14 +0530 Subject: [PATCH 1/2] Add core dump extraction from container in docker plugin --- score/itf/plugins/docker.py | 51 +++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/score/itf/plugins/docker.py b/score/itf/plugins/docker.py index ca1cd8f3..1bd39165 100644 --- a/score/itf/plugins/docker.py +++ b/score/itf/plugins/docker.py @@ -62,6 +62,21 @@ def pytest_addoption(parser): help="Directory to write extracted coverage files. " "Defaults to $TEST_UNDECLARED_OUTPUTS_DIR/sysroot or /tmp/sysroot.", ) + parser.addoption( + "--extract-core", + action="store_true", + default=False, + help="Extract core dump files from the container before teardown.", + ) + parser.addoption( + "--core-output-dir", + default=os.path.join( + os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR", "/tmp"), + "cores", + ), + help="Directory to write extracted core dump files. " + "Defaults to $TEST_UNDECLARED_OUTPUTS_DIR/cores or /tmp/cores.", + ) class DockerAsyncProcess(AsyncProcess): @@ -335,6 +350,31 @@ def _extract_coverage_from_container(target, output_base): logger.warning(f"Failed to extract {remote_path}", exc_info=True) +def _extract_core_from_container(target, output_base): + """Extract core dump files created inside the container.""" + logger.info(f"Attempting core extraction to {output_base}") + # Look for core files in typical locations, being specific to avoid false positives + exit_code, output = target.execute("(ls -1 /core* 2>/dev/null || true) && (ls -1 /opt/*/core* 2>/dev/null || true) && (ls -1 /root/core* 2>/dev/null || true) && (ls -1 /tmp/core* 2>/dev/null || true)") + + core_paths = [line.strip() for line in output.decode().splitlines() if line.strip()] + logger.info(f"Found {len(core_paths)} core files: {core_paths}") + if not core_paths: + return + + for remote_path in core_paths: + local_path = os.path.join(output_base, remote_path.lstrip("/")) + if not os.path.realpath(local_path).startswith(os.path.realpath(output_base)): + logger.warning(f"Skipping path traversal attempt: {remote_path}") + continue + os.makedirs(os.path.dirname(local_path), exist_ok=True) + try: + logger.info(f"Extracting core from {remote_path} to {local_path}") + target.download(remote_path, local_path) + logger.info(f"Successfully extracted {remote_path}") + except Exception: + logger.warning(f"Failed to extract core file {remote_path}", exc_info=True) + + @pytest.fixture(scope=determine_target_scope) def target_init(request, _docker_configuration): print(_docker_configuration) @@ -397,6 +437,17 @@ def target_init(request, _docker_configuration): ) except Exception: logger.warning("Coverage extraction failed", exc_info=True) + try: + extract_core_enabled = request.config.getoption("extract_core") + logger.info(f"Core extraction enabled: {extract_core_enabled}") + if target is not None and extract_core_enabled: + logger.info(f"Extracting cores to {request.config.getoption('core_output_dir')}") + _extract_core_from_container( + target, + request.config.getoption("core_output_dir"), + ) + except Exception: + logger.warning("Core extraction failed", exc_info=True) try: try: container.stop(timeout=1) From fc6416bbad6d5b4328cd53930ef3726e3daa3e70 Mon Sep 17 00:00:00 2001 From: Tejaswini-Janjale25 Date: Tue, 21 Jul 2026 15:53:14 +0530 Subject: [PATCH 2/2] Fix ruff formatting in docker plugin core extraction Issue: SWP-130715 --- README.md | 56 +++++++++++++++++++ bazel/py_itf_plugin.bzl | 17 ++++++ score/itf/plugins/BUILD | 51 ++++++++++++++++++ score/itf/plugins/core_dump.py | 98 ++++++++++++++++++++++++++++++++++ score/itf/plugins/docker.py | 51 ------------------ 5 files changed, 222 insertions(+), 51 deletions(-) create mode 100644 score/itf/plugins/core_dump.py diff --git a/README.md b/README.md index 408a3fe2..58e1d0d5 100644 --- a/README.md +++ b/README.md @@ -99,8 +99,64 @@ ITF supports modular plugins that extend functionality: - **`core`**: Basic functionality that is the entry point for plugin extensions and hooks - **`docker`**: Docker container targets with `exec`, `file_transfer`, and `restart` capabilities - **`qemu`**: QEMU virtual machine targets with `ssh`, `sftp`, `exec`, `file_transfer`, and `restart` capabilities +- **`core_dump`**: Target-agnostic core dump extraction for targets with `exec` and `file_transfer` capabilities - **`dlt`**: DLT (Diagnostic Log and Trace) message capture and analysis +### Core Dump Extraction + +Core dump extraction is provided by the standalone `core_dump_plugin`. It can be composed with both Docker and QEMU targets instead of being embedded in a target-specific plugin. + +Example with Docker: + +```starlark +py_itf_test( + name = "test_with_core_extraction", + srcs = ["test_with_core_extraction.py"], + args = ["--docker-image=ubuntu:24.04"], + plugins = [ + "@score_itf//score/itf/plugins:docker_plugin", + "@score_itf//score/itf/plugins:core_dump_plugin", + ], +) +``` + +Example with QEMU: + +```starlark +py_itf_test( + name = "test_qemu_with_core_extraction", + srcs = ["test_qemu_with_core_extraction.py"], + args = [ + "--qemu-image=$(location //path:qemu_image)", + "--qemu-config=$(location qemu_config.json)", + ], + data = [ + "//path:qemu_image", + "qemu_config.json", + ], + plugins = [ + "@score_itf//score/itf/plugins:qemu_plugin", + "@score_itf//score/itf/plugins:core_dump_plugin", + ], +) +``` + +Enable extraction during the test run with: + +```bash +bazel test //path/to:test --@score_itf//score/itf/plugins:extract_core_dumps=true +``` + +Optionally override the host output directory for extracted files: + +```bash +bazel test //path/to:test \ + --@score_itf//score/itf/plugins:extract_core_dumps=true \ + --@score_itf//score/itf/plugins:core_dumps_output_dir=/abs/path/to/coredumps +``` + +By default, extracted files are written to `$TEST_UNDECLARED_OUTPUTS_DIR/coredumps`. Under Bazel test runs this usually places them under the target's `bazel-testlogs/.../test.outputs/coredumps` directory. + ## Writing Tests ### Basic Test Structure diff --git a/bazel/py_itf_plugin.bzl b/bazel/py_itf_plugin.bzl index 967ef255..39c30bef 100644 --- a/bazel/py_itf_plugin.bzl +++ b/bazel/py_itf_plugin.bzl @@ -12,6 +12,7 @@ # ******************************************************************************* """Bazel rule for defining ITF test plugins.""" +load("@bazel_skylib//rules:common_settings.bzl", "BuildSettingInfo") load("@rules_python//python:defs.bzl", "PyInfo") PyItfPluginInfo = provider( @@ -34,6 +35,15 @@ def _py_itf_plugin_impl(ctx): arg = arg.replace("$(location ", "$(rootpath ").replace("$(locations ", "$(rootpaths ") resolved_args.append(ctx.expand_location(arg, targets = all_data_targets)) + # Append args driven by string_flag build settings. Each mapped flag emits + # its arg template (with '{}' replaced by the flag value) only when the + # value is non-empty. This lets a value be set via `--//path/to:flag=/dir` + # instead of a hard-coded macro attribute at each call site. + for flag_target, arg_template in ctx.attr.string_flag_args.items(): + value = flag_target[BuildSettingInfo].value + if value: + resolved_args.append(arg_template.format(value)) + # Collect all plugin files and runfiles plugin_file_depsets = [] plugin_runfiles = ctx.runfiles() @@ -83,6 +93,13 @@ py_itf_plugin = rule( doc = "Additional CLI arguments. Supports $(location ...) referencing plugin_data targets.", default = [], ), + "string_flag_args": attr.label_keyed_string_dict( + doc = "Maps a string_flag build setting to a CLI arg template. " + + "'{}' is substituted with the flag value and the arg is " + + "emitted only when the value is non-empty.", + default = {}, + providers = [BuildSettingInfo], + ), "plugin_data": attr.label_list( doc = "Data files built for target configuration.", default = [], diff --git a/score/itf/plugins/BUILD b/score/itf/plugins/BUILD index 8a0d5163..fc2653fd 100644 --- a/score/itf/plugins/BUILD +++ b/score/itf/plugins/BUILD @@ -10,6 +10,7 @@ # # SPDX-License-Identifier: Apache-2.0 # ******************************************************************************* +load("@bazel_skylib//rules:common_settings.bzl", "bool_flag", "string_flag") load("@itf_pip//:requirements.bzl", "requirement") load("@rules_python//python:defs.bzl", "py_library") load("//bazel:py_itf_plugin.bzl", "py_itf_plugin") @@ -36,6 +37,16 @@ py_library( ], ) +py_library( + name = "core_dump", + srcs = ["core_dump.py"], + visibility = ["//visibility:public"], + deps = [ + ":core", + requirement("pytest"), + ], +) + # ---- ITF plugin targets (used by py_itf_test symbolic macro) ---- py_itf_plugin( @@ -47,6 +58,46 @@ py_itf_plugin( visibility = ["//visibility:public"], ) +# ---- Core dump plugin (works with any target that has exec + file_transfer) ---- + +# Enable core dump extraction. Works with both docker_plugin and qemu_plugin. +# Toggle with: bazel test --//score/itf/plugins:extract_core_dumps +bool_flag( + name = "extract_core_dumps", + build_setting_default = False, + visibility = ["//visibility:public"], +) + +config_setting( + name = "extract_core_dump_enabled", + flag_values = {":extract_core_dumps": "True"}, + visibility = ["//visibility:public"], +) + +# Override the output directory for extracted core dumps. +# Set with: bazel test --//score/itf/plugins:core_dumps_output_dir=/abs/path +string_flag( + name = "core_dumps_output_dir", + build_setting_default = "", + visibility = ["//visibility:public"], +) + +py_itf_plugin( + name = "core_dump_plugin", + enabled_plugins = [ + "score.itf.plugins.core_dump", + ], + plugin_args = select({ + ":extract_core_dump_enabled": ["--extract-core-dumps"], + "//conditions:default": [], + }), + py_library = ":core_dump", + string_flag_args = { + ":core_dumps_output_dir": "--core-dumps-output-dir={}", + }, + visibility = ["//visibility:public"], +) + py_itf_plugin( name = "qemu_plugin", enabled_plugins = [ diff --git a/score/itf/plugins/core_dump.py b/score/itf/plugins/core_dump.py new file mode 100644 index 00000000..8af2610c --- /dev/null +++ b/score/itf/plugins/core_dump.py @@ -0,0 +1,98 @@ +# ******************************************************************************* +# Copyright (c) 2025-2026 Contributors to the Eclipse Foundation +# +# See the NOTICE file(s) distributed with this work for additional +# information regarding copyright ownership. +# +# This program and the accompanying materials are made available under the +# terms of the Apache License Version 2.0 which is available at +# https://www.apache.org/licenses/LICENSE-2.0 +# +# SPDX-License-Identifier: Apache-2.0 +# ******************************************************************************* +import logging +import os + +import pytest + +from score.itf.plugins.core import determine_target_scope + +logger = logging.getLogger(__name__) + + +def pytest_addoption(parser): + parser.addoption( + "--extract-core-dumps", + action="store_true", + default=False, + help="Copy core dump files from the target to the host before teardown.", + ) + parser.addoption( + "--core-dumps-output-dir", + default=os.path.join( + os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR", "/tmp"), + "coredumps", + ), + help="Directory to write extracted core dump files. " + "Defaults to $TEST_UNDECLARED_OUTPUTS_DIR/coredumps or /tmp/coredumps.", + ) + + +def _extract_core_dumps(target, output_base): + """Extract core dump files from a target via execute and download. + + Searches common core file locations that work on both Linux and QNX guests. + + :param target: Target object providing exec and file_transfer capabilities. + :param output_base: Local directory where extracted core dump files are stored. + """ + logger.info(f"Attempting core dump extraction to {output_base}") + os.makedirs(output_base, exist_ok=True) + _exit_code, output = target.execute( + "(ls -1 /core* 2>/dev/null || true)" + " && (ls -1 /opt/*/core* 2>/dev/null || true)" + " && (ls -1 /root/core* 2>/dev/null || true)" + " && (ls -1 /tmp/core* 2>/dev/null || true)" + " && (ls -1 /data/*/core* 2>/dev/null || true)" + " && (ls -1 /tmp/*.core /tmp/*.core.gz /var/*.core /var/*.core.gz 2>/dev/null || true)" + " && (ls -1 /opt/*/*.core /opt/*/*.core.gz /root/*.core /root/*.core.gz" + " /data/*/*.core /data/*/*.core.gz 2>/dev/null || true)" + ) + core_dump_paths = [line.strip() for line in output.decode().splitlines() if line.strip()] + logger.info(f"Found {len(core_dump_paths)} core files: {core_dump_paths}") + if not core_dump_paths: + return + + for remote_path in core_dump_paths: + local_path = os.path.join(output_base, remote_path.lstrip("/")) + if not os.path.realpath(local_path).startswith(os.path.realpath(output_base)): + logger.warning(f"Skipping path traversal attempt: {remote_path}") + continue + os.makedirs(os.path.dirname(local_path), exist_ok=True) + try: + logger.info(f"Extracting core dump from {remote_path} to {local_path}") + target.download(remote_path, local_path) + logger.info(f"Successfully extracted {remote_path}") + except Exception: + logger.warning(f"Failed to extract core dump file {remote_path}", exc_info=True) + + +@pytest.fixture(scope=determine_target_scope, autouse=True) +def _core_dump_extraction(request, target): + """Autouse fixture that extracts core dumps after target teardown. + + Activated by --extract-core-dumps. Silently skipped when the target + does not advertise exec and file_transfer capabilities. + """ + yield + if not request.config.getoption("extract_core_dumps"): + return + if not target.has_all_capabilities({"exec", "file_transfer"}): + logger.warning("Target does not support exec/file_transfer; skipping core dump extraction.") + return + output_dir = request.config.getoption("core_dumps_output_dir") + logger.info(f"Core dump extraction enabled, writing to {output_dir}") + try: + _extract_core_dumps(target, output_dir) + except Exception: + logger.warning("Core dump extraction failed", exc_info=True) diff --git a/score/itf/plugins/docker.py b/score/itf/plugins/docker.py index 1bd39165..ca1cd8f3 100644 --- a/score/itf/plugins/docker.py +++ b/score/itf/plugins/docker.py @@ -62,21 +62,6 @@ def pytest_addoption(parser): help="Directory to write extracted coverage files. " "Defaults to $TEST_UNDECLARED_OUTPUTS_DIR/sysroot or /tmp/sysroot.", ) - parser.addoption( - "--extract-core", - action="store_true", - default=False, - help="Extract core dump files from the container before teardown.", - ) - parser.addoption( - "--core-output-dir", - default=os.path.join( - os.environ.get("TEST_UNDECLARED_OUTPUTS_DIR", "/tmp"), - "cores", - ), - help="Directory to write extracted core dump files. " - "Defaults to $TEST_UNDECLARED_OUTPUTS_DIR/cores or /tmp/cores.", - ) class DockerAsyncProcess(AsyncProcess): @@ -350,31 +335,6 @@ def _extract_coverage_from_container(target, output_base): logger.warning(f"Failed to extract {remote_path}", exc_info=True) -def _extract_core_from_container(target, output_base): - """Extract core dump files created inside the container.""" - logger.info(f"Attempting core extraction to {output_base}") - # Look for core files in typical locations, being specific to avoid false positives - exit_code, output = target.execute("(ls -1 /core* 2>/dev/null || true) && (ls -1 /opt/*/core* 2>/dev/null || true) && (ls -1 /root/core* 2>/dev/null || true) && (ls -1 /tmp/core* 2>/dev/null || true)") - - core_paths = [line.strip() for line in output.decode().splitlines() if line.strip()] - logger.info(f"Found {len(core_paths)} core files: {core_paths}") - if not core_paths: - return - - for remote_path in core_paths: - local_path = os.path.join(output_base, remote_path.lstrip("/")) - if not os.path.realpath(local_path).startswith(os.path.realpath(output_base)): - logger.warning(f"Skipping path traversal attempt: {remote_path}") - continue - os.makedirs(os.path.dirname(local_path), exist_ok=True) - try: - logger.info(f"Extracting core from {remote_path} to {local_path}") - target.download(remote_path, local_path) - logger.info(f"Successfully extracted {remote_path}") - except Exception: - logger.warning(f"Failed to extract core file {remote_path}", exc_info=True) - - @pytest.fixture(scope=determine_target_scope) def target_init(request, _docker_configuration): print(_docker_configuration) @@ -437,17 +397,6 @@ def target_init(request, _docker_configuration): ) except Exception: logger.warning("Coverage extraction failed", exc_info=True) - try: - extract_core_enabled = request.config.getoption("extract_core") - logger.info(f"Core extraction enabled: {extract_core_enabled}") - if target is not None and extract_core_enabled: - logger.info(f"Extracting cores to {request.config.getoption('core_output_dir')}") - _extract_core_from_container( - target, - request.config.getoption("core_output_dir"), - ) - except Exception: - logger.warning("Core extraction failed", exc_info=True) try: try: container.stop(timeout=1)