Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions bazel/py_itf_plugin.bzl
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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()
Expand Down Expand Up @@ -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 = [],
Expand Down
51 changes: 51 additions & 0 deletions score/itf/plugins/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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(
Expand All @@ -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 = [
Expand Down
98 changes: 98 additions & 0 deletions score/itf/plugins/core_dump.py
Original file line number Diff line number Diff line change
@@ -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)