1# Copyright 2019 The Chromium OS Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5'''Utilities to summarize TKO results reported by tests in the suite.''' 6 7from __future__ import absolute_import 8from __future__ import division 9from __future__ import print_function 10 11import argparse 12import collections 13import contextlib 14import mysql.connector 15 16def Error(Exception): 17 """Error detected in this script.""" 18 19 20# Row corresponds to a single row of tko_test_view_2 table in AFE DB, but 21# contains only a subset of the columns in the table. 22Row = collections.namedtuple( 23 'Row', 24 'name, status, reason' 25) 26 27 28def get(conn, task_ids): 29 """Get tko_test_view_2 Row()s for given skylab task_ids. 30 31 @param conn: A MySQL connection to TKO. 32 @param task_ids: list of Skylab task request IDs to collect test views for. 33 @return: {task_id: [Row(...)...]} 34 """ 35 try: 36 task_job_ids = _get_job_idxs_from_tko(conn, task_ids) 37 job_task_ids = {v: k for k, v in task_job_ids.iteritems()} 38 job_rows = _get_rows_from_tko(conn, job_task_ids.keys()) 39 return {job_task_ids[k]: v for k, v in job_rows.iteritems()} 40 finally: 41 conn.close() 42 43 44def filter_failed(rows): 45 """Filter down given list of test_views Row() to failed tests.""" 46 return [r for r in rows if r.status in _BAD_STATUSES] 47 48 49def main(): 50 '''Entry-point to use this script standalone.''' 51 parser = argparse.ArgumentParser( 52 description='Summarize TKO results for a Skylab task') 53 parser.add_argument( 54 '--task-id', 55 action='append', 56 help='Swarming request ID for the skylab task (may be repeated)', 57 ) 58 parser.add_argument( 59 '--host', 60 required=True, 61 help='TKO host IP', 62 ) 63 parser.add_argument( 64 '--port', 65 type=int, 66 default=3306, 67 help='TKO port', 68 ) 69 parser.add_argument( 70 '--user', 71 required=True, 72 help='TKO MySQL user', 73 ) 74 parser.add_argument( 75 '--password', 76 required=True, 77 help='TKO MySQL password', 78 ) 79 args = parser.parse_args() 80 if not args.task_id: 81 raise Error('Must request at least one --task-id') 82 83 conn = mysql.connector.connect( 84 host=args.host, 85 port=args.port, 86 user=args.user, 87 password=args.password, 88 database='chromeos_autotest_db', 89 ) 90 views = get(conn, args.task_id) 91 for task_id, rows in views.iteritems(): 92 print('Task ID: %s' % task_id) 93 for row in filter_failed(rows): 94 print(' %s in status %s' % (row.name, row.status)) 95 print(' reason: %s' % (row.reason,)) 96 print('') 97 98 99_BAD_STATUSES = { 100 'ABORT', 101 'ERROR', 102 'FAIL', 103} 104 105 106def _get_rows_from_tko(conn, tko_job_ids): 107 """Get a list of Row() for the given TKO job IDs. 108 109 @param conn: A MySQL connection. 110 @param job_ids: List of tko_job_ids to get Row()s for. 111 @return: {tko_job_id: [Row]} 112 """ 113 job_rows = collections.defaultdict(list) 114 statuses = _get_status_map(conn) 115 116 _GET_TKO_TEST_VIEW_2 = """ 117 SELECT job_idx, test_name, status_idx, reason FROM tko_test_view_2 118 WHERE invalid = 0 AND job_idx IN (%s) 119 """ 120 q = _GET_TKO_TEST_VIEW_2 % ', '.join(['%s'] * len(tko_job_ids)) 121 with _cursor(conn) as cursor: 122 cursor.execute(q, tko_job_ids) 123 for job_idx, name, s_idx, reason in cursor.fetchall(): 124 job_rows[job_idx].append( 125 Row(name, statuses.get(s_idx, 'UNKNOWN'), reason)) 126 return dict(job_rows) 127 128 129def _get_job_idxs_from_tko(conn, task_ids): 130 """Get tko_job_idx for given task_ids. 131 132 Task execution reports the run ID to TKO, but Skylab clients only knows the 133 request ID of the created task. 134 Swarming executes a task with increasing run IDs, retrying on bot failure. 135 If a task is retried after the point where TKO results are reported, this 136 function returns the TKO job_idx corresponding to the last completed 137 attempt. 138 139 @param conn: MySQL connection to TKO. 140 @param task_ids: List of task request IDs to get TKO job IDs for. 141 @return {task_id: job_id} 142 """ 143 task_runs = {} 144 run_ids = [] 145 for task_id in task_ids: 146 run_ids += _run_ids_for_request(task_id) 147 task_runs[task_id] = list(reversed(run_ids)) 148 run_job_idxs = _get_job_idxs_for_run_ids(conn, run_ids) 149 150 task_job_idxs = {} 151 for task_id, run_ids in task_runs.iteritems(): 152 for run_id in run_ids: 153 if run_id in run_job_idxs: 154 task_job_idxs[task_id] = run_job_idxs[run_id] 155 break 156 return task_job_idxs 157 158 159def _get_job_idxs_for_run_ids(conn, run_ids): 160 """Get tko_job_idx for a given task run_ids. 161 162 @param conn: MySQL connection to TKO. 163 @param task_ids: List of task run IDs to get TKO job IDs for. 164 @return {run_id: job_id} 165 """ 166 _GET_TKO_JOB_Q = """ 167 SELECT task_id, tko_job_idx FROM tko_task_references 168 WHERE reference_type = "skylab" AND task_id IN (%s) 169 """ 170 q = _GET_TKO_JOB_Q % ', '.join(['%s'] * len(run_ids)) 171 172 job_idxs = {} 173 with _cursor(conn) as cursor: 174 cursor.execute(q, run_ids) 175 for run_id, tko_job_idx in cursor.fetchall(): 176 if run_id in job_idxs: 177 raise Error('task run ID %s has multiple tko references' % 178 (run_id,)) 179 job_idxs[run_id] = tko_job_idx 180 return job_idxs 181 182 183def _get_status_map(conn): 184 statuses = {} 185 with _cursor(conn) as cursor: 186 cursor.execute('SELECT status_idx, word FROM tko_status') 187 r = cursor.fetchall() 188 for idx, word in r: 189 statuses[idx] = word 190 return statuses 191 192 193def _run_ids_for_request(request_id): 194 """Return Swarming run IDs for a given request ID, in ascending order.""" 195 prefix = request_id[:len(request_id)-1] 196 return [prefix + i for i in ('1', '2')] 197 198 199@contextlib.contextmanager 200def _cursor(conn): 201 c = conn.cursor() 202 try: 203 yield c 204 finally: 205 c.close() 206 207 208if __name__ == '__main__': 209 main() 210