Add reporter for ReportPortal
Related-Prod: PRODX-48948
Change-Id: Ib879a913513c8da5b9a6090798951e1396939bc2
diff --git a/rp_reporter/README.md b/rp_reporter/README.md
new file mode 100644
index 0000000..e6381a3
--- /dev/null
+++ b/rp_reporter/README.md
@@ -0,0 +1,49 @@
+## Install
+
+In case of existed git repository
+```shell
+pip install .
+
+```
+or
+```shell
+pip install git+https//gerrit/path_in_gerrit TODO
+```
+
+## Use as a cli
+
+It will proceed to all child job and to find defined xml reports (defined in code)
+
+```shell
+rp-reporter report-job https://mos-ci.infra.mirantis.net/job/deploy_job/58/
+
+```
+
+The next command should upload testresults for all jobs from the defined view. Tool takes into account only the last built builds
+
+```shell
+rp-reporter report-view "https://mos-ci.infra.mirantis.net/view/MOSK%2024.3%20CI/"
+```
+
+## Use as a module
+
+```python
+import rp_reporter
+rp_reporter.upload_job("https://mos-ci.infra.mirantis.net/job/test-os-deploy-extended-core-ceph-local-non-dvr-ironic-mt-caracal-portgroups/6/", True)
+```
+
+## Configuration file
+By default, it's located in `~/.reportportal_config`
+You can change the default location by defining the `RP_CONFIG_FILE` environment variable
+
+### Example of config file
+Config file is formatted as yaml
+
+```yaml
+RP_APIKEY: <a lot of symbols>
+RP_ENDPOINT: http://172.19.124.20/
+RP_PROJECT: oscore
+```
+`RP_APIKEY` can be obtained in ReportPortal by url http://reportportal_url/ui/#userProfile/apiKeys
+
+All variables in configfile can be overwriten by defining the according environment variable
\ No newline at end of file
diff --git a/rp_reporter/rp_reporter/__init__.py b/rp_reporter/rp_reporter/__init__.py
new file mode 100644
index 0000000..cd4189b
--- /dev/null
+++ b/rp_reporter/rp_reporter/__init__.py
@@ -0,0 +1 @@
+from .batch_reporter import upload_job
\ No newline at end of file
diff --git a/rp_reporter/rp_reporter/batch_reporter.py b/rp_reporter/rp_reporter/batch_reporter.py
new file mode 100755
index 0000000..dd67349
--- /dev/null
+++ b/rp_reporter/rp_reporter/batch_reporter.py
@@ -0,0 +1,166 @@
+import click
+import logging
+import ipdb
+import itertools
+import sys
+
+import jenkins_jinny.main as jj
+from copy import deepcopy
+
+from .report_from_xml import report_xml, timestamp
+from .report_from_xml import client as rp_client
+
+sys.path.append('../rp_reporter')
+import settings
+
+LOG = logging.getLogger(__name__)
+
+def upload_job(job:str, suite_per_job=False):
+ if isinstance(job, str):
+ job = jj.Build(job)
+ print(f"│˶˙ᵕ˙˶)꜆ I take {job}")
+ link = job.url
+
+ tags = {
+ "start_time": job.start_time.strftime("%Y-%m-%d-%H-%M-%S")
+ }
+ if context_name:=job.param.OPENSTACK_CONTEXT_NAME:
+ tags.update(
+ {
+ "openstack": context_name.split("/")[0],
+ "context": context_name.split("/")[-1],
+ }
+ )
+
+ if "mosk-24.3" in job.name:
+ tags.update(
+ {
+ "mosk_version": "mosk-24.3"
+ }
+ )
+ elif "mosk-25.1" in job.name:
+ tags.update(
+ {
+ "mosk_version": "mosk-25.1"
+ }
+ )
+ else:
+ tags.update(
+ {
+ "mosk_version": "master"
+ }
+ )
+
+ # deploy_job = job.get_child_jobs("deploy-openstack-k8s-env")[0]
+ # for file_location in deploy_job.get_artifacts("deployed.yaml"):
+ # osdpl_content = yaml.safe_load_all(open(file_location))
+ # yaml.safe_load_all
+ # tags["rockoon"] =
+
+ launch_id = None
+ if suite_per_job:
+ launch_id = rp_client.start_launch(name=f"{job.name} number: {job.number}",
+ start_time=timestamp(),
+ attributes=tags,
+ description=f"Main job {job.url}"
+ )
+
+ for child in itertools.chain([job], job.heirs):
+ print(f"⫍⌕⫎ tests in {child}")
+ test_tags = deepcopy(tags)
+ test_results_files = None
+ match child.name:
+ case "tempest-runner-k8s":
+ title = "Tempest"
+ test_results_files = [file_url
+ for file_url in child.get_link_from_description()
+ if "tempest_report.xml" in file_url]
+ case "stepler-runner-k8s":
+ title = "Stepler"
+ # report_path = [file_url
+ # for file_url in child.get_link_from_description()
+ # if "stepler_test_results.xml" in file_url ][0]
+ test_results_files = child.get_artifacts("stepler_test_results.xml")
+ if not test_results_files:
+ LOG.error(f"Can't found 'stepler_test_results.xml' in "
+ f"{child.url} artifacts")
+ case "oscore-si-tests-runner-k8s":
+ title = "SI tests"
+ test_tags["test"] = child.param.RUN_TESTS
+ test_results_files = [file_url
+ for file_url in child.get_link_from_description()
+ if "si_test_report.xml" in file_url ]
+ if not test_results_files:
+ LOG.error(f"Can't found 'si_test_report.xml' in {child.url}")
+ case "oscore-functional-tests-runner":
+ title = "Rockoon Functional"
+ test_results_files = [file_url
+ for file_url in child.get_link_from_description()
+ if "si_test_report.xml" in file_url]
+ if not test_results_files:
+ LOG.error(f"Can't found 'si_test_report.xml' in {child.url}")
+ case "si-test-check-downtime-statistic":
+ title = "Downtime tests"
+ test_results_files = [
+ f"{file_url}/artifacts/test_check_downtime_statistic_result.xml"
+ for file_url in child.get_link_from_description()
+ ]
+ if not test_results_files:
+ LOG.error(f"Can't found 'test_check_downtime_statistic_result.xml' in {child.url}")
+
+ if not test_results_files:
+ # We can iterate by child jobs which don't contain any reports
+ continue
+ report_path = test_results_files[0]
+ LOG.info("=== report_xml {kwargs}".format( kwargs = dict(report_path=report_path,
+ title = title,
+ attributes=test_tags,
+ link=link,
+ to_specific_launch=launch_id)))
+
+ print(f"(っ・-・)っ Uploading {report_path}")
+ report_xml(
+ report_path=report_path,
+ title = title,
+ attributes=test_tags,
+ link=link,
+ to_specific_launch=launch_id,
+ )
+
+ if suite_per_job:
+ rp_client.finish_launch(end_time=timestamp())
+ LOG.info(rp_client.get_launch_info())
+ if suite_per_job:
+ report_url = f"{settings.RP_ENDPOINT.strip('/')}/ui/#{settings.RP_PROJECT}/launches/all/{launch_id}"
+ print(f"maybe report is here {report_url}")
+ print(f" ʕノ•ᴥ•ʔノ Completed")
+
+
+def upload_view(view, pattern):
+ for job in jj.jobs_in_view(view, ""):
+ if not job.number:
+ continue
+ if pattern and not pattern in job.name:
+ continue
+ upload_job(job, suite_per_job=True)
+
+@click.group()
+def cli():
+ pass
+
+@cli.command()
+@click.argument("job_url")
+def report_job(job_url):
+ upload_job(job_url, suite_per_job=True)
+
+@cli.command()
+@click.option("--pattern", default=None, help="Upload only job with pattern")
+@click.argument('view_url')
+def report_view(view_url, pattern):
+ """
+ :param view_url: Url to the view
+ """
+ upload_view(view_url, pattern)
+
+if __name__ == '__main__':
+ cli()
diff --git a/rp_reporter/rp_reporter/report_from_xml.py b/rp_reporter/rp_reporter/report_from_xml.py
new file mode 100644
index 0000000..52fe9e6
--- /dev/null
+++ b/rp_reporter/rp_reporter/report_from_xml.py
@@ -0,0 +1,235 @@
+import os
+import click
+import xunitparser
+import logging
+import pathlib
+import datetime
+import re
+import wget
+import sys
+import time
+
+from reportportal_client import RPClient
+from reportportal_client.helpers import timestamp
+
+sys.path.append('../rp_reporter')
+import settings
+
+logging.basicConfig(level=logging.INFO,
+ format='%(asctime)s %(levelname)s - %(filename)s:%(lineno)d (%(funcName)s) - %(message)s',
+ filename='report.log',
+ filemode='w')
+LOG = logging.getLogger(__name__)
+LOG.setLevel(logging.WARNING)
+scheduled = []
+
+def schedule_finishing(item):
+ scheduled.append(item)
+
+def finish_all():
+ while scheduled:
+ finishing_item = scheduled.pop()
+ client.finish_test_item(item_id=finishing_item,
+ end_time=timestamp()
+ )
+
+client = RPClient(endpoint=settings.RP_ENDPOINT,
+ project=settings.RP_PROJECT,
+ api_key=settings.RP_APIKEY,
+ is_skipped_an_issue=False,
+ truncate_attributes=False
+ )
+
+uuid_by_subfolder = dict()
+def create_subfolders(list_of_subfolders:list, root_folder=None) -> str:
+ parent_folder = root_folder
+ for number, subfolder in enumerate(list_of_subfolders):
+ subfolder_fullpath = list_of_subfolders[:number]
+ subfolder_fullpath.append(subfolder)
+ fullpath = ".".join(subfolder_fullpath)
+ if fullpath not in uuid_by_subfolder.keys():
+ created_folder = client.start_test_item(
+ name=subfolder,
+ start_time=timestamp(),
+ item_type="suite",
+ parent_item_id=parent_folder
+ )
+ LOG.debug(f"Started suite: uuid={created_folder} for suite {subfolder} in {fullpath} ")
+ schedule_finishing(created_folder)
+ uuid_by_subfolder[fullpath] = created_folder
+ folder_uuid = uuid_by_subfolder.get(fullpath)
+ parent_folder = folder_uuid
+ return parent_folder
+
+def report_xml(report_path, title,
+ attributes, link, to_specific_launch=None):
+ ts: xunitparser.TestSuite
+ tr: xunitparser.TestResult
+ tc: xunitparser.TestCase
+ # FIXME rewrite it to not use globals
+ global uuid_by_subfolder
+ uuid_by_subfolder = dict()
+ global scheduled
+ scheduled = list()
+ _report_path = str(report_path)
+ if report_path.startswith("http"):
+ report_path = wget.download(report_path, f"/tmp/")
+ if pathlib.Path(report_path).exists():
+ report_file = report_path
+ else:
+ raise FileNotFoundError(report_path)
+ ts, tr = xunitparser.parse(open(report_file))
+ # ipdb.set_trace()
+
+ # xunitparser can't provide start_time from xml
+ # so we can use only now()
+ # if tr.timestamp:
+ # start_time = datetime.datetime.fromisoformat(tr.timestamp)
+ # ipdb.set_trace()
+ # if tr.time
+ start_time = int(os.stat(report_file).st_birthtime * 1000)
+ attributes.update(ts.properties)
+
+ last_subfolder = None
+ if to_specific_launch:
+ test_suite = client.start_test_item(
+ name=title,
+ start_time=str(start_time),
+ attributes=attributes,
+ item_type="suite",
+ description=f"{link} \n uploaded from report {_report_path} ",
+ )
+ schedule_finishing(item=test_suite)
+ root_folder = test_suite
+ else:
+ client.start_launch(name=title,
+ start_time=str(start_time),
+ attributes=attributes,
+ description=f"{link} \n uploaded from report {_report_path} "
+ )
+ root_folder = None
+
+ LOG.info(f"Sending to RP")
+
+ ts_with_progress_bar = click.progressbar(ts,
+ label='Sending to RP',
+ # item_show_func=lambda a: a.methodname if a is not None,
+ length=tr.testsRun,
+ show_percent=True,
+ bar_template='[%(bar)s] %(info)s %(label)s'
+ )
+ with ts_with_progress_bar:
+ started_at = time.time()
+
+ for tc in ts_with_progress_bar:
+ if tc.classname:
+ last_subfolder = create_subfolders(
+ list_of_subfolders=tc.classname.split("."),
+ root_folder=root_folder
+ )
+ elif "setup" in tc.methodname.lower() or "teardown" in tc.methodname.lower():
+ # setup and teardown don't have classname but have path in their name like
+ # in tempest:
+ # setUpClass (tempest.api.compute.admin.test_create_server.WindowsServers11Test)
+ found_text: list = re.findall(r"\([\w.]+\)", tc.methodname)
+ if found_text:
+ found_text: str = found_text[-1]
+ if found_text:
+ found_text: str = found_text.strip("()")
+ last_subfolder = create_subfolders(
+ list_of_subfolders=found_text.split("."),
+ root_folder=root_folder)
+ # name = f"{tc.classname}.{tc.methodname}"
+ # else:
+ name = tc.methodname
+ elapsed = time.time() - started_at
+ ts_with_progress_bar.label = f"{elapsed:.2f}s {name}".replace("\n", " ")
+
+ # It's a bad way to detect setup\teardown because every testing
+ # framework has his own way to log setup\teardown
+ # Also test name can contain setup
+ # if "setup" in tc.methodname.lower():
+ # item_type="setup"
+ # elif "teardown" in tc.methodname.lower():
+ # item_type="teardown"
+ # else:
+ # item_type = "STEP"
+ item_type = "STEP"
+
+ # ipdb.set_trace()
+ test_started_at = timestamp()
+ item_id = client.start_test_item(
+ name=name,
+ start_time=test_started_at,
+ item_type=item_type,
+ description=f"{tc.classname}.{tc.methodname}",
+ parent_item_id=last_subfolder
+ )
+ if not item_id:
+ raise Exception(f"Failed to start test {name}")
+ match tc.result:
+ case "success":
+ status = "PASSED"
+ case "skipped":
+ status = "SKIPPED"
+ case "failure":
+ status = "FAILED"
+ met_failed = True
+ case "error":
+ status = "INTERRUPTED"
+ met_failed = True
+ case _:
+ raise BaseException(f"Unknown {tc.result=} in xml")
+
+ # LOG.debug(f"Logging {tc.trace}")
+ # ipdb.set_trace()
+ if tc.message:
+ client.log(time=timestamp(),
+ message=tc.message,
+ item_id=item_id
+ )
+ if tc.trace:
+ client.log(time=timestamp(),
+ message=tc.trace,
+ item_id=item_id
+ )
+
+ # timestamp() 1739905243451 in milliseconds
+ # tc.time datetime.timedelta(microseconds=259000)
+ end_time_with_duration = datetime.datetime.fromtimestamp(
+ int(test_started_at) / 1000) + tc.time
+ end_time_in_milliseconds = int(
+ end_time_with_duration.timestamp() * 1000)
+
+ client.finish_test_item(item_id=item_id,
+ end_time=str(end_time_in_milliseconds),
+ status=status,
+ )
+ finish_all()
+
+ if not to_specific_launch:
+ client.finish_launch(end_time=timestamp())
+ LOG.info(client.get_launch_info())
+
+if __name__ == "__main__":
+
+ report_path = 'https://artifactory.mcp.mirantis.net/artifactory/oscore-local/jenkins-job-artifacts/oscore-si-tests-runner-k8s/40162/si_test_report.xml'
+ title = "Rockoon functional"
+ attributes = {
+ # "openstack_version": "caracal",
+ "mosk_version": "mosk-24.3.3"
+ }
+ link = ""
+
+
+ report_xml(
+ report_path=report_path,
+ title = title,
+ attributes=attributes,
+ link=link,
+ # to_specific_launch="432ce97b-2727-4e4c-8303-9f1d966a184e"
+ )
+
+
+
+ # client.terminate()
diff --git a/rp_reporter/settings.py b/rp_reporter/settings.py
new file mode 100644
index 0000000..13f6a6c
--- /dev/null
+++ b/rp_reporter/settings.py
@@ -0,0 +1,35 @@
+from os import environ
+import yaml
+from pathlib import Path
+import logging
+
+LOG = logging.getLogger(__name__)
+
+RP_CONFIG_FILE = environ.get("RP_CONFIG_FILE") or (Path.home() / ".reportportal_config")
+
+def from_conf(key_name):
+ if not Path(RP_CONFIG_FILE).exists():
+ LOG.warning(f"Can't get {key_name} because config file "
+ f"not found: {RP_CONFIG_FILE}")
+ return None
+ with open(RP_CONFIG_FILE) as f:
+ yaml_config = yaml.safe_load(f)
+ value = yaml_config.get(key_name)
+ if value is None:
+ LOG.warning(f"Can't get {key_name} because it's absent in {RP_CONFIG_FILE}")
+ return value
+
+def call_error(key_name):
+ raise Exception(f"{key_name} should be defined in {RP_CONFIG_FILE} or "
+ f"by environment variable")
+
+RP_APIKEY = environ.get('RP_APIKEY') or from_conf('RP_APIKEY') or call_error("RP_APIKEY")
+RP_ENDPOINT = environ.get('RP_ENDPOINT') or from_conf('RP_ENDPOINT') or call_error("RP_ENDPOINT")
+RP_PROJECT = environ.get('RP_PROJECT') or from_conf('RP_PROJECT') or call_error("RP_PROJECT")
+
+RP_LOG_FILE = environ.get('RP_LOG_FILE') or from_conf('RP_LOG_FILE') or (Path.cwd() / "report.log")
+
+if __name__ == "__main__":
+ LOG.info(f"RP_APIKEY: {RP_APIKEY}")
+ LOG.info(f"RP_ENDPOINT: {RP_ENDPOINT}")
+ LOG.info(f"RP_PROJECT: {RP_PROJECT}")
\ No newline at end of file
diff --git a/rp_reporter/setup.py b/rp_reporter/setup.py
new file mode 100644
index 0000000..c94d350
--- /dev/null
+++ b/rp_reporter/setup.py
@@ -0,0 +1,25 @@
+from setuptools import setup, find_packages
+
+setup(
+ name="rp-reporter",
+ version="0.1.0",
+ author="Anna Arhipova",
+ author_email="harhipova@mirantis.com",
+ description="Reporting test results to the Report Portal",
+ long_description=open("README.md").read(),
+ long_description_content_type="text/markdown",
+ packages=find_packages(),
+ install_requires=[
+ "xunitparser", # List dependencies here
+ "reportportal-client",
+ "PyYAML",
+ "wget",
+ "jenkins-jinny @ git+https://github.com/annkapul/jenkins-jinny.git"
+ ],
+ python_requires=">=3.9",
+ entry_points={
+ "console_scripts": [
+ "rp-reporter=rp_reporter.batch_reporter:cli",
+ ],
+ },
+)
\ No newline at end of file