From c6cd363c76978360202d625d692679222804c708 Mon Sep 17 00:00:00 2001 From: Pawel Filipczak Date: Sat, 8 Feb 2025 23:44:32 +0100 Subject: [PATCH] Run OpenTelemetry Instrumentations tests with EDOT (#146) (#160) --- .github/workflows/build.yml | 6 + .github/workflows/test-otel-unit.yml | 52 +++++ docker/otel-tests/docker-compose.yml | 89 ++++++++ docker/otel-tests/docker/Dockerfile | 18 ++ docker/otel-tests/docker/kafka/update_run.sh | 11 + docker/otel-tests/docker/mysql/init.sql | 34 +++ tools/build/junit_summary.py | 72 ++++++ tools/build/test_otel_unit_tests.sh | 206 ++++++++++++++++++ tools/build/test_otel_unit_tests_in_docker.sh | 105 +++++++++ 9 files changed, 593 insertions(+) create mode 100644 .github/workflows/test-otel-unit.yml create mode 100644 docker/otel-tests/docker-compose.yml create mode 100644 docker/otel-tests/docker/Dockerfile create mode 100755 docker/otel-tests/docker/kafka/update_run.sh create mode 100644 docker/otel-tests/docker/mysql/init.sql create mode 100755 tools/build/junit_summary.py create mode 100755 tools/build/test_otel_unit_tests.sh create mode 100755 tools/build/test_otel_unit_tests_in_docker.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e6fef80..41a8944 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -52,6 +52,12 @@ jobs: with: build_arch: all + test-otel-unit: + needs: + - build-packages + uses: ./.github/workflows/test-otel-unit.yml + + # The very last job to report whether the Workflow passed. # This will act as the Branch Protection gatekeeper ci: diff --git a/.github/workflows/test-otel-unit.yml b/.github/workflows/test-otel-unit.yml new file mode 100644 index 0000000..156b813 --- /dev/null +++ b/.github/workflows/test-otel-unit.yml @@ -0,0 +1,52 @@ +--- + +name: test-otel-unit + +on: + workflow_call: ~ + +permissions: + contents: read + +env: + BUILD_PACKAGES: build/packages + +jobs: + generate-php-versions: + uses: ./.github/workflows/generate-php-versions.yml + + test-otel-unit: + name: test-otel-unit + runs-on: 'ubuntu-latest' + needs: generate-php-versions + timeout-minutes: 300 + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-php-versions.outputs.php-versions) }} + env: + PHP_VERSION: ${{ matrix.php-version }} + steps: + - uses: actions/checkout@v4 + - name: Download package artifacts for Debian AMD64 + uses: actions/download-artifact@v4 + with: + pattern: packages-linux-x86-64 + path: ${{ env.BUILD_PACKAGES }} + + - name: Run otel instrumentation unit tests + continue-on-error: true + run: | + PACKAGE_FILE=$(find ${{ env.BUILD_PACKAGES }} -name elastic*amd64.deb) + ./tools/build/test_otel_unit_tests_in_docker.sh --php_versions "${PHP_VERSION}" --results_path "build/otel_unit_results" --deb_package ${PACKAGE_FILE} + + - name: Generate test summary + if: always() + run: ./tools/build/junit_summary.py --path-to-test-results "build/otel_unit_results/**/*.xml" --header "Test summary for PHP ${PHP_VERSION}" >>$GITHUB_STEP_SUMMARY + + - uses: actions/upload-artifact@v4 + if: always() + continue-on-error: true + with: + name: test-otel-unit-${{ matrix.php-version }} + path: | + build/otel_unit_results/* diff --git a/docker/otel-tests/docker-compose.yml b/docker/otel-tests/docker-compose.yml new file mode 100644 index 0000000..2e36303 --- /dev/null +++ b/docker/otel-tests/docker-compose.yml @@ -0,0 +1,89 @@ +services: + php: + build: + context: ./docker + dockerfile: Dockerfile + args: + - PHP_VERSION + volumes: + - ./:/usr/src/myapp + user: "${PHP_USER}:root" + environment: + XDEBUG_MODE: ${XDEBUG_MODE:-off} + XDEBUG_CONFIG: ${XDEBUG_CONFIG:-''} + PHP_IDE_CONFIG: ${PHP_IDE_CONFIG:-''} + RABBIT_HOST: ${RABBIT_HOST:-rabbitmq} + KAFKA_HOST: ${KAFKA_HOST:-kafka} + MONGODB_HOST: ${MONGODB_HOST:-mongodb} + MONGODB_PORT: ${MONGODB_PORT:-27017} + MYSQL_HOST: ${MYSQL_HOST:-mysql} + + zipkin: + image: openzipkin/zipkin-slim + ports: + - 9411:9411 + jaeger: + image: jaegertracing/all-in-one + environment: + COLLECTOR_ZIPKIN_HOST_PORT: 9412 + ports: + - 9412:9412 + - 16686:16686 + + collector: + image: otel/opentelemetry-collector-contrib + command: [ "--config=/etc/otel-collector-config.yml" ] + volumes: + - ./files/collector/otel-collector-config.yml:/etc/otel-collector-config.yml + + rabbitmq: + image: rabbitmq:3 + hostname: rabbitmq + healthcheck: + test: rabbitmq-diagnostics -q ping + interval: 30s + timeout: 30s + retries: 3 + ports: + - "5672:5672/tcp" + kafka: + image: confluentinc/cp-kafka:7.2.1 + hostname: kafka + ports: + - "9092:9092/tcp" + environment: + KAFKA_PROCESS_ROLES: 'broker,controller' + KAFKA_NODE_ID: 1 + KAFKA_ADVERTISED_LISTENERS: ${KAFKA_ADVERTISED_LISTENERS:-PLAINTEXT://kafka:29092,PLAINTEXT_HOST://kafka:9092} + KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: PLAINTEXT:PLAINTEXT,CONTROLLER:PLAINTEXT,PLAINTEXT_HOST:PLAINTEXT + KAFKA_LISTENERS: 'PLAINTEXT://kafka:29092,CONTROLLER://kafka:29093,PLAINTEXT_HOST://0.0.0.0:9092' + KAFKA_CONTROLLER_LISTENER_NAMES: 'CONTROLLER' + KAFKA_CONTROLLER_QUORUM_VOTERS: '1@kafka:29093' + KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 + command: "bash -c '/tmp/update_run.sh && /etc/confluent/docker/run'" + volumes: + - ./docker/kafka/update_run.sh:/tmp/update_run.sh + + mongodb: + image: mongo:4 + hostname: mongodb + ports: + - "27017:27017/tcp" + + mysql: + image: mysql:8.0 + hostname: mysql + ports: + - "3306:3306/tcp" + environment: + MYSQL_ROOT_PASSWORD: root_password + MYSQL_DATABASE: otel_db + MYSQL_USER: otel_user + MYSQL_PASSWORD: otel_passwd + healthcheck: + test: mysql -uotel_user -potel_passwd -e "USE otel_db;" + interval: 30s + timeout: 30s + retries: 3 + volumes: + - ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/init.sql diff --git a/docker/otel-tests/docker/Dockerfile b/docker/otel-tests/docker/Dockerfile new file mode 100644 index 0000000..c8cee7c --- /dev/null +++ b/docker/otel-tests/docker/Dockerfile @@ -0,0 +1,18 @@ +ARG PHP_VERSION=8.1 +FROM php:${PHP_VERSION}-cli + +USER root + +RUN apt-get update && apt-get install -y \ + jq git unzip tar + +ADD https://getcomposer.org/installer composer-setup.php + +RUN php composer-setup.php --quiet --install-dir="/usr/local/bin" --filename="composer" \ + && chmod +x /usr/local/bin/composer \ + && rm composer-setup.php + +RUN docker-php-ext-configure mysqli \ + && docker-php-ext-install -j$(nproc) mysqli + +USER php diff --git a/docker/otel-tests/docker/kafka/update_run.sh b/docker/otel-tests/docker/kafka/update_run.sh new file mode 100755 index 0000000..ef79d5a --- /dev/null +++ b/docker/otel-tests/docker/kafka/update_run.sh @@ -0,0 +1,11 @@ +# This script is required to run kafka cluster (without zookeeper) +#!/bin/sh + +# Docker workaround: Remove check for KAFKA_ZOOKEEPER_CONNECT parameter +sed -i '/KAFKA_ZOOKEEPER_CONNECT/d' /etc/confluent/docker/configure + +# Docker workaround: Ignore cub zk-ready +sed -i 's/cub zk-ready/echo ignore zk-ready/' /etc/confluent/docker/ensure + +# KRaft required step: Format the storage directory with a new cluster ID +echo "kafka-storage format --ignore-formatted -t $(kafka-storage random-uuid) -c /etc/kafka/kafka.properties" >> /etc/confluent/docker/ensure diff --git a/docker/otel-tests/docker/mysql/init.sql b/docker/otel-tests/docker/mysql/init.sql new file mode 100644 index 0000000..e1b5c34 --- /dev/null +++ b/docker/otel-tests/docker/mysql/init.sql @@ -0,0 +1,34 @@ +CREATE DATABASE IF NOT EXISTS otel_db2; +CREATE USER 'otel_user2'@'%' IDENTIFIED BY 'otel_passwd'; + + +GRANT ALL PRIVILEGES ON *.* TO 'otel_user'@'%'; +GRANT ALL PRIVILEGES ON *.* TO 'otel_user2'@'%'; +FLUSH PRIVILEGES; + + +USE otel_db; + +CREATE TABLE users ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + email VARCHAR(255) UNIQUE NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +INSERT INTO users (name, email) VALUES +('John Doe', 'john.doe@example.com'), +('Jane Smith', 'jane.smith@example.com'), +('Bob Johnson', 'bob.johnson@example.com'); + +CREATE TABLE products ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + price DECIMAL(10, 2) NOT NULL, + stock INT NOT NULL DEFAULT 0 +); + +INSERT INTO products (name, price, stock) VALUES +('Laptop', 999.99, 10), +('Smartphone', 499.99, 25), +('Headphones', 49.99, 50); diff --git a/tools/build/junit_summary.py b/tools/build/junit_summary.py new file mode 100755 index 0000000..6bac78c --- /dev/null +++ b/tools/build/junit_summary.py @@ -0,0 +1,72 @@ +#!/bin/env python3 +import argparse +import glob +import xml.etree.ElementTree as ET + +def parse_arguments(): + parser = argparse.ArgumentParser(description="Parse JUnit XML test results and generate a summary.") + parser.add_argument("--path-to-test-results", required=True, help="Path pattern to JUnit XML test result files. Supports wildcards (e.g., results/**/*.xml).") + parser.add_argument("--header", required=True, help="The title of the summary output.") + return parser.parse_args() + +def parse_junit_xml(file): + try: + tree = ET.parse(file) + root = tree.getroot() + + # Extract main test summary from the first-level testsuite (overall summary) + main_suite = root.find("./testsuite[@name='']") + if main_suite is None: + main_suite = root.find("./testsuite") + + total_tests = int(main_suite.attrib.get("tests", 0)) + total_failures = int(main_suite.attrib.get("failures", 0)) + total_errors = int(main_suite.attrib.get("errors", 0)) + total_skipped = int(main_suite.attrib.get("skipped", 0)) + total_time = float(main_suite.attrib.get("time", 0)) + passed_tests = total_tests - total_failures - total_errors - total_skipped + + failures = [] + # Extract failures from nested test suites + for ts in root.findall(".//testsuite[@name!='']"): + for tc in ts.findall("testcase[failure]"): + class_name = tc.attrib.get("classname", "Unknown") + test_name = tc.attrib.get("name", "Unknown") + file_path = tc.attrib.get("file", "Unknown") + line = tc.attrib.get("line", "Unknown") + failure_message = tc.find("failure").text.strip() if tc.find("failure") is not None else "Unknown" + failure_message = failure_message.replace("\n", "
") # Convert newlines to Markdown format + failures.append((class_name, test_name, f"{file_path}:{line}", failure_message)) + + return file, passed_tests, total_failures, total_skipped, total_errors, total_time, failures + except Exception as e: + print(f"Error processing {file}: {e}") + return file, 0, 0, 0, 0, 0.0, [] + +def main(): + args = parse_arguments() + files = glob.glob(args.path_to_test_results, recursive=True) + + print(f"## {args.header}\n") + print("| Status | File | ✅ Passed | ❌ Failed | ⚠ Skipped | 🔍 Errors | ⏱ Time (s) |") + print("|--------|------|---------|---------|---------|---------|---------|") + + all_failures = [] + for file in files: + file, passed, failed, skipped, errors, total_time, failures = parse_junit_xml(file) + status = "✅" if failed == 0 else "❌" + print(f"| {status} | {file} | {passed} | {failed} | {skipped} | {errors} | {total_time:.3f} |") + all_failures.extend(failures) + + if all_failures: + print("
\nFailure Details\n") + print("### Failure Details\n") + print("| Test Class | Test Name | File:Line | Failure Message |") + print("|------------|----------|----------|----------------|") + for class_name, test_name, file_line, failure_message in all_failures: + print(f"| {class_name} | {test_name} | {file_line} | {failure_message} |") + print("\n
\n") + + +if __name__ == "__main__": + main() diff --git a/tools/build/test_otel_unit_tests.sh b/tools/build/test_otel_unit_tests.sh new file mode 100755 index 0000000..a2f42e3 --- /dev/null +++ b/tools/build/test_otel_unit_tests.sh @@ -0,0 +1,206 @@ +#!/bin/bash + +OPT_WORKINGDIR=/tmp/test-run +OPT_REPORTS_DESTINATION_PATH=/tmp/reports + +show_help() { + cat < -p [-p ...] + -f Path to the composer.json file + -p Package name pattern (can be specified multiple times) + -w Working directory (default '/tmp/test-run') + -r Destination path for junit reports (default '/tmp/reports') + -q Optional. Composer in quiet mode. + -h Display this help message +EOF +} + +export ELASTIC_OTEL_TRANSACTION_SPAN_ENABLED_CLI=false +export ELASTIC_OTEL_TRANSACTION_SPAN_ENABLED=false + +OPT_COMPOSER_FILE="" +OPT_PATTERNS=() + +# Parse options using getopts +while getopts "f:r:p:w:rhq" opt; do + case "$opt" in + f) + OPT_COMPOSER_FILE="$OPTARG" + ;; + r) + OPT_REPORTS_DESTINATION_PATH="$OPTARG" + ;; + p) + OPT_PATTERNS+=("$OPTARG") + ;; + w) + OPT_WORKINGDIR="$OPTARG" + ;; + q) + OPT_QUIET=" --quiet " + ;; + h) + show_help + exit 0 + ;; + *) + show_help + exit 1 + ;; + esac +done + +if [[ -z "$OPT_COMPOSER_FILE" || ${#OPT_PATTERNS[@]} -eq 0 ]]; then + echo "::error::You must provide a composer.json file path and at least one pattern." >&2 + show_help + exit 1 +fi + +if [[ ! -f "$OPT_COMPOSER_FILE" ]]; then + echo "::error::File '$OPT_COMPOSER_FILE' does not exist." >&2 + exit 1 +fi + +# Verify that jq is installed +if ! command -v jq &>/dev/null; then + echo "::error::'jq' is not installed. Please install it and try again." >&2 + exit 1 +fi + +array_contains_element() { + local element + for element in "${@:2}"; do + [[ "$element" == "$1" ]] && return 0 + done + return 1 +} + +match_packages() { + MATCHED_PACKAGES=() + local pattern + local local_found + for pattern in "${OPT_PATTERNS[@]}"; do + echo "Searching for packages matching pattern: $pattern" + local_found=0 + while IFS= read -r pkg; do + # Use Bash pattern matching (wildcards) to compare package names + if [[ "$pkg" == $pattern ]]; then + echo " Matched: $pkg" + local_found=1 + + if ! array_contains_element "$pkg" "${MATCHED_PACKAGES[@]}"; then + MATCHED_PACKAGES+=("$pkg") + fi + fi + done <<<"$REQUIRE_PACKAGES" + if [[ $local_found -eq 0 ]]; then + echo " No packages match pattern: $pattern" + fi + done +} + +setup_composer_project() { + echo "::group::Installing composer project" + composer ${OPT_QUIET} init -n --name "elastic/otel-tests" + composer ${OPT_QUIET} config --no-plugins allow-plugins.php-http/discovery false + composer ${OPT_QUIET} config allow-plugins.tbachert/spi false + local package + for package in "${MATCHED_PACKAGES[@]}"; do + composer ${OPT_QUIET} config preferred-install.$package source + done + composer ${OPT_QUIET} config preferred-install.* dist + composer ${OPT_QUIET} update + echo "::endgroup::" +} + +function build_list_of_packages_with_version_to_install { # Build a list of packages with version constraints to install at once + LIST_OF_PACKAGES_TO_INSTALL=() + local package + local version + + for package in "${MATCHED_PACKAGES[@]}"; do + # Extract the version constraint from the composer.json file + version=$(jq -r --arg pkg "$package" '.require[$pkg]' "$OPT_COMPOSER_FILE") + if [[ -z "$version" || "$version" == "null" ]]; then + echo "::error::No version constraint found for package $package." >&2 + continue + fi + LIST_OF_PACKAGES_TO_INSTALL+=("$package:$version") + done +} + +# Extract package names from the "require" section using jq +REQUIRE_PACKAGES=$(jq -r '.require | keys[]' "$OPT_COMPOSER_FILE") + +if [[ -z "$REQUIRE_PACKAGES" ]]; then + echo "No packages found in the 'require' section of $OPT_COMPOSER_FILE." + exit 0 +fi + +match_packages + +if [[ ${#MATCHED_PACKAGES[@]} -eq 0 ]]; then + echo "::error::No packages matched the provided OPT_PATTERNS." + exit 1 +fi + +build_list_of_packages_with_version_to_install + +if [[ ${#LIST_OF_PACKAGES_TO_INSTALL[@]} -eq 0 ]]; then + echo "::error::No valid packages with version constraints found." + exit 1 +fi + +mkdir -p "${OPT_REPORTS_DESTINATION_PATH}" +mkdir -p "${OPT_WORKINGDIR}" + +cd "${OPT_WORKINGDIR}" + +setup_composer_project + +echo "::group::Installing matched packages with specified versions:" +echo " ${LIST_OF_PACKAGES_TO_INSTALL[*]}" + +composer ${OPT_QUIET} require --dev --ignore-platform-req php "${LIST_OF_PACKAGES_TO_INSTALL[@]}" +if [[ $? -ne 0 ]]; then + echo "::error::Failed to install one or more packages" + echo "::endgroup::" + popd + exit 1 +fi + +cd - + +echo "::endgroup::" + +FAILURE=false + +for package in "${MATCHED_PACKAGES[@]}"; do + vendor_dir="${OPT_WORKINGDIR}/vendor/$package" + + echo "Preparing PHPUnit tests for package in directory: $vendor_dir" + + cd $vendor_dir + echo "::group::Installing $package dependencies" + composer ${OPT_QUIET} config --no-plugins allow-plugins.php-http/discovery false + composer ${OPT_QUIET} config allow-plugins.tbachert/spi true + composer ${OPT_QUIET} install --dev --ignore-platform-req php + echo "::endgroup::" + + echo "::group::🚀 Running $package tests 🚀" + ./vendor/bin/phpunit --debug --log-junit ${OPT_REPORTS_DESTINATION_PATH}/$package.xml + + if [[ $? -ne 0 ]]; then + echo "::error::PHPUnit tests failed for package $package" + FAILURE=true + fi + + echo "::endgroup::" + cd - + +done + +if [ "$FAILURE" = "true" ]; then + echo "::error::At least one test failed" + exit 1 +fi diff --git a/tools/build/test_otel_unit_tests_in_docker.sh b/tools/build/test_otel_unit_tests_in_docker.sh new file mode 100755 index 0000000..3e4e4e3 --- /dev/null +++ b/tools/build/test_otel_unit_tests_in_docker.sh @@ -0,0 +1,105 @@ +#!/bin/bash + +RESULTS_PATH="${PWD}/build/otel_test_results" + +show_help() { + echo "Usage: $0 --build_architecture --php_versions --results_path --deb_package " + echo + echo "Arguments:" + echo " --php_versions Required. List of PHP versions separated by spaces (e.g., '81 82 83 84')." + echo " --results_path Optional. The path where the results will be saved if a test failure occurs. (default is '${RESULTS_PATH}')" + echo " --deb_package Optional. The path to debian package to install inside container." + echo " --quiet Optional. Quiet composer." + echo + echo "Example:" + echo " $0 --php_versions '81 82 83 84'" +} + +parse_args() { + while [[ "$#" -gt 0 ]]; do + case $1 in + --php_versions) + PHP_VERSIONS=($2) + shift + ;; + --results_path) + RESULTS_PATH=($2) + shift + ;; + --deb_package) + PACKAGE=($2) + shift + ;; + --quiet) + QUIET=" -q " + shift + ;; + --help) + show_help + exit 0 + ;; + *) + echo "Unknown parameter passed: $1" + show_help + exit 1 + ;; + esac + shift + done +} + +ROOT_PATH=${PWD} + +parse_args "$@" + +if [[ -z "$PHP_VERSIONS" ]]; then + echo "Error: Missing required arguments." + show_help + exit 1 +fi + +echo "PHP_VERSIONS: ${PHP_VERSIONS[@]}" +echo "RESULTS PATH: ${RESULTS_PATH}" + +TEST_ERROR=0 + +COMPOSE_FILE=${PWD}/docker/otel-tests/docker-compose.yml + +for PHP_VERSION in "${PHP_VERSIONS[@]}"; do + export PHP_VERSION="${PHP_VERSION:0:1}.${PHP_VERSION:1}" + + echo "::group::Building docker images" + docker compose -f ${COMPOSE_FILE} build php + echo "::endgroup::" + + echo "::group::Starting dependency containers" + docker compose -f ${COMPOSE_FILE} up --force-recreate -d mysql + echo "::endgroup::" + + VOLUMES=" -v ${PWD}:/source -v $(realpath ${RESULTS_PATH}):/results" + COMMAND="/source/tools/build/test_otel_unit_tests.sh -f /source/composer.json -r /results -w /tmp/otel-test-run ${QUIET} -p open-telemetry/opentelemetry-auto-\*" + + if [ -n "${PACKAGE}" ]; then + VOLUMES+=" -v $(realpath ${PACKAGE}):/package/package.deb " + COMMAND="apt install /package/package.deb && ${COMMAND}" + fi + + docker compose --progress plain -f ${COMPOSE_FILE} run --remove-orphans --rm ${VOLUMES} \ + -w /tmp php sh -c "${COMMAND}" + + ERRCODE=$? + + if [ $ERRCODE -ne 0 ]; then + TEST_ERROR=$ERRCODE + fi + + echo "::group::Stopping and removing containers" + docker compose -f ${COMPOSE_FILE} stop + docker compose -f ${COMPOSE_FILE} rm -f + echo "::endgroup::" +done + +if [[ $TEST_ERROR -ne 0 ]]; then + echo "::error::At least one test failed" + exit 1 +fi