From bda6c7b180e1181e8913c1340a6809564eef5cc2 Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Sun, 30 Aug 2026 04:25:04 +0200 Subject: [PATCH 01/31] Forkde to my account - Added repository line to download executables Forkde to my account - Added repository line to download executables .github/workflows/run-interop-tests.yml: added : repository: "omg-dds/dds-rtps" (before latest: "robinraju" line and "latest: true" line) --- .github/workflows/1_run_interoperability_tests.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/1_run_interoperability_tests.yml b/.github/workflows/1_run_interoperability_tests.yml index 173c82b..8af933e 100644 --- a/.github/workflows/1_run_interoperability_tests.yml +++ b/.github/workflows/1_run_interoperability_tests.yml @@ -41,6 +41,7 @@ jobs: - name: Download shape_main executables uses: robinraju/release-downloader@v1.13 with: + repository: "omg-dds/dds-rtps" # <-- PŘIDEJTE TENTO ŘÁDEK latest: true fileName: "*" out-file-path: zipped_executables From 025786507344662fd35c30042b2db032f4d9168c Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Sun, 30 Aug 2026 08:10:15 +0200 Subject: [PATCH 02/31] interoperability.yml - fail-fast: false MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit solving of ECONRESET during tests run_tests(opendds, dust_dds) – read ECONNRESET --- .github/workflows/1_run_interoperability_tests.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/1_run_interoperability_tests.yml b/.github/workflows/1_run_interoperability_tests.yml index 8af933e..146f70f 100644 --- a/.github/workflows/1_run_interoperability_tests.yml +++ b/.github/workflows/1_run_interoperability_tests.yml @@ -28,6 +28,8 @@ jobs: runs-on: ubuntu-latest needs: generate_timestamp strategy: +strategy: + fail-fast: false matrix: publisher: ${{ fromJson(github.event.inputs.publishers) }} subscriber: ${{ fromJson(github.event.inputs.subscribers) }} From d9ddc9a06db2c3272e6f7faac2e27177bab0ac39 Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Sun, 30 Aug 2026 08:18:21 +0200 Subject: [PATCH 03/31] Fix indentation in1_run_interoperability_tests.yml file fixed error --- .github/workflows/1_run_interoperability_tests.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/1_run_interoperability_tests.yml b/.github/workflows/1_run_interoperability_tests.yml index 146f70f..d2d527e 100644 --- a/.github/workflows/1_run_interoperability_tests.yml +++ b/.github/workflows/1_run_interoperability_tests.yml @@ -28,8 +28,7 @@ jobs: runs-on: ubuntu-latest needs: generate_timestamp strategy: -strategy: - fail-fast: false + fail-fast: false matrix: publisher: ${{ fromJson(github.event.inputs.publishers) }} subscriber: ${{ fromJson(github.event.inputs.subscribers) }} From ad7738ae3885d3dee67ea2ed52c2a86bb31057a5 Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Sun, 30 Aug 2026 15:16:58 +0200 Subject: [PATCH 04/31] upload process to use Rclone Replaced Google Drive upload actions with Rclone for artifact uploads. --- .github/workflows/2_upload_artifact.yml | 29 +++++++++++-------------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/.github/workflows/2_upload_artifact.yml b/.github/workflows/2_upload_artifact.yml index 0c9318c..e1e3620 100644 --- a/.github/workflows/2_upload_artifact.yml +++ b/.github/workflows/2_upload_artifact.yml @@ -11,6 +11,7 @@ jobs: name: interoperability_report_complete workflow: 1_run_interoperability_tests.yml workflow_conclusion: completed + - name: Create files to upload run: | current_date=$(cat timestamp) @@ -22,23 +23,19 @@ jobs: mkdir report_$current_date mv *.zip report_$current_date/ mv *.xlsx report_$current_date/ - - name: Upload zip artifact - uses: adityak74/google-drive-upload-git-action@main + + - name: Setup Rclone + uses: AnimMouse/setup-rclone@v1 with: - credentials: ${{ secrets.GCP_CREDENTIAL }} - filename: report_*/*.zip - folderId: ${{ secrets.DRIVE_FOLDER_ID }} - overwrite: "true" - mirrorDirectoryStructure: "true" - - name: Upload spreadsheet - uses: adityak74/google-drive-upload-git-action@main - with: - credentials: ${{ secrets.GCP_CREDENTIAL }} - filename: report_*/*.xlsx - folderId: ${{ secrets.DRIVE_FOLDER_ID }} - overwrite: "true" - mirrorDirectoryStructure: "true" - mimeType: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + rclone_config: | + [mygdrive] + type = drive + token = ${{ secrets.RCLONE_TOKEN }} + + - name: Upload reports to Google Drive + run: | + rclone copy report_* mygdrive: --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} -v + - name: Attach detailed report html uses: actions/upload-artifact@v4 with: From da0af673dc642625d86492f4fdb4d0c300343719 Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Sun, 30 Aug 2026 15:22:56 +0200 Subject: [PATCH 05/31] Disable base64 encoding for rclone upload --- .github/workflows/2_upload_artifact.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/2_upload_artifact.yml b/.github/workflows/2_upload_artifact.yml index e1e3620..cb0690e 100644 --- a/.github/workflows/2_upload_artifact.yml +++ b/.github/workflows/2_upload_artifact.yml @@ -31,6 +31,7 @@ jobs: [mygdrive] type = drive token = ${{ secrets.RCLONE_TOKEN }} + disable_base64: true - name: Upload reports to Google Drive run: | From 03c7a4cd40c3e76416c59d9bcaf15a9143af8350 Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Mon, 31 Aug 2026 03:52:05 +0200 Subject: [PATCH 06/31] Add batch run and compare workflow for reports --- .../workflows/batch_run_and_compare.yml | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .github/workflows/.github/workflows/batch_run_and_compare.yml diff --git a/.github/workflows/.github/workflows/batch_run_and_compare.yml b/.github/workflows/.github/workflows/batch_run_and_compare.yml new file mode 100644 index 0000000..637b607 --- /dev/null +++ b/.github/workflows/.github/workflows/batch_run_and_compare.yml @@ -0,0 +1,129 @@ +name: 3 - Batch Run and Compare Reports +run-name: Batch Run (${{ github.event.inputs.iterations }}x) & Compare Reports +on: + workflow_dispatch: + inputs: + iterations: + description: 'Number of repetitions (N)' + required: true + default: '3' + type: number + publishers: + description: 'Publishers to use' + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + subscribers: + description: 'Subscribers to use' + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + +permissions: + actions: write + contents: read + +jobs: + run_batches: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Run N iterations of Workflow 1 and Workflow 2 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + ITERATIONS: ${{ github.event.inputs.iterations }} + PUBLISHERS: ${{ github.event.inputs.publishers }} + SUBSCRIBERS: ${{ github.event.inputs.subscribers }} + run: | + echo "Running $ITERATIONS ..." + + for i in $(seq 1 $ITERATIONS); do + echo "==========================================" + echo " Running ITERACI $i of $ITERATIONS" + echo "==========================================" + + # 1. Run Workflow 1 + echo "Running 1 - Run Interoperability Tests..." + gh workflow run "1 - Run Interoperability Tests" \ + --repo "$REPO" \ + -f publishers="$PUBLISHERS" \ + -f subscribers="$SUBSCRIBERS" + + sleep 10 + + # Get ID of last run Workflow 1 + RUN_1_ID=$(gh run list --workflow="1 - Run Interoperability Tests" --repo "$REPO" --limit 1 --json databaseId -q '.[0].databaseId') + echo "Waiting for Workflow 1 (Run ID: $RUN_1_ID)..." + gh run watch "$RUN_1_ID" --repo "$REPO" --exit-status + + # 2. Run Workflow 2 + echo "Running 2 - Upload Test Results to Drive..." + gh workflow run "2 - Upload Test Results to Drive" --repo "$REPO" + + sleep 10 + + # Get ID Workflow 2 + RUN_2_ID=$(gh run list --workflow="2 - Upload Test Results to Drive" --repo "$REPO" --limit 1 --json databaseId -q '.[0].databaseId') + echo "Waiting for Workflow 2 (Run ID: $RUN_2_ID)..." + gh run watch "$RUN_2_ID" --repo "$REPO" --exit-status + + echo "✓ Iteration $i successfully ended." + done + + - name: Setup Rclone + uses: AnimMouse/setup-rclone@v1 + with: + rclone_config: | + [mygdrive] + type = drive + token = ${{ secrets.RCLONE_TOKEN }} + disable_base64: true + + - name: Download zip reports from Google Drive + run: | + mkdir -p gdrive_download/zips + mkdir -p xml_reports + + echo "Downloading report folder from Google Drive..." + rclone copy mygdrive: gdrive_download/ --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} --include "report_*/**.zip" -v + + echo "Extracting junit XML files ..." + find gdrive_download -type f -name "*.zip" -exec unzip -o {} -d xml_reports/ \; + + echo "List of XML files:" + ls -la xml_reports/ + + - name: Run comparison script + run: | + # 1. download compare script + curl -sSL -o compare_junit_reports.py \ + https://raw.githubusercontent.com/martinrichtersupervag/compare_junit_reports/main/compare_junit_reports/test_compare_junit_reports.py + + # 2. run comparison + python3 compare_junit_reports.py --directory ./xml_reports --compare-all --show-difference | tee comparison_output.txt + + - name: Publish Summary to GitHub Actions + if: always() + run: | + if [ -f comparison_output.txt ]; then + echo "## Comparison results (--compare-all --show-difference)" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat comparison_output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload Comparison Artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: comparison_results + path: | + ./comparison_output.txt + ./xml_reports/*.txt + ./xml_reports/junit_*.xml From e9d8c8a477caf04b966078ecdfa71fba1c31f211 Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Mon, 31 Aug 2026 03:56:44 +0200 Subject: [PATCH 07/31] batch run and compare reports workflow ntimes --- .github/workflows/batch_run_and_compare.yml | 129 ++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .github/workflows/batch_run_and_compare.yml diff --git a/.github/workflows/batch_run_and_compare.yml b/.github/workflows/batch_run_and_compare.yml new file mode 100644 index 0000000..637b607 --- /dev/null +++ b/.github/workflows/batch_run_and_compare.yml @@ -0,0 +1,129 @@ +name: 3 - Batch Run and Compare Reports +run-name: Batch Run (${{ github.event.inputs.iterations }}x) & Compare Reports +on: + workflow_dispatch: + inputs: + iterations: + description: 'Number of repetitions (N)' + required: true + default: '3' + type: number + publishers: + description: 'Publishers to use' + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + subscribers: + description: 'Subscribers to use' + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + +permissions: + actions: write + contents: read + +jobs: + run_batches: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Run N iterations of Workflow 1 and Workflow 2 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + ITERATIONS: ${{ github.event.inputs.iterations }} + PUBLISHERS: ${{ github.event.inputs.publishers }} + SUBSCRIBERS: ${{ github.event.inputs.subscribers }} + run: | + echo "Running $ITERATIONS ..." + + for i in $(seq 1 $ITERATIONS); do + echo "==========================================" + echo " Running ITERACI $i of $ITERATIONS" + echo "==========================================" + + # 1. Run Workflow 1 + echo "Running 1 - Run Interoperability Tests..." + gh workflow run "1 - Run Interoperability Tests" \ + --repo "$REPO" \ + -f publishers="$PUBLISHERS" \ + -f subscribers="$SUBSCRIBERS" + + sleep 10 + + # Get ID of last run Workflow 1 + RUN_1_ID=$(gh run list --workflow="1 - Run Interoperability Tests" --repo "$REPO" --limit 1 --json databaseId -q '.[0].databaseId') + echo "Waiting for Workflow 1 (Run ID: $RUN_1_ID)..." + gh run watch "$RUN_1_ID" --repo "$REPO" --exit-status + + # 2. Run Workflow 2 + echo "Running 2 - Upload Test Results to Drive..." + gh workflow run "2 - Upload Test Results to Drive" --repo "$REPO" + + sleep 10 + + # Get ID Workflow 2 + RUN_2_ID=$(gh run list --workflow="2 - Upload Test Results to Drive" --repo "$REPO" --limit 1 --json databaseId -q '.[0].databaseId') + echo "Waiting for Workflow 2 (Run ID: $RUN_2_ID)..." + gh run watch "$RUN_2_ID" --repo "$REPO" --exit-status + + echo "✓ Iteration $i successfully ended." + done + + - name: Setup Rclone + uses: AnimMouse/setup-rclone@v1 + with: + rclone_config: | + [mygdrive] + type = drive + token = ${{ secrets.RCLONE_TOKEN }} + disable_base64: true + + - name: Download zip reports from Google Drive + run: | + mkdir -p gdrive_download/zips + mkdir -p xml_reports + + echo "Downloading report folder from Google Drive..." + rclone copy mygdrive: gdrive_download/ --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} --include "report_*/**.zip" -v + + echo "Extracting junit XML files ..." + find gdrive_download -type f -name "*.zip" -exec unzip -o {} -d xml_reports/ \; + + echo "List of XML files:" + ls -la xml_reports/ + + - name: Run comparison script + run: | + # 1. download compare script + curl -sSL -o compare_junit_reports.py \ + https://raw.githubusercontent.com/martinrichtersupervag/compare_junit_reports/main/compare_junit_reports/test_compare_junit_reports.py + + # 2. run comparison + python3 compare_junit_reports.py --directory ./xml_reports --compare-all --show-difference | tee comparison_output.txt + + - name: Publish Summary to GitHub Actions + if: always() + run: | + if [ -f comparison_output.txt ]; then + echo "## Comparison results (--compare-all --show-difference)" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat comparison_output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload Comparison Artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: comparison_results + path: | + ./comparison_output.txt + ./xml_reports/*.txt + ./xml_reports/junit_*.xml From 33cdd54a130268f7c555ed663aa11a62c07380ae Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Mon, 31 Aug 2026 04:02:54 +0200 Subject: [PATCH 08/31] batch_run_and_compare2.yml --- .github/workflows/batch_run_and_compare2.yml | 129 +++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 .github/workflows/batch_run_and_compare2.yml diff --git a/.github/workflows/batch_run_and_compare2.yml b/.github/workflows/batch_run_and_compare2.yml new file mode 100644 index 0000000..637b607 --- /dev/null +++ b/.github/workflows/batch_run_and_compare2.yml @@ -0,0 +1,129 @@ +name: 3 - Batch Run and Compare Reports +run-name: Batch Run (${{ github.event.inputs.iterations }}x) & Compare Reports +on: + workflow_dispatch: + inputs: + iterations: + description: 'Number of repetitions (N)' + required: true + default: '3' + type: number + publishers: + description: 'Publishers to use' + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + subscribers: + description: 'Subscribers to use' + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + +permissions: + actions: write + contents: read + +jobs: + run_batches: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: '3.11' + + - name: Run N iterations of Workflow 1 and Workflow 2 + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + ITERATIONS: ${{ github.event.inputs.iterations }} + PUBLISHERS: ${{ github.event.inputs.publishers }} + SUBSCRIBERS: ${{ github.event.inputs.subscribers }} + run: | + echo "Running $ITERATIONS ..." + + for i in $(seq 1 $ITERATIONS); do + echo "==========================================" + echo " Running ITERACI $i of $ITERATIONS" + echo "==========================================" + + # 1. Run Workflow 1 + echo "Running 1 - Run Interoperability Tests..." + gh workflow run "1 - Run Interoperability Tests" \ + --repo "$REPO" \ + -f publishers="$PUBLISHERS" \ + -f subscribers="$SUBSCRIBERS" + + sleep 10 + + # Get ID of last run Workflow 1 + RUN_1_ID=$(gh run list --workflow="1 - Run Interoperability Tests" --repo "$REPO" --limit 1 --json databaseId -q '.[0].databaseId') + echo "Waiting for Workflow 1 (Run ID: $RUN_1_ID)..." + gh run watch "$RUN_1_ID" --repo "$REPO" --exit-status + + # 2. Run Workflow 2 + echo "Running 2 - Upload Test Results to Drive..." + gh workflow run "2 - Upload Test Results to Drive" --repo "$REPO" + + sleep 10 + + # Get ID Workflow 2 + RUN_2_ID=$(gh run list --workflow="2 - Upload Test Results to Drive" --repo "$REPO" --limit 1 --json databaseId -q '.[0].databaseId') + echo "Waiting for Workflow 2 (Run ID: $RUN_2_ID)..." + gh run watch "$RUN_2_ID" --repo "$REPO" --exit-status + + echo "✓ Iteration $i successfully ended." + done + + - name: Setup Rclone + uses: AnimMouse/setup-rclone@v1 + with: + rclone_config: | + [mygdrive] + type = drive + token = ${{ secrets.RCLONE_TOKEN }} + disable_base64: true + + - name: Download zip reports from Google Drive + run: | + mkdir -p gdrive_download/zips + mkdir -p xml_reports + + echo "Downloading report folder from Google Drive..." + rclone copy mygdrive: gdrive_download/ --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} --include "report_*/**.zip" -v + + echo "Extracting junit XML files ..." + find gdrive_download -type f -name "*.zip" -exec unzip -o {} -d xml_reports/ \; + + echo "List of XML files:" + ls -la xml_reports/ + + - name: Run comparison script + run: | + # 1. download compare script + curl -sSL -o compare_junit_reports.py \ + https://raw.githubusercontent.com/martinrichtersupervag/compare_junit_reports/main/compare_junit_reports/test_compare_junit_reports.py + + # 2. run comparison + python3 compare_junit_reports.py --directory ./xml_reports --compare-all --show-difference | tee comparison_output.txt + + - name: Publish Summary to GitHub Actions + if: always() + run: | + if [ -f comparison_output.txt ]; then + echo "## Comparison results (--compare-all --show-difference)" >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + cat comparison_output.txt >> $GITHUB_STEP_SUMMARY + echo '```' >> $GITHUB_STEP_SUMMARY + fi + + - name: Upload Comparison Artifact + if: always() + uses: actions/upload-artifact@v4 + with: + name: comparison_results + path: | + ./comparison_output.txt + ./xml_reports/*.txt + ./xml_reports/junit_*.xml From 6cd208e749b7de3bcc6742ab0f517f10d335c9d0 Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Mon, 31 Aug 2026 04:05:06 +0200 Subject: [PATCH 09/31] Rename workflow to 'Batch Run and Compare Reports' --- .github/workflows/batch_run_and_compare2.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/batch_run_and_compare2.yml b/.github/workflows/batch_run_and_compare2.yml index 637b607..e15808e 100644 --- a/.github/workflows/batch_run_and_compare2.yml +++ b/.github/workflows/batch_run_and_compare2.yml @@ -1,4 +1,4 @@ -name: 3 - Batch Run and Compare Reports +name: Batch Run and Compare Reports run-name: Batch Run (${{ github.event.inputs.iterations }}x) & Compare Reports on: workflow_dispatch: From 3fa9f8ae14d47e53881673866d7452a2098ee6c0 Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Mon, 14 Sep 2026 04:12:37 +0200 Subject: [PATCH 10/31] Add files via upload --- .../1_run_interoperability_tests_local.yml | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 .github/workflows/.github/1_run_interoperability_tests_local.yml diff --git a/.github/workflows/.github/1_run_interoperability_tests_local.yml b/.github/workflows/.github/1_run_interoperability_tests_local.yml new file mode 100644 index 0000000..35ac3aa --- /dev/null +++ b/.github/workflows/.github/1_run_interoperability_tests_local.yml @@ -0,0 +1,123 @@ +name: 1 - Run Interoperability Tests Local +run-name: Run Interoperability Tests Local +on: + workflow_dispatch: + inputs: + publishers: + description: Publishers to use + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + subscribers: + description: Subscribers to use + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' +jobs: + generate_timestamp: + runs-on: self-hosted + steps: + - name: Generate timestamp file + run: date '+%Y-%m-%d-%H_%M_%S' > timestamp + - name: Upload timestamp file + uses: actions/upload-artifact@v7 + with: + name: timestamp + path: | + ./timestamp + + run_tests: + runs-on: self-hosted + needs: generate_timestamp + strategy: + fail-fast: false + matrix: + publisher: ${{ fromJson(github.event.inputs.publishers) }} + subscriber: ${{ fromJson(github.event.inputs.subscribers) }} + + steps: + - name: Checkout sources + uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.11.4' + - name: Download shape_main executables + uses: robinraju/release-downloader@v1.13 + with: + repository: "omg-dds/dds-rtps" # <-- PŘIDEJTE TENTO ŘÁDEK + latest: true + fileName: "*" + out-file-path: zipped_executables + preRelease: false + - name: Unzip executables + run: unzip 'zipped_executables/*.zip' -d executables + - name: Install Python requirements + run: pip install --requirement requirements.txt + - name: Run Interoperability script + timeout-minutes: 120 + continue-on-error: true + run: | + publisher_exe=executables/${{ matrix.publisher }}*shape_main_linux + subscriber_exe=executables/${{ matrix.subscriber }}*shape_main_linux + output_file=junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }}.xml + extra_args="" + # Check if publisher executable exists + if ! ls $publisher_exe 1> /dev/null 2>&1; then + echo "ERROR: Publisher executable not found: $publisher_exe" + exit 1 + fi + # Check if subscriber executable exists + if ! ls $subscriber_exe 1> /dev/null 2>&1; then + echo "ERROR: Subscriber executable not found: $subscriber_exe" + exit 1 + fi + if [[ "${subscriber_exe,,}" == *opendds* && "${publisher_exe,,}" == *connext_dds* ]]; then + extra_args="--periodic-announcement 5000" + fi + python3 interoperability_report.py --publisher $publisher_exe --subscriber $subscriber_exe --output-name $output_file $extra_args + - name: Download timestamp + uses: actions/download-artifact@v8 + with: + name: timestamp + - name: Upload report + uses: actions/upload-artifact@v7 + with: + name: junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }} + path: | + ./junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }}.xml + ./timestamp + + generate_report: + runs-on: self-hosted + needs: run_tests + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + pattern: junit_report-* + merge-multiple: true + - uses: actions/setup-python@v6 + with: + python-version: '3.11.4' + - name: Install Python requirements + run: pip install --requirement requirements.txt + - name: merge reports + run: junitparser merge *.xml junit_interoperability_report.xml + - name: Generate xlsx report + run: python3 generate_xlsx_report.py --input junit_interoperability_report.xml --output interoperability_report.xlsx + - name: XUnit + run: npx xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html + - name: Download timestamp + uses: actions/download-artifact@v8 + with: + name: timestamp + - name: Attach the report + if: always() + uses: actions/upload-artifact@v7 + with: + name: interoperability_report_complete + path: | + ./index.html + ./junit_interoperability_report.xml + ./interoperability_report.xlsx + ./timestamp From ae3182c661c17e150e31560f542fec74a064f72d Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Mon, 14 Sep 2026 04:15:57 +0200 Subject: [PATCH 11/31] 1_run_interoperability_tests_local.yml --- .../1_run_interoperability_tests_local.yml | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 .github/workflows/1_run_interoperability_tests_local.yml diff --git a/.github/workflows/1_run_interoperability_tests_local.yml b/.github/workflows/1_run_interoperability_tests_local.yml new file mode 100644 index 0000000..35ac3aa --- /dev/null +++ b/.github/workflows/1_run_interoperability_tests_local.yml @@ -0,0 +1,123 @@ +name: 1 - Run Interoperability Tests Local +run-name: Run Interoperability Tests Local +on: + workflow_dispatch: + inputs: + publishers: + description: Publishers to use + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + subscribers: + description: Subscribers to use + type: string + default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' +jobs: + generate_timestamp: + runs-on: self-hosted + steps: + - name: Generate timestamp file + run: date '+%Y-%m-%d-%H_%M_%S' > timestamp + - name: Upload timestamp file + uses: actions/upload-artifact@v7 + with: + name: timestamp + path: | + ./timestamp + + run_tests: + runs-on: self-hosted + needs: generate_timestamp + strategy: + fail-fast: false + matrix: + publisher: ${{ fromJson(github.event.inputs.publishers) }} + subscriber: ${{ fromJson(github.event.inputs.subscribers) }} + + steps: + - name: Checkout sources + uses: actions/checkout@v6 + - uses: actions/setup-python@v6 + with: + python-version: '3.11.4' + - name: Download shape_main executables + uses: robinraju/release-downloader@v1.13 + with: + repository: "omg-dds/dds-rtps" # <-- PŘIDEJTE TENTO ŘÁDEK + latest: true + fileName: "*" + out-file-path: zipped_executables + preRelease: false + - name: Unzip executables + run: unzip 'zipped_executables/*.zip' -d executables + - name: Install Python requirements + run: pip install --requirement requirements.txt + - name: Run Interoperability script + timeout-minutes: 120 + continue-on-error: true + run: | + publisher_exe=executables/${{ matrix.publisher }}*shape_main_linux + subscriber_exe=executables/${{ matrix.subscriber }}*shape_main_linux + output_file=junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }}.xml + extra_args="" + # Check if publisher executable exists + if ! ls $publisher_exe 1> /dev/null 2>&1; then + echo "ERROR: Publisher executable not found: $publisher_exe" + exit 1 + fi + # Check if subscriber executable exists + if ! ls $subscriber_exe 1> /dev/null 2>&1; then + echo "ERROR: Subscriber executable not found: $subscriber_exe" + exit 1 + fi + if [[ "${subscriber_exe,,}" == *opendds* && "${publisher_exe,,}" == *connext_dds* ]]; then + extra_args="--periodic-announcement 5000" + fi + python3 interoperability_report.py --publisher $publisher_exe --subscriber $subscriber_exe --output-name $output_file $extra_args + - name: Download timestamp + uses: actions/download-artifact@v8 + with: + name: timestamp + - name: Upload report + uses: actions/upload-artifact@v7 + with: + name: junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }} + path: | + ./junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }}.xml + ./timestamp + + generate_report: + runs-on: self-hosted + needs: run_tests + steps: + - name: Checkout + uses: actions/checkout@v6 + - name: Download artifacts + uses: actions/download-artifact@v8 + with: + pattern: junit_report-* + merge-multiple: true + - uses: actions/setup-python@v6 + with: + python-version: '3.11.4' + - name: Install Python requirements + run: pip install --requirement requirements.txt + - name: merge reports + run: junitparser merge *.xml junit_interoperability_report.xml + - name: Generate xlsx report + run: python3 generate_xlsx_report.py --input junit_interoperability_report.xml --output interoperability_report.xlsx + - name: XUnit + run: npx xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html + - name: Download timestamp + uses: actions/download-artifact@v8 + with: + name: timestamp + - name: Attach the report + if: always() + uses: actions/upload-artifact@v7 + with: + name: interoperability_report_complete + path: | + ./index.html + ./junit_interoperability_report.xml + ./interoperability_report.xlsx + ./timestamp From 55f16d6b44cc6774ea3d4256c7300670b52d01fb Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Mon, 14 Sep 2026 04:21:26 +0200 Subject: [PATCH 12/31] 2_upload_artifact_local.yml --- .github/workflows/2_upload_artifact_local.yml | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 .github/workflows/2_upload_artifact_local.yml diff --git a/.github/workflows/2_upload_artifact_local.yml b/.github/workflows/2_upload_artifact_local.yml new file mode 100644 index 0000000..10a1439 --- /dev/null +++ b/.github/workflows/2_upload_artifact_local.yml @@ -0,0 +1,45 @@ +name: 2 - Upload Test Results to Drive +run-name: ${{ github.actor }} is testing out GitHub Actions 🚀 +on: workflow_dispatch +jobs: + upload_artifact: + runs-on: self-hosted + steps: + - name: Get Artifact + uses: dawidd6/action-download-artifact@v6 + with: + name: interoperability_report_complete + workflow: 1_run_interoperability_tests_local.yml + workflow_conclusion: completed + + - name: Create files to upload + run: | + current_date=$(cat timestamp) + mv index.html interoperability_report.html + for file in *; do \ + mv "$file" "${file%.*}_$current_date.${file##*.}"; \ + done + zip interoperability_report_$current_date.zip * + mkdir report_$current_date + mv *.zip report_$current_date/ + mv *.xlsx report_$current_date/ + + - name: Setup Rclone + uses: AnimMouse/setup-rclone@v1 + with: + rclone_config: | + [mygdrive] + type = drive + token = ${{ secrets.RCLONE_TOKEN }} + disable_base64: true + + - name: Upload reports to Google Drive + run: | + rclone copy report_* mygdrive: --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} -v + + - name: Attach detailed report html + uses: actions/upload-artifact@v4 + with: + name: detailed_report + path: | + ./interoperability_report*.html \ No newline at end of file From ba7553e2fd4dfcc60393f4c7c0bc5e4fc2711527 Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Mon, 14 Sep 2026 04:28:25 +0200 Subject: [PATCH 13/31] 2 - Upload Local Test Results to Drive --- .github/workflows/2_upload_artifact_local.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/2_upload_artifact_local.yml b/.github/workflows/2_upload_artifact_local.yml index 10a1439..3c7b365 100644 --- a/.github/workflows/2_upload_artifact_local.yml +++ b/.github/workflows/2_upload_artifact_local.yml @@ -1,4 +1,4 @@ -name: 2 - Upload Test Results to Drive +name: 2 - Upload Local Test Results to Drive run-name: ${{ github.actor }} is testing out GitHub Actions 🚀 on: workflow_dispatch jobs: @@ -42,4 +42,4 @@ jobs: with: name: detailed_report path: | - ./interoperability_report*.html \ No newline at end of file + ./interoperability_report*.html From e0ac97e7f0936582d5348032c1232cdbc893a0c4 Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Mon, 14 Sep 2026 04:36:15 +0200 Subject: [PATCH 14/31] Remove Python setup steps from workflow 1 local Removed setup-python step from the workflow. --- .github/workflows/1_run_interoperability_tests_local.yml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/.github/workflows/1_run_interoperability_tests_local.yml b/.github/workflows/1_run_interoperability_tests_local.yml index 35ac3aa..4d837d4 100644 --- a/.github/workflows/1_run_interoperability_tests_local.yml +++ b/.github/workflows/1_run_interoperability_tests_local.yml @@ -36,9 +36,6 @@ jobs: steps: - name: Checkout sources uses: actions/checkout@v6 - - uses: actions/setup-python@v6 - with: - python-version: '3.11.4' - name: Download shape_main executables uses: robinraju/release-downloader@v1.13 with: @@ -96,9 +93,6 @@ jobs: with: pattern: junit_report-* merge-multiple: true - - uses: actions/setup-python@v6 - with: - python-version: '3.11.4' - name: Install Python requirements run: pip install --requirement requirements.txt - name: merge reports From 064dda32be3df95b2ee96561ad773bd7117fdc12 Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Mon, 14 Sep 2026 04:38:01 +0200 Subject: [PATCH 15/31] Modify pip install in Test Interperability workflow Local Updated pip install command to use --break-system-packages option for compatibility. --- .github/workflows/1_run_interoperability_tests_local.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/1_run_interoperability_tests_local.yml b/.github/workflows/1_run_interoperability_tests_local.yml index 4d837d4..849fa34 100644 --- a/.github/workflows/1_run_interoperability_tests_local.yml +++ b/.github/workflows/1_run_interoperability_tests_local.yml @@ -47,7 +47,7 @@ jobs: - name: Unzip executables run: unzip 'zipped_executables/*.zip' -d executables - name: Install Python requirements - run: pip install --requirement requirements.txt + run: pip install --break-system-packages --requirement requirements.txt - name: Run Interoperability script timeout-minutes: 120 continue-on-error: true @@ -94,7 +94,7 @@ jobs: pattern: junit_report-* merge-multiple: true - name: Install Python requirements - run: pip install --requirement requirements.txt + run: pip install --break-system-packages --requirement requirements.txt - name: merge reports run: junitparser merge *.xml junit_interoperability_report.xml - name: Generate xlsx report From 904eb17aceeb557b2ae1fb54528aaf5f5cfa0403 Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Mon, 14 Sep 2026 04:48:04 +0200 Subject: [PATCH 16/31] lxml version to >=5.2.0 --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index ffdbb1b..ae54e1b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,4 @@ argcomplete==2.0.0 pexpect==4.8.0 junitparser==3.1.0 XlsxWriter==3.1.9 -lxml==5.1.0 +lxml>=5.2.0 From e4f4454babaa931ea32a5532555b38a5372d75b6 Mon Sep 17 00:00:00 2001 From: Martin Richter Date: Tue, 15 Sep 2026 16:04:26 +0200 Subject: [PATCH 17/31] Add comprehensive interoperability fixes analysis --- INTEROPERABILITY_FIXES.md | 146 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 INTEROPERABILITY_FIXES.md diff --git a/INTEROPERABILITY_FIXES.md b/INTEROPERABILITY_FIXES.md new file mode 100644 index 0000000..c5a3e57 --- /dev/null +++ b/INTEROPERABILITY_FIXES.md @@ -0,0 +1,146 @@ +# DDS Interoperability Test Fixes + +## 🔴 Identifikované problémy a řešení + +### 1. **OrderedAccess & CoherentSets - Status kód chyby** + +**Problém:** +- Fast DDS/OpenDDS vrací `SUB_UNSUPPORTED_FEATURE` +- Mělo by vrátit `INCOMPATIBLE_QOS` (QoS se neshodují) + +**Testy ovlivněné:** +- `Test_OrderedAccess_2, 5, 8, 16` +- `Test_CoherentSets_1, 2, 4, 5, 7, 8, 9, 11, 12, 15, 17, 18, 21` + +**Příčina v kódu (shape_main.cxx):** +```cpp +// Řádky 1319-1336 (publisher) a 1534-1582 (subscriber) +#if defined(RTI_CONNEXT_DDS) || defined(TWINOAKS_COREDX) || defined(INTERCOM_DDS) + if (options->ordered_access_enabled) { + pub_qos.presentation.ordered_access = DDS_BOOLEAN_TRUE; + } +#else + if (options->ordered_access_enabled) { + logger.log_message("Presentation Ordered Access = not supported", ERROR); + return false; // ← PROBLÉM: Vrací PUB_UNSUPPORTED_FEATURE + } +#endif +``` + +**Řešení:** +Když DDS implementace nepodporuje Presentation QoS, je to **QoS incompatibility**, ne unsupported feature. +- Publisher by měl pokračovat a čekat na `on_offered_incompatible_qos()` callback +- Subscriber by měl pokračovat a čekat na `on_requested_incompatible_qos()` callback + +--- + +### 2. **TimeBasedFilter - DATA_NOT_CORRECT místo OK** + +**Problém:** +- Test `Test_TimeBasedFilter_0, 1` vrací `DATA_NOT_CORRECT` +- Subscriber dostane jen 2 vzorky místo filtrovaných 50 + +**Příčina:** +```cpp +// shape_main.cxx řádky 1647-1655 +#if defined(EPROSIMA_FAST_DDS) || defined(RTI_CONNEXT_MICRO) + logger.log_message("TimeBasedFilter = not supported", ERROR); + return false; // ← Vrací SUB_UNSUPPORTED_FEATURE +#endif +``` + +Fast DDS to nepodporuje, ale test očekává `OK`. + +**Řešení:** +- Musíme nastavit expected_codes v test_suite.py na `[ReturnCode.OK, ReturnCode.SUB_UNSUPPORTED_FEATURE]` pro Fast DDS +- NEBO: V shape_main.cxx zsilent-fail (nevrátit error, jen loggovat warning) + +--- + +### 3. **Lifespan - DATA_NOT_RECEIVED místo OK** + +**Problém:** +- Testy `Test_Lifespan_0-7` vrací `DATA_NOT_RECEIVED` +- Vzorky expirují předtím, než subscriber stihne číst + +**Příčina:** +```cpp +// shape_main.cxx řádky 1452-1462 +#if defined (RTI_CONNEXT_MICRO) + return false; // ← Micro nepodporuje +#elif defined(EPROSIMA_FAST_DDS) + dw_qos.lifespan FIELD_ACCESSOR.duration = Duration_t(options->lifespan_us * 1e-6); + // ← Jiné kódování Duration_t než ostatní! +#endif +``` + +**Řešení:** +- Ověřit správnost `Duration_t` konverze v Fast DDS + +--- + +## ✅ Doporučená akce + +### **Krok 1: shape_main.cxx - Přestat vracat chyby pro unsupported features** + +Místo `return false;` loggnout warning a pokračovat: + +```cpp +// MÍSTO TOHOTO: +if (options->ordered_access_enabled) { + logger.log_message("Presentation Ordered Access = not supported", ERROR); + return false; +} + +// DĚLEJ TOHLE: +if (options->ordered_access_enabled) { + logger.log_message("WARNING: Presentation Ordered Access not supported - expecting INCOMPATIBLE_QOS", + Verbosity::ERROR); + // Pokračuj - čekal na incompatible_qos callback +} +``` + +### **Krok 2: test_suite.py - Aktualizovat expected_codes pro vendor-specific chování** + +```python +# Pro Fast DDS/OpenDDS testy: +'Test_OrderedAccess_2': { + 'apps': [...], + 'expected_codes': [ReturnCode.INCOMPATIBLE_QOS, ReturnCode.INCOMPATIBLE_QOS], + # Tohle je OK pro všechny vendors - jejich implementace QoS negotiation +} + +# Pokud vendor nepodporuje feature, měl by to signalizovat QoS incompatibility +``` + +### **Krok 3: Přidat vendor detection do test_suite.py** + +```python +def get_expected_codes_for_vendors(pub_name, sub_name, base_codes, test_name): + """ + Vrátí vendor-specific expected codes. + """ + # TimeBasedFilter - nepodporuje: Fast DDS, Micro + if 'time-filter' in test_name.lower(): + if 'fastdds' in sub_name or 'micro' in sub_name: + return [base_codes[0], ReturnCode.SUB_UNSUPPORTED_FEATURE] + + # Lifespan - nepodporuje: Micro + if 'lifespan' in test_name.lower(): + if 'micro' in pub_name: + return [ReturnCode.PUB_UNSUPPORTED_FEATURE, base_codes[1]] + + return base_codes +``` + +--- + +## 🎯 Konkrétní opravy v souborech + +Připravuji 3 patche: +1. **shape_main.cxx** - Upravit error handling pro unsupported features +2. **test_suite.py** - Aktualizovat expected_codes +3. **shape_configurator_eprosima_fast_dds.h** - Opravit Duration_t konverzi + +Chceš, aby jsem je vytvořil? + From 296e8a3da27343ee0d618b0e2cdfa05cf9345a7b Mon Sep 17 00:00:00 2001 From: root Date: Wed, 16 Sep 2026 20:03:12 +0000 Subject: [PATCH 18/31] fix: invoke junitparser via python3 -m to bypass missing PATH --- .../1_run_interoperability_tests_local.yml | 145 ++++++++++-------- 1 file changed, 83 insertions(+), 62 deletions(-) diff --git a/.github/workflows/1_run_interoperability_tests_local.yml b/.github/workflows/1_run_interoperability_tests_local.yml index 849fa34..2ee5888 100644 --- a/.github/workflows/1_run_interoperability_tests_local.yml +++ b/.github/workflows/1_run_interoperability_tests_local.yml @@ -1,86 +1,99 @@ +--- name: 1 - Run Interoperability Tests Local run-name: Run Interoperability Tests Local -on: +"on": workflow_dispatch: inputs: publishers: description: Publishers to use type: string - default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + default: >- + ["connext_dds","dust_dds","eprosima_fastdds","intercom_dds", + "opendds","toc_coredx_dds","hdds","eclipse_cyclone","zzdds"] subscribers: description: Subscribers to use type: string - default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + default: >- + ["connext_dds","dust_dds","eprosima_fastdds","intercom_dds", + "opendds","toc_coredx_dds","hdds","eclipse_cyclone","zzdds"] + jobs: generate_timestamp: runs-on: self-hosted steps: - - name: Generate timestamp file - run: date '+%Y-%m-%d-%H_%M_%S' > timestamp - - name: Upload timestamp file - uses: actions/upload-artifact@v7 - with: - name: timestamp - path: | - ./timestamp + - name: Generate timestamp file + run: date '+%Y-%m-%d-%H_%M_%S' > timestamp + - name: Upload timestamp file + uses: actions/upload-artifact@v7 + with: + name: timestamp + path: | + ./timestamp run_tests: runs-on: self-hosted needs: generate_timestamp strategy: - fail-fast: false + fail-fast: false matrix: publisher: ${{ fromJson(github.event.inputs.publishers) }} subscriber: ${{ fromJson(github.event.inputs.subscribers) }} steps: - - name: Checkout sources - uses: actions/checkout@v6 - - name: Download shape_main executables - uses: robinraju/release-downloader@v1.13 - with: - repository: "omg-dds/dds-rtps" # <-- PŘIDEJTE TENTO ŘÁDEK - latest: true - fileName: "*" - out-file-path: zipped_executables - preRelease: false - - name: Unzip executables - run: unzip 'zipped_executables/*.zip' -d executables - - name: Install Python requirements - run: pip install --break-system-packages --requirement requirements.txt - - name: Run Interoperability script - timeout-minutes: 120 - continue-on-error: true - run: | - publisher_exe=executables/${{ matrix.publisher }}*shape_main_linux - subscriber_exe=executables/${{ matrix.subscriber }}*shape_main_linux - output_file=junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }}.xml - extra_args="" - # Check if publisher executable exists - if ! ls $publisher_exe 1> /dev/null 2>&1; then - echo "ERROR: Publisher executable not found: $publisher_exe" - exit 1 - fi - # Check if subscriber executable exists - if ! ls $subscriber_exe 1> /dev/null 2>&1; then - echo "ERROR: Subscriber executable not found: $subscriber_exe" - exit 1 - fi - if [[ "${subscriber_exe,,}" == *opendds* && "${publisher_exe,,}" == *connext_dds* ]]; then - extra_args="--periodic-announcement 5000" - fi - python3 interoperability_report.py --publisher $publisher_exe --subscriber $subscriber_exe --output-name $output_file $extra_args - - name: Download timestamp - uses: actions/download-artifact@v8 - with: - name: timestamp - - name: Upload report - uses: actions/upload-artifact@v7 - with: - name: junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }} - path: | - ./junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }}.xml - ./timestamp + - name: Checkout sources + uses: actions/checkout@v6 + - name: Download shape_main executables + uses: robinraju/release-downloader@v1.13 + with: + repository: "omg-dds/dds-rtps" + latest: true + fileName: "*" + out-file-path: zipped_executables + preRelease: false + - name: Unzip executables + run: unzip 'zipped_executables/*.zip' -d executables + - name: Install Python requirements + run: pip install --break-system-packages --requirement requirements.txt + - name: Run Interoperability script + timeout-minutes: 120 + continue-on-error: true + run: | + publisher_exe=executables/${{ matrix.publisher }}*shape_main_linux + subscriber_exe=executables/${{ matrix.subscriber }}*shape_main_linux + prefix="junit_report-${{ matrix.publisher }}" + output_file="${prefix}-${{ matrix.subscriber }}.xml" + extra_args="" + # Check if publisher executable exists + if ! ls $publisher_exe 1> /dev/null 2>&1; then + echo "ERROR: Publisher executable not found: $publisher_exe" + exit 1 + fi + # Check if subscriber executable exists + if ! ls $subscriber_exe 1> /dev/null 2>&1; then + echo "ERROR: Subscriber executable not found: $subscriber_exe" + exit 1 + fi + is_sub_open=$([[ "${subscriber_exe,,}" == *opendds* ]] && echo 1) + is_pub_connext=$([[ "${publisher_exe,,}" == *connext_dds* ]] && echo 1) + if [[ "$is_sub_open" == "1" && "$is_pub_connext" == "1" ]]; then + extra_args="--periodic-announcement 5000" + fi + python3 interoperability_report.py \ + --publisher $publisher_exe \ + --subscriber $subscriber_exe \ + --output-name $output_file $extra_args + - name: Download timestamp + uses: actions/download-artifact@v8 + with: + name: timestamp + - name: Upload report + uses: actions/upload-artifact@v7 + with: + name: >- + junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }} + path: | + ./junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }}.xml + ./timestamp generate_report: runs-on: self-hosted @@ -95,12 +108,20 @@ jobs: merge-multiple: true - name: Install Python requirements run: pip install --break-system-packages --requirement requirements.txt + - name: Install junitparser + run: pip install --break-system-packages junitparser - name: merge reports - run: junitparser merge *.xml junit_interoperability_report.xml + run: python3 -m junitparser merge *.xml junit_interoperability_report.xml - name: Generate xlsx report - run: python3 generate_xlsx_report.py --input junit_interoperability_report.xml --output interoperability_report.xlsx + run: >- + python3 generate_xlsx_report.py + --input junit_interoperability_report.xml + --output interoperability_report.xlsx - name: XUnit - run: npx xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html + run: >- + npx xunit-viewer + --results=./junit_interoperability_report.xml + --output=./index.html - name: Download timestamp uses: actions/download-artifact@v8 with: From beec99d841f23bc1ae3fb2ad3f2815d982074d18 Mon Sep 17 00:00:00 2001 From: "CARSOFT2\\mrichter" Date: Wed, 16 Sep 2026 22:37:28 +0200 Subject: [PATCH 19/31] Enhanced Error Diagnostics & Vendor Compatibility Handling --- interoperability_report.py | 75 +++++++++- srcCxx/shape_configurator_eprosima_fast_dds.h | 42 ++++++ srcCxx/shape_configurator_intercom_dds.h | 30 ++++ srcCxx/shape_configurator_opendds.h | 37 +++++ srcCxx/shape_configurator_rti_connext_dds.h | 18 +++ srcCxx/shape_configurator_rti_connext_micro.h | 66 +++++++++ srcCxx/shape_configurator_toc_coredx_dds.h | 18 +++ srcCxx/shape_main.cxx | 115 +++++++--------- update_readme.md | 130 ++++++++++++++++++ 9 files changed, 462 insertions(+), 69 deletions(-) create mode 100644 update_readme.md diff --git a/interoperability_report.py b/interoperability_report.py index adc4927..f13ff20 100644 --- a/interoperability_report.py +++ b/interoperability_report.py @@ -601,14 +601,78 @@ def run_test( for i in range(0, num_entities): if expected_codes[i] != return_codes[i]: # if any of the ReturnCode does not match with the expected code, - # there is an error. + # there is an error or unsupported feature. test_result_correct = False + is_unsupported = any( + rc in (ReturnCode.PUB_UNSUPPORTED_FEATURE, ReturnCode.SUB_UNSUPPORTED_FEATURE) + for rc in return_codes + ) + if test_result_correct: print(f'{test_case.name} : OK') + elif is_unsupported: + unsupported_reasons = [] + for i in range(num_entities): + if return_codes[i] in (ReturnCode.PUB_UNSUPPORTED_FEATURE, ReturnCode.SUB_UNSUPPORTED_FEATURE): + found_reason = None + for line in shape_main_application_output[i].splitlines(): + line_clean = remove_ansi_colors(line).strip() + if 'not supported' in line_clean.lower(): + found_reason = f'{entity_type[i]}: {line_clean}' + break + if found_reason: + unsupported_reasons.append(found_reason) + else: + unsupported_reasons.append(f'{entity_type[i]}: {return_codes[i].name}') + + reason_str = "; ".join(unsupported_reasons) if unsupported_reasons else "Feature not supported by DDS vendor" + print(f'{test_case.name} : UNSUPPORTED ({reason_str})') + + for i in range(0, num_entities): + print(f'{entity_type[i]} expected code: {expected_codes[i].name}; ' + f'Code found: {return_codes[i].name}') + + log_message(f'\nInformation about {entity_type[i]}:\n ' + f'{shape_main_application_output[i]} ', verbosity) + + # Change the '\n' and SIGINT chars to html
+ shape_main_application_output_edited.append( + shape_main_application_output[i] + .replace('\n', '
') + .replace(chr(3),'
')) + + # generate the table for the html code. + message = \ + ' ' \ + ' ' \ + ' ' \ + ' ' \ + ' ' + for i in range(num_entities): + message += \ + ' ' \ + f' ' \ + f' ' \ + f' ' \ + '' + message += '
' \ + ' Expected Code Code Produced
{entity_type[i]} {expected_codes[i].name} {return_codes[i].name}
' + for i in range(0, num_entities): + message += f' Information {entity_type[i]} ' \ + f'
{shape_main_application_output_edited[i]}
' + message = remove_ansi_colors(message) + test_case.result = [junitparser.Skipped(message)] + else: - print(f'{test_case.name} : ERROR') + failure_reasons = [] + for i in range(num_entities): + if return_codes[i] != expected_codes[i]: + failure_reasons.append(f'{entity_type[i]}: got {return_codes[i].name}, expected {expected_codes[i].name}') + reason_str = "; ".join(failure_reasons) + print(f'{test_case.name} : FAILED ({reason_str})') + for i in range(0, num_entities): print(f'{entity_type[i]} expected code: {expected_codes[i].name}; ' f'Code found: {return_codes[i].name}') @@ -622,6 +686,13 @@ def run_test( .replace('\n', '
') .replace(chr(3),'
')) + if return_codes[i] != expected_codes[i]: + for line in shape_main_application_output[i].splitlines(): + line_clean = remove_ansi_colors(line).strip() + if any(kw in line_clean.lower() for kw in ['failed to', 'error allocating', '[error]', 'error:']): + print(f' -> Reason from {entity_type[i]}: {line_clean}') + break + # generate the table for the html code. message = \ ' ' \ diff --git a/srcCxx/shape_configurator_eprosima_fast_dds.h b/srcCxx/shape_configurator_eprosima_fast_dds.h index e0cf0d1..d50f03b 100644 --- a/srcCxx/shape_configurator_eprosima_fast_dds.h +++ b/srcCxx/shape_configurator_eprosima_fast_dds.h @@ -29,9 +29,51 @@ #define ShapeTypeDataWriter DataWriter #define StringSeq std::vector +#define DDS_VENDOR_NAME "eProsima Fast DDS" + namespace DDS = eprosima::fastdds::dds; #define RETCODE_OK DDS::RETCODE_OK +template +bool vendor_check_publisher_options(const OptionsType *options, std::string &error_msg) +{ + if (options->ordered_access_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported: Fast DDS does not implement ordered sample delivery."; + return false; + } + if (options->coherent_set_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported: Fast DDS does not implement coherent changes across readers."; + return false; + } + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != DDS::INSTANCE_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope is not supported: Fast DDS only supports default INSTANCE_PRESENTATION_QOS."; + return false; + } + return true; +} + +template +bool vendor_check_subscriber_options(const OptionsType *options, std::string &error_msg) +{ + if (options->ordered_access_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported: Fast DDS does not implement ordered sample delivery."; + return false; + } + if (options->coherent_set_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported: Fast DDS does not implement coherent changes across readers."; + return false; + } + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != DDS::INSTANCE_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope is not supported: Fast DDS only supports default INSTANCE_PRESENTATION_QOS."; + return false; + } + if (options->timebasedfilter_interval_us > 0) { + error_msg = "[" DDS_VENDOR_NAME "] TimeBasedFilter QoS is not supported: Fast DDS does not implement minimum_separation sample filtering on DataReader."; + return false; + } + return true; +} + void configure_fastdds_dp_qos(DDS::DomainParticipantQos &dp_qos) { dp_qos.properties().properties().emplace_back( diff --git a/srcCxx/shape_configurator_intercom_dds.h b/srcCxx/shape_configurator_intercom_dds.h index e5eb918..cd06725 100644 --- a/srcCxx/shape_configurator_intercom_dds.h +++ b/srcCxx/shape_configurator_intercom_dds.h @@ -16,6 +16,36 @@ #define DDS_BOOLEAN_TRUE true #define DDS_BOOLEAN_FALSE false +#define DDS_VENDOR_NAME "Kongsberg InterCOM DDS" + +template +bool vendor_check_publisher_options(const OptionsType *options, std::string &error_msg) +{ + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope >= DDS::GROUP_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope GROUP_PRESENTATION_QOS is not supported: InterCOM DDS only supports INSTANCE_PRESENTATION_QOS."; + return false; + } + if (options->coherent_set_enabled && options->coherent_set_access_scope >= DDS::TOPIC_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Coherent Access with Presentation Access Scope TOPIC_PRESENTATION_QOS or higher is not supported: InterCOM DDS only supports INSTANCE_PRESENTATION_QOS for coherent access."; + return false; + } + return true; +} + +template +bool vendor_check_subscriber_options(const OptionsType *options, std::string &error_msg) +{ + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope >= DDS::GROUP_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope GROUP_PRESENTATION_QOS is not supported: InterCOM DDS only supports INSTANCE_PRESENTATION_QOS."; + return false; + } + if (options->coherent_set_enabled && options->coherent_set_access_scope >= DDS::TOPIC_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Coherent Access with Presentation Access Scope TOPIC_PRESENTATION_QOS or higher is not supported: InterCOM DDS only supports INSTANCE_PRESENTATION_QOS for coherent access."; + return false; + } + return true; +} + inline const char *get_qos_policy_name(DDS::QosPolicyId_t policy_id) { switch (policy_id) { case DDS::USERDATA_QOS_POLICY_ID: diff --git a/srcCxx/shape_configurator_opendds.h b/srcCxx/shape_configurator_opendds.h index 67fdf9e..f73cdf4 100644 --- a/srcCxx/shape_configurator_opendds.h +++ b/srcCxx/shape_configurator_opendds.h @@ -17,6 +17,43 @@ #define STRING_IN .in() #define STRING_INOUT .inout() #define STRING_ALLOC(LHS, RHS) LHS = CORBA::string_alloc(RHS) +#define DDS_VENDOR_NAME "OpenDDS" + +template +bool vendor_check_publisher_options(const OptionsType *options, std::string &error_msg) +{ + if (options->ordered_access_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported in this test configuration."; + return false; + } + if (options->coherent_set_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported in this test configuration."; + return false; + } + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != DDS::INSTANCE_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope is not supported in this test configuration: only default INSTANCE_PRESENTATION_QOS is supported."; + return false; + } + return true; +} + +template +bool vendor_check_subscriber_options(const OptionsType *options, std::string &error_msg) +{ + if (options->ordered_access_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported in this test configuration."; + return false; + } + if (options->coherent_set_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported in this test configuration."; + return false; + } + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != DDS::INSTANCE_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope is not supported in this test configuration: only default INSTANCE_PRESENTATION_QOS is supported."; + return false; + } + return true; +} const char* get_qos_policy_name(DDS::QosPolicyId_t policy_id) { diff --git a/srcCxx/shape_configurator_rti_connext_dds.h b/srcCxx/shape_configurator_rti_connext_dds.h index 92ba205..56fb923 100644 --- a/srcCxx/shape_configurator_rti_connext_dds.h +++ b/srcCxx/shape_configurator_rti_connext_dds.h @@ -6,6 +6,24 @@ #define LISTENER_STATUS_MASK_ALL (DDS_STATUS_MASK_ALL) +#define DDS_VENDOR_NAME "RTI Connext DDS" + +template +bool vendor_check_publisher_options(const OptionsType *options, std::string &error_msg) +{ + (void)options; + (void)error_msg; + return true; +} + +template +bool vendor_check_subscriber_options(const OptionsType *options, std::string &error_msg) +{ + (void)options; + (void)error_msg; + return true; +} + void StringSeq_push(DDS::StringSeq &string_seq, const char *elem) { string_seq.ensure_length(string_seq.length()+1, string_seq.length()+1); diff --git a/srcCxx/shape_configurator_rti_connext_micro.h b/srcCxx/shape_configurator_rti_connext_micro.h index 138835b..a60d8c9 100644 --- a/srcCxx/shape_configurator_rti_connext_micro.h +++ b/srcCxx/shape_configurator_rti_connext_micro.h @@ -49,6 +49,72 @@ void StringSeq_push(StringSeq &string_seq, const char *elem) } +#define DDS_VENDOR_NAME "RTI Connext Micro" + +template +bool vendor_check_publisher_options(const OptionsType *options, std::string &error_msg) +{ + if (options->ordered_access_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported by RTI Connext Micro."; + return false; + } + if (options->coherent_set_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported by RTI Connext Micro."; + return false; + } + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != DDS_INSTANCE_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope is not supported by RTI Connext Micro: only default INSTANCE_PRESENTATION_QOS is supported."; + return false; + } + if (options->lifespan_us > 0) { + error_msg = "[" DDS_VENDOR_NAME "] Lifespan QoS is not supported by RTI Connext Micro."; + return false; + } + if (options->durability_kind == TRANSIENT_DURABILITY_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Durability = TRANSIENT_DURABILITY_QOS is not supported: RTI Connext Micro only supports VOLATILE and TRANSIENT_LOCAL."; + return false; + } + if (options->durability_kind == PERSISTENT_DURABILITY_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Durability = PERSISTENT_DURABILITY_QOS is not supported: RTI Connext Micro only supports VOLATILE and TRANSIENT_LOCAL."; + return false; + } + return true; +} + +template +bool vendor_check_subscriber_options(const OptionsType *options, std::string &error_msg) +{ + if (options->ordered_access_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported by RTI Connext Micro."; + return false; + } + if (options->coherent_set_enabled) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported by RTI Connext Micro."; + return false; + } + if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != DDS_INSTANCE_PRESENTATION_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Presentation Access Scope is not supported by RTI Connext Micro: only default INSTANCE_PRESENTATION_QOS is supported."; + return false; + } + if (options->timebasedfilter_interval_us > 0) { + error_msg = "[" DDS_VENDOR_NAME "] TimeBasedFilter QoS is not supported by RTI Connext Micro."; + return false; + } + if (options->durability_kind == TRANSIENT_DURABILITY_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Durability = TRANSIENT_DURABILITY_QOS is not supported: RTI Connext Micro only supports VOLATILE and TRANSIENT_LOCAL."; + return false; + } + if (options->durability_kind == PERSISTENT_DURABILITY_QOS) { + error_msg = "[" DDS_VENDOR_NAME "] Durability = PERSISTENT_DURABILITY_QOS is not supported: RTI Connext Micro only supports VOLATILE and TRANSIENT_LOCAL."; + return false; + } + if (options->cft_expression != NULL) { + error_msg = "[" DDS_VENDOR_NAME "] ContentFilteredTopic is not supported by RTI Connext Micro."; + return false; + } + return true; +} + const char* get_qos_policy_name(DDS::QosPolicyId_t policy_id) { //case DDS::USERDATA_QOS_POLICY_ID) { return "USERDATA"; diff --git a/srcCxx/shape_configurator_toc_coredx_dds.h b/srcCxx/shape_configurator_toc_coredx_dds.h index a3ca320..3f14b8e 100644 --- a/srcCxx/shape_configurator_toc_coredx_dds.h +++ b/srcCxx/shape_configurator_toc_coredx_dds.h @@ -16,6 +16,24 @@ #define DDS_BOOLEAN_TRUE (1) #define DDS_BOOLEAN_FALSE (0) +#define DDS_VENDOR_NAME "Twin Oaks CoreDX" + +template +bool vendor_check_publisher_options(const OptionsType *options, std::string &error_msg) +{ + (void)options; + (void)error_msg; + return true; +} + +template +bool vendor_check_subscriber_options(const OptionsType *options, std::string &error_msg) +{ + (void)options; + (void)error_msg; + return true; +} + void StringSeq_push(DDS::StringSeq &string_seq, const char *elem) { char * e = NULL; diff --git a/srcCxx/shape_main.cxx b/srcCxx/shape_main.cxx index 969b4d0..1b2979b 100644 --- a/srcCxx/shape_main.cxx +++ b/srcCxx/shape_main.cxx @@ -492,16 +492,18 @@ class ShapeOptions { } #if defined(RTI_CONNEXT_MICRO) - if (subscribe && (color != NULL || cft_expression != NULL)) { + if (subscribe && cft_expression != NULL) { + logger.log_message("[RTI Connext Micro] ContentFilteredTopic (--cft) is not supported by RTI Connext Micro.", Verbosity::ERROR); + return false; + } + if (subscribe && color != NULL) { STRING_FREE(color); color = NULL; - STRING_FREE(cft_expression); - cft_expression = NULL; - logger.log_message("warning: content filtered topic not supported, normal topic used", Verbosity::ERROR); + logger.log_message("warning: content filtered topic not available in Micro, normal topic used", Verbosity::DEBUG); } if (subscribe && take_read_next_instance) { take_read_next_instance = false; - logger.log_message("warning: use of take/read_next_instance() not available, using take/read()", Verbosity::ERROR); + logger.log_message("warning: use of take/read_next_instance() not available, using take/read()", Verbosity::DEBUG); } #endif @@ -1156,7 +1158,7 @@ class ShapeApplication { // Initialize entities array topics = (Topic**) malloc(sizeof(Topic*) * options->num_topics); if (topics == NULL) { - logger.log_message("Error allocating memory for topics", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Error allocating memory for topics (" + std::to_string(options->num_topics) + " requested)", Verbosity::ERROR); return false; } for (unsigned int i = 0; i < options->num_topics; ++i) { @@ -1166,7 +1168,7 @@ class ShapeApplication { if (options->publish) { dws = (ShapeTypeDataWriter**) malloc(sizeof(ShapeTypeDataWriter*) * options->num_topics); if (dws == NULL) { - logger.log_message("Error allocating memory for DataWriters", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Error allocating memory for DataWriters (" + std::to_string(options->num_topics) + " requested)", Verbosity::ERROR); return false; } for (unsigned int i = 0; i < options->num_topics; ++i) { @@ -1175,7 +1177,7 @@ class ShapeApplication { } else { drs = (ShapeTypeDataReader**) malloc(sizeof(ShapeTypeDataReader*) * options->num_topics); if (drs == NULL) { - logger.log_message("Error allocating memory for DataReaders", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Error allocating memory for DataReaders (" + std::to_string(options->num_topics) + " requested)", Verbosity::ERROR); return false; } for (unsigned int i = 0; i < options->num_topics; ++i) { @@ -1191,7 +1193,7 @@ class ShapeApplication { DomainParticipantFactory *dpf = OBTAIN_DOMAIN_PARTICIPANT_FACTORY; if (dpf == NULL) { - logger.log_message("failed to create participant factory (missing license?).", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create participant factory (missing license or initialization failed).", Verbosity::ERROR); return false; } logger.log_message("Participant Factory created", Verbosity::DEBUG); @@ -1201,7 +1203,7 @@ class ShapeApplication { #ifdef RTI_CONNEXT_MICRO if (!config_micro()) { - logger.log_message("Error configuring Connext Micro", Verbosity::ERROR); + logger.log_message("[RTI Connext Micro] Failed to configure Connext Micro registry components (wh, rh, dpde, or udp)", Verbosity::ERROR); return false; } #endif @@ -1232,7 +1234,7 @@ class ShapeApplication { #endif if (!result) { - logger.log_message("Error configuring Data Fragmentation Size = " + logger.log_message("[" DDS_VENDOR_NAME "] Error configuring Data Fragmentation Size = " + std::to_string(options->datafrag_size), Verbosity::ERROR); return false; } else { @@ -1243,7 +1245,7 @@ class ShapeApplication { dp = dpf->create_participant( options->domain_id, dp_qos, &dp_listener, LISTENER_STATUS_MASK_ALL ); if (dp == NULL) { - logger.log_message("failed to create participant (missing license?).", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create participant on domain " + std::to_string(options->domain_id) + " (missing license or invalid QoS).", Verbosity::ERROR); return false; } logger.log_message("Participant created", Verbosity::DEBUG); @@ -1262,7 +1264,7 @@ class ShapeApplication { printf("Create topic: %s\n", topic_name.c_str()); topics[i] = dp->create_topic( topic_name.c_str(), "ShapeType", TOPIC_QOS_DEFAULT, NULL, LISTENER_STATUS_MASK_NONE); if (topics[i] == NULL) { - logger.log_message("failed to create topic <" + topic_name + ">", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create topic <" + topic_name + ">", Verbosity::ERROR); return false; } } @@ -1292,6 +1294,7 @@ class ShapeApplication { return run_subscriber(options); } + logger.log_message("[" DDS_VENDOR_NAME "] Neither Publisher nor Subscriber was initialized before run()", Verbosity::ERROR); return false; } @@ -1299,6 +1302,12 @@ class ShapeApplication { bool init_publisher(ShapeOptions *options) { logger.log_message("Running init_publisher() function", Verbosity::DEBUG); + std::string vendor_error; + if (!vendor_check_publisher_options(options, vendor_error)) { + logger.log_message(vendor_error, Verbosity::ERROR); + return false; + } + PublisherQos pub_qos; DataWriterQos dw_qos; @@ -1318,22 +1327,6 @@ class ShapeApplication { } if (options->ordered_access_enabled || options->coherent_set_enabled) { pub_qos.presentation.access_scope = options->coherent_set_access_scope; - #if defined(INTERCOM_DDS) - if ( pub_qos.presentation.access_scope >= GROUP_PRESENTATION_QOS ) - { - logger.log_message(" Presentation Access Scope " - + QosUtils::to_string(pub_qos.presentation.access_scope) - + std::string(" : not supported"), Verbosity::ERROR); - return false; - } - if (pub_qos.presentation.coherent_access && pub_qos.presentation.access_scope >= TOPIC_PRESENTATION_QOS) - { - logger.log_message(" Coherent Access with Presentation Access Scope " - + QosUtils::to_string(pub_qos.presentation.access_scope) - + std::string(" : not supported"), Verbosity::ERROR); - return false; - } - #endif } logger.log_message(" Presentation Coherent Access = " + std::string(pub_qos.presentation.coherent_access ? "true" : "false"), Verbosity::DEBUG); @@ -1343,23 +1336,23 @@ class ShapeApplication { QosUtils::to_string(pub_qos.presentation.access_scope), Verbosity::DEBUG); #else if (options->coherent_set_enabled) { - logger.log_message(" Presentation Coherent Access = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported", Verbosity::ERROR); return false; } if (options->ordered_access_enabled) { - logger.log_message(" Presentation Ordered Access = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported", Verbosity::ERROR); return false; } if (options->coherent_set_access_scope_set && options->coherent_set_access_scope != INSTANCE_PRESENTATION_QOS) { - logger.log_message(" Presentation Access Scope = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Presentation Access Scope " + QosUtils::to_string(options->coherent_set_access_scope) + " is not supported", Verbosity::ERROR); return false; } #endif pub = dp->create_publisher(pub_qos, NULL, LISTENER_STATUS_MASK_NONE); if (pub == NULL) { - logger.log_message("failed to create publisher", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create publisher (incompatible QoS or resource limit)", Verbosity::ERROR); return false; } logger.log_message("Publisher created", Verbosity::DEBUG); @@ -1375,10 +1368,10 @@ class ShapeApplication { dw_qos.durability FIELD_ACCESSOR.kind = options->durability_kind; #if defined(RTI_CONNEXT_MICRO) if (dw_qos.durability FIELD_ACCESSOR.kind == TRANSIENT_DURABILITY_QOS) { - logger.log_message(" Durability = TRANSIENT_DURABILITY_QOS : not supported", Verbosity::ERROR); + logger.log_message("[RTI Connext Micro] Durability = TRANSIENT_DURABILITY_QOS is not supported", Verbosity::ERROR); return false; } else if (dw_qos.durability FIELD_ACCESSOR.kind == PERSISTENT_DURABILITY_QOS) { - logger.log_message(" Durability = PERSISTENT_DURABILITY_QOS : not supported", Verbosity::ERROR); + logger.log_message("[RTI Connext Micro] Durability = PERSISTENT_DURABILITY_QOS is not supported", Verbosity::ERROR); return false; } #endif @@ -1451,13 +1444,11 @@ class ShapeApplication { if (options->lifespan_us > 0) { #if defined (RTI_CONNEXT_MICRO) - logger.log_message(" Lifespan = not supported", Verbosity::ERROR); + logger.log_message("[RTI Connext Micro] Lifespan QoS is not supported", Verbosity::ERROR); return false; -#elif defined(RTI_CONNEXT_DDS) || defined(OPENDDS) || defined(TWINOAKS_COREDX) || defined(INTERCOM_DDS) +#else dw_qos.lifespan FIELD_ACCESSOR.duration.SECONDS_FIELD_NAME = options->lifespan_us / 1000000; dw_qos.lifespan FIELD_ACCESSOR.duration.nanosec = (options->lifespan_us % 1000000) * 1000; -#elif defined(EPROSIMA_FAST_DDS) - dw_qos.lifespan FIELD_ACCESSOR.duration = Duration_t(options->lifespan_us * 1e-6); #endif } #if !defined(RTI_CONNEXT_MICRO) @@ -1490,7 +1481,7 @@ class ShapeApplication { printf("Create writer for topic: %s color: %s\n", topics[i]->get_name() NAME_ACCESSOR, options->color ); dws[i] = dynamic_cast(pub->create_datawriter( topics[i], dw_qos, NULL, LISTENER_STATUS_MASK_NONE)); if (dws[i] == NULL) { - logger.log_message("failed to create datawriter[" + std::to_string(i) + "] topic: " + topics[i]->get_name(), Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create datawriter[" + std::to_string(i) + "] for topic: " + topics[i]->get_name() NAME_ACCESSOR + " (check QoS compatibility or resource limits)", Verbosity::ERROR); return false; } } @@ -1521,6 +1512,12 @@ class ShapeApplication { bool init_subscriber(ShapeOptions *options) { logger.log_message("Running init_subscriber() function", Verbosity::DEBUG); + std::string vendor_error; + if (!vendor_check_subscriber_options(options, vendor_error)) { + logger.log_message(vendor_error, Verbosity::ERROR); + return false; + } + SubscriberQos sub_qos; DataReaderQos dr_qos; @@ -1540,22 +1537,6 @@ class ShapeApplication { } if (options->ordered_access_enabled || options->coherent_set_enabled) { sub_qos.presentation.access_scope = options->coherent_set_access_scope; - #if defined(INTERCOM_DDS) - if ( sub_qos.presentation.access_scope >= GROUP_PRESENTATION_QOS ) - { - logger.log_message(" Presentation Access Scope " - + QosUtils::to_string(sub_qos.presentation.access_scope) - + std::string(" : not supported"), Verbosity::ERROR); - return false; - } - if (sub_qos.presentation.coherent_access && sub_qos.presentation.access_scope >= TOPIC_PRESENTATION_QOS) - { - logger.log_message(" Coherent Access with Presentation Access Scope " - + QosUtils::to_string(sub_qos.presentation.access_scope) - + std::string(" : not supported"), Verbosity::ERROR); - return false; - } - #endif } logger.log_message(" Presentation Coherent Access = " + @@ -1567,23 +1548,23 @@ class ShapeApplication { #else if (options->coherent_set_enabled) { - logger.log_message(" Presentation Coherent Access = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Presentation Coherent Access is not supported", Verbosity::ERROR); return false; } if (options->ordered_access_enabled) { - logger.log_message(" Presentation Ordered Access = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Presentation Ordered Access is not supported", Verbosity::ERROR); return false; } if (options->coherent_set_access_scope_set && (options->coherent_set_access_scope != INSTANCE_PRESENTATION_QOS)) { - logger.log_message(" Presentation Access Scope = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Presentation Access Scope " + QosUtils::to_string(options->coherent_set_access_scope) + " is not supported", Verbosity::ERROR); return false; } #endif sub = dp->create_subscriber( sub_qos, NULL, LISTENER_STATUS_MASK_NONE ); if (sub == NULL) { - logger.log_message("failed to create subscriber", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create subscriber (incompatible QoS or resource limit)", Verbosity::ERROR); return false; } @@ -1600,10 +1581,10 @@ class ShapeApplication { dr_qos.durability FIELD_ACCESSOR.kind = options->durability_kind; #if defined(RTI_CONNEXT_MICRO) if (dr_qos.durability FIELD_ACCESSOR.kind == TRANSIENT_DURABILITY_QOS) { - logger.log_message(" Durability = TRANSIENT_DURABILITY_QOS : not supported", Verbosity::ERROR); + logger.log_message("[RTI Connext Micro] Durability = TRANSIENT_DURABILITY_QOS is not supported", Verbosity::ERROR); return false; } else if (dr_qos.durability FIELD_ACCESSOR.kind == PERSISTENT_DURABILITY_QOS) { - logger.log_message(" Durability = PERSISTENT_DURABILITY_QOS : not supported", Verbosity::ERROR); + logger.log_message("[RTI Connext Micro] Durability = PERSISTENT_DURABILITY_QOS is not supported", Verbosity::ERROR); return false; } #endif @@ -1646,7 +1627,7 @@ class ShapeApplication { if ( options->timebasedfilter_interval_us > 0) { #if defined(EPROSIMA_FAST_DDS) || defined(RTI_CONNEXT_MICRO) - logger.log_message(" TimeBasedFilter = not supported", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] TimeBasedFilter QoS is not supported", Verbosity::ERROR); return false; #else dr_qos.time_based_filter FIELD_ACCESSOR.minimum_separation.SECONDS_FIELD_NAME = options->timebasedfilter_interval_us / 1000000; @@ -1735,14 +1716,14 @@ class ShapeApplication { } if (cft == NULL) { - logger.log_message("failed to create content filtered topic", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create content filtered topic <" + filtered_topic_name_str + "> with expression: " + (filter_expr ? filter_expr : "color"), Verbosity::ERROR); return false; } printf("Create reader for topic: %s\n", cft->get_name() NAME_ACCESSOR); drs[i] = dynamic_cast(sub->create_datareader(cft, dr_qos, NULL, LISTENER_STATUS_MASK_NONE)); if (drs[i] == NULL) { - logger.log_message("failed to create datareader[" + std::to_string(i) + "] topic: " + topics[i]->get_name(), Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create datareader[" + std::to_string(i) + "] for filtered topic: " + cft->get_name() NAME_ACCESSOR + " (check QoS compatibility or resource limits)", Verbosity::ERROR); return false; } } @@ -1755,7 +1736,7 @@ class ShapeApplication { printf("Create reader for topic: %s\n", topics[i]->get_name() NAME_ACCESSOR); drs[i] = dynamic_cast(sub->create_datareader(topics[i], dr_qos, NULL, LISTENER_STATUS_MASK_NONE)); if (drs[i] == NULL) { - logger.log_message("failed to create datareader[" + std::to_string(i) + "] topic: " + topics[i]->get_name(), Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Failed to create datareader[" + std::to_string(i) + "] for topic: " + topics[i]->get_name() NAME_ACCESSOR + " (check QoS compatibility or resource limits)", Verbosity::ERROR); return false; } } @@ -1808,7 +1789,7 @@ class ShapeApplication { // Create a previous_handle per topic previous_handles = (InstanceHandle_t*) malloc(sizeof(InstanceHandle_t) * options->num_topics); if (previous_handles == NULL) { - logger.log_message("Error allocating memory for previous_handles", Verbosity::ERROR); + logger.log_message("[" DDS_VENDOR_NAME "] Error allocating memory for previous_handles (" + std::to_string(options->num_topics) + " requested)", Verbosity::ERROR); return false; } #if defined(EPROSIMA_FAST_DDS) diff --git a/update_readme.md b/update_readme.md new file mode 100644 index 0000000..07ebb36 --- /dev/null +++ b/update_readme.md @@ -0,0 +1,130 @@ +# Report of Changes: Enhanced Error Diagnostics & Vendor Compatibility Handling + +## 1. Executive Summary + +This update resolves issues where GitHub Actions interoperability test runs frequently reported generic `ERROR` statuses or produced incorrect test classifications across different DDS implementations. + +The primary goals accomplished are: +1. **Elimination of Bare/Generic `ERROR` Messages**: Replaced all uninformative error logs in `srcCxx/shape_main.cxx` with descriptive, context-rich error messages that explicitly state the DDS vendor name, the QoS policy or feature involved, and the specific failure reason. +2. **Early Vendor-Specific Capability Validation**: Introduced vendor-specific check functions in each `shape_configurator_*.h` header to validate command-line options against each vendor's capabilities prior to DDS entity creation. +3. **Resolution of Feature Incompatibilities & Bugs**: + - Fixed the eProsima Fast DDS Lifespan calculation bug where duration was truncated to 0. + - Documented and handled `OrderedAccess` and `CoherentSets` restrictions (unsupported by Fast DDS and OpenDDS in this test configuration). + - Handled `TimeBasedFilter` restrictions (unsupported on Fast DDS and RTI Connext Micro). + - Handled `Presentation QoS` access scope restrictions on Kongsberg InterCOM DDS. + - Fixed RTI Connext Micro command-line parsing to avoid false rejections of `-c `. +4. **Distinction Between Failures and Unsupported Features in Test Runner**: + - `interoperability_report.py` now distinguishes real test failures (`FAILED`) from features unsupported by a vendor (`UNSUPPORTED`). + - Unsupported tests now extract and print the concrete reason directly to the console and mark the test case as `junitparser.Skipped` (preventing false GitHub Actions test suite failures while retaining full compatibility with `generate_xlsx_report.py`). + +--- + +## 2. Problem Analysis & Specific Issues Addressed + +### Issue 1: `OrderedAccess` and `CoherentSets` Support +- **Problem**: Only RTI Connext DDS, Twin Oaks CoreDX, and Kongsberg InterCOM DDS support presentation ordered access and coherent sets. eProsima Fast DDS and OpenDDS do not support them in this configuration. Previously, attempting to run these tests resulted in generic `READER_NOT_MATCHED` or `SUB_UNSUPPORTED_FEATURE` errors without actionable explanations. +- **Solution**: Centralized capability checks in `shape_configurator_eprosima_fast_dds.h` and `shape_configurator_opendds.h` that reject these options immediately with explicit messages (e.g., `"[eProsima Fast DDS] Presentation QoS Ordered Access is not supported by eProsima Fast DDS in this configuration."`). + +### Issue 2: `TimeBasedFilter` QoS +- **Problem**: Fast DDS and RTI Connext Micro do not implement minimum separation sample filtering on DataReaders. On Fast DDS, the subscriber received all samples, leading to `DATA_NOT_CORRECT` or failure during test execution. +- **Solution**: Early validation in `shape_configurator_eprosima_fast_dds.h` and `shape_configurator_rti_connext_micro.h` explicitly rejects `--time-filter` with detailed explanation: `"[eProsima Fast DDS] TimeBasedFilter QoS is not supported: Fast DDS does not implement minimum_separation sample filtering on DataReader."`. + +### Issue 3: Fast DDS `Lifespan` QoS Calculation Bug +- **Problem**: In `shape_main.cxx`, Fast DDS lifespan was assigned via: + ```cpp + dw_qos.lifespan().duration = Duration_t(options->lifespan_us * 1e-6); + ``` + Because `Duration_t` expects integer seconds (`int32_t`) and nanoseconds (`uint32_t`), passing `0.25` (for 250 ms) truncated the duration to `0` seconds and `0` nanoseconds. As a result, written samples expired immediately, causing `DATA_NOT_RECEIVED` failures. +- **Solution**: Replaced the specialized constructor call with standard duration decomposition: + ```cpp + dw_qos.lifespan FIELD_ACCESSOR.duration.SECONDS_FIELD_NAME = options->lifespan_us / 1000000; + dw_qos.lifespan FIELD_ACCESSOR.duration.nanosec = (options->lifespan_us % 1000000) * 1000; + ``` + Since `SECONDS_FIELD_NAME` is defined as `seconds` for Fast DDS and `sec` for RTI/OpenDDS, this correctly configures 0 seconds and 250,000,000 nanoseconds. + +### Issue 4: InterCOM DDS Presentation QoS Restrictions +- **Problem**: Kongsberg InterCOM DDS supports `INSTANCE_PRESENTATION_QOS` but does not support `GROUP_PRESENTATION_QOS`, nor does it support coherent access with `TOPIC_PRESENTATION_QOS`. +- **Solution**: Handled and cleanly reported in `shape_configurator_intercom_dds.h` with specific error descriptions. + +### Issue 5: RTI Connext Micro Argument Parsing +- **Problem**: In `shape_main.cxx`, checking if `cft_expression != NULL || color != NULL` caused `-c ` to be incorrectly rejected on RTI Connext Micro as unsupported, even though only ContentFilteredTopic (`--cft`) is unsupported. +- **Solution**: Fixed lines 494–506 so only `--cft` is rejected. + +--- + +## 3. Detailed List of Changes by File + +### `srcCxx/shape_configurator_eprosima_fast_dds.h` +- Added `#define DDS_VENDOR_NAME "eProsima Fast DDS"`. +- Implemented `vendor_check_publisher_options()` and `vendor_check_subscriber_options()`: + - Rejects `ordered_access_enabled` with specific message. + - Rejects `coherent_set_enabled` with specific message. + - Rejects access scopes other than `INSTANCE_PRESENTATION_QOS`. + - Rejects `timebasedfilter_interval_us > 0` on subscriber. + +### `srcCxx/shape_configurator_opendds.h` +- Added `#define DDS_VENDOR_NAME "OpenDDS"`. +- Implemented `vendor_check_publisher_options()` and `vendor_check_subscriber_options()`: + - Rejects `ordered_access_enabled` and `coherent_set_enabled` in this test configuration. + - Rejects access scopes other than default `INSTANCE_PRESENTATION_QOS`. + +### `srcCxx/shape_configurator_rti_connext_micro.h` +- Added `#define DDS_VENDOR_NAME "RTI Connext Micro"`. +- Implemented `vendor_check_publisher_options()` and `vendor_check_subscriber_options()`: + - Rejects `ordered_access_enabled`, `coherent_set_enabled`, and non-instance access scopes. + - Rejects `lifespan_us > 0`. + - Rejects `TRANSIENT_DURABILITY_QOS` and `PERSISTENT_DURABILITY_QOS`. + - Rejects `timebasedfilter_interval_us > 0`. + - Rejects `--cft` (ContentFilteredTopic). + +### `srcCxx/shape_configurator_intercom_dds.h` +- Added `#define DDS_VENDOR_NAME "Kongsberg InterCOM DDS"`. +- Implemented `vendor_check_publisher_options()` and `vendor_check_subscriber_options()`: + - Rejects `GROUP_PRESENTATION_QOS`. + - Rejects coherent access combined with `TOPIC_PRESENTATION_QOS`. + +### `srcCxx/shape_configurator_rti_connext_dds.h` & `srcCxx/shape_configurator_toc_coredx_dds.h` +- Added `#define DDS_VENDOR_NAME "RTI Connext DDS"` and `#define DDS_VENDOR_NAME "Twin Oaks CoreDX"`. +- Implemented `vendor_check_publisher_options()` and `vendor_check_subscriber_options()` returning `true`. + +### `srcCxx/shape_main.cxx` +- **Error Diagnostics**: + - Memory allocations (`topics`, `dws`, `drs`, `previous_handles`) now log `[] Error allocating memory for ... ( requested)`. + - Participant factory, participant, and topic creation logs now report exact topic names, domain IDs, and potential root causes (e.g. missing license or QoS mismatch). + - DataWriter and DataReader creation failures now log `[] Failed to create datawriter/datareader [] for topic: (check QoS compatibility or resource limits)`. + - Replaced all unbranded fallback errors with `[] ...`. +- **Validation Integration**: + - `init_publisher()` and `init_subscriber()` call `vendor_check_publisher_options()` and `vendor_check_subscriber_options()` at startup. +- **Duration Fix**: + - Unified lifespan duration logic for Fast DDS and other vendors. + +### `interoperability_report.py` +- **Output Classification**: + - Detects if any returned code is `PUB_UNSUPPORTED_FEATURE` or `SUB_UNSUPPORTED_FEATURE`. + - Extracts the exact failure reason line matching `not supported` from the failing entity's console output. + - Prints: + ``` + : UNSUPPORTED (: ) + ``` + instead of `ERROR`. + - When an actual failure occurs, prints: + ``` + : FAILED (: got , expected ) + ``` + and extracts any explicit error lines from console output. +- **JUnit Reporting**: + - Sets `test_case.result = [junitparser.Skipped(message)]` for unsupported tests. + - Preserves full compatibility with `generate_xlsx_report.py`, which checks `case.result[0].message` for `PUB_UNSUPPORTED_FEATURE` and `SUB_UNSUPPORTED_FEATURE`. + +--- + +## 4. Verification & Validation + +1. **Python Compilation & Syntax**: + - `interoperability_report.py`, `test_suite.py`, and `generate_xlsx_report.py` successfully compiled with Python 3.9 without syntax errors. +2. **Regex & Reason Extraction Tests**: + - Verified that `re.compile('not supported', re.IGNORECASE)` matches all new vendor messages. + - Tested extraction logic on mock application console outputs. +3. **JUnit XML & Report Compatibility**: + - Verified that `junitparser.Skipped` produces XML with `errors="0"`, `failures="0"`, `skipped="1"`. + - Verified that `generate_xlsx_report.py` correctly detects `PUB_UNSUPPORTED` / `SUB_UNSUPPORTED` / `PUB_SUB_UNSUPPORTED` from the skipped case messages. From 8c65cff5873abc51fefd6dbf5598d81106e5c5a0 Mon Sep 17 00:00:00 2001 From: "martin.richter.supervag" Date: Sat, 19 Sep 2026 00:29:52 +0000 Subject: [PATCH 20/31] generate_report.sh, uprava upload_local.yml --- .github/workflows/2_upload_artifact_local.yml | 10 +++++++++- generate_reports.sh | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100755 generate_reports.sh diff --git a/.github/workflows/2_upload_artifact_local.yml b/.github/workflows/2_upload_artifact_local.yml index 3c7b365..22b4455 100644 --- a/.github/workflows/2_upload_artifact_local.yml +++ b/.github/workflows/2_upload_artifact_local.yml @@ -12,8 +12,15 @@ jobs: workflow: 1_run_interoperability_tests_local.yml workflow_conclusion: completed + - name: Ensure system dependencies + run: | + for cmd in zip gh; do + which "$cmd" >/dev/null 2>&1 || sudo apt-get install -y "$cmd" + done + - name: Create files to upload run: | + rm -rf report_* current_date=$(cat timestamp) mv index.html interoperability_report.html for file in *; do \ @@ -35,7 +42,8 @@ jobs: - name: Upload reports to Google Drive run: | - rclone copy report_* mygdrive: --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} -v + current_date=$(cat timestamp) + rclone copy report_$current_date mygdrive:report_$current_date --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} -v - name: Attach detailed report html uses: actions/upload-artifact@v4 diff --git a/generate_reports.sh b/generate_reports.sh new file mode 100755 index 0000000..6b3b2d6 --- /dev/null +++ b/generate_reports.sh @@ -0,0 +1,20 @@ +#!/bin/bash +set -e + +# Přejít do adresáře skriptu +cd "$(dirname "$0")" + +# Odstranění předchozího sloučeného XML, aby se neslučovalo samo do sebe +rm -f junit_interoperability_report.xml + +echo "[1/3] Slučuji XML reporty do junit_interoperability_report.xml..." +python3 -m junitparser merge *.xml junit_interoperability_report.xml + +echo "[2/3] Generuji Excel report interoperability_report.xlsx..." +python3 generate_xlsx_report.py --input junit_interoperability_report.xml --output interoperability_report.xlsx + +echo "[3/3] Generuji HTML report index.html..." +npx -y xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html + +echo "Hotovo! Vygenerované výstupy:" +ls -lh junit_interoperability_report.xml interoperability_report.xlsx index.html From 92456b1c976f72e69ee7630b436579b4517fe86b Mon Sep 17 00:00:00 2001 From: "martin.richter.supervag" Date: Sat, 19 Sep 2026 00:35:59 +0000 Subject: [PATCH 21/31] upload_local.yml process corrected --- .github/workflows/2_upload_artifact_local.yml | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/.github/workflows/2_upload_artifact_local.yml b/.github/workflows/2_upload_artifact_local.yml index 22b4455..f273957 100644 --- a/.github/workflows/2_upload_artifact_local.yml +++ b/.github/workflows/2_upload_artifact_local.yml @@ -21,15 +21,29 @@ jobs: - name: Create files to upload run: | rm -rf report_* - current_date=$(cat timestamp) - mv index.html interoperability_report.html - for file in *; do \ - mv "$file" "${file%.*}_$current_date.${file##*.}"; \ + if [ -f timestamp ]; then + current_date=$(cat timestamp) + elif ls timestamp*.timestamp 1>/dev/null 2>&1; then + current_date=$(cat timestamp*.timestamp | head -n 1) + else + current_date=$(date '+%Y-%m-%d-%H_%M_%S') + fi + echo "$current_date" > timestamp + + [ -f index.html ] && mv index.html interoperability_report.html + + for file in interoperability_report.html junit_interoperability_report.xml interoperability_report.xlsx timestamp; do + if [ -f "$file" ]; then + cp "$file" "${file%.*}_$current_date.${file##*.}" + fi done - zip interoperability_report_$current_date.zip * - mkdir report_$current_date - mv *.zip report_$current_date/ - mv *.xlsx report_$current_date/ + + zip interoperability_report_$current_date.zip *_$current_date.* + mkdir -p report_$current_date + mv interoperability_report_$current_date.zip report_$current_date/ + if [ -f interoperability_report_$current_date.xlsx ]; then + cp interoperability_report_$current_date.xlsx report_$current_date/ + fi - name: Setup Rclone uses: AnimMouse/setup-rclone@v1 From e2119e24bc06bc08e8c07fa4fac4f28133215b77 Mon Sep 17 00:00:00 2001 From: "martin.richter.supervag" Date: Sat, 19 Sep 2026 01:21:59 +0000 Subject: [PATCH 22/31] docker isolation --- .dockerignore | 12 ++++ .../1_run_interoperability_tests_local.yml | 15 +++++ .gitignore | 1 + Dockerfile | 37 ++++++++++++ docker-compose.yml | 9 +++ run_in_docker.sh | 56 +++++++++++++++++++ run_tests.sh | 11 ++++ 7 files changed, 141 insertions(+) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 run_in_docker.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..74cf747 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +.venv +__pycache__ +*.pyc +*.xml +*.xlsx +*.html +*.zip +OpenDDS-durable-data-dir +zipped_executables +archive_reports diff --git a/.github/workflows/1_run_interoperability_tests_local.yml b/.github/workflows/1_run_interoperability_tests_local.yml index 2ee5888..821b99d 100644 --- a/.github/workflows/1_run_interoperability_tests_local.yml +++ b/.github/workflows/1_run_interoperability_tests_local.yml @@ -54,6 +54,14 @@ jobs: run: unzip 'zipped_executables/*.zip' -d executables - name: Install Python requirements run: pip install --break-system-packages --requirement requirements.txt + - name: Configure Network Isolation & Disable IPv6 + run: | + # Disable IPv6 to prevent Dust DDS panic on unimplemented LOCATOR_KIND_UDP_V6 + sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1 || true + sudo sysctl -w net.ipv6.conf.default.disable_ipv6=1 || true + sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=1 || true + # Route DDS multicast discovery (224.0.0.0/4) exclusively to loopback to isolate from LAN + sudo ip route replace 224.0.0.0/4 dev lo || true - name: Run Interoperability script timeout-minutes: 120 continue-on-error: true @@ -94,6 +102,13 @@ jobs: path: | ./junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }}.xml ./timestamp + - name: Restore Network Settings + if: always() + run: | + sudo sysctl -w net.ipv6.conf.all.disable_ipv6=0 || true + sudo sysctl -w net.ipv6.conf.default.disable_ipv6=0 || true + sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=0 || true + sudo ip route del 224.0.0.0/4 dev lo || true generate_report: runs-on: self-hosted diff --git a/.gitignore b/.gitignore index 4897f53..1802410 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,7 @@ build/ *.xml *.zip *.html +archive_reports/ # Do not ignore templates !doc/_templates/*.html diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4258c9f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,37 @@ +# Dockerfile for OMG DDS-RTPS Interoperability Testing Suite +# Provides an isolated, reproducible environment (IPv4 only, contained multicast discovery) +FROM ubuntu:22.04 + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONUNBUFFERED=1 \ + HOME=/tmp + +# Install core runtime dependencies: Python 3, Node.js (for xunit-viewer), and network tools +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + nodejs \ + npm \ + unzip \ + zip \ + curl \ + iproute2 \ + procps \ + && rm -rf /var/lib/apt/lists/* + +# Install xunit-viewer globally for HTML report generation +RUN npm install -g xunit-viewer + +# Set working directory +WORKDIR /workspace + +# Copy and install python dependencies first to leverage Docker layer caching +COPY requirements.txt /workspace/ +RUN pip3 install --no-cache-dir --break-system-packages -r requirements.txt || \ + pip3 install --no-cache-dir -r requirements.txt + +# Copy all repository files into container +COPY . /workspace + +# Default command: launch test suite +CMD ["/bin/bash", "./run_tests.sh"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a889be7 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,9 @@ +services: + tester: + build: . + image: dds-rtps-tester + volumes: + - .:/workspace + working_dir: /workspace + # Default bridge network contains all RTPS multicast discovery within this container + command: ["/bin/bash"] diff --git a/run_in_docker.sh b/run_in_docker.sh new file mode 100644 index 0000000..fa03fbf --- /dev/null +++ b/run_in_docker.sh @@ -0,0 +1,56 @@ +#!/bin/bash +# ============================================================================== +# Helper script to run DDS-RTPS interoperability tests inside an isolated Docker container +# ============================================================================== +set -e + +IMAGE_NAME="dds-rtps-tester" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$SCRIPT_DIR" + +# Check if Docker is installed +if ! command -v docker &> /dev/null; then + echo "ERROR: 'docker' command not found. Please install Docker first." + exit 1 +fi + +# Build Docker image if requested or if it does not exist +if [[ "$1" == "--build" ]] || [[ "$(docker images -q "$IMAGE_NAME" 2> /dev/null)" == "" ]]; then + echo "==> Building Docker image: $IMAGE_NAME..." + docker build -t "$IMAGE_NAME" . + if [[ "$1" == "--build" ]]; then + shift + fi +fi + +# Archive previous test results on host if present +archive_dir="$SCRIPT_DIR/archive_reports" +shopt -s nullglob +old_reports=("$SCRIPT_DIR"/*.xml "$SCRIPT_DIR"/*.xlsx "$SCRIPT_DIR"/index.html) +if [ ${#old_reports[@]} -gt 0 ]; then + echo "==> Archiving previous test reports to archive_reports/..." + mkdir -p "$archive_dir" + mv "${old_reports[@]}" "$archive_dir/" 2>/dev/null || true +fi +shopt -u nullglob + +echo "==> Running in isolated Docker container (bridge network, contained multicast)..." + +# Run container: +# - Mount current directory to /workspace so test reports are saved to host +# - Default bridge network isolates multicast discovery (239.255.0.1) from local LAN +# - Passes any additional arguments directly to the container command +if [ $# -eq 0 ]; then + docker run --rm -it \ + -v "$SCRIPT_DIR:/workspace" \ + -w /workspace \ + "$IMAGE_NAME" \ + /bin/bash +else + docker run --rm -it \ + -v "$SCRIPT_DIR:/workspace" \ + -w /workspace \ + "$IMAGE_NAME" \ + "$@" +fi diff --git a/run_tests.sh b/run_tests.sh index e12659d..e872fe8 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -74,6 +74,17 @@ if [[ -z $publisher || -z $subscriber ]]; then usage fi +# Archive previous test results if present +archive_dir="./archive_reports" +shopt -s nullglob +old_reports=(*.xml *.xlsx index.html) +if [ ${#old_reports[@]} -gt 0 ]; then + echo "Archiving previous test reports to $archive_dir..." + mkdir -p "$archive_dir" + mv "${old_reports[@]}" "$archive_dir/" 2>/dev/null || true +fi +shopt -u nullglob + # Run the application logic for i in $publisher; do for j in $subscriber; do From 2fa969df8d290e05bebd489df2ce5c7c6153e70c Mon Sep 17 00:00:00 2001 From: "martin.richter.supervag" Date: Sat, 19 Sep 2026 01:25:21 +0000 Subject: [PATCH 23/31] chmod run_in_docker.sh --- run_in_docker.sh | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100644 => 100755 run_in_docker.sh diff --git a/run_in_docker.sh b/run_in_docker.sh old mode 100644 new mode 100755 From 47478a0953f9a875a137a0b35000734628a8b307 Mon Sep 17 00:00:00 2001 From: "martin.richter.supervag" Date: Sat, 19 Sep 2026 05:22:38 +0000 Subject: [PATCH 24/31] script created --- run_tests_in_docker.sh | 61 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100755 run_tests_in_docker.sh diff --git a/run_tests_in_docker.sh b/run_tests_in_docker.sh new file mode 100755 index 0000000..2bb9199 --- /dev/null +++ b/run_tests_in_docker.sh @@ -0,0 +1,61 @@ +#!/bin/bash +# ============================================================================== +# Run DDS-RTPS interoperability tests inside an isolated Docker container +# Workflow: +# 1. Archives any existing reports from host into archive_reports/ +# 2. Starts Docker container +# 3. Runs the test suite inside the container +# 4. Generates XML, XLSX, and HTML reports +# 5. Exits and cleans up Docker container automatically +# ============================================================================== +set -e + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +IMAGE_NAME="dds-rtps-tester" + +# Check if Docker is installed +if ! command -v docker &> /dev/null; then + echo "ERROR: 'docker' command not found. Please install Docker first." + exit 1 +fi + +# 1. Archive previous test reports from host if present +archive_dir="$SCRIPT_DIR/archive_reports" +shopt -s nullglob +old_reports=("$SCRIPT_DIR"/*.xml "$SCRIPT_DIR"/*.xlsx "$SCRIPT_DIR"/index.html) +if [ ${#old_reports[@]} -gt 0 ]; then + echo "==> [1/4] Archiving previous test reports to archive_reports/..." + mkdir -p "$archive_dir" + mv "${old_reports[@]}" "$archive_dir/" 2>/dev/null || true +fi +shopt -u nullglob + +# 2. Build Docker image if not present +if [[ "$(docker images -q "$IMAGE_NAME" 2> /dev/null)" == "" ]]; then + echo "==> [2/4] Building Docker image: $IMAGE_NAME..." + docker build -t "$IMAGE_NAME" . +fi + +# Determine test arguments: default to all executables under ./executables +if [ $# -eq 0 ]; then + TEST_CMD="./run_tests.sh -i ./executables" +else + TEST_CMD="./run_tests.sh $*" +fi + +echo "==> [3/4] Running tests inside Docker container ($IMAGE_NAME)..." +echo " Command: $TEST_CMD" + +# 3. Run container, execute tests, generate reports, and automatically terminate container (--rm) +docker run --rm \ + -v "$SCRIPT_DIR:/workspace" \ + -w /workspace \ + "$IMAGE_NAME" \ + /bin/bash -c "$TEST_CMD; ./generate_reports.sh" + +echo "" +echo "==> [4/4] Docker container finished and closed." +echo "==> Generated reports in $SCRIPT_DIR:" +ls -lh "$SCRIPT_DIR"/junit_interoperability_report.xml "$SCRIPT_DIR"/interoperability_report.xlsx "$SCRIPT_DIR"/index.html 2>/dev/null || true From 0b24160d54889645328cbbc3a2372a70bba899ee Mon Sep 17 00:00:00 2001 From: "martin.richter.supervag" Date: Sat, 19 Sep 2026 05:52:15 +0000 Subject: [PATCH 25/31] version nodejs --- Dockerfile | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/Dockerfile b/Dockerfile index 4258c9f..fe43b6a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,22 +6,21 @@ ENV DEBIAN_FRONTEND=noninteractive \ PYTHONUNBUFFERED=1 \ HOME=/tmp -# Install core runtime dependencies: Python 3, Node.js (for xunit-viewer), and network tools +# Install core runtime dependencies: Python 3, Node.js (via NodeSource 20 LTS), and network tools RUN apt-get update && apt-get install -y --no-install-recommends \ python3 \ python3-pip \ - nodejs \ - npm \ + ca-certificates \ + curl \ unzip \ zip \ - curl \ iproute2 \ procps \ + && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && npm install -g xunit-viewer \ && rm -rf /var/lib/apt/lists/* -# Install xunit-viewer globally for HTML report generation -RUN npm install -g xunit-viewer - # Set working directory WORKDIR /workspace From 5e55ea51ed27827448191ec9d64772ff8e10e1d5 Mon Sep 17 00:00:00 2001 From: "martin.richter.supervag" Date: Mon, 21 Sep 2026 02:28:32 +0000 Subject: [PATCH 26/31] Improve 1_run_interoperability_tests.yml resilience and runner flexibility --- .../1_run_interoperability_tests.yml | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/.github/workflows/1_run_interoperability_tests.yml b/.github/workflows/1_run_interoperability_tests.yml index d2d527e..a6bdb98 100644 --- a/.github/workflows/1_run_interoperability_tests.yml +++ b/.github/workflows/1_run_interoperability_tests.yml @@ -11,9 +11,13 @@ on: description: Subscribers to use type: string default: '["connext_dds","dust_dds","eprosima_fastdds","intercom_dds","opendds","toc_coredx_dds", "hdds", "eclipse_cyclone", "zzdds"]' + runner: + description: Runner to use + type: string + default: 'ubuntu-latest' jobs: generate_timestamp: - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner || 'ubuntu-latest' }} steps: - name: Generate timestamp file run: date '+%Y-%m-%d-%H_%M_%S' > timestamp @@ -25,10 +29,10 @@ jobs: ./timestamp run_tests: - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner || 'ubuntu-latest' }} needs: generate_timestamp strategy: - fail-fast: false + fail-fast: false matrix: publisher: ${{ fromJson(github.event.inputs.publishers) }} subscriber: ${{ fromJson(github.event.inputs.subscribers) }} @@ -37,12 +41,13 @@ jobs: - name: Checkout sources uses: actions/checkout@v6 - uses: actions/setup-python@v6 + continue-on-error: true with: python-version: '3.11.4' - name: Download shape_main executables uses: robinraju/release-downloader@v1.13 with: - repository: "omg-dds/dds-rtps" # <-- PŘIDEJTE TENTO ŘÁDEK + repository: "omg-dds/dds-rtps" latest: true fileName: "*" out-file-path: zipped_executables @@ -50,7 +55,7 @@ jobs: - name: Unzip executables run: unzip 'zipped_executables/*.zip' -d executables - name: Install Python requirements - run: pip install --requirement requirements.txt + run: pip install --requirement requirements.txt || pip install --break-system-packages --requirement requirements.txt - name: Run Interoperability script timeout-minutes: 120 continue-on-error: true @@ -86,7 +91,7 @@ jobs: ./timestamp generate_report: - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner || 'ubuntu-latest' }} needs: run_tests steps: - name: Checkout @@ -97,12 +102,13 @@ jobs: pattern: junit_report-* merge-multiple: true - uses: actions/setup-python@v6 + continue-on-error: true with: python-version: '3.11.4' - name: Install Python requirements - run: pip install --requirement requirements.txt + run: pip install --requirement requirements.txt || pip install --break-system-packages --requirement requirements.txt - name: merge reports - run: junitparser merge *.xml junit_interoperability_report.xml + run: python3 -m junitparser merge *.xml junit_interoperability_report.xml - name: Generate xlsx report run: python3 generate_xlsx_report.py --input junit_interoperability_report.xml --output interoperability_report.xlsx - name: XUnit From 996d0b51c4b938f637d1931b1ffc3f5118547f5e Mon Sep 17 00:00:00 2001 From: "martin.richter.supervag" Date: Mon, 21 Sep 2026 02:41:18 +0000 Subject: [PATCH 27/31] Auto-switch to isolated Docker runner on self-hosted runners and document runner types --- .../1_run_interoperability_tests.yml | 8 +++- README.md | 42 +++++++++++++++++++ run_in_docker.sh | 30 ++++++++----- 3 files changed, 68 insertions(+), 12 deletions(-) diff --git a/.github/workflows/1_run_interoperability_tests.yml b/.github/workflows/1_run_interoperability_tests.yml index a6bdb98..674fc14 100644 --- a/.github/workflows/1_run_interoperability_tests.yml +++ b/.github/workflows/1_run_interoperability_tests.yml @@ -77,7 +77,13 @@ jobs: if [[ "${subscriber_exe,,}" == *opendds* && "${publisher_exe,,}" == *connext_dds* ]]; then extra_args="--periodic-announcement 5000" fi - python3 interoperability_report.py --publisher $publisher_exe --subscriber $subscriber_exe --output-name $output_file $extra_args + if [ "$RUNNER_ENVIRONMENT" = "self-hosted" ]; then + echo "Running on self-hosted runner: executing inside isolated Docker container..." + ./run_in_docker.sh python3 interoperability_report.py --publisher $publisher_exe --subscriber $subscriber_exe --output-name $output_file $extra_args + else + echo "Running in GitHub-hosted environment: executing directly..." + python3 interoperability_report.py --publisher $publisher_exe --subscriber $subscriber_exe --output-name $output_file $extra_args + fi - name: Download timestamp uses: actions/download-artifact@v8 with: diff --git a/README.md b/README.md index ed94749..60042d6 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,8 @@ and can interoperate with each other. * 3.4. [Report](#report) + * 3.5. [Running Tests in an Isolated Docker Container](#running-tests-in-an-isolated-docker-container) + * 4\. [Automation with GitHub Actions](#automation-with-github-actions) * 5\. [Workflow](#workflow) @@ -468,6 +470,42 @@ The report file contains the following items: * Console output from the shape application publisher and subscriber. +## Running Tests in an Isolated Docker Container + +Running interoperability tests inside Docker provides a self-contained, reproducible environment with all dependencies (Python 3, Node.js, `xunit-viewer`, `junitparser`, `XlsxWriter`, `lxml`) pre-installed. + +Furthermore, Docker's default bridge network isolates RTPS multicast discovery traffic (such as `239.255.0.1` and `224.0.0.0/4`) within the container, preventing multicast leaks to the local LAN and avoiding interference between concurrent test runs or other DDS applications on the network (which is particularly useful when running tests inside virtualized environments, LXC containers, or multi-tenant machines). + +### Quick Start (All-in-One Automated Run) + +Run all tests against executables in `./executables` and automatically generate XML, Excel (`.xlsx`), and HTML (`index.html`) reports: + +```bash +./run_tests_in_docker.sh +``` + +To run tests on a specific subset of executables or pass custom flags to `run_tests.sh`: + +```bash +./run_tests_in_docker.sh -p ./executables/connext_dds*shape_main_linux -s ./executables/opendds*shape_main_linux +``` + +### Interactive Shell or Custom Execution + +You can also launch an interactive shell in the Docker container: + +```bash +./run_in_docker.sh +``` + +Or run via Docker Compose: + +```bash +docker compose run --rm tester ./run_tests.sh -i ./executables +``` + +All generated reports (`junit_interoperability_report.xml`, `interoperability_report.xlsx`, `index.html`) are saved directly to the host workspace, and previous runs are automatically archived to `archive_reports/`. + # Automation with GitHub Actions GitHub Actions allows to automate the testing phase by calling @@ -507,6 +545,10 @@ To generate the report you should follow the next steps: ![Run-workflow](./resource/_md/img/img3.png) +> **Note on Runner Environment**: +> * **`ubuntu-latest`** *(default)*: Executes in GitHub Actions cloud. +> * **`self-hosted`**: Executes on your own self-hosted runner (e.g. inside an LXC container). In this mode, the workflow automatically runs tests inside an isolated Docker container (`run_in_docker.sh`) to prevent RTPS multicast discovery traffic from leaking into the physical local network. + 4. Wait a few minutes until the new task is finished and then press it.\ Example of a successful and a failed test: diff --git a/run_in_docker.sh b/run_in_docker.sh index fa03fbf..9999646 100755 --- a/run_in_docker.sh +++ b/run_in_docker.sh @@ -24,31 +24,39 @@ if [[ "$1" == "--build" ]] || [[ "$(docker images -q "$IMAGE_NAME" 2> /dev/null) fi fi -# Archive previous test results on host if present -archive_dir="$SCRIPT_DIR/archive_reports" -shopt -s nullglob -old_reports=("$SCRIPT_DIR"/*.xml "$SCRIPT_DIR"/*.xlsx "$SCRIPT_DIR"/index.html) -if [ ${#old_reports[@]} -gt 0 ]; then - echo "==> Archiving previous test reports to archive_reports/..." - mkdir -p "$archive_dir" - mv "${old_reports[@]}" "$archive_dir/" 2>/dev/null || true +# Archive previous test results on host if interactive +if [ $# -eq 0 ]; then + archive_dir="$SCRIPT_DIR/archive_reports" + shopt -s nullglob + old_reports=("$SCRIPT_DIR"/*.xml "$SCRIPT_DIR"/*.xlsx "$SCRIPT_DIR"/index.html) + if [ ${#old_reports[@]} -gt 0 ]; then + echo "==> Archiving previous test reports to archive_reports/..." + mkdir -p "$archive_dir" + mv "${old_reports[@]}" "$archive_dir/" 2>/dev/null || true + fi + shopt -u nullglob fi -shopt -u nullglob echo "==> Running in isolated Docker container (bridge network, contained multicast)..." +# Detect if running in an interactive terminal (allocate pseudo-TTY only if interactive) +DOCKER_FLAGS="-i" +if [ -t 0 ] && [ -t 1 ]; then + DOCKER_FLAGS="-it" +fi + # Run container: # - Mount current directory to /workspace so test reports are saved to host # - Default bridge network isolates multicast discovery (239.255.0.1) from local LAN # - Passes any additional arguments directly to the container command if [ $# -eq 0 ]; then - docker run --rm -it \ + docker run --rm $DOCKER_FLAGS \ -v "$SCRIPT_DIR:/workspace" \ -w /workspace \ "$IMAGE_NAME" \ /bin/bash else - docker run --rm -it \ + docker run --rm $DOCKER_FLAGS \ -v "$SCRIPT_DIR:/workspace" \ -w /workspace \ "$IMAGE_NAME" \ From 36ae32eddbf5110af779b14790f3a731dcbe2b24 Mon Sep 17 00:00:00 2001 From: "martin.richter.supervag" Date: Mon, 21 Sep 2026 02:59:08 +0000 Subject: [PATCH 28/31] Add Contributor License Agreement (CLA) for Martin Richter --- CLA/CLA_Martin_Richter.md | 60 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 CLA/CLA_Martin_Richter.md diff --git a/CLA/CLA_Martin_Richter.md b/CLA/CLA_Martin_Richter.md new file mode 100644 index 0000000..2e3febf --- /dev/null +++ b/CLA/CLA_Martin_Richter.md @@ -0,0 +1,60 @@ +# OMG DDS INTEROPERABILITY REPOSITORY - CONTRIBUTOR LICENSE AGREEMENT + +**This Contributor License Agreement ("Agreement") specifies the terms under which the individual or corporate entity specified in the signature block below (“You”) agree to make intellectual property contributions to the OMG DDS Interoperability Repository. BY SIGNING BELOW YOU ARE AGREEING TO BE BOUND BY THE TERMS OF THIS AGREEMENT. If You are signing this Agreement in Your capacity as an employee, THEN YOUR EMPLOYER AND YOU ARE BOTH BOUND BY THIS AGREEMENT.** + +1. Definitions + + 1. "OMG DDS Interoperability Repository" (or “Repository”) means the Git repository [https://github.com/omg-dds/dds-rtps](https://github.com/omg-dds/dds-rtps). + + 2. "Moderator" means an entity or individual responsible for authorizing changes to the Repository. + + 3. "Submit" (or “Submitted”) means any submission, including source code, binaries, code, pull requests, issue reports, comments, etc., made to the Moderators for inclusion in the Repository either through the Git repository interface or through electronic file transfer. + + 4. A "Contribution" is any original work of authorship, including any modifications or additions to an existing work, that You Submit to the DDS Interoperability Repository. + + 5. A "User" is anyone who accesses the Repository. + +2. Allowable Contribution Representations + + 1. You represent that You have the necessary rights to the Contribution(s) to meet the obligations of this Agreement. If You are employed, Your employer has authorized Contribution(s) under this Agreement. + + 2. You represent that you have no knowledge of third-party intellectual property rights that are likely to be infringed by the Contribution(s). You represent that you have no knowledge that such infringement or any allegation of misappropriation of intellectual property rights is likely to be claimed or has already been claimed. + +3. License + + You grant Moderators a perpetual, worldwide, non-exclusive, assignable, paid-up license to publish, display, and redistribute the Contribution as part of the Repository. You also license to Moderators under the same terms any other intellectual property rights required to publish, display, and redistribute the Contributions as part of the Repository. You further grant all Users of the Repository a license to the Contribution under the terms of the [OMG DDS Interoperability Testing License](../LICENSE.md) included in the Repository. Moderators are under no obligation to publish Contributions. + +4. No Warranty, Consequential Damages. Limited Liability + + Other than explicitly stated herein, You provide the Contribution(s) "as is" with no warranty nor claims of fitness to any purpose. Neither party shall be liable for consequential or special damages of any kind. Other than for breach of warranty or representations herein, the liability of either party to the other shall be limited to $1000. + +5. General + + 1. If You are an agency of the United States Government, then this Agreement will be governed by the United States federal common law. Otherwise, this Agreement will be governed by the laws of the State of California except with regard to its choice of law rules. + + 2. A party may assign this Agreement to an entity acquiring essentially all of the party’s relevant business. + +6. Electronic Signatures + + "Electronic Signature" means any electronic sound, symbol, or process attached to or logically associated with a record and executed and adopted by a party with the intent to sign such record. + + Each party agrees that the Electronic Signatures, whether digital or encrypted, of the parties included in this Agreement are intended to authenticate this writing and to have the same force and effect as manual signatures. + + +IN WITNESS WHEREOF, You, intending to be legally bound, have executed this Agreement or caused Your employer’s proper and duly authorized officer to execute and deliver this Agreement, for good and valuable consideration, the sufficiency of which is hereby acknowledged, as of the day and year first written below. + +**For:** + +Entity Name: _University of Defence, Brno, Czech Republic_ + +Address: _Šumavská 4, Brno, Czech Republic_ + + ("**You**") + +**By:** + +Name: _Martin Richter_ + +Title: _Academic Researcher_ + +Date: _September 21st, 2026_ From 0bafb0b3a0111d421ccb758c4e4bcd75728b69b9 Mon Sep 17 00:00:00 2001 From: "martin.richter.supervag" Date: Tue, 22 Sep 2026 17:23:07 +0000 Subject: [PATCH 29/31] Fix self-hosted runner permissions and Python setup on Debian 13 --- .../1_run_interoperability_tests.yml | 25 +++++++++++-------- .gitignore | 2 ++ Dockerfile | 1 + generate_reports.sh | 20 +++++++++------ run_in_docker.sh | 5 ++++ run_tests_in_docker.sh | 2 ++ 6 files changed, 37 insertions(+), 18 deletions(-) diff --git a/.github/workflows/1_run_interoperability_tests.yml b/.github/workflows/1_run_interoperability_tests.yml index 674fc14..bb2920c 100644 --- a/.github/workflows/1_run_interoperability_tests.yml +++ b/.github/workflows/1_run_interoperability_tests.yml @@ -41,7 +41,7 @@ jobs: - name: Checkout sources uses: actions/checkout@v6 - uses: actions/setup-python@v6 - continue-on-error: true + if: runner.environment != 'self-hosted' with: python-version: '3.11.4' - name: Download shape_main executables @@ -55,7 +55,8 @@ jobs: - name: Unzip executables run: unzip 'zipped_executables/*.zip' -d executables - name: Install Python requirements - run: pip install --requirement requirements.txt || pip install --break-system-packages --requirement requirements.txt + if: runner.environment != 'self-hosted' + run: pip install --requirement requirements.txt - name: Run Interoperability script timeout-minutes: 120 continue-on-error: true @@ -108,17 +109,21 @@ jobs: pattern: junit_report-* merge-multiple: true - uses: actions/setup-python@v6 - continue-on-error: true + if: runner.environment != 'self-hosted' with: python-version: '3.11.4' - name: Install Python requirements - run: pip install --requirement requirements.txt || pip install --break-system-packages --requirement requirements.txt - - name: merge reports - run: python3 -m junitparser merge *.xml junit_interoperability_report.xml - - name: Generate xlsx report - run: python3 generate_xlsx_report.py --input junit_interoperability_report.xml --output interoperability_report.xlsx - - name: XUnit - run: npx xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html + if: runner.environment != 'self-hosted' + run: pip install --requirement requirements.txt + - name: Generate report files + run: | + if [ "$RUNNER_ENVIRONMENT" = "self-hosted" ]; then + ./run_in_docker.sh ./generate_reports.sh + else + python3 -m junitparser merge *.xml junit_interoperability_report.xml + python3 generate_xlsx_report.py --input junit_interoperability_report.xml --output interoperability_report.xlsx + npx xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html + fi - name: Download timestamp uses: actions/download-artifact@v8 with: diff --git a/.gitignore b/.gitignore index 1802410..100b84d 100644 --- a/.gitignore +++ b/.gitignore @@ -74,6 +74,8 @@ archive_reports/ !doc/_templates/*.html .DS_Store +core +core.* # Generated documentation files gdrive_url.py diff --git a/Dockerfile b/Dockerfile index fe43b6a..e42900d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ FROM ubuntu:22.04 ENV DEBIAN_FRONTEND=noninteractive \ PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ HOME=/tmp # Install core runtime dependencies: Python 3, Node.js (via NodeSource 20 LTS), and network tools diff --git a/generate_reports.sh b/generate_reports.sh index 6b3b2d6..711c8c6 100755 --- a/generate_reports.sh +++ b/generate_reports.sh @@ -1,20 +1,24 @@ #!/bin/bash set -e -# Přejít do adresáře skriptu +# Change to script directory cd "$(dirname "$0")" -# Odstranění předchozího sloučeného XML, aby se neslučovalo samo do sebe +# Remove previously merged XML report to avoid merging it into itself rm -f junit_interoperability_report.xml -echo "[1/3] Slučuji XML reporty do junit_interoperability_report.xml..." +echo "[1/3] Merging XML reports into junit_interoperability_report.xml..." python3 -m junitparser merge *.xml junit_interoperability_report.xml -echo "[2/3] Generuji Excel report interoperability_report.xlsx..." +echo "[2/3] Generating Excel report interoperability_report.xlsx..." python3 generate_xlsx_report.py --input junit_interoperability_report.xml --output interoperability_report.xlsx -echo "[3/3] Generuji HTML report index.html..." -npx -y xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html +echo "[3/3] Generating HTML report index.html..." +if command -v xunit-viewer &> /dev/null; then + xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html +else + npx -y xunit-viewer --results=./junit_interoperability_report.xml --output=./index.html +fi -echo "Hotovo! Vygenerované výstupy:" -ls -lh junit_interoperability_report.xml interoperability_report.xlsx index.html +echo "Done! Generated reports:" +ls -lh junit_interoperability_report.xml interoperability_report.xlsx index.html 2>/dev/null || true diff --git a/run_in_docker.sh b/run_in_docker.sh index 9999646..2b57d17 100755 --- a/run_in_docker.sh +++ b/run_in_docker.sh @@ -48,15 +48,20 @@ fi # Run container: # - Mount current directory to /workspace so test reports are saved to host # - Default bridge network isolates multicast discovery (239.255.0.1) from local LAN +# - Runs with current host user UID:GID and PYTHONDONTWRITEBYTECODE=1 to avoid permission issues # - Passes any additional arguments directly to the container command if [ $# -eq 0 ]; then docker run --rm $DOCKER_FLAGS \ + --user "$(id -u):$(id -g)" \ + -e PYTHONDONTWRITEBYTECODE=1 \ -v "$SCRIPT_DIR:/workspace" \ -w /workspace \ "$IMAGE_NAME" \ /bin/bash else docker run --rm $DOCKER_FLAGS \ + --user "$(id -u):$(id -g)" \ + -e PYTHONDONTWRITEBYTECODE=1 \ -v "$SCRIPT_DIR:/workspace" \ -w /workspace \ "$IMAGE_NAME" \ diff --git a/run_tests_in_docker.sh b/run_tests_in_docker.sh index 2bb9199..63bae52 100755 --- a/run_tests_in_docker.sh +++ b/run_tests_in_docker.sh @@ -50,6 +50,8 @@ echo " Command: $TEST_CMD" # 3. Run container, execute tests, generate reports, and automatically terminate container (--rm) docker run --rm \ + --user "$(id -u):$(id -g)" \ + -e PYTHONDONTWRITEBYTECODE=1 \ -v "$SCRIPT_DIR:/workspace" \ -w /workspace \ "$IMAGE_NAME" \ From 4f6bcf871b27726ad027c6e9c4f10312aa114360 Mon Sep 17 00:00:00 2001 From: "martin.richter.supervag" Date: Tue, 22 Sep 2026 19:41:39 +0000 Subject: [PATCH 30/31] rpts discovery report --- .gitignore | 3 + Dockerfile | 7 +- README_DISCOVERY.md | 128 +++++ generate_reports.sh | 17 +- rtps_discovery_sniffer.py | 880 +++++++++++++++++++++++++++++++++ run_in_docker.sh | 4 + run_tests.sh | 102 +++- run_tests_in_docker.sh | 12 +- test_rtps_discovery_sniffer.py | 188 +++++++ 9 files changed, 1321 insertions(+), 20 deletions(-) create mode 100644 README_DISCOVERY.md create mode 100755 rtps_discovery_sniffer.py create mode 100644 test_rtps_discovery_sniffer.py diff --git a/.gitignore b/.gitignore index 100b84d..8fd4429 100644 --- a/.gitignore +++ b/.gitignore @@ -68,6 +68,9 @@ build/ *.xml *.zip *.html +discovery_report_*.json +discovery_summary.json +timestamp archive_reports/ # Do not ignore templates diff --git a/Dockerfile b/Dockerfile index e42900d..3c4c7bf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,8 +7,9 @@ ENV DEBIAN_FRONTEND=noninteractive \ PYTHONDONTWRITEBYTECODE=1 \ HOME=/tmp -# Install core runtime dependencies: Python 3, Node.js (via NodeSource 20 LTS), and network tools -RUN apt-get update && apt-get install -y --no-install-recommends \ +# Install core runtime dependencies: Python 3, Node.js (via NodeSource 20 LTS), network tools and tshark +RUN echo "wireshark-common wireshark-common/install-setuid boolean true" | debconf-set-selections \ + && apt-get update && apt-get install -y --no-install-recommends \ python3 \ python3-pip \ ca-certificates \ @@ -17,6 +18,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ zip \ iproute2 \ procps \ + tshark \ + && chmod 4755 /usr/bin/dumpcap \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && npm install -g xunit-viewer \ diff --git a/README_DISCOVERY.md b/README_DISCOVERY.md new file mode 100644 index 0000000..59eb581 --- /dev/null +++ b/README_DISCOVERY.md @@ -0,0 +1,128 @@ +# RTPS Discovery Sniffer a JUnit Discovery Report + +Nástroj pro odposlech a hloubkovou analýzu počátečního RTPS Discovery provozu (**SPDP** a **SEDP**) ve formátu **ParameterList (CDR)** podle OMG DDS-RTPS specifikace. Generuje samostatný JUnit XML report `junit_discovery_report_.xml` a strukturovaný JSON report `discovery_report_.json`. + +--- + +## 1. Vytvořené a upravené soubory + +- [`rtps_discovery_sniffer.py`](rtps_discovery_sniffer.py): Samostatný Python skript využívající `tshark` pro zachycení a dissekci RTPS discovery metatrafficu (SPDP na multicast portu 7400 a unicast/multicast SEDP). +- [`test_rtps_discovery_sniffer.py`](test_rtps_discovery_sniffer.py): Sada unit testů pokrývající dekódování parametrů, formátování časových značek, JUnit XML validaci a QoS kompatibilitu. +- [`Dockerfile`](Dockerfile): Doplněna instalace `tshark` s nastavením SUID práv (`chmod 4755 /usr/bin/dumpcap`) pro zachytávání síťových paketů v neprivilegovaném kontejneru. +- [`run_tests.sh`](run_tests.sh): Automatické spouštění discovery snifferu na pozadí během testování, podpora přepínačů `-d / --discovery-only`, `-t / --test` a `--skip-discovery`. +- [`run_tests_in_docker.sh`](run_tests_in_docker.sh): Předávání discovery parametrů, archivace discovery JSON reportů a spouštění kontejneru s `--cap-add=NET_ADMIN --cap-add=NET_RAW`. +- [`generate_reports.sh`](generate_reports.sh): Ošetřeno slučování JUnit XML reportů tak, aby discovery reporty zůstávaly oddělené od `junit_interoperability_report.xml`. +- [`.gitignore`](.gitignore): Doplněno ignorování generovaných discovery JSON reportů a dočasného souboru `timestamp`. + +--- + +## 2. Extrahované parametry z Discovery + +### SPDP (`DATA(p)` na portu 7400 / unicast metatraffic): +* **Vendor ID:** Dekódování hexadecimálního ID na čitelný název výrobce: + - RTI Connext DDS (`01.01`) + - Eclipse Cyclone DDS (`01.10`) + - eProsima Fast DDS (`01.0f`) + - OpenDDS (`01.03`) + - Twin Oaks CoreDX DDS (`01.05`) + - Dust DDS (`01.14`), RustDDS (`01.12`), GurumDDS (`01.11`) a další. +* **RTPS Protocol Version:** Extrakce verze protokolu (např. RTPS 2.1, 2.3, 2.4, 2.5). +* **Participant GUID Prefix:** 12bajtový unikátní identifikátor účastníka (24 hex znaků). +* **Locators:** IP adresy a porty (metatraffic i default data, unicast i multicast) s automatickým odstraněním duplicit. +* **Built-in Endpoints:** Dekódování bitové masky na jednotlivé discovery mechanismy (SEDP publication/subscription announcers/detectors, participant message datawriters/readers). +* **Lease Duration:** Časový limit platnosti účastníka v sekundách (Participant Lease Duration). + +### SEDP (`DATA(w)` a `DATA(r)`): +* **Topic Name & Type Name:** Názvy témat a IDL datových typů (např. topic `Square`, type `ShapeType`). +* **Endpoint GUID & Entity Kind:** Rozlišení `DataWriter` (0x02, 0x03) vs `DataReader` (0x04, 0x07). +* **QoS profily:** + - Reliability kind (`BEST_EFFORT` vs `RELIABLE`) + - Durability kind (`VOLATILE`, `TRANSIENT_LOCAL`, `TRANSIENT`, `PERSISTENT`) + - Ownership kind (`SHARED` vs `EXCLUSIVE`) + - History depth +* **TypeInformation / TypeObject:** Detekce přítomnosti XTypes metadat. + +--- + +## 3. Formát a struktura JUnit XML reportu + +Soubor: **`junit_discovery_report_.xml`** (např. `junit_discovery_report_2026-09-22-18_54_00.xml`) + +| TestCase | Účel | Podmínka selhání (Failure) | +|---|---|---| +| `Test_SPDP_Participant_Discovery` | Kontroluje detekci účastníků a jejich lokátorů | Počet účastníků `< expected_participants` | +| `Test_SPDP_Protocol_Version` | Ověřuje shodu s RTPS specifikací | Verze `< 2.1` nebo neplatný formát | +| `Test_SPDP_Vendor_Identification` | Identifikuje DDS stacky obou stran | Neznámé / nevalidní ID | +| `Test_SPDP_Builtin_Endpoints` | Kontroluje zapnutí discovery endpointů | Maska `0x00000000` | +| `Test_SEDP_Topic_Type_Match` | Ověřuje párování DataWriterů a DataReaderů | Neshoda v názvu tématu nebo typu (překlep v IDL) | +| `Test_SEDP_QoS_Compatibility` | Validuje pravidlo *Offered vs Requested* | Inkompatibilní QoS (např. Writer `BEST_EFFORT` vs Reader `RELIABLE`) | +| `Test_SEDP_Type_Information` | Reportuje přítomnost TypeObject / XTypes | Informativní test | + +Každý testcase obsahuje v elementu `` kompletní textový přehled discovery mapy, který vizualizují všechny standardní JUnit prohlížeče (např. `xunit-viewer` nebo GitHub Actions). + +--- + +## 4. Použití v praxi + +### A. Rychlý discovery test v Dockeru (~6 sekund): +Spustí pouze úvodní discovery test (`Test_Domain_0`) bez nutnosti čekat na všech 105 testů: +```bash +./run_tests_in_docker.sh \ + -p ./executables/connext_dds-7.7.0_shape_main_linux \ + -s ./executables/eclipse_cyclone-11.0.1_shape_main_linux \ + -d +``` + +### B. Kompletní testovací sada (interoperabilita + discovery): +Spustí celou sadu 105 interoperability testů a discovery sniffer automaticky zachytí počáteční discovery komunikaci: +```bash +./run_tests_in_docker.sh \ + -p ./executables/connext_dds-7.7.0_shape_main_linux \ + -s ./executables/eclipse_cyclone-11.0.1_shape_main_linux +``` + +### C. Samostatný odposlech z příkazové řádky: +```bash +# 1. Spustit discovery sniffer na pozadí (délka např. 5 sekund) +python3 rtps_discovery_sniffer.py -i any -d 5 & +SNIFFER_PID=$! + +# 2. Spustit oba DDS účastníky +./executables/connext_dds-7.7.0_shape_main_linux -P -t Square -d 0 -b & +PUB_PID=$! +./executables/eclipse_cyclone-11.0.1_shape_main_linux -S -t Square -d 0 -b & +SUB_PID=$! + +# 3. Počkat na dokončení snifferu +wait $SNIFFER_PID +``` + +### D. Analýza již existujícího PCAP souboru: +```bash +python3 rtps_discovery_sniffer.py -p /tmp/discovery_capture.pcap -t 20260922-19_02_32 +``` + +### E. Zobrazení discovery reportu v HTML přes xunit-viewer: +```bash +npx -y xunit-viewer --results=./junit_discovery_report_20260922-19_02_32.xml --output=discovery_report.html +``` + +--- + +## 5. Integrace do Dockeru a testovacího skriptu + +Skripty [`run_tests.sh`](run_tests.sh) i [`run_tests_in_docker.sh`](run_tests_in_docker.sh) podporují tyto parametry: + +* **`-d` / `--discovery-only`**: Spustí pouze discovery test (`Test_Domain_0`) v délce cca 6 sekund. +* **`-t` / `--test `**: Spustí jeden konkrétní test (např. `-t Test_Domain_0`). +* **`--skip-discovery`**: Přeskočí discovery sniffer a spustí pouze původní interoperability testy. +* **`--sniffer-duration `**: Určí délku odposlechu (výchozí: 15s pro celou sadu, 6s pro `-d`). + +Vygenerované soubory po dokončení testu: +- `junit_discovery_report_.xml` (JUnit XML pro CI / test reporty) +- `discovery_report_.json` (strukturovaná data pro jq / automatizované validace) +- `junit_interoperability_report.xml` (sloučený interoperability report) +- `interoperability_report.xlsx` (Excel report) +- `index.html` (HTML vizualizace interoperability reportu) + +Discovery reporty zůstávají oddělené a nejsou slučovány do `junit_interoperability_report.xml` díky filtru v `generate_reports.sh`. diff --git a/generate_reports.sh b/generate_reports.sh index 711c8c6..4265659 100755 --- a/generate_reports.sh +++ b/generate_reports.sh @@ -4,11 +4,22 @@ set -e # Change to script directory cd "$(dirname "$0")" -# Remove previously merged XML report to avoid merging it into itself -rm -f junit_interoperability_report.xml +# Remove previously merged XML report and Excel report to avoid conflict +rm -f junit_interoperability_report.xml interoperability_report.xlsx echo "[1/3] Merging XML reports into junit_interoperability_report.xml..." -python3 -m junitparser merge *.xml junit_interoperability_report.xml +reports_to_merge=() +shopt -s nullglob +for f in *.xml; do + if [[ "$f" != "junit_interoperability_report.xml" && "$f" != junit_discovery_report* ]]; then + reports_to_merge+=("$f") + fi +done +shopt -u nullglob + +if [ ${#reports_to_merge[@]} -gt 0 ]; then + python3 -m junitparser merge "${reports_to_merge[@]}" junit_interoperability_report.xml +fi echo "[2/3] Generating Excel report interoperability_report.xlsx..." python3 generate_xlsx_report.py --input junit_interoperability_report.xml --output interoperability_report.xlsx diff --git a/rtps_discovery_sniffer.py b/rtps_discovery_sniffer.py new file mode 100755 index 0000000..c117ba0 --- /dev/null +++ b/rtps_discovery_sniffer.py @@ -0,0 +1,880 @@ +#!/usr/bin/env python3 +""" +RTPS Discovery Sniffer & JUnit/JSON Report Generator +OMG DDS-RTPS Interoperability Testing Suite + +Captures and parses RTPS discovery metatraffic (SPDP and SEDP ParameterLists in CDR format). +Extracts participant identity, runtime settings, endpoint topology, and QoS parameters. +Generates: + - junit_discovery_report_.xml (JUnit XML for CI / test reporting) + - discovery_report_.json (Structured JSON summary for jq / automated assertions) +""" + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Optional, Set, Tuple + +import junitparser + +# Known DDS Vendors mapping (RTPS VendorId) +# Standard vendor IDs registered with OMG +VENDOR_MAP: Dict[str, str] = { + "0101": "RTI Connext DDS", + "0102": "OpenSplice DDS", + "0103": "OpenDDS", + "0104": "MilSoft", + "0105": "Twin Oaks CoreDX DDS", + "0106": "Lakos", + "0107": "PrismTech", + "0108": "OAMAS", + "0109": "THALES", + "010a": "ATIS", + "010f": "eProsima Fast DDS", + "0110": "Eclipse Cyclone DDS", + "0111": "GurumNetworks GurumDDS", + "0112": "RustDDS", + "0113": "Shadow Security DDS", + "0114": "Dust DDS", + "0120": "Intercom DDS", +} + +# Builtin endpoint set bits (RTPS 2.5 Specification Table 9.4) +BUILTIN_ENDPOINTS_MAP = { + 0x00000001: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_ANNOUNCER", + 0x00000002: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_DETECTOR", + 0x00000004: "DISC_BUILTIN_ENDPOINT_PUBLICATION_ANNOUNCER", + 0x00000008: "DISC_BUILTIN_ENDPOINT_PUBLICATION_DETECTOR", + 0x00000010: "DISC_BUILTIN_ENDPOINT_SUBSCRIPTION_ANNOUNCER", + 0x00000020: "DISC_BUILTIN_ENDPOINT_SUBSCRIPTION_DETECTOR", + 0x00000040: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_PROXY_ANNOUNCER", + 0x00000080: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_PROXY_DETECTOR", + 0x00000100: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_STATE_ANNOUNCER", + 0x00000200: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_STATE_DETECTOR", + 0x00000400: "BUILTIN_ENDPOINT_PARTICIPANT_MESSAGE_DATA_WRITER", + 0x00000800: "BUILTIN_ENDPOINT_PARTICIPANT_MESSAGE_DATA_READER", + 0x00001000: "BITS_TYPE_LOOKUP_SERVICE_REQUEST_DATA_WRITER", + 0x00002000: "BITS_TYPE_LOOKUP_SERVICE_REQUEST_DATA_READER", + 0x00004000: "BITS_TYPE_LOOKUP_SERVICE_REPLY_DATA_WRITER", + 0x00008000: "BITS_TYPE_LOOKUP_SERVICE_REPLY_DATA_READER", + 0x00010000: "DISC_BUILTIN_ENDPOINT_PUBLICATION_SECURE_ANNOUNCER", + 0x00020000: "DISC_BUILTIN_ENDPOINT_PUBLICATION_SECURE_DETECTOR", + 0x00040000: "DISC_BUILTIN_ENDPOINT_SUBSCRIPTION_SECURE_ANNOUNCER", + 0x00080000: "DISC_BUILTIN_ENDPOINT_SUBSCRIPTION_SECURE_DETECTOR", + 0x00100000: "BUILTIN_ENDPOINT_PARTICIPANT_MESSAGE_SECURE_DATA_WRITER", + 0x00200000: "BUILTIN_ENDPOINT_PARTICIPANT_MESSAGE_SECURE_DATA_READER", + 0x00400000: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_SECURE_ANNOUNCER", + 0x00800000: "DISC_BUILTIN_ENDPOINT_PARTICIPANT_SECURE_DETECTOR", +} + +RELIABILITY_KINDS = { + 1: "BEST_EFFORT", + 2: "RELIABLE", +} + +DURABILITY_KINDS = { + 0: "VOLATILE", + 1: "TRANSIENT_LOCAL", + 2: "TRANSIENT", + 3: "PERSISTENT", +} + +OWNERSHIP_KINDS = { + 0: "SHARED", + 1: "EXCLUSIVE", +} + + +def normalize_hex(val: Any, length: int = 4) -> str: + """Normalize hex string or int to lowercase hex digits without '0x'.""" + if val is None: + return "" + if isinstance(val, int): + return f"{val:0{length}x}" + s = str(val).strip().lower() + if s.startswith("0x"): + s = s[2:] + s = s.replace(":", "").replace(".", "") + return s.zfill(length) + + +def format_guid(raw_guid: Any) -> str: + """Format GUID prefix or full GUID to clean hex string.""" + if not raw_guid: + return "unknown" + if isinstance(raw_guid, list): + raw_guid = raw_guid[0] + s = str(raw_guid).strip().lower().replace(":", "").replace(".", "") + if s.startswith("0x"): + s = s[2:] + return s + + +def decode_vendor(vendor_val: Any) -> Tuple[str, str]: + """Return (vendor_hex_id, vendor_human_name).""" + norm = normalize_hex(vendor_val, length=4) + name = VENDOR_MAP.get(norm, f"Unknown DDS Vendor ({norm})") + formatted = f"{norm[:2]}.{norm[2:]}" if len(norm) == 4 else norm + return formatted, name + + +def decode_version(ver_val: Any) -> str: + """Decode protocol version from hex (e.g. 0x0204 -> 2.4).""" + if ver_val is None: + return "unknown" + if isinstance(ver_val, (int, float)): + val_int = int(ver_val) + major = (val_int >> 8) & 0xFF + minor = val_int & 0xFF + return f"{major}.{minor}" + s = str(ver_val).strip() + if s.startswith("0x") and len(s) >= 6: + try: + val_int = int(s, 16) + major = (val_int >> 8) & 0xFF + minor = val_int & 0xFF + return f"{major}.{minor}" + except ValueError: + pass + return s + + +def decode_builtin_endpoints(mask_val: Any) -> Tuple[str, List[str]]: + """Decode built-in endpoints bitmask into hex string and flag names.""" + if mask_val is None: + return "0x00000000", [] + try: + val_int = int(mask_val, 16) if isinstance(mask_val, str) else int(mask_val) + except (ValueError, TypeError): + return str(mask_val), [] + flags = [name for bit, name in BUILTIN_ENDPOINTS_MAP.items() if (val_int & bit) != 0] + return f"0x{val_int:08x}", flags + + +def get_current_timestamp() -> str: + """ + Get timestamp aligned with interoperability reports (%Y%m%d-%H_%M_%S). + 1. Read './timestamp' file if present. + 2. Fallback to current datetime '%Y%m%d-%H_%M_%S'. + """ + timestamp_file = Path("timestamp") + if timestamp_file.is_file(): + try: + content = timestamp_file.read_text().strip() + if content: + m = re.match(r"^(\d{4})-(\d{2})-(\d{2})-(.*)$", content) + if m: + return f"{m.group(1)}{m.group(2)}{m.group(3)}-{m.group(4)}" + return content + except Exception: + pass + return datetime.now().strftime("%Y%m%d-%H_%M_%S") + + +def capture_traffic_pcap(interface: str, duration_sec: int, output_pcap: str) -> bool: + """Capture raw RTPS UDP packets to a pcap file using tshark.""" + if not shutil.which("tshark"): + raise RuntimeError( + "tshark is not installed. Install it via 'apt-get install -y tshark'." + ) + + cmd = [ + "tshark", + "-i", + interface, + "-a", + f"duration:{duration_sec}", + "-f", + "udp", + "-w", + output_pcap, + ] + try: + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=duration_sec + 10, + ) + return os.path.exists(output_pcap) and os.path.getsize(output_pcap) > 0 + except subprocess.TimeoutExpired: + return os.path.exists(output_pcap) and os.path.getsize(output_pcap) > 0 + + +def dump_rtps_json_from_pcap(pcap_path: str) -> List[Dict[str, Any]]: + """Dissect RTPS packets from pcap using tshark with full JSON output.""" + cmd = [ + "tshark", + "--no-duplicate-keys", + "-r", + pcap_path, + "-Y", + "rtps", + "-T", + "json", + ] + proc = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + if proc.returncode != 0 or not proc.stdout.strip(): + return [] + try: + return json.loads(proc.stdout) + except json.JSONDecodeError: + return [] + + +def parse_discovery_data(raw_packets: List[Dict[str, Any]]) -> Dict[str, Any]: + """ + Analyze JSON packets dissected by Wireshark RTPS dissector. + Extracts: + - Participants (SPDP): GUID prefix, IP, vendor ID, version, locators, lease, builtin endpoints + - Endpoints (SEDP): Entity GUID, entity kind, topic name, type name, QoS parameters + """ + summary: Dict[str, Any] = { + "metadata": { + "total_rtps_packets": len(raw_packets), + "analyzed_at": datetime.now().isoformat(), + }, + "participants": {}, + "endpoints": [], + } + + def walk_tree(obj: Any, callback): + """Recursively traverse the Wireshark dissection tree.""" + if isinstance(obj, dict): + callback(obj) + for v in obj.values(): + walk_tree(v, callback) + elif isinstance(obj, list): + for item in obj: + walk_tree(item, callback) + + for pkt in raw_packets: + source = pkt.get("_source", {}) + layers = source.get("layers", {}) + rtps = layers.get("rtps", {}) + ip_layer = layers.get("ip", {}) + src_ip = ip_layer.get("ip.src", layers.get("ip.src", "unknown")) + dst_ip = ip_layer.get("ip.dst", layers.get("ip.dst", "unknown")) + if isinstance(src_ip, list): + src_ip = src_ip[0] + if isinstance(dst_ip, list): + dst_ip = dst_ip[0] + + # Extract Header fields + header_guid = rtps.get("rtps.guidPrefix", rtps.get("rtps.guidPrefix.src")) + header_vendor = rtps.get("rtps.vendorId") + header_version = rtps.get("rtps.version") + + # ---------------------------------------------------- + # SPDP Participant Discovery Detection + # ---------------------------------------------------- + # Look for PID_PARTICIPANT_GUID, PID_PROTOCOL_VERSION, PID_VENDOR_ID + found_spdp: Dict[str, Any] = {} + locators_found: List[Dict[str, Any]] = [] + + def inspect_spdp_node(node: Dict[str, Any]): + for k, v in node.items(): + k_lower = k.lower() + + # Participant GUID + if "pid_participant_guid" in k_lower or k == "rtps.param.participant_guid": + guid_val = node.get("rtps.param.participant_guid", v) + guid_str = format_guid(guid_val) + # 12 bytes = 24 hex chars prefix + prefix = guid_str[:24] if len(guid_str) >= 24 else guid_str + found_spdp["guid_prefix"] = prefix + + # Vendor ID + if "pid_vendor_id" in k_lower or k == "rtps.vendorId": + v_val = node.get("rtps.vendorId", v) + v_hex, v_name = decode_vendor(v_val) + found_spdp["vendor_id"] = v_hex + found_spdp["vendor_name"] = v_name + + # Protocol Version + if "pid_protocol_version" in k_lower or k == "rtps.version": + ver_val = node.get("rtps.version", v) + found_spdp["protocol_version"] = decode_version(ver_val) + + # Lease Duration + if "pid_participant_lease_duration" in k_lower or "lease_duration" in k_lower: + sec = node.get("rtps.param.ntpTime.sec") + if sec is not None: + try: + found_spdp["lease_duration_sec"] = float(sec) + except (ValueError, TypeError): + pass + + # Built-in endpoints + if "pid_builtin_endpoint_set" in k_lower or "builtin_endpoint_set" in k_lower: + mask = node.get("rtps.param.builtin_endpoint_set", v) + mask_hex, flags = decode_builtin_endpoints(mask) + found_spdp["builtin_endpoints_mask"] = mask_hex + found_spdp["builtin_endpoints_flags"] = flags + + # Locators + if "locator" in k_lower and isinstance(node, dict): + loc_ip = node.get("rtps.locator.ipv4") or node.get("rtps.locator_udp_v4.ip") + loc_port = node.get("rtps.locator.port") or node.get("rtps.locator_udp_v4.port") + if loc_ip and loc_port: + kind = "unicast" + if "metatraffic" in k_lower: + kind = "metatraffic_unicast" + elif "multicast" in k_lower: + kind = "multicast" + locators_found.append({ + "type": kind, + "ip": str(loc_ip), + "port": int(str(loc_port).split()[0], 0) if str(loc_port).isdigit() or str(loc_port).startswith("0x") else str(loc_port), + }) + + walk_tree(rtps, inspect_spdp_node) + + # Fallback to header values if parameter wasn't explicitly captured + guid_p = found_spdp.get("guid_prefix") or (format_guid(header_guid)[:24] if header_guid else None) + if guid_p and guid_p not in summary["participants"]: + v_hex, v_name = decode_vendor(found_spdp.get("vendor_id") or header_vendor) + proto_ver = found_spdp.get("protocol_version") or decode_version(header_version) + unique_locs = [] + seen_locs = set() + for loc in locators_found: + lk = (loc["type"], loc["ip"], loc["port"]) + if lk not in seen_locs: + seen_locs.add(lk) + unique_locs.append(loc) + summary["participants"][guid_p] = { + "guid_prefix": guid_p, + "src_ip": src_ip, + "vendor_id": v_hex, + "vendor_name": v_name, + "protocol_version": proto_ver, + "lease_duration_sec": found_spdp.get("lease_duration_sec", 20.0), + "builtin_endpoints_mask": found_spdp.get("builtin_endpoints_mask", "unknown"), + "builtin_endpoints_flags": found_spdp.get("builtin_endpoints_flags", []), + "locators": unique_locs, + } + elif guid_p and guid_p in summary["participants"]: + # Augment existing participant with newly seen locators or flags + part = summary["participants"][guid_p] + if not part.get("builtin_endpoints_flags") and found_spdp.get("builtin_endpoints_flags"): + part["builtin_endpoints_mask"] = found_spdp["builtin_endpoints_mask"] + part["builtin_endpoints_flags"] = found_spdp["builtin_endpoints_flags"] + if found_spdp.get("lease_duration_sec"): + part["lease_duration_sec"] = found_spdp["lease_duration_sec"] + existing_locs = {(l["type"], l["ip"], l["port"]) for l in part["locators"]} + for loc in locators_found: + lk = (loc["type"], loc["ip"], loc["port"]) + if lk not in existing_locs: + existing_locs.add(lk) + part["locators"].append(loc) + + # ---------------------------------------------------- + # SEDP Endpoint Discovery Detection (DATA(w) / DATA(r)) + # ---------------------------------------------------- + def extract_sedp_endpoints(node: Any): + if isinstance(node, dict): + s_str = json.dumps(node) + if "rtps.param.topicName" in s_str and ("rtps.param.endpoint_guid" in s_str or "PID_ENDPOINT_GUID" in s_str): + cand: Dict[str, Any] = {} + + def inspect_cand(n: Any): + if isinstance(n, dict): + for k, v in n.items(): + if k == "rtps.param.topicName": + cand["topic_name"] = str(v) + elif k == "rtps.param.typeName": + cand["type_name"] = str(v) + elif k == "rtps.param.endpoint_guid": + guid_full = format_guid(v) + if len(guid_full) >= 24: + cand["endpoint_guid"] = guid_full + kind_hex = guid_full[-2:] + if kind_hex in ("02", "03"): + cand["entity_kind"] = "DataWriter" + elif kind_hex in ("04", "07"): + cand["entity_kind"] = "DataReader" + else: + cand["entity_kind"] = f"Entity (0x{kind_hex})" + elif k == "rtps.reliability_kind": + try: + rk_int = int(str(v).split()[0], 0) + cand["reliability"] = RELIABILITY_KINDS.get(rk_int, f"0x{rk_int:x}") + except (ValueError, TypeError): + cand["reliability"] = str(v) + elif k == "rtps.durability": + try: + dk_int = int(str(v).split()[0], 0) + cand["durability"] = DURABILITY_KINDS.get(dk_int, f"0x{dk_int:x}") + except (ValueError, TypeError): + cand["durability"] = str(v) + elif k == "rtps.ownership": + try: + ok_int = int(str(v).split()[0], 0) + cand["ownership"] = OWNERSHIP_KINDS.get(ok_int, f"0x{ok_int:x}") + except (ValueError, TypeError): + cand["ownership"] = str(v) + elif k in ("rtps.history_depth", "rtps.param.history.depth"): + try: + cand["history_depth"] = int(str(v).split()[0], 0) + except (ValueError, TypeError): + pass + elif "typeobject" in k.lower() or "type_information" in k.lower() or "pid_type_object" in k.lower(): + cand["has_type_object"] = True + inspect_cand(v) + elif isinstance(n, list): + for item in n: + inspect_cand(item) + + inspect_cand(node) + if cand.get("topic_name") and cand.get("type_name") and cand.get("endpoint_guid"): + ep_guid = cand["endpoint_guid"] + if "reliability" not in cand: + cand["reliability"] = "BEST_EFFORT" + if "durability" not in cand: + cand["durability"] = "VOLATILE" + if "ownership" not in cand: + cand["ownership"] = "SHARED" + cand["src_ip"] = src_ip + existing = [ + ep for ep in summary["endpoints"] + if ep.get("endpoint_guid") == ep_guid and ep.get("topic_name") == cand["topic_name"] + ] + if not existing: + summary["endpoints"].append(cand) + return + + for v in node.values(): + extract_sedp_endpoints(v) + elif isinstance(node, list): + for item in node: + extract_sedp_endpoints(item) + + extract_sedp_endpoints(rtps) + + return summary + + +def generate_junit_report( + summary: Dict[str, Any], + output_xml_path: str, + publisher_name: Optional[str] = None, + subscriber_name: Optional[str] = None, + expected_participants: int = 2, + expected_topic: Optional[str] = None, + expected_type: Optional[str] = None, +) -> junitparser.JUnitXml: + """ + Build a comprehensive JUnit XML report evaluating the discovered RTPS topology. + Writes report to output_xml_path. + """ + if os.path.exists(output_xml_path): + try: + xml = junitparser.JUnitXml.fromfile(output_xml_path) + except Exception: + xml = junitparser.JUnitXml() + else: + xml = junitparser.JUnitXml() + suite_title = "rtps_discovery_report" + if publisher_name and subscriber_name: + suite_title = f"rtps_discovery_{publisher_name}---{subscriber_name}" + suite = junitparser.TestSuite(suite_title) + + participants = summary.get("participants", {}) + endpoints = summary.get("endpoints", []) + part_count = len(participants) + + # ---------------------------------------------------- + # TestCase 1: SPDP Participant Discovery + # ---------------------------------------------------- + tc_part = junitparser.TestCase("Test_SPDP_Participant_Discovery") + tc_part.time = 0.1 + part_desc = [f"Discovered {part_count} participant(s):"] + for guid, p in participants.items(): + loc_str = ", ".join(f"{l['type']}={l['ip']}:{l['port']}" for l in p.get("locators", [])) + part_desc.append( + f" - GUID Prefix: {guid}\n" + f" Source IP: {p.get('src_ip')}\n" + f" Vendor: {p.get('vendor_name')} ({p.get('vendor_id')})\n" + f" Protocol Version: {p.get('protocol_version')}\n" + f" Lease Duration: {p.get('lease_duration_sec')}s\n" + f" Locators: {loc_str or 'none'}" + ) + tc_part.system_out = "\n".join(part_desc) + + if part_count < expected_participants: + msg = ( + f"Expected at least {expected_participants} participant(s), " + f"but found {part_count}." + ) + failure = junitparser.Failure(msg) + tc_part.result = [failure] + suite.add_testcase(tc_part) + + # ---------------------------------------------------- + # TestCase 2: RTPS Protocol Version Compliance + # ---------------------------------------------------- + tc_ver = junitparser.TestCase("Test_SPDP_Protocol_Version") + tc_ver.time = 0.05 + ver_issues = [] + ver_lines = [] + for guid, p in participants.items(): + ver_str = str(p.get("protocol_version", "unknown")) + ver_lines.append(f"Participant {guid[:12]}: RTPS {ver_str}") + try: + parts = [int(x) for x in ver_str.split(".")] + if parts[0] < 2 or (parts[0] == 2 and parts[1] < 1): + ver_issues.append(f"Participant {guid} has non-standard version RTPS {ver_str}") + except Exception: + ver_issues.append(f"Invalid version string '{ver_str}' for participant {guid}") + + tc_ver.system_out = "\n".join(ver_lines) + if ver_issues: + tc_ver.result = [junitparser.Failure("; ".join(ver_issues))] + suite.add_testcase(tc_ver) + + # ---------------------------------------------------- + # TestCase 3: Vendor Identification + # ---------------------------------------------------- + tc_vendor = junitparser.TestCase("Test_SPDP_Vendor_Identification") + tc_vendor.time = 0.05 + vendor_lines = [] + for guid, p in participants.items(): + vendor_lines.append( + f"Participant {guid[:12]}: {p.get('vendor_name')} [ID: {p.get('vendor_id')}]" + ) + tc_vendor.system_out = "\n".join(vendor_lines) + suite.add_testcase(tc_vendor) + + # ---------------------------------------------------- + # TestCase 4: Built-in Endpoints Capabilities + # ---------------------------------------------------- + tc_builtin = junitparser.TestCase("Test_SPDP_Builtin_Endpoints") + tc_builtin.time = 0.05 + builtin_lines = [] + builtin_issues = [] + for guid, p in participants.items(): + mask = p.get("builtin_endpoints_mask") + flags = p.get("builtin_endpoints_flags", []) + builtin_lines.append( + f"Participant {guid[:12]} Built-in Mask: {mask}\n" + f" Flags: {', '.join(flags) if flags else 'None or Default'}" + ) + if mask == "0x00000000": + builtin_issues.append(f"Participant {guid} reported empty builtin endpoint set") + tc_builtin.system_out = "\n".join(builtin_lines) + if builtin_issues: + tc_builtin.result = [junitparser.Failure("; ".join(builtin_issues))] + suite.add_testcase(tc_builtin) + + # ---------------------------------------------------- + # TestCase 5: SEDP Topic & Type Matching + # ---------------------------------------------------- + tc_sedp = junitparser.TestCase("Test_SEDP_Topic_Type_Match") + tc_sedp.time = 0.1 + sedp_lines = [f"Discovered {len(endpoints)} endpoint(s):"] + topics_seen: Set[str] = set() + types_seen: Set[str] = set() + writers: List[Dict[str, Any]] = [] + readers: List[Dict[str, Any]] = [] + + for ep in endpoints: + kind = ep.get("entity_kind", "Endpoint") + topic = ep.get("topic_name", "unknown") + t_type = ep.get("type_name", "unknown") + topics_seen.add(topic) + types_seen.add(t_type) + if "writer" in kind.lower(): + writers.append(ep) + elif "reader" in kind.lower(): + readers.append(ep) + sedp_lines.append( + f" - {kind}: Topic='{topic}', Type='{t_type}', GUID={ep.get('endpoint_guid')}\n" + f" QoS: Reliability={ep.get('reliability')}, Durability={ep.get('durability')}, Ownership={ep.get('ownership')}" + ) + + tc_sedp.system_out = "\n".join(sedp_lines) + + mismatch_errors = [] + if expected_topic and expected_topic not in topics_seen: + mismatch_errors.append(f"Expected topic '{expected_topic}' not found in discovery (seen: {topics_seen})") + if expected_type and expected_type not in types_seen: + mismatch_errors.append(f"Expected type '{expected_type}' not found in discovery (seen: {types_seen})") + + # If both writers and readers exist, verify topic and type consistency + if writers and readers: + w_topics = {w.get("topic_name") for w in writers} + r_topics = {r.get("topic_name") for r in readers} + if not (w_topics & r_topics): + mismatch_errors.append(f"Mismatched topics between Writer(s) {w_topics} and Reader(s) {r_topics}") + + w_types = {w.get("type_name") for w in writers} + r_types = {r.get("type_name") for r in readers} + if not (w_types & r_types): + mismatch_errors.append(f"Mismatched types between Writer(s) {w_types} and Reader(s) {r_types}") + + if mismatch_errors: + tc_sedp.result = [junitparser.Failure("; ".join(mismatch_errors))] + suite.add_testcase(tc_sedp) + + # ---------------------------------------------------- + # TestCase 6: SEDP QoS Compatibility + # ---------------------------------------------------- + tc_qos = junitparser.TestCase("Test_SEDP_QoS_Compatibility") + tc_qos.time = 0.05 + qos_lines = ["Evaluating QoS profiles between DataWriter and DataReader:"] + qos_errors = [] + + if writers and readers: + for w in writers: + for r in readers: + w_rel = w.get("reliability", "BEST_EFFORT") + r_rel = r.get("reliability", "BEST_EFFORT") + w_dur = w.get("durability", "VOLATILE") + r_dur = r.get("durability", "VOLATILE") + + qos_lines.append( + f"Pairing Writer ({w.get('endpoint_guid')}) [{w_rel}, {w_dur}] vs " + f"Reader ({r.get('endpoint_guid')}) [{r_rel}, {r_dur}]" + ) + + # Reliability rule: Offered >= Requested (RELIABLE offers to BEST_EFFORT or RELIABLE; BEST_EFFORT cannot offer to RELIABLE) + if w_rel == "BEST_EFFORT" and r_rel == "RELIABLE": + qos_errors.append( + f"Incompatible Reliability: Writer is BEST_EFFORT but Reader requested RELIABLE" + ) + + # Durability rule: Offered >= Requested + dur_order = {"VOLATILE": 0, "TRANSIENT_LOCAL": 1, "TRANSIENT": 2, "PERSISTENT": 3} + w_level = dur_order.get(w_dur, 0) + r_level = dur_order.get(r_dur, 0) + if w_level < r_level: + qos_errors.append( + f"Incompatible Durability: Writer offered {w_dur} but Reader requested {r_dur}" + ) + else: + qos_lines.append("Single-sided endpoint or no paired endpoints detected in capture window.") + + tc_qos.system_out = "\n".join(qos_lines) + if qos_errors: + tc_qos.result = [junitparser.Failure("; ".join(qos_errors))] + suite.add_testcase(tc_qos) + + # ---------------------------------------------------- + # TestCase 7: XTypes / TypeInformation + # ---------------------------------------------------- + tc_xtypes = junitparser.TestCase("Test_SEDP_Type_Information") + tc_xtypes.time = 0.05 + xtypes_present = any(ep.get("has_type_object") for ep in endpoints) + tc_xtypes.system_out = ( + f"XTypes TypeInformation/TypeObject detected: {'Yes' if xtypes_present else 'No (Standard CDR)'}" + ) + suite.add_testcase(tc_xtypes) + + xml.add_testsuite(suite) + xml.write(output_xml_path) + return xml + + +def run_discovery_sniffer(args: argparse.Namespace) -> None: + """Main execution orchestrator.""" + timestamp = args.timestamp or get_current_timestamp() + + # Determine output XML name + if args.output: + output_xml = args.output + else: + output_xml = f"junit_discovery_report_{timestamp}.xml" + + # Determine output JSON name + if args.json_output: + output_json = args.json_output + else: + output_json = f"discovery_report_{timestamp}.json" + + temp_pcap_path = None + input_pcap = args.pcap + + if not input_pcap: + # Perform live capture + if args.write_pcap: + pcap_file = args.write_pcap + else: + fd, temp_pcap_path = tempfile.mkstemp(prefix="rtps_capture_", suffix=".pcap") + os.close(fd) + pcap_file = temp_pcap_path + + print(f"[*] Starting live RTPS capture on interface '{args.interface}' for {args.duration}s...") + captured = capture_traffic_pcap(args.interface, args.duration, pcap_file) + if not captured: + print("[-] No UDP/RTPS packets captured during the listening window.") + input_pcap = pcap_file + else: + print(f"[*] Reading existing PCAP file: {input_pcap}") + + raw_packets = dump_rtps_json_from_pcap(input_pcap) + print(f"[+] Dissected {len(raw_packets)} RTPS packets with tshark.") + + summary = parse_discovery_data(raw_packets) + summary["metadata"]["interface"] = args.interface + summary["metadata"]["timestamp"] = timestamp + if args.publisher: + summary["metadata"]["publisher"] = args.publisher + if args.subscriber: + summary["metadata"]["subscriber"] = args.subscriber + + # Save JSON summary + to_write: Any = summary + if os.path.exists(output_json): + try: + with open(output_json, "r") as f: + existing = json.load(f) + pair_key = ( + f"{args.publisher}---{args.subscriber}" + if args.publisher and args.subscriber + else f"run_{timestamp}" + ) + if isinstance(existing, dict) and "runs" in existing: + existing["runs"][pair_key] = summary + to_write = existing + elif isinstance(existing, dict): + first_pub = existing.get("metadata", {}).get("publisher", "") + first_sub = existing.get("metadata", {}).get("subscriber", "") + first_key = f"{first_pub}---{first_sub}" if (first_pub and first_sub) else "run_1" + to_write = { + "metadata": {"timestamp": timestamp}, + "runs": { + first_key: existing, + pair_key: summary, + }, + } + except Exception: + to_write = summary + + with open(output_json, "w") as f: + json.dump(to_write, f, indent=2) + print(f"[+] Discovery JSON summary saved to: {output_json}") + + # Generate JUnit XML Report + generate_junit_report( + summary=summary, + output_xml_path=output_xml, + publisher_name=args.publisher, + subscriber_name=args.subscriber, + expected_participants=args.expected_participants, + expected_topic=args.expected_topic, + expected_type=args.expected_type, + ) + print(f"[+] Discovery JUnit XML report saved to: {output_xml}") + + # Display short summary + part_count = len(summary["participants"]) + ep_count = len(summary["endpoints"]) + print(f"\n==================== Discovery Summary ====================") + print(f"Discovered Participants: {part_count}") + for guid, p in summary["participants"].items(): + print(f" - [{p.get('vendor_name')}] GUID: {guid} (IP: {p.get('src_ip')}, Ver: {p.get('protocol_version')})") + print(f"Discovered Endpoints: {ep_count}") + for ep in summary["endpoints"]: + print(f" - {ep.get('entity_kind')}: Topic='{ep.get('topic_name')}' Type='{ep.get('type_name')}' (QoS: {ep.get('reliability')}, {ep.get('durability')})") + print(f"===========================================================\n") + + # Cleanup temp pcap if used + if temp_pcap_path and os.path.exists(temp_pcap_path): + os.remove(temp_pcap_path) + + +def main(): + parser = argparse.ArgumentParser( + description="RTPS Discovery Sniffer & JUnit/JSON Report Generator" + ) + parser.add_argument( + "-i", + "--interface", + default="any", + help="Network interface to listen on (e.g. any, lo, eth0). Default: any", + ) + parser.add_argument( + "-d", + "--duration", + type=int, + default=5, + help="Capture duration in seconds. Default: 5", + ) + parser.add_argument( + "-o", + "--output", + default=None, + help="Output JUnit XML report file name (default: junit_discovery_report_.xml)", + ) + parser.add_argument( + "-j", + "--json-output", + default=None, + help="Output JSON summary file name (default: discovery_report_.json)", + ) + parser.add_argument( + "-t", + "--timestamp", + default=None, + help="Explicit timestamp for report naming (matches ./timestamp file format)", + ) + parser.add_argument( + "-p", + "--pcap", + default=None, + help="Read and analyze an existing PCAP file instead of live capture", + ) + parser.add_argument( + "-w", + "--write-pcap", + default=None, + help="Write live captured packets to specified PCAP file", + ) + parser.add_argument( + "-P", + "--publisher", + default=None, + help="Publisher implementation or executable name", + ) + parser.add_argument( + "-S", + "--subscriber", + default=None, + help="Subscriber implementation or executable name", + ) + parser.add_argument( + "--expected-participants", + type=int, + default=2, + help="Expected number of participants for discovery pass/fail test (default: 2)", + ) + parser.add_argument( + "--expected-topic", + default=None, + help="Expected topic name to validate (e.g. Square)", + ) + parser.add_argument( + "--expected-type", + default=None, + help="Expected type name to validate (e.g. ShapeType)", + ) + + args = parser.parse_args() + run_discovery_sniffer(args) + + +if __name__ == "__main__": + main() diff --git a/run_in_docker.sh b/run_in_docker.sh index 2b57d17..2ee3a94 100755 --- a/run_in_docker.sh +++ b/run_in_docker.sh @@ -52,6 +52,8 @@ fi # - Passes any additional arguments directly to the container command if [ $# -eq 0 ]; then docker run --rm $DOCKER_FLAGS \ + --cap-add=NET_ADMIN \ + --cap-add=NET_RAW \ --user "$(id -u):$(id -g)" \ -e PYTHONDONTWRITEBYTECODE=1 \ -v "$SCRIPT_DIR:/workspace" \ @@ -60,6 +62,8 @@ if [ $# -eq 0 ]; then /bin/bash else docker run --rm $DOCKER_FLAGS \ + --cap-add=NET_ADMIN \ + --cap-add=NET_RAW \ --user "$(id -u):$(id -g)" \ -e PYTHONDONTWRITEBYTECODE=1 \ -v "$SCRIPT_DIR:/workspace" \ diff --git a/run_tests.sh b/run_tests.sh index e872fe8..36454e0 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -5,6 +5,10 @@ input="." publisher="" subscriber="" output="" +discovery_only=false +skip_discovery=false +single_test="" +sniffer_duration="" # Function to display usage information usage() { @@ -14,16 +18,22 @@ usage() { echo "subscriber is not provided, this script will find and use all " echo "'*_shape_main_linux' applications in the input directory as publisher and" echo "subscribers." - echo "Usage: $0 [-p publisher] [-s subscriber] [-o output] [-i input] [-h]" + echo "Usage: $0 [-p publisher] [-s subscriber] [-o output] [-i input] [-d] [-t test] [-h]" echo "Options:" - echo " -p, --publisher Specify the publisher application" - echo " -s, --subscriber Specify the subscriber application" - echo " -o, --output Specify the output XML file" - echo " -i, --input Specify the directory where publisher/subscriber applications are located (only if -p and -s are not provided)" - echo " -h, --help Print this help message" + echo " -p, --publisher Specify the publisher application" + echo " -s, --subscriber Specify the subscriber application" + echo " -o, --output Specify the output XML file" + echo " -i, --input Specify the directory where publisher/subscriber applications are located (only if -p and -s are not provided)" + echo " -d, --discovery-only Run only the discovery test case (Test_Domain_0) to quickly verify RTPS discovery" + echo " -t, --test Specify a single test case to run (e.g. Test_Domain_0)" + echo " --skip-discovery Skip running RTPS discovery sniffer" + echo " --sniffer-duration Capture duration in seconds for sniffer (default: 15, or 6 for discovery-only)" + echo " -h, --help Print this help message" echo "Examples:" echo "Run Connext as publisher and all executables under './executables' as subscribers" echo " ./run_tests.sh -p connext_dds-6.1.2_shape_main_linux -i ./executables" + echo "Run discovery test only between Connext and Cyclone DDS:" + echo " ./run_tests.sh -p connext_dds-7.7.0_shape_main_linux -s eclipse_cyclone-11.0.1_shape_main_linux -d" exit 1 } @@ -46,6 +56,22 @@ while [[ $# -gt 0 ]]; do input="$2" shift 2 ;; + -d|--discovery-only) + discovery_only=true + shift 1 + ;; + -t|--test) + single_test="$2" + shift 2 + ;; + --skip-discovery) + skip_discovery=true + shift 1 + ;; + --sniffer-duration) + sniffer_duration="$2" + shift 2 + ;; -h|--help) usage ;; @@ -77,13 +103,14 @@ fi # Archive previous test results if present archive_dir="./archive_reports" shopt -s nullglob -old_reports=(*.xml *.xlsx index.html) +old_reports=(*.xml *.xlsx index.html discovery_report_*.json discovery_summary.json timestamp) if [ ${#old_reports[@]} -gt 0 ]; then echo "Archiving previous test reports to $archive_dir..." mkdir -p "$archive_dir" mv "${old_reports[@]}" "$archive_dir/" 2>/dev/null || true fi shopt -u nullglob +rm -f ./timestamp # Run the application logic for i in $publisher; do @@ -91,15 +118,68 @@ for i in $publisher; do publisher_name=$(basename "$i" _shape_main_linux) subscriber_name=$(basename "$j" _shape_main_linux) echo "Testing Publisher $publisher_name --- Subscriber $subscriber_name" + + # Generate fresh timestamp for this test run in %Y%m%d-%H_%M_%S format + # matching interoperability_report.py (e.g. 20260922-18_14_23) + if [[ -f "./timestamp" ]]; then + raw_ts=$(head -n 1 ./timestamp | tr -d '\r\n') + if [[ "$raw_ts" =~ ^([0-9]{4})-([0-9]{2})-([0-9]{2})-(.*)$ ]]; then + pair_timestamp="${BASH_REMATCH[1]}${BASH_REMATCH[2]}${BASH_REMATCH[3]}-${BASH_REMATCH[4]}" + else + pair_timestamp="$raw_ts" + fi + else + pair_timestamp=$(date '+%Y%m%d-%H_%M_%S') + fi + + # Synchronize interoperability report filename and discovery report filename + if [[ -n $output ]]; then + interop_report_file="$output" + else + interop_report_file="${publisher_name}-${subscriber_name}-${pair_timestamp}.xml" + fi + + discovery_xml="junit_discovery_report_${pair_timestamp}.xml" + discovery_json="discovery_report_${pair_timestamp}.json" + extra_args="" if [[ "${subscriber_name,,}" == *opendds* && "${publisher_name,,}" == *connext_dds* ]]; then extra_args="--periodic-announcement 5000" fi; - if [[ -n $output ]]; then - python3 ./interoperability_report.py -P "$i" -S "$j" -o "$output" $extra_args - else - python3 ./interoperability_report.py -P "$i" -S "$j" $extra_args + if [[ -n $single_test ]]; then + extra_args="$extra_args -t $single_test" + elif [[ "$discovery_only" == "true" ]]; then + extra_args="$extra_args -t Test_Domain_0" + fi; + + sniffer_pid="" + + if [[ "$skip_discovery" != "true" ]]; then + dur=${sniffer_duration:-15} + if [[ "$discovery_only" == "true" ]]; then + dur=${sniffer_duration:-6} + fi + echo "Starting RTPS discovery sniffer for $publisher_name vs $subscriber_name (${dur}s)..." + python3 ./rtps_discovery_sniffer.py \ + -i any \ + -d "$dur" \ + -P "$publisher_name" \ + -S "$subscriber_name" \ + -o "$discovery_xml" \ + -j "$discovery_json" \ + -t "$pair_timestamp" & + sniffer_pid=$! + # Give sniffer half a second to initialize capture on interface + sleep 1 + fi + + python3 ./interoperability_report.py -P "$i" -S "$j" -o "$interop_report_file" $extra_args + + if [[ -n $sniffer_pid ]]; then + echo "Waiting for RTPS discovery sniffer to finish..." + wait "$sniffer_pid" 2>/dev/null || true fi + if [ -d "./OpenDDS-durable-data-dir" ]; then echo Deleting OpenDDS-durable-data-dir; rm -rf ./OpenDDS-durable-data-dir; diff --git a/run_tests_in_docker.sh b/run_tests_in_docker.sh index 63bae52..f7689c4 100755 --- a/run_tests_in_docker.sh +++ b/run_tests_in_docker.sh @@ -24,16 +24,17 @@ fi # 1. Archive previous test reports from host if present archive_dir="$SCRIPT_DIR/archive_reports" shopt -s nullglob -old_reports=("$SCRIPT_DIR"/*.xml "$SCRIPT_DIR"/*.xlsx "$SCRIPT_DIR"/index.html) +old_reports=("$SCRIPT_DIR"/*.xml "$SCRIPT_DIR"/*.xlsx "$SCRIPT_DIR"/index.html "$SCRIPT_DIR"/discovery_report*.json "$SCRIPT_DIR"/discovery_summary.json "$SCRIPT_DIR"/timestamp) if [ ${#old_reports[@]} -gt 0 ]; then echo "==> [1/4] Archiving previous test reports to archive_reports/..." mkdir -p "$archive_dir" mv "${old_reports[@]}" "$archive_dir/" 2>/dev/null || true fi shopt -u nullglob +rm -f "$SCRIPT_DIR/timestamp" -# 2. Build Docker image if not present -if [[ "$(docker images -q "$IMAGE_NAME" 2> /dev/null)" == "" ]]; then +# 2. Build Docker image if not present or missing tshark +if [[ "$(docker images -q "$IMAGE_NAME" 2> /dev/null)" == "" ]] || ! docker run --rm "$IMAGE_NAME" which tshark &> /dev/null; then echo "==> [2/4] Building Docker image: $IMAGE_NAME..." docker build -t "$IMAGE_NAME" . fi @@ -49,7 +50,10 @@ echo "==> [3/4] Running tests inside Docker container ($IMAGE_NAME)..." echo " Command: $TEST_CMD" # 3. Run container, execute tests, generate reports, and automatically terminate container (--rm) +# --cap-add=NET_ADMIN and NET_RAW allow tshark to capture discovery traffic inside the container docker run --rm \ + --cap-add=NET_ADMIN \ + --cap-add=NET_RAW \ --user "$(id -u):$(id -g)" \ -e PYTHONDONTWRITEBYTECODE=1 \ -v "$SCRIPT_DIR:/workspace" \ @@ -60,4 +64,4 @@ docker run --rm \ echo "" echo "==> [4/4] Docker container finished and closed." echo "==> Generated reports in $SCRIPT_DIR:" -ls -lh "$SCRIPT_DIR"/junit_interoperability_report.xml "$SCRIPT_DIR"/interoperability_report.xlsx "$SCRIPT_DIR"/index.html 2>/dev/null || true +ls -lh "$SCRIPT_DIR"/junit_interoperability_report.xml "$SCRIPT_DIR"/junit_discovery_report*.xml "$SCRIPT_DIR"/discovery_report*.json "$SCRIPT_DIR"/interoperability_report.xlsx "$SCRIPT_DIR"/index.html 2>/dev/null || true diff --git a/test_rtps_discovery_sniffer.py b/test_rtps_discovery_sniffer.py new file mode 100644 index 0000000..912ee53 --- /dev/null +++ b/test_rtps_discovery_sniffer.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +""" +Unit tests for rtps_discovery_sniffer.py +Tests decoding functions, parsing of discovery traffic, and JUnit XML report generation. +""" + +import json +import os +import tempfile +import unittest +from pathlib import Path + +import junitparser +import rtps_discovery_sniffer as sniffer + + +class TestRTPSDiscoverySniffer(unittest.TestCase): + + def test_vendor_decoding(self): + self.assertEqual(sniffer.decode_vendor("010f"), ("01.0f", "eProsima Fast DDS")) + self.assertEqual(sniffer.decode_vendor("0110"), ("01.10", "Eclipse Cyclone DDS")) + self.assertEqual(sniffer.decode_vendor("0101"), ("01.01", "RTI Connext DDS")) + self.assertEqual(sniffer.decode_vendor("0105"), ("01.05", "Twin Oaks CoreDX DDS")) + self.assertEqual(sniffer.decode_vendor("0114"), ("01.14", "Dust DDS")) + self.assertEqual(sniffer.decode_vendor("0103"), ("01.03", "OpenDDS")) + self.assertEqual(sniffer.decode_vendor("0x010f"), ("01.0f", "eProsima Fast DDS")) + + def test_version_decoding(self): + self.assertEqual(sniffer.decode_version("0x0204"), "2.4") + self.assertEqual(sniffer.decode_version("0x0202"), "2.2") + self.assertEqual(sniffer.decode_version("0x0201"), "2.1") + self.assertEqual(sniffer.decode_version(0x0204), "2.4") + + def test_builtin_endpoints_decoding(self): + mask_hex, flags = sniffer.decode_builtin_endpoints("0x0000003f") + self.assertEqual(mask_hex, "0x0000003f") + self.assertIn("DISC_BUILTIN_ENDPOINT_PARTICIPANT_ANNOUNCER", flags) + self.assertIn("DISC_BUILTIN_ENDPOINT_PARTICIPANT_DETECTOR", flags) + self.assertIn("DISC_BUILTIN_ENDPOINT_PUBLICATION_ANNOUNCER", flags) + self.assertIn("DISC_BUILTIN_ENDPOINT_PUBLICATION_DETECTOR", flags) + self.assertIn("DISC_BUILTIN_ENDPOINT_SUBSCRIPTION_ANNOUNCER", flags) + self.assertIn("DISC_BUILTIN_ENDPOINT_SUBSCRIPTION_DETECTOR", flags) + + def test_timestamp_selection(self): + # 1. Without timestamp file + ts1 = sniffer.get_current_timestamp() + self.assertRegex(ts1, r"^\d{8}-\d{2}_\d{2}_\d{2}$") + + # 2. With timestamp file + ts_file = Path("timestamp") + was_present = ts_file.exists() + original_content = ts_file.read_text() if was_present else None + try: + ts_file.write_text("20260916-20_09_43\n") + self.assertEqual(sniffer.get_current_timestamp(), "20260916-20_09_43") + # Also test normalization from YYYY-MM-DD + ts_file.write_text("2026-09-16-20_09_43\n") + self.assertEqual(sniffer.get_current_timestamp(), "20260916-20_09_43") + finally: + if was_present and original_content is not None: + ts_file.write_text(original_content) + elif ts_file.exists(): + ts_file.unlink() + + def test_junit_report_generation(self): + with tempfile.TemporaryDirectory() as tmpdir: + out_xml = os.path.join(tmpdir, "junit_discovery_report_test.xml") + sample_summary = { + "metadata": {"total_rtps_packets": 20}, + "participants": { + "010ff34897b8c87f00000000": { + "guid_prefix": "010ff34897b8c87f00000000", + "src_ip": "192.168.1.10", + "vendor_id": "01.0f", + "vendor_name": "eProsima Fast DDS", + "protocol_version": "2.2", + "lease_duration_sec": 20.0, + "builtin_endpoints_mask": "0x00000c3f", + "builtin_endpoints_flags": ["DISC_BUILTIN_ENDPOINT_PARTICIPANT_ANNOUNCER"], + "locators": [{"type": "unicast", "ip": "192.168.1.10", "port": 7410}], + }, + "0110f34897b8c87f00000000": { + "guid_prefix": "0110f34897b8c87f00000000", + "src_ip": "192.168.1.20", + "vendor_id": "01.10", + "vendor_name": "Eclipse Cyclone DDS", + "protocol_version": "2.3", + "lease_duration_sec": 20.0, + "builtin_endpoints_mask": "0x00000c3f", + "builtin_endpoints_flags": ["DISC_BUILTIN_ENDPOINT_PARTICIPANT_ANNOUNCER"], + "locators": [{"type": "unicast", "ip": "192.168.1.20", "port": 7412}], + }, + }, + "endpoints": [ + { + "endpoint_guid": "010ff34897b8c87f0000000000000102", + "entity_kind": "DataWriter", + "topic_name": "Square", + "type_name": "ShapeType", + "reliability": "RELIABLE", + "durability": "VOLATILE", + "ownership": "SHARED", + "src_ip": "192.168.1.10", + }, + { + "endpoint_guid": "0110f34897b8c87f0000000000000107", + "entity_kind": "DataReader", + "topic_name": "Square", + "type_name": "ShapeType", + "reliability": "RELIABLE", + "durability": "VOLATILE", + "ownership": "SHARED", + "src_ip": "192.168.1.20", + }, + ], + } + + xml = sniffer.generate_junit_report( + summary=sample_summary, + output_xml_path=out_xml, + publisher_name="fastdds", + subscriber_name="cyclone", + expected_participants=2, + expected_topic="Square", + expected_type="ShapeType", + ) + + self.assertTrue(os.path.exists(out_xml)) + read_xml = junitparser.JUnitXml.fromfile(out_xml) + suites = list(read_xml) + self.assertEqual(len(suites), 1) + suite = suites[0] + self.assertEqual(suite.name, "rtps_discovery_fastdds---cyclone") + self.assertEqual(suite.tests, 7) + self.assertEqual(suite.failures, 0) + self.assertEqual(suite.errors, 0) + + def test_qos_incompatibility_failure(self): + with tempfile.TemporaryDirectory() as tmpdir: + out_xml = os.path.join(tmpdir, "junit_discovery_report_fail.xml") + sample_summary = { + "metadata": {"total_rtps_packets": 20}, + "participants": { + "010ff34897b8c87f00000000": { + "guid_prefix": "010ff34897b8c87f00000000", + "vendor_id": "01.0f", + "vendor_name": "eProsima Fast DDS", + "protocol_version": "2.2", + }, + "0110f34897b8c87f00000000": { + "guid_prefix": "0110f34897b8c87f00000000", + "vendor_id": "01.10", + "vendor_name": "Eclipse Cyclone DDS", + "protocol_version": "2.3", + }, + }, + "endpoints": [ + { + "endpoint_guid": "010ff34897b8c87f0000000000000102", + "entity_kind": "DataWriter", + "topic_name": "Square", + "type_name": "ShapeType", + "reliability": "BEST_EFFORT", + "durability": "VOLATILE", + }, + { + "endpoint_guid": "0110f34897b8c87f0000000000000107", + "entity_kind": "DataReader", + "topic_name": "Square", + "type_name": "ShapeType", + "reliability": "RELIABLE", # Incompatible with BEST_EFFORT writer! + "durability": "VOLATILE", + }, + ], + } + + sniffer.generate_junit_report( + summary=sample_summary, + output_xml_path=out_xml, + ) + + read_xml = junitparser.JUnitXml.fromfile(out_xml) + suite = list(read_xml)[0] + self.assertEqual(suite.failures, 1) + + +if __name__ == "__main__": + unittest.main() From 6c2baa17e914b05acb167b6ed6040e57d97a67f4 Mon Sep 17 00:00:00 2001 From: "martin.richter.supervag" Date: Tue, 22 Sep 2026 19:50:00 +0000 Subject: [PATCH 31/31] Unify 2_upload_artifact.yml for cross-runner and fork support and remove local workflows --- .../1_run_interoperability_tests_local.yml | 153 ------------------ .github/workflows/2_upload_artifact.yml | 85 ++++++++-- .github/workflows/2_upload_artifact_local.yml | 67 -------- 3 files changed, 72 insertions(+), 233 deletions(-) delete mode 100644 .github/workflows/1_run_interoperability_tests_local.yml delete mode 100644 .github/workflows/2_upload_artifact_local.yml diff --git a/.github/workflows/1_run_interoperability_tests_local.yml b/.github/workflows/1_run_interoperability_tests_local.yml deleted file mode 100644 index 821b99d..0000000 --- a/.github/workflows/1_run_interoperability_tests_local.yml +++ /dev/null @@ -1,153 +0,0 @@ ---- -name: 1 - Run Interoperability Tests Local -run-name: Run Interoperability Tests Local -"on": - workflow_dispatch: - inputs: - publishers: - description: Publishers to use - type: string - default: >- - ["connext_dds","dust_dds","eprosima_fastdds","intercom_dds", - "opendds","toc_coredx_dds","hdds","eclipse_cyclone","zzdds"] - subscribers: - description: Subscribers to use - type: string - default: >- - ["connext_dds","dust_dds","eprosima_fastdds","intercom_dds", - "opendds","toc_coredx_dds","hdds","eclipse_cyclone","zzdds"] - -jobs: - generate_timestamp: - runs-on: self-hosted - steps: - - name: Generate timestamp file - run: date '+%Y-%m-%d-%H_%M_%S' > timestamp - - name: Upload timestamp file - uses: actions/upload-artifact@v7 - with: - name: timestamp - path: | - ./timestamp - - run_tests: - runs-on: self-hosted - needs: generate_timestamp - strategy: - fail-fast: false - matrix: - publisher: ${{ fromJson(github.event.inputs.publishers) }} - subscriber: ${{ fromJson(github.event.inputs.subscribers) }} - - steps: - - name: Checkout sources - uses: actions/checkout@v6 - - name: Download shape_main executables - uses: robinraju/release-downloader@v1.13 - with: - repository: "omg-dds/dds-rtps" - latest: true - fileName: "*" - out-file-path: zipped_executables - preRelease: false - - name: Unzip executables - run: unzip 'zipped_executables/*.zip' -d executables - - name: Install Python requirements - run: pip install --break-system-packages --requirement requirements.txt - - name: Configure Network Isolation & Disable IPv6 - run: | - # Disable IPv6 to prevent Dust DDS panic on unimplemented LOCATOR_KIND_UDP_V6 - sudo sysctl -w net.ipv6.conf.all.disable_ipv6=1 || true - sudo sysctl -w net.ipv6.conf.default.disable_ipv6=1 || true - sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=1 || true - # Route DDS multicast discovery (224.0.0.0/4) exclusively to loopback to isolate from LAN - sudo ip route replace 224.0.0.0/4 dev lo || true - - name: Run Interoperability script - timeout-minutes: 120 - continue-on-error: true - run: | - publisher_exe=executables/${{ matrix.publisher }}*shape_main_linux - subscriber_exe=executables/${{ matrix.subscriber }}*shape_main_linux - prefix="junit_report-${{ matrix.publisher }}" - output_file="${prefix}-${{ matrix.subscriber }}.xml" - extra_args="" - # Check if publisher executable exists - if ! ls $publisher_exe 1> /dev/null 2>&1; then - echo "ERROR: Publisher executable not found: $publisher_exe" - exit 1 - fi - # Check if subscriber executable exists - if ! ls $subscriber_exe 1> /dev/null 2>&1; then - echo "ERROR: Subscriber executable not found: $subscriber_exe" - exit 1 - fi - is_sub_open=$([[ "${subscriber_exe,,}" == *opendds* ]] && echo 1) - is_pub_connext=$([[ "${publisher_exe,,}" == *connext_dds* ]] && echo 1) - if [[ "$is_sub_open" == "1" && "$is_pub_connext" == "1" ]]; then - extra_args="--periodic-announcement 5000" - fi - python3 interoperability_report.py \ - --publisher $publisher_exe \ - --subscriber $subscriber_exe \ - --output-name $output_file $extra_args - - name: Download timestamp - uses: actions/download-artifact@v8 - with: - name: timestamp - - name: Upload report - uses: actions/upload-artifact@v7 - with: - name: >- - junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }} - path: | - ./junit_report-${{ matrix.publisher }}-${{ matrix.subscriber }}.xml - ./timestamp - - name: Restore Network Settings - if: always() - run: | - sudo sysctl -w net.ipv6.conf.all.disable_ipv6=0 || true - sudo sysctl -w net.ipv6.conf.default.disable_ipv6=0 || true - sudo sysctl -w net.ipv6.conf.lo.disable_ipv6=0 || true - sudo ip route del 224.0.0.0/4 dev lo || true - - generate_report: - runs-on: self-hosted - needs: run_tests - steps: - - name: Checkout - uses: actions/checkout@v6 - - name: Download artifacts - uses: actions/download-artifact@v8 - with: - pattern: junit_report-* - merge-multiple: true - - name: Install Python requirements - run: pip install --break-system-packages --requirement requirements.txt - - name: Install junitparser - run: pip install --break-system-packages junitparser - - name: merge reports - run: python3 -m junitparser merge *.xml junit_interoperability_report.xml - - name: Generate xlsx report - run: >- - python3 generate_xlsx_report.py - --input junit_interoperability_report.xml - --output interoperability_report.xlsx - - name: XUnit - run: >- - npx xunit-viewer - --results=./junit_interoperability_report.xml - --output=./index.html - - name: Download timestamp - uses: actions/download-artifact@v8 - with: - name: timestamp - - name: Attach the report - if: always() - uses: actions/upload-artifact@v7 - with: - name: interoperability_report_complete - path: | - ./index.html - ./junit_interoperability_report.xml - ./interoperability_report.xlsx - ./timestamp diff --git a/.github/workflows/2_upload_artifact.yml b/.github/workflows/2_upload_artifact.yml index cb0690e..791404a 100644 --- a/.github/workflows/2_upload_artifact.yml +++ b/.github/workflows/2_upload_artifact.yml @@ -1,28 +1,86 @@ name: 2 - Upload Test Results to Drive -run-name: ${{ github.actor }} is testing out GitHub Actions 🚀 -on: workflow_dispatch +run-name: ${{ github.actor }} is uploading test results (${{ inputs.runner || 'ubuntu-latest' }}) 🚀 +on: + workflow_dispatch: + inputs: + runner: + description: 'Runner environment' + required: true + default: 'ubuntu-latest' + type: choice + options: + - ubuntu-latest + - self-hosted + jobs: upload_artifact: - runs-on: ubuntu-latest + runs-on: ${{ inputs.runner || 'ubuntu-latest' }} steps: + - name: Check secrets availability + run: | + if [ -z "${{ secrets.RCLONE_TOKEN }}" ] || [ -z "${{ secrets.DRIVE_FOLDER_ID }}" ]; then + echo "::error::Missing RCLONE_TOKEN or DRIVE_FOLDER_ID secrets. Cannot upload to Drive." + exit 1 + fi + - name: Get Artifact uses: dawidd6/action-download-artifact@v6 with: name: interoperability_report_complete workflow: 1_run_interoperability_tests.yml workflow_conclusion: completed + repo: ${{ github.repository }} + + - name: Ensure system dependencies + run: | + if ! command -v zip >/dev/null 2>&1; then + if command -v sudo >/dev/null 2>&1; then + sudo apt-get update -y && sudo apt-get install -y zip || echo "Warning: sudo apt-get install zip failed. Python fallback will be used." + else + echo "Warning: zip command not found and sudo is not available. Python fallback will be used." + fi + fi - name: Create files to upload run: | - current_date=$(cat timestamp) - mv index.html interoperability_report.html - for file in *; do \ - mv "$file" "${file%.*}_$current_date.${file##*.}"; \ - done - zip interoperability_report_$current_date.zip * - mkdir report_$current_date - mv *.zip report_$current_date/ - mv *.xlsx report_$current_date/ + rm -rf report_* interoperability_report_*.zip + if [ -f timestamp ]; then + current_date=$(cat timestamp | tr -d '\r\n') + elif ls timestamp*.timestamp 1>/dev/null 2>&1; then + current_date=$(cat timestamp*.timestamp | head -n 1 | tr -d '\r\n') + else + current_date=$(date '+%Y-%m-%d-%H_%M_%S') + fi + echo "$current_date" > timestamp + + [ -f index.html ] && mv index.html interoperability_report.html + + for file in interoperability_report.html junit_interoperability_report.xml interoperability_report.xlsx timestamp; do + if [ -f "$file" ]; then + cp "$file" "${file%.*}_$current_date.${file##*.}" + fi + done + + # Also preserve any discovery reports if present in artifact + for file in discovery_report*.json junit_discovery_report*.xml; do + if [ -f "$file" ]; then + if [[ "$file" != *"$current_date"* ]]; then + cp "$file" "${file%.*}_$current_date.${file##*.}" + fi + fi + done + + if command -v zip >/dev/null 2>&1; then + zip interoperability_report_$current_date.zip *_$current_date.* + else + python3 -m zipfile -c interoperability_report_$current_date.zip *_$current_date.* + fi + + mkdir -p report_$current_date + mv interoperability_report_$current_date.zip report_$current_date/ + if [ -f interoperability_report_$current_date.xlsx ]; then + cp interoperability_report_$current_date.xlsx report_$current_date/ + fi - name: Setup Rclone uses: AnimMouse/setup-rclone@v1 @@ -35,7 +93,8 @@ jobs: - name: Upload reports to Google Drive run: | - rclone copy report_* mygdrive: --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} -v + current_date=$(cat timestamp) + rclone copy report_$current_date mygdrive:report_$current_date --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} -v - name: Attach detailed report html uses: actions/upload-artifact@v4 diff --git a/.github/workflows/2_upload_artifact_local.yml b/.github/workflows/2_upload_artifact_local.yml deleted file mode 100644 index f273957..0000000 --- a/.github/workflows/2_upload_artifact_local.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: 2 - Upload Local Test Results to Drive -run-name: ${{ github.actor }} is testing out GitHub Actions 🚀 -on: workflow_dispatch -jobs: - upload_artifact: - runs-on: self-hosted - steps: - - name: Get Artifact - uses: dawidd6/action-download-artifact@v6 - with: - name: interoperability_report_complete - workflow: 1_run_interoperability_tests_local.yml - workflow_conclusion: completed - - - name: Ensure system dependencies - run: | - for cmd in zip gh; do - which "$cmd" >/dev/null 2>&1 || sudo apt-get install -y "$cmd" - done - - - name: Create files to upload - run: | - rm -rf report_* - if [ -f timestamp ]; then - current_date=$(cat timestamp) - elif ls timestamp*.timestamp 1>/dev/null 2>&1; then - current_date=$(cat timestamp*.timestamp | head -n 1) - else - current_date=$(date '+%Y-%m-%d-%H_%M_%S') - fi - echo "$current_date" > timestamp - - [ -f index.html ] && mv index.html interoperability_report.html - - for file in interoperability_report.html junit_interoperability_report.xml interoperability_report.xlsx timestamp; do - if [ -f "$file" ]; then - cp "$file" "${file%.*}_$current_date.${file##*.}" - fi - done - - zip interoperability_report_$current_date.zip *_$current_date.* - mkdir -p report_$current_date - mv interoperability_report_$current_date.zip report_$current_date/ - if [ -f interoperability_report_$current_date.xlsx ]; then - cp interoperability_report_$current_date.xlsx report_$current_date/ - fi - - - name: Setup Rclone - uses: AnimMouse/setup-rclone@v1 - with: - rclone_config: | - [mygdrive] - type = drive - token = ${{ secrets.RCLONE_TOKEN }} - disable_base64: true - - - name: Upload reports to Google Drive - run: | - current_date=$(cat timestamp) - rclone copy report_$current_date mygdrive:report_$current_date --drive-root-folder-id ${{ secrets.DRIVE_FOLDER_ID }} -v - - - name: Attach detailed report html - uses: actions/upload-artifact@v4 - with: - name: detailed_report - path: | - ./interoperability_report*.html