1*760c253cSXin Li# Copyright 2024 The ChromiumOS Authors 2*760c253cSXin Li# Use of this source code is governed by a BSD-style license that can be 3*760c253cSXin Li# found in the LICENSE file. 4*760c253cSXin Li 5*760c253cSXin Li"""Tools for interacting with CrOS CLs, and the CQ in particular.""" 6*760c253cSXin Li 7*760c253cSXin Liimport dataclasses 8*760c253cSXin Liimport json 9*760c253cSXin Liimport logging 10*760c253cSXin Liimport re 11*760c253cSXin Liimport subprocess 12*760c253cSXin Lifrom typing import Any, Dict, Iterable, List, Optional 13*760c253cSXin Li 14*760c253cSXin Li 15*760c253cSXin LiBuildID = int 16*760c253cSXin Li 17*760c253cSXin Li 18*760c253cSXin Lidef _run_bb_decoding_output(command: List[str], multiline: bool = False) -> Any: 19*760c253cSXin Li """Runs `bb` with the `json` flag, and decodes the command's output. 20*760c253cSXin Li 21*760c253cSXin Li Args: 22*760c253cSXin Li command: Command to run 23*760c253cSXin Li multiline: If True, this function will parse each line of bb's output 24*760c253cSXin Li as a separate JSON object, and a return a list of all parsed 25*760c253cSXin Li objects. 26*760c253cSXin Li """ 27*760c253cSXin Li # `bb` always parses argv[1] as a command, so put `-json` after the first 28*760c253cSXin Li # arg to `bb`. 29*760c253cSXin Li run_command = ["bb", command[0], "-json"] + command[1:] 30*760c253cSXin Li stdout = subprocess.run( 31*760c253cSXin Li run_command, 32*760c253cSXin Li check=True, 33*760c253cSXin Li stdin=subprocess.DEVNULL, 34*760c253cSXin Li stdout=subprocess.PIPE, 35*760c253cSXin Li encoding="utf-8", 36*760c253cSXin Li ).stdout 37*760c253cSXin Li 38*760c253cSXin Li def parse_or_log(text: str) -> Any: 39*760c253cSXin Li try: 40*760c253cSXin Li return json.loads(text) 41*760c253cSXin Li except json.JSONDecodeError: 42*760c253cSXin Li logging.error( 43*760c253cSXin Li "Error parsing JSON from command %r; bubbling up. Tried to " 44*760c253cSXin Li "parse: %r", 45*760c253cSXin Li run_command, 46*760c253cSXin Li text, 47*760c253cSXin Li ) 48*760c253cSXin Li raise 49*760c253cSXin Li 50*760c253cSXin Li if multiline: 51*760c253cSXin Li return [ 52*760c253cSXin Li parse_or_log(line) 53*760c253cSXin Li for line in stdout.splitlines() 54*760c253cSXin Li if line and not line.isspace() 55*760c253cSXin Li ] 56*760c253cSXin Li return parse_or_log(stdout) 57*760c253cSXin Li 58*760c253cSXin Li 59*760c253cSXin Li@dataclasses.dataclass(frozen=True, eq=True) 60*760c253cSXin Liclass ChangeListURL: 61*760c253cSXin Li """A consistent representation of a CL URL. 62*760c253cSXin Li 63*760c253cSXin Li The __str__s always converts to a crrev.com URL. 64*760c253cSXin Li """ 65*760c253cSXin Li 66*760c253cSXin Li cl_id: int 67*760c253cSXin Li patch_set: Optional[int] = None 68*760c253cSXin Li internal: bool = False 69*760c253cSXin Li 70*760c253cSXin Li @classmethod 71*760c253cSXin Li def parse(cls, url: str) -> "ChangeListURL": 72*760c253cSXin Li url_re = re.compile( 73*760c253cSXin Li # Match an optional https:// header. 74*760c253cSXin Li r"(?:https?://)?" 75*760c253cSXin Li # Match either chromium-review or crrev, leaving the CL number and 76*760c253cSXin Li # patch set as the next parts. These can be parsed in unison. 77*760c253cSXin Li r"(chromium-review\.googlesource\.com.*/\+/" 78*760c253cSXin Li r"|crrev\.com/[ci]/" 79*760c253cSXin Li r"|chrome-internal-review\.googlesource\.com.*/\+/)" 80*760c253cSXin Li # Match the CL number... 81*760c253cSXin Li r"(\d+)" 82*760c253cSXin Li # and (optionally) the patch-set, as well as consuming any of the 83*760c253cSXin Li # path after the patch-set. 84*760c253cSXin Li r"(?:/(\d+)?(?:/.*)?)?" 85*760c253cSXin Li # Validate any sort of GET params for completeness. 86*760c253cSXin Li r"(?:$|[?&].*)" 87*760c253cSXin Li ) 88*760c253cSXin Li 89*760c253cSXin Li m = url_re.fullmatch(url) 90*760c253cSXin Li if not m: 91*760c253cSXin Li raise ValueError( 92*760c253cSXin Li f"URL {url!r} was not recognized. Supported URL formats are " 93*760c253cSXin Li "crrev.com/c/${cl_number}/${patch_set_number}, and " 94*760c253cSXin Li "chromium-review.googlesource.com/c/project/path/+/" 95*760c253cSXin Li "${cl_number}/${patch_set_number}. The patch-set number is " 96*760c253cSXin Li "optional, and there may be a preceding http:// or https://. " 97*760c253cSXin Li "Internal CL links are also supported." 98*760c253cSXin Li ) 99*760c253cSXin Li host, cl_id, maybe_patch_set = m.groups() 100*760c253cSXin Li internal = host.startswith("chrome-internal-review") or host.startswith( 101*760c253cSXin Li "crrev.com/i/" 102*760c253cSXin Li ) 103*760c253cSXin Li if maybe_patch_set is not None: 104*760c253cSXin Li maybe_patch_set = int(maybe_patch_set) 105*760c253cSXin Li return cls(int(cl_id), maybe_patch_set, internal) 106*760c253cSXin Li 107*760c253cSXin Li @classmethod 108*760c253cSXin Li def parse_with_patch_set(cls, url: str) -> "ChangeListURL": 109*760c253cSXin Li """parse(), but raises a ValueError if no patchset is specified.""" 110*760c253cSXin Li result = cls.parse(url) 111*760c253cSXin Li if result.patch_set is None: 112*760c253cSXin Li raise ValueError("A patchset number must be specified.") 113*760c253cSXin Li return result 114*760c253cSXin Li 115*760c253cSXin Li def crrev_url_without_http(self): 116*760c253cSXin Li namespace = "i" if self.internal else "c" 117*760c253cSXin Li result = f"crrev.com/{namespace}/{self.cl_id}" 118*760c253cSXin Li if self.patch_set is not None: 119*760c253cSXin Li result += f"/{self.patch_set}" 120*760c253cSXin Li return result 121*760c253cSXin Li 122*760c253cSXin Li def __str__(self): 123*760c253cSXin Li return f"https://{self.crrev_url_without_http()}" 124*760c253cSXin Li 125*760c253cSXin Li 126*760c253cSXin Lidef builder_url(build_id: BuildID) -> str: 127*760c253cSXin Li """Returns a builder URL given a build ID.""" 128*760c253cSXin Li return f"https://ci.chromium.org/b/{build_id}" 129*760c253cSXin Li 130*760c253cSXin Li 131*760c253cSXin Lidef fetch_cq_orchestrator_ids( 132*760c253cSXin Li cl: ChangeListURL, 133*760c253cSXin Li) -> List[BuildID]: 134*760c253cSXin Li """Returns the BuildID of completed cq-orchestrator runs on a CL. 135*760c253cSXin Li 136*760c253cSXin Li Newer runs are sorted later in the list. 137*760c253cSXin Li """ 138*760c253cSXin Li results: List[Dict[str, Any]] = _run_bb_decoding_output( 139*760c253cSXin Li [ 140*760c253cSXin Li "ls", 141*760c253cSXin Li "-cl", 142*760c253cSXin Li str(cl), 143*760c253cSXin Li "chromeos/cq/cq-orchestrator", 144*760c253cSXin Li ], 145*760c253cSXin Li multiline=True, 146*760c253cSXin Li ) 147*760c253cSXin Li 148*760c253cSXin Li # We can theoretically filter on a status flag, but it seems to only accept 149*760c253cSXin Li # at most one value. Filter here instead; parsing one or two extra JSON 150*760c253cSXin Li # objects is cheap. 151*760c253cSXin Li finished_results = [ 152*760c253cSXin Li x for x in results if x["status"] not in ("scheduled", "started") 153*760c253cSXin Li ] 154*760c253cSXin Li 155*760c253cSXin Li # Sort by createTime. Fall back to build ID if a tie needs to be broken. 156*760c253cSXin Li # While `createTime` is a string, it's formatted so it can be sorted 157*760c253cSXin Li # correctly without parsing. 158*760c253cSXin Li finished_results.sort(key=lambda x: (x["createTime"], x["id"])) 159*760c253cSXin Li return [int(x["id"]) for x in finished_results] 160*760c253cSXin Li 161*760c253cSXin Li 162*760c253cSXin Li@dataclasses.dataclass(frozen=True) 163*760c253cSXin Liclass CQOrchestratorOutput: 164*760c253cSXin Li """A class representing the output of a cq-orchestrator builder.""" 165*760c253cSXin Li 166*760c253cSXin Li # The status of the CQ builder. 167*760c253cSXin Li status: str 168*760c253cSXin Li # A dict of builders that this CQ builder spawned. 169*760c253cSXin Li child_builders: Dict[str, BuildID] 170*760c253cSXin Li 171*760c253cSXin Li @classmethod 172*760c253cSXin Li def fetch(cls, bot_id: BuildID) -> "CQOrchestratorOutput": 173*760c253cSXin Li decoded: Dict[str, Any] = _run_bb_decoding_output( 174*760c253cSXin Li ["get", "-steps", str(bot_id)] 175*760c253cSXin Li ) 176*760c253cSXin Li results = {} 177*760c253cSXin Li 178*760c253cSXin Li # cq-orchestrator spawns builders in a series of steps. Each step has a 179*760c253cSXin Li # markdownified link to the builder in the summaryMarkdown for each 180*760c253cSXin Li # step. This loop parses those out. 181*760c253cSXin Li build_url_re = re.compile( 182*760c253cSXin Li re.escape("https://cr-buildbucket.appspot.com/build/") + r"(\d+)" 183*760c253cSXin Li ) 184*760c253cSXin Li # Example step name containing a build URL: 185*760c253cSXin Li # "run builds|schedule new builds|${builder_name}". `builder_name` 186*760c253cSXin Li # contains no spaces, though follow-up steps with the same prefix might 187*760c253cSXin Li # include spaces. 188*760c253cSXin Li step_name_re = re.compile( 189*760c253cSXin Li re.escape("run builds|schedule new builds|") + "([^ ]+)" 190*760c253cSXin Li ) 191*760c253cSXin Li for step in decoded["steps"]: 192*760c253cSXin Li step_name = step["name"] 193*760c253cSXin Li m = step_name_re.fullmatch(step_name) 194*760c253cSXin Li if not m: 195*760c253cSXin Li continue 196*760c253cSXin Li 197*760c253cSXin Li builder = m.group(1) 198*760c253cSXin Li summary = step["summaryMarkdown"] 199*760c253cSXin Li ids = build_url_re.findall(summary) 200*760c253cSXin Li if len(ids) != 1: 201*760c253cSXin Li raise ValueError( 202*760c253cSXin Li f"Parsing summary of builder {builder} failed: wanted one " 203*760c253cSXin Li f"match for {build_url_re}; got {ids}. Full summary: " 204*760c253cSXin Li f"{summary!r}" 205*760c253cSXin Li ) 206*760c253cSXin Li if builder in results: 207*760c253cSXin Li raise ValueError(f"Builder {builder} spawned multiple times?") 208*760c253cSXin Li results[builder] = int(ids[0]) 209*760c253cSXin Li return cls(child_builders=results, status=decoded["status"]) 210*760c253cSXin Li 211*760c253cSXin Li 212*760c253cSXin Li@dataclasses.dataclass(frozen=True) 213*760c253cSXin Liclass CQBoardBuilderOutput: 214*760c253cSXin Li """A class representing the output of a *-cq builder (e.g., brya-cq).""" 215*760c253cSXin Li 216*760c253cSXin Li # The status of the CQ builder. 217*760c253cSXin Li status: str 218*760c253cSXin Li # Link to artifacts produced by this builder. Not available if the builder 219*760c253cSXin Li # isn't yet finished, and not available if the builder failed in a weird 220*760c253cSXin Li # way (e.g., INFRA_ERROR) 221*760c253cSXin Li artifacts_link: Optional[str] 222*760c253cSXin Li 223*760c253cSXin Li @classmethod 224*760c253cSXin Li def fetch_many( 225*760c253cSXin Li cls, bot_ids: Iterable[BuildID] 226*760c253cSXin Li ) -> List["CQBoardBuilderOutput"]: 227*760c253cSXin Li """Fetches CQBoardBuilderOutput for the given bots.""" 228*760c253cSXin Li bb_output = _run_bb_decoding_output( 229*760c253cSXin Li ["get", "-p"] + [str(x) for x in bot_ids], multiline=True 230*760c253cSXin Li ) 231*760c253cSXin Li results = [] 232*760c253cSXin Li for result in bb_output: 233*760c253cSXin Li status = result["status"] 234*760c253cSXin Li output = result.get("output") 235*760c253cSXin Li if output is None: 236*760c253cSXin Li artifacts_link = None 237*760c253cSXin Li else: 238*760c253cSXin Li artifacts_link = output["properties"].get("artifact_link") 239*760c253cSXin Li results.append(cls(status=status, artifacts_link=artifacts_link)) 240*760c253cSXin Li return results 241*760c253cSXin Li 242*760c253cSXin Li 243*760c253cSXin Lidef parse_release_from_builder_artifacts_link(artifacts_link: str) -> str: 244*760c253cSXin Li """Parses the release version from a builder artifacts link. 245*760c253cSXin Li 246*760c253cSXin Li >>> parse_release_from_builder_artifacts_link( 247*760c253cSXin Li "gs://chromeos-image-archive/amd64-generic-asan-cq/" 248*760c253cSXin Li "R122-15711.0.0-59730-8761718482083052481") 249*760c253cSXin Li "R122-15711.0.0" 250*760c253cSXin Li """ 251*760c253cSXin Li results = re.findall(r"/(R\d+-\d+\.\d+\.\d+)-", artifacts_link) 252*760c253cSXin Li if len(results) != 1: 253*760c253cSXin Li raise ValueError( 254*760c253cSXin Li f"Expected one release version in {artifacts_link}; got: {results}" 255*760c253cSXin Li ) 256*760c253cSXin Li return results[0] 257