Add changes from TIF/Feature branch.

Signed-off-by: John <jonawals@amazon.com>
This commit is contained in:
John
2021-08-10 14:20:46 +01:00
parent 4b68e6c766
commit c2a2d1c5c7
42 changed files with 3534 additions and 1069 deletions
@@ -89,7 +89,7 @@
"CONFIGURATION": "profile",
"SCRIPT_PATH": "scripts/build/TestImpactAnalysis/tiaf_driver.py",
"SCRIPT_PARAMETERS":
"--config=\"!OUTPUT_DIRECTORY!/bin/TestImpactFramework/persistent/tiaf.profile.json\" --suite=main --test-failure-policy=continue --src-branch=!BRANCH_NAME! --dst-branch=!CHANGE_TARGET! --pipeline=!PIPELINE_NAME! --dest-commit=!CHANGE_ID! --seeding-branches=!BUILD_SNAPSHOTS! --seeding-pipelines=default"
"--config=\"!OUTPUT_DIRECTORY!/bin/TestImpactFramework/profile/Persistent/tiaf.json\" --src-branch=!BRANCH_NAME! --dst-branch=!BRANCH_NAME! --commit=!CHANGE_ID! --s3-bucket=!TEST_IMPACT_S3_BUCKET! --mars-index-prefix=jonawals --suite=main --test-failure-policy=continue"
}
},
"debug_vs2019": {
+55 -22
View File
@@ -6,34 +6,67 @@
#
#
import os
import subprocess
import git
import pathlib
# Returns True if the dst commit descends from the src commit, otherwise False
def is_descendent(src_commit_hash, dst_commit_hash):
if src_commit_hash is None or dst_commit_hash is None:
return False
result = subprocess.run(["git", "merge-base", "--is-ancestor", src_commit_hash, dst_commit_hash])
return result.returncode == 0
# Attempts to create a diff from the src and dst commits and write to the specified output file
def create_diff_file(src_commit_hash, dst_commit_hash, output_path):
if os.path.isfile(output_path):
os.remove(output_path)
os.makedirs(os.path.dirname(output_path), exist_ok=True)
# git diff will only write to the output file if both commit hashes are valid
subprocess.run(["git", "diff", "--name-status", f"--output={output_path}", src_commit_hash, dst_commit_hash])
if not os.path.isfile(output_path):
raise FileNotFoundError(f"Source commit '{src_commit_hash}' and/or destination commit '{dst_commit_hash}' are invalid")
# Basic representation of a repository
# Basic representation of a git repository
class Repo:
def __init__(self, repo_path):
self.__repo = git.Repo(repo_path)
def __init__(self, repo_path: str):
self._repo = git.Repo(repo_path)
# Returns the current branch
@property
def current_branch(self):
branch = self.__repo.active_branch
branch = self._repo.active_branch
return branch.name
def create_diff_file(self, src_commit_hash: str, dst_commit_hash: str, output_path: pathlib.Path):
"""
Attempts to create a diff from the src and dst commits and write to the specified output file.
@param src_commit_hash: The hash for the source commit.
@param dst_commit_hash: The hash for the destination commit.
@param output_path: The path to the file to write the diff to.
"""
try:
# Remove the existing file (if any) and create the parent directory
output_path.unlink(missing_ok=True)
output_path.parent.mkdir(exist_ok=True)
except EnvironmentError as e:
raise RuntimeError(f"Could not create path for output file '{output_path}'")
# git diff will only write to the output file if both commit hashes are valid
subprocess.run(["git", "diff", "--name-status", f"--output={output_path}", src_commit_hash, dst_commit_hash])
if not output_path.is_file():
raise RuntimeError(f"Source commit '{src_commit_hash}' and/or destination commit '{dst_commit_hash}' are invalid")
def is_descendent(self, src_commit_hash: str, dst_commit_hash: str):
"""
Determines whether or not dst_commit is a descendent of src_commit.
@param src_commit_hash: The hash for the source commit.
@param dst_commit_hash: The hash for the destination commit.
@return: True if the dst commit descends from the src commit, otherwise False.
"""
if not src_commit_hash and not dst_commit_hash:
return False
result = subprocess.run(["git", "merge-base", "--is-ancestor", src_commit_hash, dst_commit_hash])
return result.returncode == 0
# Returns the distance between two commits
def commit_distance(self, src_commit_hash: str, dst_commit_hash: str):
"""
Determines the number of commits between src_commit and dst_commit.
@param src_commit_hash: The hash for the source commit.
@param dst_commit_hash: The hash for the destination commit.
@return: The distance between src_commit and dst_commit (if both are valid commits), otherwise None.
"""
if not src_commit_hash and not dst_commit_hash:
return None
commits = self._repo.iter_commits(src_commit_hash + '..' + dst_commit_hash)
return len(list(commits))
@@ -0,0 +1,452 @@
#
# Copyright (c) Contributors to the Open 3D Engine Project.
# For complete copyright and license terms please see the LICENSE at the root of this distribution.
#
# SPDX-License-Identifier: Apache-2.0 OR MIT
#
#
import datetime
import json
import socket
from tiaf_logger import get_logger
logger = get_logger(__file__)
MARS_JOB_KEY = "job"
SRC_COMMIT_KEY = "src_commit"
DST_COMMIT_KEY = "src_commit"
COMMIT_DISTANCE_KEY = "commit_distance"
SRC_BRANCH_KEY = "src_branch"
DST_BRANCH_KEY = "dst_branch"
SUITE_KEY = "suite"
SOURCE_OF_TRUTH_BRANCH_KEY = "source_of_truth_branch"
IS_SOURCE_OF_TRUTH_BRANCH_KEY = "is_source_of_truth_branch"
USE_TEST_IMPACT_ANALYSIS_KEY = "use_test_impact_analysis"
HAS_CHANGE_LIST_KEY = "has_change_list"
HAS_HISTORIC_DATA_KEY = "has_historic_data"
S3_BUCKET_KEY = "s3_bucket"
DRIVER_ARGS_KEY = "driver_args"
RUNTIME_ARGS_KEY = "runtime_args"
RUNTIME_RETURN_CODE_KEY = "return_code"
NAME_KEY = "name"
RESULT_KEY = "result"
NUM_PASSING_TESTS_KEY = "num_passing_tests"
NUM_FAILING_TESTS_KEY = "num_failing_tests"
NUM_DISABLED_TESTS_KEY = "num_disabled_tests"
COMMAND_ARGS_STRING = "command_args"
NUM_PASSING_TEST_RUNS_KEY = "num_passing_test_runs"
NUM_FAILING_TEST_RUNS_KEY = "num_failing_test_runs"
NUM_EXECUTION_FAILURE_TEST_RUNS_KEY = "num_execution_failure_test_runs"
NUM_TIMED_OUT_TEST_RUNS_KEY = "num_timed_out_test_runs"
NUM_UNEXECUTED_TEST_RUNS_KEY = "num_unexecuted_test_runs"
TOTAL_NUM_PASSING_TESTS_KEY = "total_num_passing_tests"
TOTAL_NUM_FAILING_TESTS_KEY = "total_num_failing_tests"
TOTAL_NUM_DISABLED_TESTS_KEY = "total_num_disabled_tests"
START_TIME_KEY = "start_time"
END_TIME_KEY = "end_time"
DURATION_KEY = "duration"
INCLUDED_TEST_RUNS_KEY = "included_test_runs"
EXCLUDED_TEST_RUNS_KEY = "excluded_test_runs"
NUM_INCLUDED_TEST_RUNS_KEY = "num_included_test_runs"
NUM_EXCLUDED_TEST_RUNS_KEY = "num_excluded_test_runs"
TOTAL_NUM_TEST_RUNS_KEY = "total_num_test_runs"
PASSING_TEST_RUNS_KEY = "passing_test_runs"
FAILING_TEST_RUNS_KEY = "failing_test_runs"
EXECUTION_FAILURE_TEST_RUNS_KEY = "execution_failure_test_runs"
TIMED_OUT_TEST_RUNS_KEY = "timed_out_test_runs"
UNEXECUTED_TEST_RUNS_KEY = "unexecuted_test_runs"
TOTAL_NUM_PASSING_TEST_RUNS_KEY = "total_num_passing_test_runs"
TOTAL_NUM_FAILING_TEST_RUNS_KEY = "total_num_failing_test_runs"
TOTAL_NUM_EXECUTION_FAILURE_TEST_RUNS_KEY = "total_num_execution_failure_test_runs"
TOTAL_NUM_TIMED_OUT_TEST_RUNS_KEY = "total_num_timed_out_test_runs"
TOTAL_NUM_UNEXECUTED_TEST_RUNS_KEY = "total_num_unexecuted_test_runs"
SEQUENCE_TYPE_KEY = "type"
IMPACT_ANALYSIS_SEQUENCE_TYPE_KEY = "impact_analysis"
SAFE_IMPACT_ANALYSIS_SEQUENCE_TYPE_KEY = "safe_impact_analysis"
SEED_SEQUENCE_TYPE_KEY = "seed"
TEST_TARGET_TIMEOUT_KEY = "test_target_timeout"
GLOBAL_TIMEOUT_KEY = "global_timeout"
MAX_CONCURRENCY_KEY = "max_concurrency"
SELECTED_KEY = "selected"
DRAFTED_KEY = "drafted"
DISCARDED_KEY = "discarded"
SELECTED_TEST_RUN_REPORT_KEY = "selected_test_run_report"
DISCARDED_TEST_RUN_REPORT_KEY = "discarded_test_run_report"
DRAFTED_TEST_RUN_REPORT_KEY = "drafted_test_run_report"
SELECTED_TEST_RUNS_KEY = "selected_test_runs"
DRAFTED_TEST_RUNS_KEY = "drafted_test_runs"
DISCARDED_TEST_RUNS_KEY = "discarded_test_runs"
INSTRUMENTATION_KEY = "instrumentation"
EFFICIENCY_KEY = "efficiency"
CONFIG_KEY = "config"
POLICY_KEY = "policy"
CHANGE_LIST_KEY = "change_list"
TEST_RUN_SELECTION_KEY = "test_run_selection"
DYNAMIC_DEPENDENCY_MAP_POLICY_KEY = "dynamic_dependency_map"
DYNAMIC_DEPENDENCY_MAP_POLICY_UPDATE_KEY = "update"
REPORT_KEY = "report"
class FilebeatExn(Exception):
pass
class FilebeatClient(object):
def __init__(self, host="127.0.0.1", port=9000, timeout=20):
self._filebeat_host = host
self._filebeat_port = port
self._socket_timeout = timeout
self._socket = None
self._open_socket()
def send_event(self, payload, index, timestamp=None, pipeline="filebeat"):
if timestamp is None:
timestamp = datetime.datetime.utcnow().timestamp()
event = {
"index": index,
"timestamp": timestamp,
"pipeline": pipeline,
"payload": json.dumps(payload)
}
# Serialise event, add new line and encode as UTF-8 before sending to Filebeat.
data = json.dumps(event, sort_keys=True) + "\n"
data = data.encode()
#print(f"-> {data}")
self._send_data(data)
def _open_socket(self):
logger.info(f"Connecting to Filebeat on {self._filebeat_host}:{self._filebeat_port}")
self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self._socket.settimeout(self._socket_timeout)
try:
self._socket.connect((self._filebeat_host, self._filebeat_port))
except (ConnectionError, socket.timeout):
raise FilebeatExn("Failed to connect to Filebeat") from None
def _send_data(self, data):
total_sent = 0
while total_sent < len(data):
try:
sent = self._socket.send(data[total_sent:])
except BrokenPipeError:
logging.error("Filebeat socket closed by peer")
self._socket.close()
self._open_socket()
total_sent = 0
else:
total_sent = total_sent + sent
def format_timestamp(timestamp: float):
"""
Formats the given floating point timestamp into "yyyy-MM-dd'T'HH:mm:ss.SSSXX" format.
@param timestamp: The timestamp to format.
@return: The formatted timestamp.
"""
return datetime.datetime.utcfromtimestamp(timestamp).strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
def generate_mars_timestamp(t0_offset_milliseconds: int, t0_timestamp: float):
"""
Generates a MARS timestamp in the format "yyyy-MM-dd'T'HH:mm:ss.SSSXX" by offsetting the T0 timestamp
by the specified amount of milliseconds.
@param t0_offset_milliseconds: The amount of time to offset from T0.
@param t0_timestamp: The T0 timestamp that TIAF timings will be offst from.
@return: The formatted timestamp offset from T0 by the specified amount of milliseconds.
"""
t0_offset_seconds = get_duration_in_seconds(t0_offset_milliseconds)
t0_offset_timestamp = t0_timestamp + t0_offset_seconds
return format_timestamp(t0_offset_timestamp)
def get_duration_in_seconds(duration_in_milliseconds: int):
"""
Gets the specified duration in milliseconds (as used by TIAF) in seconds (as used my MARS documents).
@param duration_in_milliseconds: The millisecond duration to transform into seconds.
@return: The duration in seconds.
"""
return duration_in_milliseconds * 0.001
def generate_mars_job(tiaf_result, driver_args):
"""
Generates a MARS job document using the job meta-data used to drive the TIAF sequence.
@param tiaf_result: The result object generated by the TIAF script.
@param driver_args: The arguments specified to the driver script.
@return: The MARS job document with the job meta-data.
"""
mars_job = {key:tiaf_result[key] for key in
[
SRC_COMMIT_KEY,
DST_COMMIT_KEY,
COMMIT_DISTANCE_KEY,
SRC_BRANCH_KEY,
DST_BRANCH_KEY,
SUITE_KEY,
SOURCE_OF_TRUTH_BRANCH_KEY,
IS_SOURCE_OF_TRUTH_BRANCH_KEY,
USE_TEST_IMPACT_ANALYSIS_KEY,
HAS_CHANGE_LIST_KEY,
HAS_HISTORIC_DATA_KEY,
S3_BUCKET_KEY,
RUNTIME_ARGS_KEY,
RUNTIME_RETURN_CODE_KEY
]}
mars_job[DRIVER_ARGS_KEY] = driver_args
return mars_job
def generate_test_run_list(test_runs):
"""
Generates a list of test run name strings from the list of TIAF test runs.
@param test_runs: The list of TIAF test runs to generate the name strings from.
@return: The list of test run name strings.
"""
test_run_list = []
for test_run in test_runs:
test_run_list.append(test_run[NAME_KEY])
return test_run_list
def generate_mars_test_run_selections(test_run_selection, test_run_report, t0_timestamp: float):
"""
Generates a list of MARS test run selections from a TIAF test run selection and report.
@param test_run_selection: The TIAF test run selection.
@param test_run_report: The TIAF test run report.
@param t0_timestamp: The T0 timestamp that TIAF timings will be offst from.
@return: The list of TIAF test runs.
"""
mars_test_run_selection = {key:test_run_report[key] for key in
[
RESULT_KEY,
NUM_PASSING_TEST_RUNS_KEY,
NUM_FAILING_TEST_RUNS_KEY,
NUM_EXECUTION_FAILURE_TEST_RUNS_KEY,
NUM_TIMED_OUT_TEST_RUNS_KEY,
NUM_UNEXECUTED_TEST_RUNS_KEY,
TOTAL_NUM_PASSING_TESTS_KEY,
TOTAL_NUM_FAILING_TESTS_KEY,
TOTAL_NUM_DISABLED_TESTS_KEY
]}
mars_test_run_selection[START_TIME_KEY] = generate_mars_timestamp(test_run_report[START_TIME_KEY], t0_timestamp)
mars_test_run_selection[END_TIME_KEY] = generate_mars_timestamp(test_run_report[END_TIME_KEY], t0_timestamp)
mars_test_run_selection[DURATION_KEY] = get_duration_in_seconds(test_run_report[DURATION_KEY])
mars_test_run_selection[INCLUDED_TEST_RUNS_KEY] = test_run_selection[INCLUDED_TEST_RUNS_KEY]
mars_test_run_selection[EXCLUDED_TEST_RUNS_KEY] = test_run_selection[EXCLUDED_TEST_RUNS_KEY]
mars_test_run_selection[NUM_INCLUDED_TEST_RUNS_KEY] = test_run_selection[NUM_INCLUDED_TEST_RUNS_KEY]
mars_test_run_selection[NUM_EXCLUDED_TEST_RUNS_KEY] = test_run_selection[NUM_EXCLUDED_TEST_RUNS_KEY]
mars_test_run_selection[TOTAL_NUM_TEST_RUNS_KEY] = test_run_selection[TOTAL_NUM_TEST_RUNS_KEY]
mars_test_run_selection[PASSING_TEST_RUNS_KEY] = generate_test_run_list(test_run_report[PASSING_TEST_RUNS_KEY])
mars_test_run_selection[FAILING_TEST_RUNS_KEY] = generate_test_run_list(test_run_report[FAILING_TEST_RUNS_KEY])
mars_test_run_selection[EXECUTION_FAILURE_TEST_RUNS_KEY] = generate_test_run_list(test_run_report[EXECUTION_FAILURE_TEST_RUNS_KEY])
mars_test_run_selection[TIMED_OUT_TEST_RUNS_KEY] = generate_test_run_list(test_run_report[TIMED_OUT_TEST_RUNS_KEY])
mars_test_run_selection[UNEXECUTED_TEST_RUNS_KEY] = generate_test_run_list(test_run_report[UNEXECUTED_TEST_RUNS_KEY])
return mars_test_run_selection
def generate_test_runs_from_list(test_run_list: list):
"""
Generates a list of TIAF test runs from a list of test target name strings.
@param test_run_list: The list of test target names.
@return: The list of TIAF test runs.
"""
test_run_list = {
TOTAL_NUM_TEST_RUNS_KEY: len(test_run_list),
NUM_INCLUDED_TEST_RUNS_KEY: len(test_run_list),
NUM_EXCLUDED_TEST_RUNS_KEY: 0,
INCLUDED_TEST_RUNS_KEY: test_run_list,
EXCLUDED_TEST_RUNS_KEY: []
}
return test_run_list
def generate_mars_sequence(sequence_report: dict, mars_job: dict, change_list:dict, t0_timestamp: float):
"""
Generates the MARS sequence document from the specified TIAF sequence report.
@param sequence_report: The TIAF runtime sequence report.
@param mars_job: The MARS job for this sequence.
@param change_list: The change list for which the TIAF sequence was run.
@param t0_timestamp: The T0 timestamp that TIAF timings will be offst from.
@return: The MARS sequence document for the specified TIAF sequence report.
"""
mars_sequence = {key:sequence_report[key] for key in
[
SEQUENCE_TYPE_KEY,
RESULT_KEY,
POLICY_KEY,
TOTAL_NUM_TEST_RUNS_KEY,
TOTAL_NUM_PASSING_TEST_RUNS_KEY,
TOTAL_NUM_FAILING_TEST_RUNS_KEY,
TOTAL_NUM_EXECUTION_FAILURE_TEST_RUNS_KEY,
TOTAL_NUM_TIMED_OUT_TEST_RUNS_KEY,
TOTAL_NUM_UNEXECUTED_TEST_RUNS_KEY,
TOTAL_NUM_PASSING_TESTS_KEY,
TOTAL_NUM_FAILING_TESTS_KEY,
TOTAL_NUM_DISABLED_TESTS_KEY
]}
mars_sequence[START_TIME_KEY] = generate_mars_timestamp(sequence_report[START_TIME_KEY], t0_timestamp)
mars_sequence[END_TIME_KEY] = generate_mars_timestamp(sequence_report[END_TIME_KEY], t0_timestamp)
mars_sequence[DURATION_KEY] = get_duration_in_seconds(sequence_report[DURATION_KEY])
config = {key:sequence_report[key] for key in
[
TEST_TARGET_TIMEOUT_KEY,
GLOBAL_TIMEOUT_KEY,
MAX_CONCURRENCY_KEY
]}
test_run_selection = {}
test_run_selection[SELECTED_KEY] = generate_mars_test_run_selections(sequence_report[SELECTED_TEST_RUNS_KEY], sequence_report[SELECTED_TEST_RUN_REPORT_KEY], t0_timestamp)
if sequence_report[SEQUENCE_TYPE_KEY] == IMPACT_ANALYSIS_SEQUENCE_TYPE_KEY or sequence_report[SEQUENCE_TYPE_KEY] == SAFE_IMPACT_ANALYSIS_SEQUENCE_TYPE_KEY:
total_test_runs = sequence_report[TOTAL_NUM_TEST_RUNS_KEY]
if total_test_runs > 0:
test_run_selection[SELECTED_KEY][EFFICIENCY_KEY] = (1.0 - (test_run_selection[SELECTED_KEY][TOTAL_NUM_TEST_RUNS_KEY] / total_test_runs)) * 100
else:
test_run_selection[SELECTED_KEY][EFFICIENCY_KEY] = 100
test_run_selection[DRAFTED_KEY] = generate_mars_test_run_selections(generate_test_runs_from_list(sequence_report[DRAFTED_TEST_RUNS_KEY]), sequence_report[DRAFTED_TEST_RUN_REPORT_KEY], t0_timestamp)
if sequence_report[SEQUENCE_TYPE_KEY] == SAFE_IMPACT_ANALYSIS_SEQUENCE_TYPE_KEY:
test_run_selection[DISCARDED_KEY] = generate_mars_test_run_selections(sequence_report[DISCARDED_TEST_RUNS_KEY], sequence_report[DISCARDED_TEST_RUN_REPORT_KEY], t0_timestamp)
else:
test_run_selection[SELECTED_KEY][EFFICIENCY_KEY] = 0
mars_sequence[MARS_JOB_KEY] = mars_job
mars_sequence[CONFIG_KEY] = config
mars_sequence[TEST_RUN_SELECTION_KEY] = test_run_selection
mars_sequence[CHANGE_LIST_KEY] = change_list
return mars_sequence
def extract_mars_test_target(test_run, instrumentation, mars_job, t0_timestamp: float):
"""
Extracts a MARS test target from the specified TIAF test run.
@param test_run: The TIAF test run.
@param instrumentation: Flag specifying whether or not instrumentation was used for the test targets in this run.
@param mars_job: The MARS job for this test target.
@param t0_timestamp: The T0 timestamp that TIAF timings will be offst from.
@return: The MARS test target documents for the specified TIAF test target.
"""
mars_test_run = {key:test_run[key] for key in
[
NAME_KEY,
RESULT_KEY,
NUM_PASSING_TESTS_KEY,
NUM_FAILING_TESTS_KEY,
NUM_DISABLED_TESTS_KEY,
COMMAND_ARGS_STRING
]}
mars_test_run[START_TIME_KEY] = generate_mars_timestamp(test_run[START_TIME_KEY], t0_timestamp)
mars_test_run[END_TIME_KEY] = generate_mars_timestamp(test_run[END_TIME_KEY], t0_timestamp)
mars_test_run[DURATION_KEY] = get_duration_in_seconds(test_run[DURATION_KEY])
mars_test_run[MARS_JOB_KEY] = mars_job
mars_test_run[INSTRUMENTATION_KEY] = instrumentation
return mars_test_run
def extract_mars_test_targets_from_report(test_run_report, instrumentation, mars_job, t0_timestamp: float):
"""
Extracts the MARS test targets from the specified TIAF test run report.
@param test_run_report: The TIAF runtime test run report.
@param instrumentation: Flag specifying whether or not instrumentation was used for the test targets in this run.
@param mars_job: The MARS job for these test targets.
@param t0_timestamp: The T0 timestamp that TIAF timings will be offst from.
@return: The list of all MARS test target documents for the test targets in the TIAF test run report.
"""
mars_test_targets = []
for test_run in test_run_report[PASSING_TEST_RUNS_KEY]:
mars_test_targets.append(extract_mars_test_target(test_run, instrumentation, mars_job, t0_timestamp))
for test_run in test_run_report[FAILING_TEST_RUNS_KEY]:
mars_test_targets.append(extract_mars_test_target(test_run, instrumentation, mars_job, t0_timestamp))
for test_run in test_run_report[EXECUTION_FAILURE_TEST_RUNS_KEY]:
mars_test_targets.append(extract_mars_test_target(test_run, instrumentation, mars_job, t0_timestamp))
for test_run in test_run_report[TIMED_OUT_TEST_RUNS_KEY]:
mars_test_targets.append(extract_mars_test_target(test_run, instrumentation, mars_job, t0_timestamp))
for test_run in test_run_report[UNEXECUTED_TEST_RUNS_KEY]:
mars_test_targets.append(extract_mars_test_target(test_run, instrumentation, mars_job, t0_timestamp))
return mars_test_targets
def generate_mars_test_targets(sequence_report: dict, mars_job: dict, t0_timestamp: float):
"""
Generates a MARS test target document for each test target in the TIAF sequence report.
@param sequence_report: The TIAF runtime sequence report.
@param mars_job: The MARS job for this sequence.
@param t0_timestamp: The T0 timestamp that TIAF timings will be offst from.
@return: The list of all MARS test target documents for the test targets in the TIAF sequence report.
"""
mars_test_targets = []
# Determine whether or not the test targets were executed with instrumentation
if sequence_report[SEQUENCE_TYPE_KEY] == SEED_SEQUENCE_TYPE_KEY or sequence_report[SEQUENCE_TYPE_KEY] == SAFE_IMPACT_ANALYSIS_SEQUENCE_TYPE_KEY or (sequence_report[SEQUENCE_TYPE_KEY] == IMPACT_ANALYSIS_SEQUENCE_TYPE_KEY and sequence_report[POLICY_KEY][DYNAMIC_DEPENDENCY_MAP_POLICY_KEY] == DYNAMIC_DEPENDENCY_MAP_POLICY_UPDATE_KEY):
instrumentation = True
else:
instrumentation = False
# Extract the MARS test target documents from each of the test run reports
mars_test_targets += extract_mars_test_targets_from_report(sequence_report[SELECTED_TEST_RUN_REPORT_KEY], instrumentation, mars_job, t0_timestamp)
if sequence_report[SEQUENCE_TYPE_KEY] == IMPACT_ANALYSIS_SEQUENCE_TYPE_KEY or sequence_report[SEQUENCE_TYPE_KEY] == SAFE_IMPACT_ANALYSIS_SEQUENCE_TYPE_KEY:
mars_test_targets += extract_mars_test_targets_from_report(sequence_report[DRAFTED_TEST_RUN_REPORT_KEY], instrumentation, mars_job, t0_timestamp)
if sequence_report[SEQUENCE_TYPE_KEY] == SAFE_IMPACT_ANALYSIS_SEQUENCE_TYPE_KEY:
mars_test_targets += extract_mars_test_targets_from_report(sequence_report[DISCARDED_TEST_RUN_REPORT_KEY], instrumentation, mars_job, t0_timestamp)
return mars_test_targets
def transmit_report_to_mars(mars_index_prefix: str, tiaf_result: dict, driver_args: list):
"""
Transforms the TIAF result into the appropriate MARS documents and transmits them to MARS.
@param mars_index_prefix: The index prefix to be used for all MARS documents.
@param tiaf_result: The result object from the TIAF script.
@param driver_args: The arguments passed to the TIAF driver script.
"""
try:
filebeat = FilebeatClient("localhost", 9000, 60)
# T0 is the current timestamp that the report timings will be offset from
t0_timestamp = datetime.datetime.now().timestamp()
# Generate and transmit the MARS job document
mars_job = generate_mars_job(tiaf_result, driver_args)
filebeat.send_event(mars_job, f"{mars_index_prefix}.tiaf.job")
if tiaf_result[REPORT_KEY] is not None:
# Generate and transmit the MARS sequence document
mars_sequence = generate_mars_sequence(tiaf_result[REPORT_KEY], mars_job, tiaf_result[CHANGE_LIST_KEY], t0_timestamp)
filebeat.send_event(mars_sequence, f"{mars_index_prefix}.tiaf.sequence")
# Generate and transmit the MARS test target documents
mars_test_targets = generate_mars_test_targets(tiaf_result[REPORT_KEY], mars_job, t0_timestamp)
for mars_test_target in mars_test_targets:
filebeat.send_event(mars_test_target, f"{mars_index_prefix}.tiaf.test_target")
except FilebeatExn as e:
logger.error(e)
except KeyError as e:
logger.error(f"The report does not contain the key {str(e)}.")
+245 -183
View File
@@ -6,237 +6,299 @@
#
#
import os
import json
import subprocess
import re
import git_utils
import uuid
import pathlib
from git_utils import Repo
from enum import Enum
from tiaf_persistent_storage_local import PersistentStorageLocal
from tiaf_persistent_storage_s3 import PersistentStorageS3
from tiaf_logger import get_logger
# Returns True if the specified child path is a child of the specified parent path, otherwise False
def is_child_path(parent_path, child_path):
parent_path = os.path.abspath(parent_path)
child_path = os.path.abspath(child_path)
return os.path.commonpath([os.path.abspath(parent_path)]) == os.path.commonpath([os.path.abspath(parent_path), os.path.abspath(child_path)])
logger = get_logger(__file__)
class TestImpact:
def __init__(self, config_file, dst_commit, src_branch, dst_branch, pipeline, seeding_branches, seeding_pipelines):
# Commit
self.__dst_commit = dst_commit
print(f"Commit: '{self.__dst_commit}'.")
self.__src_commit = None
self.__has_src_commit = False
# Branch
self.__src_branch = src_branch
print(f"Source branch: '{self.__src_branch}'.")
self.__dst_branch = dst_branch
print(f"Destination branch: '{self.__dst_branch}'.")
print(f"Seeding branches: '{seeding_branches}'.")
if self.__src_branch in seeding_branches:
self.__is_seeding_branch = True
else:
self.__is_seeding_branch = False
print(f"Is seeding branch: '{self.__is_seeding_branch}'.")
# Pipeline
self.__pipeline = pipeline
print(f"Pipeline: '{self.__pipeline}'.")
print(f"Seeding pipelines: '{seeding_pipelines}'.")
if self.__pipeline in seeding_pipelines:
self.__is_seeding_pipeline = True
else:
self.__is_seeding_pipeline = False
print(f"Is seeding pipeline: '{self.__is_seeding_pipeline}'.")
# Config
self.__parse_config_file(config_file)
# Sequence
if self.__is_seeding_branch and self.__is_seeding_pipeline:
self.__is_seeding = True
else:
self.__is_seeding = False
print(f"Is seeding: '{self.__is_seeding}'.")
if self.__use_test_impact_analysis and not self.__is_seeding:
self.__generate_change_list()
def __init__(self, config_file: str):
"""
Initializes the test impact model with the commit, branches as runtime configuration.
# Parse the configuration file and retrieve the data needed for launching the test impact analysis runtime
def __parse_config_file(self, config_file):
print(f"Attempting to parse configuration file '{config_file}'...")
with open(config_file, "r") as config_data:
config = json.load(config_data)
self.__repo_dir = config["repo"]["root"]
self.__repo = Repo(self.__repo_dir)
# TIAF
self.__use_test_impact_analysis = config["jenkins"]["use_test_impact_analysis"]
print(f"Is using test impact analysis: '{self.__use_test_impact_analysis}'.")
self.__tiaf_bin = config["repo"]["tiaf_bin"]
if self.__use_test_impact_analysis and not os.path.isfile(self.__tiaf_bin):
raise FileNotFoundError("Could not find tiaf binary")
# Workspaces
self.__active_workspace = config["workspace"]["active"]["root"]
self.__historic_workspace = config["workspace"]["historic"]["root"]
self.__temp_workspace = config["workspace"]["temp"]["root"]
# Last commit hash
last_commit_hash_path_file = config["workspace"]["historic"]["relative_paths"]["last_run_hash_file"]
self.__last_commit_hash_path = os.path.join(self.__historic_workspace, last_commit_hash_path_file)
print("The configuration file was parsed successfully.")
@param config_file: The runtime config file to obtain the runtime configuration data from.
"""
# Restricts change lists from checking in test impact analysis files
def __check_for_restricted_files(self, file_path):
if is_child_path(self.__active_workspace, file_path) or is_child_path(self.__historic_workspace, file_path) or is_child_path(self.__temp_workspace, file_path):
raise ValueError(f"Checking in test impact analysis framework files is illegal: '{file_path}''.")
self._has_change_list = False
self._parse_config_file(config_file)
def __read_last_run_hash(self):
self.__has_src_commit = False
if os.path.isfile(self.__last_commit_hash_path):
print(f"Previous commit hash found at '{self.__last_commit_hash_path}'.")
with open(self.__last_commit_hash_path) as file:
self.__src_commit = file.read()
self.__has_src_commit = True
def _parse_config_file(self, config_file: str):
"""
Parse the configuration file and retrieve the data needed for launching the test impact analysis runtime.
def __write_last_run_hash(self, last_run_hash):
os.makedirs(self.__historic_workspace, exist_ok=True)
f = open(self.__last_commit_hash_path, "w")
f.write(last_run_hash)
f.close()
@param config_file: The runtime config file to obtain the runtime configuration data from.
"""
logger.info(f"Attempting to parse configuration file '{config_file}'...")
try:
with open(config_file, "r") as config_data:
self._config = json.load(config_data)
self._repo_dir = self._config["repo"]["root"]
self._repo = Repo(self._repo_dir)
# TIAF
self._use_test_impact_analysis = self._config["jenkins"]["use_test_impact_analysis"]
self._tiaf_bin = pathlib.Path(self._config["repo"]["tiaf_bin"])
if self._use_test_impact_analysis and not self._tiaf_bin.is_file():
logger.warning(f"Could not find TIAF binary at location {self._tiaf_bin}, TIAF will be turned off.")
self._use_test_impact_analysis = False
# Workspaces
self._active_workspace = self._config["workspace"]["active"]["root"]
self._historic_workspace = self._config["workspace"]["historic"]["root"]
self._temp_workspace = self._config["workspace"]["temp"]["root"]
logger.info("The configuration file was parsed successfully.")
except KeyError as e:
logger.error(f"The config does not contain the key {str(e)}.")
return
def _attempt_to_generate_change_list(self, last_commit_hash, instance_id: str):
"""
Attempts to determine the change list bewteen now and the last tiaf run (if any).
@param last_commit_hash: The commit hash of the last TIAF run.
@param instance_id: The unique id to derive the change list file name from.
"""
self._has_change_list = False
self._change_list_path = None
# Determines the change list bewteen now and the last tiaf run (if any)
def __generate_change_list(self):
self.__has_change_list = False
self.__change_list_path = None
# Check whether or not a previous commit hash exists (no hash is not a failure)
self.__read_last_run_hash()
if self.__has_src_commit == True:
if git_utils.is_descendent(self.__src_commit, self.__dst_commit) == False:
print(f"Source commit '{self.__src_commit}' and destination commit '{self.__dst_commit}' are not related.")
self._src_commit = last_commit_hash
if self._src_commit is not None:
if self._repo.is_descendent(self._src_commit, self._dst_commit) == False:
logger.info(f"Source commit '{self._src_commit}' and destination commit '{self._dst_commit}' are not related.")
return
diff_path = os.path.join(self.__temp_workspace, "changelist.diff")
self._commit_distance = self._repo.commit_distance(self._src_commit, self._dst_commit)
diff_path = pathlib.Path(pathlib.PurePath(self._temp_workspace).joinpath(f"changelist.{instance_id}.diff"))
try:
git_utils.create_diff_file(self.__src_commit, self.__dst_commit, diff_path)
except FileNotFoundError as e:
print(e)
self._repo.create_diff_file(self._src_commit, self._dst_commit, diff_path)
except RuntimeError as e:
logger.error(e)
return
# A diff was generated, attempt to parse the diff and construct the change list
print(f"Generated diff between commits '{self.__src_commit}' and '{self.__dst_commit}': '{diff_path}'.")
change_list = {}
change_list["createdFiles"] = []
change_list["updatedFiles"] = []
change_list["deletedFiles"] = []
logger.info(f"Generated diff between commits '{self._src_commit}' and '{self._dst_commit}': '{diff_path}'.")
with open(diff_path, "r") as diff_data:
lines = diff_data.readlines()
for line in lines:
match = re.split("^R[0-9]+\\s(\\S+)\\s(\\S+)", line)
if len(match) > 1:
# File rename
self.__check_for_restricted_files(match[1])
self.__check_for_restricted_files(match[2])
# Treat renames as a deletion and an addition
change_list["deletedFiles"].append(match[1])
change_list["createdFiles"].append(match[2])
self._change_list["deletedFiles"].append(match[1])
self._change_list["createdFiles"].append(match[2])
else:
match = re.split("^[AMD]\\s(\\S+)", line)
self.__check_for_restricted_files(match[1])
if len(match) > 1:
if line[0] == 'A':
# File addition
change_list["createdFiles"].append(match[1])
self._change_list["createdFiles"].append(match[1])
elif line[0] == 'M':
# File modification
change_list["updatedFiles"].append(match[1])
self._change_list["updatedFiles"].append(match[1])
elif line[0] == 'D':
# File Deletion
change_list["deletedFiles"].append(match[1])
self._change_list["deletedFiles"].append(match[1])
# Serialize the change list to the JSON format the test impact analysis runtime expects
change_list_json = json.dumps(change_list, indent = 4)
change_list_path = os.path.join(self.__temp_workspace, "changelist.json")
change_list_json = json.dumps(self._change_list, indent = 4)
change_list_path = pathlib.PurePath(self._temp_workspace).joinpath(f"changelist.{instance_id}.json")
f = open(change_list_path, "w")
f.write(change_list_json)
f.close()
print(f"Change list constructed successfully: '{change_list_path}'.")
print(f"{len(change_list['createdFiles'])} created files, {len(change_list['updatedFiles'])} updated files and {len(change_list['deletedFiles'])} deleted files.")
logger.info(f"Change list constructed successfully: '{change_list_path}'.")
logger.info(f"{len(self._change_list['createdFiles'])} created files, {len(self._change_list['updatedFiles'])} updated files and {len(self._change_list['deletedFiles'])} deleted files.")
# Note: an empty change list generated due to no changes between last and current commit is valid
self.__has_change_list = True
self.__change_list_path = change_list_path
self._has_change_list = True
self._change_list_path = change_list_path
else:
print("No previous commit hash found, regular or seeded sequences only will be run.")
self.__has_change_list = False
logger.error("No previous commit hash found, regular or seeded sequences only will be run.")
self._has_change_list = False
return
# Runs the specified test sequence
def run(self, suite, test_failure_policy, safe_mode, test_timeout, global_timeout):
def _generate_result(self, s3_bucket: str, suite: str, return_code: int, report: dict, runtime_args: list):
"""
Generates the result object from the pertinent runtime meta-data and sequence report.
@param The generated result object.
"""
result = {}
result["src_commit"] = self._src_commit
result["dst_commit"] = self._dst_commit
result["commit_distance"] = self._commit_distance
result["src_branch"] = self._src_branch
result["dst_branch"] = self._dst_branch
result["suite"] = suite
result["use_test_impact_analysis"] = self._use_test_impact_analysis
result["source_of_truth_branch"] = self._source_of_truth_branch
result["is_source_of_truth_branch"] = self._is_source_of_truth_branch
result["has_change_list"] = self._has_change_list
result["has_historic_data"] = self._has_historic_data
result["s3_bucket"] = s3_bucket
result["runtime_args"] = runtime_args
result["return_code"] = return_code
result["report"] = report
result["change_list"] = self._change_list
return result
def run(self, commit: str, src_branch: str, dst_branch: str, s3_bucket: str, suite: str, test_failure_policy: str, safe_mode: bool, test_timeout: int, global_timeout: int):
"""
Determins the type of sequence to run based on the commit, source branch and test branch before running the
sequence with the specified values.
@param commit: The commit hash of the changes to run test impact analysis on.
@param src_branch: If not equal to dst_branch, the branch that is being built.
@param dst_branch: If not equal to src_branch, the destination branch for the PR being built.
@param s3_bucket: Location of S3 bucket to use for persistent storage, otherwise local disk storage will be used.
@param suite: Test suite to run.
@param test_failure_policy: Test failure policy for regular and test impact sequences (ignored when seeding).
@param safe_mode: Flag to run impact analysis tests in safe mode (ignored when seeding).
@param test_timeout: Maximum run time (in seconds) of any test target before being terminated (unlimited if None).
@param global_timeout: Maximum run time of the sequence before being terminated (unlimited if None).
"""
args = []
seed_sequence_test_failure_policy = "continue"
# Suite
args.append(f"--suite={suite}")
print(f"Test suite is set to '{suite}'.")
# Timeouts
if test_timeout != None:
args.append(f"--ttimeout={test_timeout}")
print(f"Test target timeout is set to {test_timeout} seconds.")
if global_timeout != None:
args.append(f"--gtimeout={global_timeout}")
print(f"Global sequence timeout is set to {test_timeout} seconds.")
if self.__use_test_impact_analysis:
print("Test impact analysis is enabled.")
# Seed sequences
if self.__is_seeding:
persistent_storage = None
self._has_historic_data = False
self._change_list = {}
self._change_list["createdFiles"] = []
self._change_list["updatedFiles"] = []
self._change_list["deletedFiles"] = []
# Branches
self._src_branch = src_branch
self._dst_branch = dst_branch
logger.info(f"Src branch: '{self._src_branch}'.")
logger.info(f"Dst branch: '{self._dst_branch}'.")
# Source of truth (the branch from which the coverage data will be stored/retrieved from)
if self._dst_branch is None or self._src_branch == self._dst_branch:
# Branch builds are their own source of truth and will update the coverage data for the source of truth after any instrumented sequences complete
self._is_source_of_truth_branch = True
self._source_of_truth_branch = self._src_branch
else:
# PR builds use their destination as the source of truth and never update the coverage data for the source of truth
self._is_source_of_truth_branch = False
self._source_of_truth_branch = self._dst_branch
logger.info(f"Source of truth branch: '{self._source_of_truth_branch}'.")
logger.info(f"Is source of truth branch: '{self._is_source_of_truth_branch}'.")
# Commit
self._dst_commit = commit
logger.info(f"Commit: '{self._dst_commit}'.")
self._src_commit = None
self._commit_distance = None
# Generate a unique ID to be used as part of the file name for required runtime dynamic artifacts.
instance_id = uuid.uuid4().hex
if self._use_test_impact_analysis:
logger.info("Test impact analysis is enabled.")
try:
# Persistent storage location
if s3_bucket is not None:
persistent_storage = PersistentStorageS3(self._config, suite, s3_bucket, self._source_of_truth_branch)
else:
persistent_storage = PersistentStorageLocal(self._config, suite)
except SystemError as e:
logger.warning(f"The persistent storage encountered an irrecoverable error, test impact analysis will be disabled: '{e}'")
persistent_storage = None
if persistent_storage is not None:
if persistent_storage.has_historic_data:
logger.info("Historic data found.")
self._attempt_to_generate_change_list(persistent_storage.last_commit_hash, instance_id)
else:
logger.info("No historic data found.")
# Sequence type
args.append("--sequence=seed")
print("Sequence type is set to 'seed'.")
# Test failure policy
args.append(f"--fpolicy={seed_sequence_test_failure_policy}")
print(f"Test failure policy is set to '{seed_sequence_test_failure_policy}'.")
# Impact analysis sequences
else:
if self.__has_change_list:
# Change list
args.append(f"--changelist={self.__change_list_path}")
print(f"Change list is set to '{self.__change_list_path}'.")
# Sequence type
args.append("--sequence=tianowrite")
print("Sequence type is set to 'tianowrite'.")
# Integrity failure policy
args.append("--ipolicy=continue")
print("Integration failure policy is set to 'continue'.")
if self._has_change_list:
if self._is_source_of_truth_branch:
# Use TIA sequence (instrumented subset of tests) for coverage updating branches so we can update the coverage data with the generated coverage
sequence_type = "tia"
else:
# Use TIA no-write sequence (regular subset of tests) for non coverage updating branche
sequence_type = "tianowrite"
# Ignore integrity failures for non coverage updating branches as our confidence in the
args.append("--ipolicy=continue")
logger.info("Integration failure policy is set to 'continue'.")
# Safe mode
if safe_mode:
args.append("--safemode=on")
print("Safe mode set to 'on'.")
logger.info("Safe mode set to 'on'.")
else:
args.append("--safemode=off")
print("Safe mode set to 'off'.")
logger.info("Safe mode set to 'off'.")
# Change list
args.append(f"--changelist={self._change_list_path}")
logger.info(f"Change list is set to '{self._change_list_path}'.")
else:
args.append("--sequence=regular")
print("Sequence type is set to 'regular'.")
# Test failure policy
args.append(f"--fpolicy={test_failure_policy}")
print(f"Test failure policy is set to '{test_failure_policy}'.")
else:
print("Test impact analysis is disabled.")
# Sequence type
args.append("--sequence=regular")
print("Sequence type is set to 'regular'.")
# Seeding job
if self.__is_seeding:
# Test failure policy
args.append(f"--fpolicy={seed_sequence_test_failure_policy}")
print(f"Test failure policy is set to '{seed_sequence_test_failure_policy}'.")
# Non seeding job
if self._is_source_of_truth_branch:
# Use seed sequence (instrumented all tests) for coverage updating branches so we can generate the coverage bed for future sequences
sequence_type = "seed"
# We always continue after test failures when seeding to ensure we capture the coverage for all test targets
test_failure_policy = "continue"
else:
# Use regular sequence (regular all tests) for non coverage updating branches as we have no coverage to use nor coverage to update
sequence_type = "regular"
# Ignore integrity failures for non coverage updating branches as our confidence in the
args.append("--ipolicy=continue")
logger.info("Integration failure policy is set to 'continue'.")
else:
# Test failure policy
args.append(f"--fpolicy={test_failure_policy}")
print(f"Test failure policy is set to '{test_failure_policy}'.")
print("Args: ", end='')
print(*args)
result = subprocess.run([self.__tiaf_bin] + args)
# If the sequence completed (with or without failures) we will update the historical meta-data
if result.returncode == 0 or result.returncode == 7:
print("Test impact analysis runtime returned successfully.")
if self.__is_seeding:
print("Writing historical meta-data...")
self.__write_last_run_hash(self.__dst_commit)
print("Complete!")
# Use regular sequence (regular all tests) when the persistent storage fails to avoid wasting time generating seed data that will not be preserved
sequence_type = "regular"
else:
print(f"The test impact analysis runtime returned with error: '{result.returncode}'.")
return result.returncode
# Use regular sequence (regular all tests) when test impact analysis is disabled
sequence_type = "regular"
args.append(f"--sequence={sequence_type}")
logger.info(f"Sequence type is set to '{sequence_type}'.")
# Test failure policy
args.append(f"--fpolicy={test_failure_policy}")
logger.info(f"Test failure policy is set to '{test_failure_policy}'.")
# Sequence report
report_file = pathlib.PurePath(self._temp_workspace).joinpath(f"report.{instance_id}.json")
args.append(f"--report={report_file}")
logger.info(f"Sequence report file is set to '{report_file}'.")
# Suite
args.append(f"--suite={suite}")
logger.info(f"Test suite is set to '{suite}'.")
# Timeouts
if test_timeout != None:
args.append(f"--ttimeout={test_timeout}")
logger.info(f"Test target timeout is set to {test_timeout} seconds.")
if global_timeout != None:
args.append(f"--gtimeout={global_timeout}")
logger.info(f"Global sequence timeout is set to {test_timeout} seconds.")
# Run sequence
unpacked_args = " ".join(args)
logger.info(f"Args: {unpacked_args}")
runtime_result = subprocess.run([self._tiaf_bin] + args)
report = None
# If the sequence completed (with or without failures) we will update the historical meta-data
if runtime_result.returncode == 0 or runtime_result.returncode == 7:
logger.info("Test impact analysis runtime returned successfully.")
if self._is_source_of_truth_branch and persistent_storage is not None:
persistent_storage.update_and_store_historic_data(self._dst_commit)
with open(report_file) as json_file:
report = json.load(json_file)
else:
logger.error(f"The test impact analysis runtime returned with error: '{runtime_result.returncode}'.")
return self._generate_result(s3_bucket, suite, runtime_result.returncode, report, args)
+104 -28
View File
@@ -7,60 +7,136 @@
#
import argparse
from tiaf import TestImpact
import mars_utils
import sys
import os
import datetime
import json
import socket
import pathlib
from tiaf import TestImpact
from tiaf_logger import get_logger
logger = get_logger(__file__)
def parse_args():
def file_path(value):
if os.path.isfile(value):
def valid_file_path(value):
if pathlib.Path(value).is_file():
return value
else:
raise FileNotFoundError(value)
def timout_type(value):
def valid_timout_type(value):
value = int(value)
if value <= 0:
raise ValueError("Timer values must be positive integers")
return value
def test_failure_policy(value):
def valid_test_failure_policy(value):
if value == "continue" or value == "abort" or value == "ignore":
return value
else:
raise ValueError("Test failure policy must be 'abort', 'continue' or 'ignore'")
parser = argparse.ArgumentParser()
parser.add_argument('--config', dest="config", type=file_path, help="Path to the test impact analysis framework configuration file", required=True)
parser.add_argument('--src-branch', dest="src_branch", help="The branch that is being build", required=True)
parser.add_argument('--dst-branch', dest="dst_branch", help="For PR builds, the destination branch to be merged to, otherwise empty")
parser.add_argument('--seeding-branches', dest="seeding_branches", type=lambda arg: arg.split(','), help="Comma separated branches that seeding will occur on", required=True)
parser.add_argument('--pipeline', dest="pipeline", help="Pipeline the test impact analysis framework is running on", required=True)
parser.add_argument('--seeding-pipelines', dest="seeding_pipelines", type=lambda arg: arg.split(','), help="Comma separated pipeline that seeding will occur on", required=True)
parser.add_argument('--dest-commit', dest="dst_commit", help="Commit to run test impact analysis on (ignored when seeding)", required=True)
parser.add_argument('--suite', dest="suite", help="Test suite to run", required=True)
parser.add_argument('--test-failure-policy', dest="test_failure_policy", type=test_failure_policy, help="Test failure policy for regular and test impact sequences (ignored when seeding)", required=True)
parser.add_argument('--safeMode', dest="safe_mode", action='store_true', help="Run impact analysis tests in safe mode (ignored when seeding)")
parser.add_argument('--testTimeout', dest="test_timeout", type=timout_type, help="Maximum run time (in seconds) of any test target before being terminated", required=False)
parser.add_argument('--globalTimeout', dest="global_timeout", type=timout_type, help="Maximum run time of the sequence before being terminated", required=False)
parser.set_defaults(test_timeout=None)
parser.set_defaults(global_timeout=None)
# Configuration file path
parser.add_argument(
'--config',
type=valid_file_path,
help="Path to the test impact analysis framework configuration file",
required=True
)
# Source branch
parser.add_argument(
'--src-branch',
help="Branch that is being built",
required=True
)
# Destination branch
parser.add_argument(
'--dst-branch',
help="For PR builds, the destination branch to be merged to, otherwise empty",
required=False
)
# Commit hash
parser.add_argument(
'--commit',
help="Commit that is being built",
required=True
)
# S3 bucket
parser.add_argument(
'--s3-bucket',
help="Location of S3 bucket to use for persistent storage, otherwise local disk storage will be used",
required=False
)
# MARS index prefix
parser.add_argument(
'--mars-index-prefix',
help="Index prefix to use for MARS, otherwise no data will be tramsmitted to MARS",
required=False
)
# Test suite
parser.add_argument(
'--suite',
help="Test suite to run",
required=True
)
# Test failure policy
parser.add_argument(
'--test-failure-policy',
type=valid_test_failure_policy,
help="Test failure policy for regular and test impact sequences (ignored when seeding)",
required=True
)
# Safe mode
parser.add_argument(
'--safe-mode',
action='store_true',
help="Run impact analysis tests in safe mode (ignored when seeding)",
required=False
)
# Test timeout
parser.add_argument(
'--test-timeout',
type=valid_timout_type,
help="Maximum run time (in seconds) of any test target before being terminated",
required=False
)
# Global timeout
parser.add_argument(
'--global-timeout',
type=valid_timout_type,
help="Maximum run time of the sequence before being terminated",
required=False
)
args = parser.parse_args()
return args
if __name__ == "__main__":
try:
args = parse_args()
tiaf = TestImpact(args.config, args.dst_commit, args.src_branch, args.dst_branch, args.pipeline, args.seeding_branches, args.seeding_pipelines)
return_code = tiaf.run(args.suite, args.test_failure_policy, args.safe_mode, args.test_timeout, args.global_timeout)
tiaf = TestImpact(args.config)
tiaf_result = tiaf.run(args.commit, args.src_branch, args.dst_branch, args.s3_bucket, args.suite, args.test_failure_policy, args.safe_mode, args.test_timeout, args.global_timeout)
if args.mars_index_prefix is not None:
logger.info("Transmitting report to MARS...")
mars_utils.transmit_report_to_mars(args.mars_index_prefix, tiaf_result, sys.argv)
logger.info("Complete!")
# Non-gating will be removed from this script and handled at the job level in SPEC-7413
#sys.exit(return_code)
#sys.exit(result.return_code)
sys.exit(0)
except Exception as e:
# Non-gating will be removed from this script and handled at the job level in SPEC-7413
print(f"Exception caught by TIAF driver: {e}")
logger.error(f"Exception caught by TIAF driver: '{e}'.")
@@ -0,0 +1,20 @@
#
# Copyright (c) Contributors to the Open 3D Engine Project.
# For complete copyright and license terms please see the LICENSE at the root of this distribution.
#
# SPDX-License-Identifier: Apache-2.0 OR MIT
#
#
import logging
import sys
def get_logger(name: str):
logger = logging.getLogger(name)
logger.setLevel(logging.INFO)
handler = logging.StreamHandler(sys.stdout)
handler.setLevel(logging.DEBUG)
formatter = logging.Formatter('[%(asctime)s][TIAF][%(levelname)s] %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
@@ -0,0 +1,118 @@
#
# Copyright (c) Contributors to the Open 3D Engine Project.
# For complete copyright and license terms please see the LICENSE at the root of this distribution.
#
# SPDX-License-Identifier: Apache-2.0 OR MIT
#
#
import json
import pathlib
from abc import ABC, abstractmethod
from tiaf_logger import get_logger
logger = get_logger(__file__)
# Abstraction for the persistent storage required by TIAF to store and retrieve the branch coverage data and other meta-data
class PersistentStorage(ABC):
def __init__(self, config: dict, suite: str):
"""
Initializes the persistent storage into a state for which there is no historic data available.
@param config: The runtime configuration to obtain the data file paths from.
@param suite: The test suite for which the historic data will be obtained for.
"""
# Work on the assumption that there is no historic meta-data (a valid state to be in, should none exist)
self._last_commit_hash = None
self._has_historic_data = False
try:
# The runtime expects the coverage data to be in the location specified in the config file (unless overridden with
# the --datafile command line argument, which the TIAF scripts do not do)
self._active_workspace = pathlib.Path(config["workspace"]["active"]["root"])
unpacked_coverage_data_file = config["workspace"]["active"]["relative_paths"]["test_impact_data_files"][suite]
except KeyError as e:
raise SystemError(f"The config does not contain the key {str(e)}.")
self._unpacked_coverage_data_file = self._active_workspace.joinpath(unpacked_coverage_data_file)
def _unpack_historic_data(self, historic_data_json: str):
"""
Unpacks the historic data into the appropriate memory and disk locations.
@param historic_data_json: The historic data in JSON format.
"""
self._has_historic_data = False
try:
historic_data = json.loads(historic_data_json)
self._last_commit_hash = historic_data["last_commit_hash"]
# Create the active workspace directory where the coverage data file will be placed and unpack the coverage data so
# it is accessible by the runtime
self._active_workspace.mkdir(exist_ok=True)
with open(self._unpacked_coverage_data_file, "w", newline='\n') as coverage_data:
coverage_data.write(historic_data["coverage_data"])
self._has_historic_data = True
except json.JSONDecodeError:
logger.error("The historic data does not contain valid JSON.")
except KeyError as e:
logger.error(f"The historic data does not contain the key {str(e)}.")
except EnvironmentError as e:
logger.error(f"There was a problem the coverage data file '{self._unpacked_coverage_data_file}': '{e}'.")
def _pack_historic_data(self, last_commit_hash: str):
"""
Packs the current historic data into a JSON file for serializing.
@param last_commit_hash: The commit hash to associate the coverage data (and any other meta data) with.
@return: The packed historic data in JSON format.
"""
try:
# Attempt to read the existing coverage data
if self._unpacked_coverage_data_file.is_file():
with open(self._unpacked_coverage_data_file, "r") as coverage_data:
historic_data = {"last_commit_hash": last_commit_hash, "coverage_data": coverage_data.read()}
return json.dumps(historic_data)
else:
logger.info(f"No coverage data exists at location '{self._unpacked_coverage_data_file}'.")
except EnvironmentError as e:
logger.error(f"There was a problem the coverage data file '{self._unpacked_coverage_data_file}': '{e}'.")
except TypeError:
logger.error("The historic data could not be serialized to valid JSON.")
return None
@abstractmethod
def _store_historic_data(self, historic_data_json: str):
"""
Stores the historic data in the designated persistent storage location.
@param historic_data_json: The historic data (in JSON format) to be stored in persistent storage.
"""
pass
def update_and_store_historic_data(self, last_commit_hash: str):
"""
Updates the historic data and stores it in the designated persistent storage location.
@param last_commit_hash: The commit hash to associate the coverage data (and any other meta data) with.
"""
historic_data_json = self._pack_historic_data(last_commit_hash)
if historic_data_json is not None:
self._store_historic_data(historic_data_json)
else:
logger.info("The historic data could not be successfully stored.")
@property
def has_historic_data(self):
return self._has_historic_data
@property
def last_commit_hash(self):
return self._last_commit_hash
@@ -0,0 +1,56 @@
#
# Copyright (c) Contributors to the Open 3D Engine Project.
# For complete copyright and license terms please see the LICENSE at the root of this distribution.
#
# SPDX-License-Identifier: Apache-2.0 OR MIT
#
#
import pathlib
import logging
from tiaf_persistent_storage import PersistentStorage
from tiaf_logger import get_logger
logger = get_logger(__file__)
# Implementation of local persistent storage
class PersistentStorageLocal(PersistentStorage):
def __init__(self, config: str, suite: str):
"""
Initializes the persistent storage with any local historic data available.
@param config: The runtime config file to obtain the data file paths from.
@param suite: The test suite for which the historic data will be obtained for.
"""
super().__init__(config, suite)
try:
# Attempt to obtain the local persistent data location specified in the runtime config file
self._historic_workspace = pathlib.Path(config["workspace"]["historic"]["root"])
historic_data_file = pathlib.Path(config["workspace"]["historic"]["relative_paths"]["data"])
# Attempt to unpack the local historic data file
self._historic_data_file = self._historic_workspace.joinpath(historic_data_file)
if self._historic_data_file.is_file():
with open(self._historic_data_file, "r") as historic_data_raw:
historic_data_json = historic_data_raw.read()
self._unpack_historic_data(historic_data_json)
except KeyError as e:
raise SystemError(f"The config does not contain the key {str(e)}.")
except EnvironmentError as e:
raise SystemError(f"There was a problem the historic data file '{self._historic_data_file}': '{e}'.")
def _store_historic_data(self, historic_data_json: str):
"""
Stores then historical data in historic workspace location specified in the runtime config file.
@param historic_data_json: The historic data (in JSON format) to be stored in persistent storage.
"""
try:
self._historic_workspace.mkdir(exist_ok=True)
with open(self._historic_data_file, "w") as historic_data_file:
historic_data_file.write(historic_data_json)
except EnvironmentError as e:
logger.error(f"There was a problem the historic data file '{self._historic_data_file}': '{e}'.")
@@ -0,0 +1,87 @@
#
# Copyright (c) Contributors to the Open 3D Engine Project.
# For complete copyright and license terms please see the LICENSE at the root of this distribution.
#
# SPDX-License-Identifier: Apache-2.0 OR MIT
#
#
import boto3
import botocore.exceptions
import zlib
import logging
from io import BytesIO
from tiaf_persistent_storage import PersistentStorage
from tiaf_logger import get_logger
logger = get_logger(__file__)
# Implementation of s3 bucket persistent storage
class PersistentStorageS3(PersistentStorage):
def __init__(self, config: dict, suite: str, s3_bucket: str, branch: str):
"""
Initializes the persistent storage with the specified s3 bucket.
@param config: The runtime config file to obtain the data file paths from.
@param suite: The test suite for which the historic data will be obtained for.
@param s3_bucket: The s3 bucket to use for storing nd retrieving historic data.
"""
super().__init__(config, suite)
try:
# We store the historic data as compressed JSON
object_extension = "json.zip"
# historic_data.json.zip is the file containing the coverage and meta-data of the last TIAF sequence run
historic_data_file = f"historic_data.{object_extension}"
# The location of the data is in the form <branch>/<config> so the build config of each branch gets its own historic data
self._dir = f'{branch}/{config["meta"]["build_config"]}'
self._historic_data_key = f'{self._dir}/{historic_data_file}'
logger.info(f"Attempting to retrieve historic data for branch '{branch}' at location '{self._historic_data_key}' on bucket '{s3_bucket}'...")
self._s3 = boto3.resource("s3")
self._bucket = self._s3.Bucket(s3_bucket)
# There is only one historic_data.json.zip in the specified location
for object in self._bucket.objects.filter(Prefix=self._historic_data_key):
logger.info(f"Historic data found for branch '{branch}'.")
# Archive the existing object with the name of the existing last commit hash
archive_key = f"{self._dir}/archive/{self._last_commit_hash}.{object_extension}"
logger.info(f"Archiving existing historic data to {archive_key}...")
self._bucket.copy({"Bucket": self._bucket.name, "Key": self._historic_data_key}, archive_key)
# Decode the historic data object into raw bytes
response = object.get()
file_stream = response['Body']
# Decompress and unpack the zipped historic data JSON
historic_data_json = zlib.decompress(file_stream.read()).decode('UTF-8')
self._unpack_historic_data(historic_data_json)
return
except KeyError as e:
raise SystemError(f"The config does not contain the key {str(e)}.")
except botocore.exceptions.BotoCoreError as e:
raise SystemError(f"There was a problem with the s3 bucket: {e}")
except botocore.exceptions.ClientError as e:
raise SystemError(f"There was a problem with the s3 client: {e}")
def _store_historic_data(self, historic_data_json: str):
"""
Stores then historical data in specified s3 bucket at the location <branch>/<build_config>/historical_data.json.zip.
@param historic_data_json: The historic data (in JSON format) to be stored in persistent storage.
"""
try:
data = BytesIO(zlib.compress(bytes(historic_data_json, "UTF-8")))
logger.info(f"Uploading historic data to location '{self._historic_data_key}'...")
self._bucket.upload_fileobj(data, self._historic_data_key)
logger.info("Upload complete.")
except botocore.exceptions.BotoCoreError as e:
logger.error(f"There was a problem with the s3 bucket: {e}")
except botocore.exceptions.ClientError as e:
logger.error(f"There was a problem with the s3 client: {e}")