blob: 6a949c98c708c302ffba5d456d9c030d96544fea [file] [log] [blame]
#!/usr/bin/env python3
# Copyright (C) 2024 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""CLI uploader for Mobly test results to Resultstore."""
import argparse
import dataclasses
import datetime
from importlib import resources
import logging
import mimetypes
import pathlib
import platform
import shutil
import tempfile
import warnings
from xml.etree import ElementTree
import google.auth
from google.cloud import storage
from googleapiclient import discovery
import mobly_result_converter
import resultstore_client
with warnings.catch_warnings():
warnings.simplefilter('ignore')
from google.cloud.storage import transfer_manager
logging.getLogger('googleapiclient').setLevel(logging.WARNING)
_RESULTSTORE_SERVICE_NAME = 'resultstore'
_API_VERSION = 'v2'
_DISCOVERY_SERVICE_URL = (
'https://{api}.googleapis.com/$discovery/rest?version={apiVersion}'
)
_TEST_XML = 'test.xml'
_TEST_LOGS = 'test.log'
_UNDECLARED_OUTPUTS = 'undeclared_outputs/'
_TEST_SUMMARY_YAML = 'test_summary.yaml'
_TEST_LOG_INFO = 'test_log.INFO'
_SUITE_NAME = 'suite_name'
_RUN_IDENTIFIER = 'run_identifier'
_GCS_BASE_LINK = 'https://console.cloud.google.com/storage/browser'
_GCS_UPLOAD_INSTRUCTIONS = (
'\nAutomatic upload to GCS failed.\n'
'Please follow the steps below to manually upload files:\n'
f'\t1. Follow the link {_GCS_BASE_LINK}/%s.\n'
'\t2. Click "UPLOAD FOLDER".\n'
'\t3. Select the directory "%s" to upload.'
)
_ResultstoreTreeTags = mobly_result_converter.ResultstoreTreeTags
_ResultstoreTreeAttributes = mobly_result_converter.ResultstoreTreeAttributes
_Status = resultstore_client.Status
@dataclasses.dataclass()
class _TestResultInfo:
"""Info from the parsed test summary used for the Resultstore invocation."""
# Aggregate status of the overall test run.
status: _Status = _Status.UNKNOWN
# Target ID for the test.
target_id: str | None = None
def _convert_results(
mobly_dir: pathlib.Path, dest_dir: pathlib.Path) -> _TestResultInfo:
"""Converts Mobly test results into Resultstore artifacts."""
test_result_info = _TestResultInfo()
logging.info('Converting raw Mobly logs into Resultstore artifacts...')
# Generate the test.xml
mobly_yaml_path = mobly_dir.joinpath(_TEST_SUMMARY_YAML)
if mobly_yaml_path.is_file():
test_xml = mobly_result_converter.convert(mobly_yaml_path, mobly_dir)
ElementTree.indent(test_xml)
test_xml.write(
str(dest_dir.joinpath(_TEST_XML)),
encoding='utf-8',
xml_declaration=True,
)
test_result_info = _get_test_result_info_from_test_xml(test_xml)
# Copy test_log.INFO to test.log
test_log_info = mobly_dir.joinpath(_TEST_LOG_INFO)
if test_log_info.is_file():
shutil.copyfile(test_log_info, dest_dir.joinpath(_TEST_LOGS))
# Copy directory to undeclared_outputs/
shutil.copytree(
mobly_dir,
dest_dir.joinpath(_UNDECLARED_OUTPUTS),
dirs_exist_ok=True,
)
return test_result_info
def _get_test_result_info_from_test_xml(
test_xml: ElementTree.ElementTree,
) -> _TestResultInfo:
"""Parses a test_xml element into a _TestResultInfo."""
test_result_info = _TestResultInfo()
mobly_suite_element = test_xml.getroot().find(
f'./{_ResultstoreTreeTags.TESTSUITE.value}'
)
if mobly_suite_element is None:
return test_result_info
# Set aggregate test status
test_result_info.status = _Status.PASSED
test_class_elements = mobly_suite_element.findall(
f'./{_ResultstoreTreeTags.TESTSUITE.value}')
failures = int(
mobly_suite_element.get(_ResultstoreTreeAttributes.FAILURES.value)
)
errors = int(
mobly_suite_element.get(_ResultstoreTreeAttributes.ERRORS.value))
if failures or errors:
test_result_info.status = _Status.FAILED
else:
all_skipped = all([test_case_element.get(
_ResultstoreTreeAttributes.RESULT.value) == 'skipped' for
test_class_element in test_class_elements for
test_case_element in test_class_element.findall(
f'./{_ResultstoreTreeTags.TESTCASE.value}')])
if all_skipped:
test_result_info.status = _Status.SKIPPED
# Set target ID based on test class names, suite name, and custom run
# identifier.
suite_name_value = None
run_identifier_value = None
properties_element = mobly_suite_element.find(
f'./{_ResultstoreTreeTags.PROPERTIES.value}'
)
if properties_element is not None:
suite_name = properties_element.find(
f'./{_ResultstoreTreeTags.PROPERTY.value}'
f'[@{_ResultstoreTreeAttributes.NAME.value}="{_SUITE_NAME}"]'
)
if suite_name is not None:
suite_name_value = suite_name.get(
_ResultstoreTreeAttributes.VALUE.value
)
run_identifier = properties_element.find(
f'./{_ResultstoreTreeTags.PROPERTY.value}'
f'[@{_ResultstoreTreeAttributes.NAME.value}="{_RUN_IDENTIFIER}"]'
)
if run_identifier is not None:
run_identifier_value = run_identifier.get(
_ResultstoreTreeAttributes.VALUE.value
)
if suite_name_value:
target_id = suite_name_value
else:
test_class_names = [
test_class_element.get(_ResultstoreTreeAttributes.NAME.value)
for test_class_element in test_class_elements
]
target_id = '+'.join(test_class_names)
if run_identifier_value:
target_id = f'{target_id} {run_identifier_value}'
test_result_info.target_id = target_id
return test_result_info
def _upload_dir_to_gcs(
src_dir: pathlib.Path, gcs_bucket: str, gcs_dir: str
) -> list[str]:
"""Uploads the given directory to a GCS bucket."""
# Set correct MIME types for certain text-format files.
with resources.as_file(
resources.files('data').joinpath('mime.types')) as path:
mimetypes.init([path])
bucket_obj = storage.Client().bucket(gcs_bucket)
glob = src_dir.rglob('*')
file_paths = [
str(path.relative_to(src_dir).as_posix())
for path in glob
if path.is_file()
]
logging.info(
'Uploading %s files from %s to Cloud Storage directory %s/%s...',
len(file_paths),
str(src_dir),
gcs_bucket,
gcs_dir,
)
# Ensure that the destination directory has a trailing '/'.
blob_name_prefix = gcs_dir
if blob_name_prefix and not blob_name_prefix.endswith('/'):
blob_name_prefix += '/'
# If running on Windows, disable multiprocessing for upload.
worker_type = (
transfer_manager.THREAD
if platform.system() == 'Windows'
else transfer_manager.PROCESS
)
results = transfer_manager.upload_many_from_filenames(
bucket_obj,
file_paths,
source_directory=str(src_dir),
blob_name_prefix=blob_name_prefix,
worker_type=worker_type,
)
success_paths = []
for file_name, result in zip(file_paths, results):
if isinstance(result, Exception):
logging.warning('Failed to upload %s. Error: %s', file_name, result)
else:
logging.debug('Uploaded %s.', file_name)
success_paths.append(file_name)
# If all files fail to upload, something wrong happened with the GCS client.
# Prompt the user to manually upload the files instead.
if file_paths and not success_paths:
_prompt_user_upload(src_dir, gcs_bucket)
success_paths = file_paths
return success_paths
def _prompt_user_upload(src_dir: pathlib.Path, gcs_bucket: str) -> None:
"""Prompts the user to manually upload files to GCS."""
print(_GCS_UPLOAD_INSTRUCTIONS % (gcs_bucket, src_dir))
while True:
resp = input(
'Once you see the message "# files successfully uploaded", '
'enter "Y" or "yes" to continue:')
if resp.lower() in ('y', 'yes'):
break
def _upload_to_resultstore(
gcs_bucket: str,
gcs_dir: str,
file_paths: list[str],
status: _Status,
target_id: str | None,
) -> None:
"""Uploads test results to Resultstore."""
logging.info('Generating Resultstore link...')
service = discovery.build(
_RESULTSTORE_SERVICE_NAME,
_API_VERSION,
discoveryServiceUrl=_DISCOVERY_SERVICE_URL,
)
creds, project_id = google.auth.default()
client = resultstore_client.ResultstoreClient(service, creds, project_id)
client.create_invocation()
client.create_default_configuration()
client.create_target(target_id)
client.create_configured_target()
client.create_action(f'gs://{gcs_bucket}/{gcs_dir}', file_paths)
client.set_status(status)
client.merge_configured_target()
client.finalize_configured_target()
client.merge_target()
client.finalize_target()
client.merge_invocation()
client.finalize_invocation()
def main():
parser = argparse.ArgumentParser()
parser.add_argument(
'-v', '--verbose', action='store_true', help='Enable debug logs.'
)
parser.add_argument(
'mobly_dir',
help='Directory on host where Mobly results are stored.',
)
parser.add_argument(
'--gcs_bucket',
help='Bucket in GCS where test artifacts are uploaded. If unspecified, '
'use the current GCP project name as the bucket name.',
)
parser.add_argument(
'--gcs_dir',
help=(
'Directory to save test artifacts in GCS. Specify empty string to '
'store the files in the bucket root. If unspecified, use the '
'current timestamp as the GCS directory name.'
),
)
parser.add_argument(
'--test_title',
help='Custom test title to display in the result UI.'
)
args = parser.parse_args()
logging.basicConfig(
format='%(levelname)s: %(message)s',
level=(logging.DEBUG if args.verbose else logging.INFO)
)
_, project_id = google.auth.default()
gcs_bucket = project_id if args.gcs_bucket is None else args.gcs_bucket
gcs_dir = (
datetime.datetime.now().strftime('%Y%m%d-%H%M%S')
if args.gcs_dir is None
else args.gcs_dir
)
with tempfile.TemporaryDirectory() as tmp:
converted_dir = pathlib.Path(tmp).joinpath(gcs_dir)
converted_dir.mkdir()
mobly_dir = pathlib.Path(args.mobly_dir).absolute().expanduser()
test_result_info = _convert_results(mobly_dir, converted_dir)
gcs_files = _upload_dir_to_gcs(
converted_dir, gcs_bucket, gcs_dir)
_upload_to_resultstore(
gcs_bucket,
gcs_dir,
gcs_files,
test_result_info.status,
args.test_title or test_result_info.target_id,
)
if __name__ == '__main__':
main()