Add changes from TIF/Feature branch.
Signed-off-by: John <jonawals@amazon.com>
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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)}.")
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
Reference in New Issue
Block a user