1#!/usr/bin/env python3 2# Copyright 2021 The ChromiumOS Authors 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5"""Utilities to file bugs.""" 6 7import datetime 8import enum 9import json 10import os 11import threading 12from typing import Any, Dict, List, Optional, Union 13 14 15X20_PATH = "/google/data/rw/teams/c-compiler-chrome/prod_bugs" 16 17# List of 'well-known' bug numbers to tag as parents. 18RUST_MAINTENANCE_METABUG = 322195383 19RUST_SECURITY_METABUG = 322195192 20 21 22# These constants are sourced from 23# //google3/googleclient/chrome/chromeos_toolchain/bug_manager/bugs.go 24class WellKnownComponents(enum.IntEnum): 25 """A listing of "well-known" components recognized by our infra.""" 26 27 CrOSToolchainPublic = -1 28 CrOSToolchainPrivate = -2 29 AndroidRustToolchain = -3 30 31 32class _FileNameGenerator: 33 """Generates unique file names. This container is thread-safe. 34 35 The names generated have the following properties: 36 - successive, sequenced calls to `get_json_file_name()` will produce 37 names that sort later in lists over time (e.g., 38 [generator.generate_json_file_name() for _ in range(10)] will be in 39 sorted order). 40 - file names cannot collide with file names generated on the same 41 machine (ignoring machines with unreasonable PID reuse). 42 - file names are incredibly unlikely to collide when generated on 43 multiple machines, as they have 8 bytes of entropy in them. 44 """ 45 46 _RANDOM_BYTES = 8 47 _MAX_OS_ENTROPY_VALUE = 1 << _RANDOM_BYTES * 8 48 # The intent of this is "the maximum possible size of our entropy string, 49 # so we can zfill properly below." Double the value the OS hands us, since 50 # we add to it in `generate_json_file_name`. 51 _ENTROPY_STR_SIZE = len(str(2 * _MAX_OS_ENTROPY_VALUE)) 52 53 def __init__(self): 54 self._lock = threading.Lock() 55 self._entropy = int.from_bytes( 56 os.getrandom(self._RANDOM_BYTES), byteorder="little", signed=False 57 ) 58 59 def generate_json_file_name(self, now: datetime.datetime): 60 with self._lock: 61 my_entropy = self._entropy 62 self._entropy += 1 63 64 now_str = now.isoformat("T", "seconds") + "Z" 65 entropy_str = str(my_entropy).zfill(self._ENTROPY_STR_SIZE) 66 pid = os.getpid() 67 return f"{now_str}_{entropy_str}_{pid}.json" 68 69 70_GLOBAL_NAME_GENERATOR = _FileNameGenerator() 71 72 73def _WriteBugJSONFile( 74 object_type: str, 75 json_object: Dict[str, Any], 76 directory: Optional[Union[os.PathLike, str]], 77): 78 """Writes a JSON file to `directory` with the given bug-ish object. 79 80 Args: 81 object_type: name of the object we're writing. 82 json_object: object to write. 83 directory: the directory to write to. Uses X20_PATH if None. 84 """ 85 final_object = { 86 "type": object_type, 87 "value": json_object, 88 } 89 90 if directory is None: 91 directory = X20_PATH 92 93 now = datetime.datetime.now(tz=datetime.timezone.utc) 94 file_path = os.path.join( 95 directory, _GLOBAL_NAME_GENERATOR.generate_json_file_name(now) 96 ) 97 temp_path = file_path + ".in_progress" 98 try: 99 with open(temp_path, "w", encoding="utf-8") as f: 100 json.dump(final_object, f) 101 os.rename(temp_path, file_path) 102 except: 103 os.remove(temp_path) 104 raise 105 return file_path 106 107 108def AppendToExistingBug( 109 bug_id: int, body: str, directory: Optional[os.PathLike] = None 110): 111 """Sends a reply to an existing bug.""" 112 _WriteBugJSONFile( 113 "AppendToExistingBugRequest", 114 { 115 "body": body, 116 "bug_id": bug_id, 117 }, 118 directory, 119 ) 120 121 122def CreateNewBug( 123 component_id: int, 124 title: str, 125 body: str, 126 assignee: Optional[str] = None, 127 cc: Optional[List[str]] = None, 128 directory: Optional[os.PathLike] = None, 129 parent_bug: int = 0, 130): 131 """Sends a request to create a new bug. 132 133 Args: 134 component_id: The component ID to add. Anything from WellKnownComponents 135 also works. 136 title: Title of the bug. Must be nonempty. 137 body: Body of the bug. Must be nonempty. 138 assignee: Assignee of the bug. Must be either an email address, or a 139 "well-known" assignee (detective, mage). 140 cc: A list of emails to add to the CC list. Must either be an email 141 address, or a "well-known" individual (detective, mage). 142 directory: The directory to write the report to. Defaults to our x20 143 bugs directory. 144 parent_bug: The parent bug number for this bug. If none should be 145 specified, pass the value 0. 146 """ 147 obj = { 148 "component_id": component_id, 149 "subject": title, 150 "body": body, 151 } 152 153 if assignee: 154 obj["assignee"] = assignee 155 156 if cc: 157 obj["cc"] = cc 158 159 if parent_bug: 160 obj["parent_bug"] = parent_bug 161 162 _WriteBugJSONFile("FileNewBugRequest", obj, directory) 163 164 165def SendCronjobLog( 166 cronjob_name: str, 167 failed: bool, 168 message: str, 169 turndown_time_hours: int = 0, 170 directory: Optional[os.PathLike] = None, 171 parent_bug: int = 0, 172): 173 """Sends the record of a cronjob to our bug infra. 174 175 Args: 176 cronjob_name: The name of the cronjob. Expected to remain consistent 177 over time. 178 failed: Whether the job failed or not. 179 message: Any seemingly relevant context. This is pasted verbatim in a 180 bug, if the cronjob infra deems it worthy. 181 turndown_time_hours: If nonzero, this cronjob will be considered turned 182 down if more than `turndown_time_hours` pass without a report of 183 success or failure. If zero, this job will not automatically be 184 turned down. 185 directory: The directory to write the report to. Defaults to our x20 186 bugs directory. 187 parent_bug: The parent bug number for the bug filed for this cronjob, 188 if any. If none should be specified, pass the value 0. 189 """ 190 json_object = { 191 "name": cronjob_name, 192 "message": message, 193 "failed": failed, 194 } 195 196 if turndown_time_hours: 197 json_object["cronjob_turndown_time_hours"] = turndown_time_hours 198 199 if parent_bug: 200 json_object["parent_bug"] = parent_bug 201 202 _WriteBugJSONFile("CronjobUpdate", json_object, directory) 203