xref: /aosp_15_r20/external/toolchain-utils/llvm_tools/cros_cls.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
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