|
| 1 | +#!/usr/bin/env python3 |
| 2 | + |
| 3 | +"""KCIDB auto-matching tool""" |
| 4 | + |
| 5 | + |
| 6 | +import json |
| 7 | +import sys |
| 8 | +import sqlite3 |
| 9 | +import hashlib |
| 10 | +import logging |
| 11 | +import argparse |
| 12 | +from .pattern_validator import match_fields, validate_pattern_object |
| 13 | + |
| 14 | + |
| 15 | +# Constants |
| 16 | +DB_NAME = 'patterns.db' |
| 17 | +ORIGIN = 'maestro' |
| 18 | +KCIDB_IO_VERSION = { |
| 19 | + "major": 4, |
| 20 | + "minor": 3 |
| 21 | +} |
| 22 | + |
| 23 | +# Configure logging |
| 24 | +logging.basicConfig(level=logging.INFO) |
| 25 | +logger = logging.getLogger(__name__) |
| 26 | + |
| 27 | + |
| 28 | +class PatternDatabase: |
| 29 | + """Class to handle DB table 'patterns'""" |
| 30 | + def __init__(self, db_name=DB_NAME): |
| 31 | + self.db_name = db_name |
| 32 | + self.setup_database() |
| 33 | + |
| 34 | + def setup_database(self): |
| 35 | + """Connect to DB and create 'patterns' table if doesn't exist""" |
| 36 | + with sqlite3.connect(self.db_name) as conn: |
| 37 | + cursor = conn.cursor() |
| 38 | + cursor.execute(''' |
| 39 | + CREATE TABLE IF NOT EXISTS patterns ( |
| 40 | + issue_id TEXT UNIQUE, |
| 41 | + issue_version INTEGER, |
| 42 | + pattern_object JSON |
| 43 | + ) |
| 44 | + ''') |
| 45 | + conn.commit() |
| 46 | + |
| 47 | + def add_pattern(self, issue_id, issue_version, pattern_object): |
| 48 | + """Add pattern object to DB""" |
| 49 | + with sqlite3.connect(self.db_name) as conn: |
| 50 | + cursor = conn.cursor() |
| 51 | + cursor.execute(''' |
| 52 | + INSERT INTO patterns (issue_id, issue_version, pattern_object) |
| 53 | + VALUES (?, ?, json(?)) |
| 54 | + ON CONFLICT(issue_id) DO UPDATE SET |
| 55 | + issue_version=excluded.issue_version, |
| 56 | + pattern_object=excluded.pattern_object |
| 57 | + ''', (issue_id, issue_version, json.dumps(pattern_object))) |
| 58 | + conn.commit() |
| 59 | + |
| 60 | + def remove_pattern(self, issue_id): |
| 61 | + """Remove pattern object from DB""" |
| 62 | + with sqlite3.connect(self.db_name) as conn: |
| 63 | + cursor = conn.cursor() |
| 64 | + cursor.execute('DELETE FROM patterns WHERE issue_id = ?', |
| 65 | + (issue_id,)) |
| 66 | + conn.commit() |
| 67 | + |
| 68 | + def get_all_patterns(self): |
| 69 | + """Retrieve all patterns objects from DB""" |
| 70 | + with sqlite3.connect(self.db_name) as conn: |
| 71 | + cursor = conn.cursor() |
| 72 | + cursor.execute('SELECT issue_id, issue_version, pattern_object ' |
| 73 | + 'FROM patterns') |
| 74 | + while True: |
| 75 | + row = cursor.fetchone() |
| 76 | + if row is None: |
| 77 | + break |
| 78 | + yield row |
| 79 | + |
| 80 | + def update_patterns(self, issue): |
| 81 | + """Update patterns for existing issue""" |
| 82 | + if not issue.misc: |
| 83 | + return |
| 84 | + |
| 85 | + if not issue.misc.get("pattern_object"): |
| 86 | + self.remove_pattern(issue.id) |
| 87 | + return |
| 88 | + |
| 89 | + pattern_object = issue.misc.get("pattern_object") |
| 90 | + if not validate_pattern_object(pattern_object): |
| 91 | + logger.error("Pattern object validation failed for issue id: %s", |
| 92 | + issue.id) |
| 93 | + return |
| 94 | + self.add_pattern(issue.id, issue.version, pattern_object) |
| 95 | + |
| 96 | + |
| 97 | +class IncidentGenerator: |
| 98 | + """Class to generate incidents""" |
| 99 | + def __init__(self, db_name=DB_NAME): |
| 100 | + self.db = PatternDatabase(db_name) |
| 101 | + |
| 102 | + def create_incident(self, kcidb_io_object, issue_id, issue_version): |
| 103 | + """Create and return an incident object""" |
| 104 | + if tests := kcidb_io_object.get('tests'): |
| 105 | + type_id_key = "test_id" |
| 106 | + type_id_value = tests[0]['id'] |
| 107 | + elif builds := kcidb_io_object.get('builds'): |
| 108 | + type_id_key = "build_id" |
| 109 | + type_id_value = builds[0]['id'] |
| 110 | + else: |
| 111 | + raise ValueError("The KCIDB IO object must contain at least " |
| 112 | + "one non-empty test or build") |
| 113 | + |
| 114 | + unique_string = f"{issue_id}{issue_version}{type_id_value}" |
| 115 | + incident_id = f"{ORIGIN}:" \ |
| 116 | + f"{hashlib.sha256(unique_string.encode()).hexdigest()}" |
| 117 | + |
| 118 | + return { |
| 119 | + 'id': incident_id, |
| 120 | + 'origin': ORIGIN, |
| 121 | + 'issue_id': issue_id, |
| 122 | + 'issue_version': issue_version, |
| 123 | + 'present': True, |
| 124 | + type_id_key: type_id_value, |
| 125 | + } |
| 126 | + |
| 127 | + def generate_incident_on_match(self, kcidb_io_object, issue_id, |
| 128 | + issue_version, issue_pattern_object): |
| 129 | + """Generate incident if issue pattern is found in a build/test |
| 130 | + object""" |
| 131 | + incident = {} |
| 132 | + |
| 133 | + if match_fields(issue_pattern_object, kcidb_io_object): |
| 134 | + incident = self.create_incident(kcidb_io_object, issue_id, |
| 135 | + issue_version) |
| 136 | + |
| 137 | + return incident |
| 138 | + |
| 139 | + def generate_incidents_from_db(self, kcidb_io_object): |
| 140 | + """ |
| 141 | + Generate incidents by trying to match the kcidb_io_object |
| 142 | + against the patterns saved in the database |
| 143 | + """ |
| 144 | + incidents = [] |
| 145 | + |
| 146 | + for row in self.db.get_all_patterns(): |
| 147 | + issue_id, issue_version, pattern_object_json = row |
| 148 | + pattern_object = json.loads(pattern_object_json) |
| 149 | + incident = self.generate_incident_on_match( |
| 150 | + kcidb_io_object, issue_id, issue_version, pattern_object) |
| 151 | + if incident: |
| 152 | + incidents.append(incident) |
| 153 | + |
| 154 | + return { |
| 155 | + "version": KCIDB_IO_VERSION, |
| 156 | + "incidents": incidents |
| 157 | + } |
| 158 | + |
| 159 | + def generate_incidents_from_test(self, test): |
| 160 | + """Generate incident from test object""" |
| 161 | + kcidb_io_object = {"tests": [test._data], |
| 162 | + "builds": [test.build._data], |
| 163 | + "checkouts": [test.build.checkout._data]} |
| 164 | + return self.generate_incidents_from_db(kcidb_io_object) |
| 165 | + |
| 166 | + def generate_incidents_from_build(self, build): |
| 167 | + """Generate incident from build object""" |
| 168 | + kcidb_io_object = {"builds": [build._data], |
| 169 | + "checkouts": [build.checkout._data]} |
| 170 | + return self.generate_incidents_from_db(kcidb_io_object) |
| 171 | + |
| 172 | + |
| 173 | +def parse_arguments(): |
| 174 | + """Parse command-line arguments""" |
| 175 | + class CustomHelpFormatter(argparse.RawTextHelpFormatter): |
| 176 | + """Help string formatter for command-line tools""" |
| 177 | + |
| 178 | + parser = argparse.ArgumentParser( |
| 179 | + description='KCIDB Match Tool', |
| 180 | + formatter_class=CustomHelpFormatter, |
| 181 | + epilog='''\ |
| 182 | +Usage examples: |
| 183 | +
|
| 184 | +export -x DB_OPTS="postgresql:host=127.0.0.1 port=5432 sslmode=disable |
| 185 | +dbname=playground_kcidb user=helen.koike@collabora.com" |
| 186 | +
|
| 187 | +# Update patterns |
| 188 | +kcidb-query -i "kernelci_api:70d17807303641a9d6d2a8aeb1aee829221cefcf" |
| 189 | +-d "$DB_OPTS" | ./kcidb-match.py --update-patterns |
| 190 | +
|
| 191 | +# Generate incidents |
| 192 | +kcidb-query -t "maestro:6690dbfc7488a1b744200e82" -d "$DB_OPTS" |
| 193 | +--parents | ./kcidb-match.py --generate-incidents |
| 194 | +
|
| 195 | +# Check test ID |
| 196 | +cat issue.json | ./kcidb-match.py --check_test_id |
| 197 | +"maestro:6690dbfc7488a1b744200e82" -d "$DB_OPTS" |
| 198 | +
|
| 199 | +# Check build ID |
| 200 | +cat issue.json | ./kcidb-match.py --check_build_id |
| 201 | +"maestro:6690dbfc7488a1b744200e82" -d "$DB_OPTS" |
| 202 | +''' |
| 203 | + ) |
| 204 | + |
| 205 | + parser.add_argument('--update-patterns', action='store_true', |
| 206 | + help='Update patterns from issues. Other ' |
| 207 | + 'arguments are ignored when used. Expects ' |
| 208 | + 'KCIDB-IO object with issues via stdin.') |
| 209 | + |
| 210 | + parser.add_argument('--generate-incidents', action='store_true', |
| 211 | + help='Generate incidents for matched issues. ' |
| 212 | + 'Expects KCIDB-IO object with build and/or ' |
| 213 | + 'test via stdin.') |
| 214 | + |
| 215 | + parser.add_argument('--ignore-db', action='store_true', |
| 216 | + help='Ignore the database and generate incidents ' |
| 217 | + 'based on the issues field in the KCIDB-IO ' |
| 218 | + 'object via stdin.') |
| 219 | + |
| 220 | + parser.add_argument('--check_test_id', type=str, |
| 221 | + help='Test ID to check. Requires --db_conn. ' |
| 222 | + 'Implies --ignore-db. Expects KCIDB-IO ' |
| 223 | + 'object with issues via stdin.') |
| 224 | + |
| 225 | + parser.add_argument('--check_build_id', type=str, |
| 226 | + help='Build ID to check. Requires --db_conn. ' |
| 227 | + 'Implies --ignore-db. ' |
| 228 | + 'Expects KCIDB-IO object with issues via stdin.') |
| 229 | + |
| 230 | + parser.add_argument('-d', '--db_conn', type=str, |
| 231 | + help='Database connection string for kcidb-query.' |
| 232 | + 'Required with --check_test_id or ' |
| 233 | + '--check_build_id.') |
| 234 | + |
| 235 | + args = parser.parse_args() |
| 236 | + |
| 237 | + if args.check_test_id and args.check_build_id: |
| 238 | + parser.error("Cannot use both --check_test_id and --check_build_id") |
| 239 | + |
| 240 | + if (args.check_test_id or args.check_build_id) and not args.db_conn: |
| 241 | + parser.error("--db_conn is required when using --check_test_id or " |
| 242 | + "--check_build_id") |
| 243 | + |
| 244 | + if args.check_test_id or args.check_build_id: |
| 245 | + args.ignore_db = True |
| 246 | + |
| 247 | + return args |
| 248 | + |
| 249 | + |
| 250 | +def main(): |
| 251 | + """Main function""" |
| 252 | + args = parse_arguments() |
| 253 | + |
| 254 | + if args.update_patterns: |
| 255 | + issue_objects = json.load(sys.stdin) |
| 256 | + IncidentGenerator().db.update_patterns(issue_objects) |
| 257 | + return |
| 258 | + |
| 259 | + kcidb_io_object = json.load(sys.stdin) |
| 260 | + |
| 261 | + incident_generator = IncidentGenerator() |
| 262 | + |
| 263 | + results = incident_generator.generate_incidents_from_db( |
| 264 | + kcidb_io_object) |
| 265 | + |
| 266 | + if args.generate_incidents: |
| 267 | + print(json.dumps(results, indent=2)) |
| 268 | + return |
| 269 | + |
| 270 | + for incident in results['incidents']: |
| 271 | + print("Matched issue ID:", incident['issue_id'], "Version:", |
| 272 | + incident['issue_version']) |
| 273 | + |
| 274 | + |
| 275 | +if __name__ == "__main__": |
| 276 | + main() |
0 commit comments