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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions docs/dictionary/en-custom.txt
Original file line number Diff line number Diff line change
Expand Up @@ -536,6 +536,7 @@ shiftstack
shiftstackclient
sig
Sinha
Stackviz
sizepercent
skbg
skiplist
Expand All @@ -556,6 +557,7 @@ str
stricthostkeychecking
submodule
submodules
subunit
subnet
subnets
sudo
Expand All @@ -573,6 +575,7 @@ tempestconf
testcases
testenv
testproject
testtools
timestamper
timesync
tldca
Expand Down
4 changes: 4 additions & 0 deletions roles/test_operator/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ Execute tests via the [test-operator](https://openstack-k8s-operators.github.io/

## Parameters
* `cifmw_test_operator_artifacts_basedir`: (String) Directory where we will have all test-operator related files. Default value: `{{ cifmw_basedir }}/tests/test_operator` which defaults to `~/ci-framework-data/tests/test_operator`
* `cifmw_test_operator_stackviz_generate`: (Boolean) Enable automatic generation of Stackviz HTML reports from Tempest subunit test results. When enabled, generates interactive visualizations of test results that can be viewed in a browser. When `cifmw_test_operator_tempest_rerun_failed_tests` is enabled, stackviz will generate both `tempest-viz.html` (original test run) and `tempest_retry_viz.html` (retry test run) reports for easy comparison. Default value: `true`
* `cifmw_test_operator_stackviz_debug`: (Boolean) Enable debug mode for Stackviz report generation. When enabled, displays detailed information about the generation process. Default value: `false`
* `cifmw_test_operator_stackviz_auto_install_deps`: (Boolean) Automatically install required RPM packages (python3-subunit, python3-testtools) for Stackviz generation. When disabled, the role will fail if the packages are not already installed. Default value: `true`
* `cifmw_test_operator_stackviz_create_index`: (Boolean) Create a summary index page (index.html) when multiple test stages are run. The index provides links to all individual Stackviz reports. Only applicable when using workflows with multiple test stages. Default value: `true`
* `cifmw_test_operator_namespace`: (String) Namespace inside which all the resources are created. Default value: `openstack`
* `cifmw_test_operator_controller_namespace`: (String) Namespace inside which the test-operator-controller-manager is created. Default value: `openstack-operators`
* `cifmw_test_operator_controller_priv_key_file_path`: (String) Specifies the path to the CIFMW private key file. Note: Please ensure this file is available in the environment where the ci-framework test-operator role is executed. Default value: `~/.ssh/id_cifw`
Expand Down
4 changes: 4 additions & 0 deletions roles/test_operator/defaults/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ cifmw_test_operator_stages:
type: tempest
cifmw_test_operator_fail_on_test_failure: true
cifmw_test_operator_artifacts_basedir: "{{ cifmw_basedir }}/tests/test_operator"
cifmw_test_operator_stackviz_generate: true
cifmw_test_operator_stackviz_debug: false
cifmw_test_operator_stackviz_auto_install_deps: true
cifmw_test_operator_stackviz_create_index: true
cifmw_test_operator_namespace: openstack
cifmw_test_operator_controller_namespace: openstack-operators
cifmw_test_operator_bundle: ""
Expand Down
4 changes: 2 additions & 2 deletions roles/test_operator/tasks/collect-logs.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -75,10 +75,10 @@
KUBECONFIG: "{{ cifmw_openshift_kubeconfig }}"
PATH: "{{ cifmw_path }}"
vars:
pod_path: mnt/logs-{{ test_operator_instance_name }}-step-{{ index }}
pod_path: /mnt/logs-{{ test_operator_instance_name }}-step-{{ index }}
ansible.builtin.shell: >
oc cp -n {{ stage_vars_dict.cifmw_test_operator_namespace }}
test-operator-logs-pod-{{ run_test_fw }}-{{ test_operator_instance_name }}:{{ pod_path }}
test-operator-logs-pod-{{ run_test_fw }}-{{ test_operator_instance_name }}:{{ pod_path }}/.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure why this is needed (/.) 🤔 The change above (/mnt/logs...) seems like a good change as the mountpath earlier also starts with backslash.

{{ cifmw_test_operator_artifacts_basedir }}
loop: "{{ logsPVCs.resources }}"
loop_control:
Expand Down
102 changes: 102 additions & 0 deletions roles/test_operator/tasks/generate-stackviz-main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
---
# Main: Generate stackviz HTML reports from tempest subunit results
# This is the main orchestrator that coordinates stackviz report generation
# Included after log collection when tempest tests complete
# Handles multiple test stages by generating separate reports for each
# Calls generate-stackviz-worker.yml for each subunit file found

- name: Find all tempest subunit files (results and retries, compressed or uncompressed)
ansible.builtin.find:
paths: "{{ cifmw_test_operator_artifacts_basedir }}"
patterns:
- "tempest_results.subunit*"
- "tempest_retry.subunit*"
recurse: true
register: subunit_gz_files

- name: Display found subunit files
ansible.builtin.debug:
msg: "Found {{ subunit_gz_files.files | length }} subunit file(s) (results + retries)"

- name: Process stackviz reports
when: subunit_gz_files.files | length > 0
block:
- name: Install required RPM packages for stackviz
ansible.builtin.package:
name:
- python3-subunit
- python3-testtools
state: present
become: true
when: cifmw_test_operator_stackviz_auto_install_deps

# Initialize list to track generated reports
- name: Initialize stackviz reports list
ansible.builtin.set_fact:
_stackviz_generated_reports: []

# Process each subunit file
- name: Process each subunit file and generate individual reports
vars:
_current_subunit_source: "{{ subunit_file.path }}"
_current_dir: "{{ subunit_file.path | dirname }}"
_current_stage_name: "{{ subunit_file.path | dirname | basename }}"
_current_is_compressed: "{{ subunit_file.path.endswith('.gz') }}"
_is_retry_file: "{{ 'tempest_retry' in subunit_file.path }}"
_base_filename: "{{ 'tempest_retry' if 'tempest_retry' in subunit_file.path else 'tempest_results' }}"
_html_filename: "{{ 'tempest_retry_viz.html' if 'tempest_retry' in subunit_file.path else 'tempest-viz.html' }}"
ansible.builtin.include_tasks: generate-stackviz-worker.yml
Copy link
Contributor

@danpawlik danpawlik Feb 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

now I see, that in generate-stackviz-worker.yml you are setting facts.
TBH, would be better to make vars, instead of additional tasks with set_facts if that is not needed.
Especially, that those vars are only needed for this task.
For example:

    - name: Process each subunit file and generate individual reports
      vars:
        _current_subunit_source: "{{ subunit_file.path }}"
        _current_dir: "{{ subunit_file.path | dirname }}"
        _current_stage_name: "{{ subunit_file.path | dirname | basename }}"
        _current_is_compressed: "{{ subunit_file.path.endswith('.gz') }}"
        _is_retry_file: "{{ 'tempest_retry' in subunit_file.path }}"
        _base_filename: "{{ 'tempest_retry' if 'tempest_retry' in subunit_file.path else 'tempest_results' }}"
        _html_filename: "{{ 'tempest_retry_viz.html' if 'tempest_retry' in subunit_file.path else 'tempest-viz.html' }}"
      ansible.builtin.include_tasks: generate-stackviz-worker.yml
      loop: "{{ subunit_gz_files.files }}"
      loop_control:
        loop_var: subunit_file

then in generate-stackviz-worker.yml leave:

- name: Set output paths for decompressed file and HTML report 
  ansible.builtin.set_fact:
    _current_subunit: "{{ _current_dir }}/{{ _base_filename }}.subunit"
    _current_html: "{{ _current_dir }}/{{ _html_filename }}"

as it was

loop: "{{ subunit_gz_files.files }}"
loop_control:
loop_var: subunit_file

# Create summary index page
- name: Create stackviz summary index
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why create index?

when:
- _stackviz_generated_reports | length > 0
- cifmw_test_operator_stackviz_create_index
block:
- name: Create stackviz directory
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stackviz?

ansible.builtin.file:
path: "{{ cifmw_test_operator_artifacts_basedir }}/stackviz"
state: directory
mode: '0755'

- name: Generate summary index HTML
ansible.builtin.template:
src: stackviz-index.html.j2
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why?

dest: "{{ cifmw_test_operator_artifacts_basedir }}/stackviz/index.html"
mode: '0644'

- name: Display stackviz summary
ansible.builtin.debug:
msg: |
===================================================
Stackviz Report Generation Complete!

Total Reports Generated: {{ _stackviz_generated_reports | length }}

{% for report in _stackviz_generated_reports %}
- {{ report.report_label }}
{{ report.html_path }}
{% endfor %}

{% if cifmw_test_operator_stackviz_create_index %}
Summary Index: {{ cifmw_test_operator_artifacts_basedir }}/stackviz/index.html

To view all reports:
{% if ansible_os_family == 'Darwin' %}
open {{ cifmw_test_operator_artifacts_basedir }}/stackviz/index.html
{% else %}
xdg-open {{ cifmw_test_operator_artifacts_basedir }}/stackviz/index.html
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

agree, why?

{% endif %}
{% endif %}
===================================================
when: _stackviz_generated_reports | length > 0

- name: Display warning when no subunit files found
ansible.builtin.debug:
msg: >
WARNING: No tempest subunit files (tempest_results.subunit* or tempest_retry.subunit*) found in {{ cifmw_test_operator_artifacts_basedir }}.
Stackviz report generation skipped.
when: subunit_gz_files.files | length == 0
72 changes: 72 additions & 0 deletions roles/test_operator/tasks/generate-stackviz-worker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
---
# Worker: Process a single subunit.gz file and generate HTML report
# This file is included in a loop from generate-stackviz-main.yml
# Variables available:
# - subunit_file: the file object from find results
# - All path and naming variables are passed as vars from the main file

- name: Set output paths for decompressed file and HTML report
ansible.builtin.set_fact:
_current_subunit: "{{ _current_dir }}/{{ _base_filename }}.subunit"
_current_html: "{{ _current_dir }}/{{ _html_filename }}"

# Tempest can produce compressed .subunit.gz files to save disk space,
# especially for large test runs. We decompress them for processing.
- name: Decompress subunit file if compressed
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why it can be compressed? I don't believe in that phase it is compressed, or?

ansible.builtin.shell: |
gunzip -c "{{ _current_subunit_source }}" > "{{ _current_subunit }}"
args:
creates: "{{ _current_subunit }}"
when: _current_is_compressed | bool

- name: Use uncompressed file directly if not compressed
ansible.builtin.set_fact:
_current_subunit: "{{ _current_subunit_source }}"
when: not (_current_is_compressed | bool)

- name: Verify subunit file exists
ansible.builtin.stat:
path: "{{ _current_subunit }}"
register: _current_subunit_stat

- name: Fail if subunit file is missing
ansible.builtin.fail:
msg: |
ERROR: Subunit file not found: {{ _current_subunit }}
Expected file after decompression or direct usage.
This may indicate an issue with test execution or file handling.
when: not _current_subunit_stat.stat.exists

- name: Generate stackviz HTML report for this stage
ansible.builtin.command:
cmd: >
python3 {{ cifmw_repo }}/scripts/generate-stackviz-report.py
{{ _current_subunit }}
{{ _current_html }}
register: _current_stackviz_generation

- name: Display generation output
ansible.builtin.debug:
var: _current_stackviz_generation.stdout_lines
when:
- _current_stackviz_generation is defined
- _current_stackviz_generation is not skipped
- cifmw_test_operator_stackviz_debug | default(false)

- name: Track generated report with type metadata
ansible.builtin.set_fact:
_stackviz_generated_reports: >-
{{
_stackviz_generated_reports + [{
'stage_name': _current_stage_name,
'html_path': _current_html,
'subunit_path': _current_subunit,
'directory': _current_dir,
'is_retry': _is_retry_file | default(false),
'report_label': ('Retry Results: ' if (_is_retry_file | default(false)) else 'Original Results: ') + _current_stage_name
}]
}}

- name: Display success for this report
ansible.builtin.debug:
msg: "✓ Generated report for {{ _current_stage_name }}: {{ _current_html }}"
7 changes: 7 additions & 0 deletions roles/test_operator/tasks/run-test-operator-job.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@
- not testpod_timed_out
ansible.builtin.include_tasks: collect-logs.yaml

- name: Generate stackviz HTML report
when:
- not testpod_timed_out
- cifmw_test_operator_stackviz_generate
- run_test_fw == 'tempest'
ansible.builtin.include_tasks: generate-stackviz-main.yml

- name: Get list of all pods
kubernetes.core.k8s_info:
kubeconfig: "{{ cifmw_openshift_kubeconfig }}"
Expand Down
86 changes: 86 additions & 0 deletions roles/test_operator/templates/stackviz-index.html.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tempest Test Reports - Summary</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
background-color: #0d1117;
color: #c9d1d9;
margin: 40px;
line-height: 1.6;
}
h1 { color: #58a6ff; margin-bottom: 30px; }
h2 { color: #8b949e; margin-top: 30px; }
.report-list { list-style: none; padding: 0; }
.report-item {
background-color: #161b22;
border: 1px solid #30363d;
border-radius: 6px;
padding: 20px;
margin-bottom: 15px;
transition: background-color 0.2s;
}
.report-item:hover { background-color: #1c2128; }
.report-link {
color: #58a6ff;
text-decoration: none;
font-size: 18px;
font-weight: bold;
}
.report-link:hover { text-decoration: underline; }
.report-path {
color: #8b949e;
font-size: 14px;
margin-top: 5px;
font-family: monospace;
}
.report-count {
color: #7ee787;
font-size: 24px;
font-weight: bold;
}
.timestamp {
color: #8b949e;
font-size: 12px;
margin-top: 20px;
}
.badge {
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
margin-right: 8px;
font-weight: bold;
}
.retry-badge {
background-color: #9e6a03;
color: white;
}
</style>
</head>
<body>
<h1>Tempest Test Reports</h1>
<p>Total Reports Generated: <span class="report-count">{{ _stackviz_generated_reports | length }}</span></p>

<h2>Individual Test Stage Reports</h2>
<ul class="report-list">
{% for report in _stackviz_generated_reports %}
<li class="report-item">
<a href="{{ report.html_path | relpath(cifmw_test_operator_artifacts_basedir ~ '/stackviz') }}" class="report-link">
{% if report.is_retry %}
<span class="badge retry-badge">RETRY</span>
{% endif %}
{{ report.stage_name }}
</a>
<div class="report-path">{{ report.html_path }}</div>
</li>
{% endfor %}
</ul>

<div class="timestamp">
Generated: {{ ansible_date_time.iso8601 }}
</div>
</body>
</html>
Loading