xref: /aosp_15_r20/external/toolchain-utils/afdo_tools/update_kernel_afdo.py (revision 760c253c1ed00ce9abd48f8546f08516e57485fe)
1*760c253cSXin Li#!/usr/bin/env python3
2*760c253cSXin Li# Copyright 2024 The ChromiumOS Authors
3*760c253cSXin Li# Use of this source code is governed by a BSD-style license that can be
4*760c253cSXin Li# found in the LICENSE file.
5*760c253cSXin Li
6*760c253cSXin Li"""This script updates kernel profiles based on what's available in gs://.
7*760c253cSXin Li
8*760c253cSXin LiIt supports updating on canary, stable, and beta branches.
9*760c253cSXin Li"""
10*760c253cSXin Li
11*760c253cSXin Liimport argparse
12*760c253cSXin Liimport dataclasses
13*760c253cSXin Liimport datetime
14*760c253cSXin Liimport enum
15*760c253cSXin Liimport json
16*760c253cSXin Liimport logging
17*760c253cSXin Liimport os
18*760c253cSXin Lifrom pathlib import Path
19*760c253cSXin Liimport re
20*760c253cSXin Liimport shlex
21*760c253cSXin Liimport subprocess
22*760c253cSXin Liimport sys
23*760c253cSXin Lifrom typing import Dict, Iterable, List, Optional, Tuple
24*760c253cSXin Li
25*760c253cSXin Lifrom cros_utils import git_utils
26*760c253cSXin Li
27*760c253cSXin Li
28*760c253cSXin Li# Folks who should be on the R-line of any CLs that get uploaded.
29*760c253cSXin LiCL_REVIEWERS = (git_utils.REVIEWER_DETECTIVE,)
30*760c253cSXin Li
31*760c253cSXin Li# Folks who should be on the CC-line of any CLs that get uploaded.
32*760c253cSXin LiCL_CC = (
33*760c253cSXin Li    "[email protected]",
34*760c253cSXin Li    "[email protected]",
35*760c253cSXin Li)
36*760c253cSXin Li
37*760c253cSXin Li# Determine which gsutil to use.
38*760c253cSXin Li# 'gsutil.py' is provided by depot_tools, whereas 'gsutil'
39*760c253cSXin Li# is provided by either https://cloud.google.com/sdk/docs/install, or
40*760c253cSXin Li# the 'google-cloud-cli' package. Since we need depot_tools to even
41*760c253cSXin Li# use 'repo', 'gsutil.py' is guaranteed to exist.
42*760c253cSXin LiGSUTIL = "gsutil.py"
43*760c253cSXin Li
44*760c253cSXin Li
45*760c253cSXin Liclass Arch(enum.Enum):
46*760c253cSXin Li    """An enum for CPU architectures."""
47*760c253cSXin Li
48*760c253cSXin Li    AMD64 = "amd64"
49*760c253cSXin Li    ARM = "arm"
50*760c253cSXin Li
51*760c253cSXin Li    @property
52*760c253cSXin Li    def cwp_gs_location(self) -> str:
53*760c253cSXin Li        """Returns the location in gs:// where these profiles live."""
54*760c253cSXin Li        if self == self.AMD64:
55*760c253cSXin Li            return "gs://chromeos-prebuilt/afdo-job/vetted/kernel/amd64"
56*760c253cSXin Li        if self == self.ARM:
57*760c253cSXin Li            return "gs://chromeos-prebuilt/afdo-job/vetted/kernel/arm"
58*760c253cSXin Li        assert False, f"Uncovered arch -> gs:// mapping for {self}"
59*760c253cSXin Li
60*760c253cSXin Li
61*760c253cSXin Li@dataclasses.dataclass(frozen=True, eq=True, order=True)
62*760c253cSXin Liclass KernelVersion:
63*760c253cSXin Li    """A class representing a version of the kernel."""
64*760c253cSXin Li
65*760c253cSXin Li    major: int
66*760c253cSXin Li    minor: int
67*760c253cSXin Li
68*760c253cSXin Li    def __str__(self):
69*760c253cSXin Li        return f"{self.major}.{self.minor}"
70*760c253cSXin Li
71*760c253cSXin Li    @classmethod
72*760c253cSXin Li    def parse(cls, val: str) -> "KernelVersion":
73*760c253cSXin Li        m = re.fullmatch(r"(\d+).(\d+)", val)
74*760c253cSXin Li        if not m:
75*760c253cSXin Li            raise ValueError(f"{val!r} is an invalid kernel version")
76*760c253cSXin Li        return cls(major=int(m.group(1)), minor=int(m.group(2)))
77*760c253cSXin Li
78*760c253cSXin Li
79*760c253cSXin Li# Versions that rolling should be skipped on, for one reason or another.
80*760c253cSXin LiSKIPPED_VERSIONS: Dict[int, Iterable[Tuple[Arch, KernelVersion]]] = {
81*760c253cSXin Li    # Kernel tracing was disabled on ARM in 114, b/275560674
82*760c253cSXin Li    114: ((Arch.ARM, KernelVersion(5, 15)),),
83*760c253cSXin Li    115: ((Arch.ARM, KernelVersion(5, 15)),),
84*760c253cSXin Li}
85*760c253cSXin Li
86*760c253cSXin Li
87*760c253cSXin Liclass Channel(enum.Enum):
88*760c253cSXin Li    """An enum that discusses channels."""
89*760c253cSXin Li
90*760c253cSXin Li    # Ordered from closest-to-ToT to farthest-from-ToT
91*760c253cSXin Li    CANARY = "canary"
92*760c253cSXin Li    BETA = "beta"
93*760c253cSXin Li    STABLE = "stable"
94*760c253cSXin Li
95*760c253cSXin Li    @classmethod
96*760c253cSXin Li    def parse(cls, val: str) -> "Channel":
97*760c253cSXin Li        for x in cls:
98*760c253cSXin Li            if val == x.value:
99*760c253cSXin Li                return x
100*760c253cSXin Li        raise ValueError(
101*760c253cSXin Li            f"No such channel: {val!r}; try one of {[x.value for x in cls]}"
102*760c253cSXin Li        )
103*760c253cSXin Li
104*760c253cSXin Li
105*760c253cSXin Li@dataclasses.dataclass(frozen=True)
106*760c253cSXin Liclass ProfileSelectionInfo:
107*760c253cSXin Li    """Preferences about profiles to select."""
108*760c253cSXin Li
109*760c253cSXin Li    # A consistent timestamp for the program to run with.
110*760c253cSXin Li    now: datetime.datetime
111*760c253cSXin Li
112*760c253cSXin Li    # Maximum age of a profile that can be selected.
113*760c253cSXin Li    max_profile_age: datetime.timedelta
114*760c253cSXin Li
115*760c253cSXin Li
116*760c253cSXin Lidef get_parser():
117*760c253cSXin Li    """Returns an argument parser for this script."""
118*760c253cSXin Li    parser = argparse.ArgumentParser(
119*760c253cSXin Li        description=__doc__,
120*760c253cSXin Li        formatter_class=argparse.RawDescriptionHelpFormatter,
121*760c253cSXin Li    )
122*760c253cSXin Li    parser.add_argument(
123*760c253cSXin Li        "--debug",
124*760c253cSXin Li        action="store_true",
125*760c253cSXin Li        help="Enable debug logging.",
126*760c253cSXin Li    )
127*760c253cSXin Li    parser.add_argument(
128*760c253cSXin Li        "--upload",
129*760c253cSXin Li        action="store_true",
130*760c253cSXin Li        help="Automatically upload all changes that were made.",
131*760c253cSXin Li    )
132*760c253cSXin Li    parser.add_argument(
133*760c253cSXin Li        "--fetch",
134*760c253cSXin Li        action="store_true",
135*760c253cSXin Li        help="Run `git fetch` in toolchain-utils prior to running.",
136*760c253cSXin Li    )
137*760c253cSXin Li    parser.add_argument(
138*760c253cSXin Li        "--max-age-days",
139*760c253cSXin Li        type=int,
140*760c253cSXin Li        default=10,
141*760c253cSXin Li        help="""
142*760c253cSXin Li        The maximum number of days old a kernel profile can be before
143*760c253cSXin Li        it's ignored by this script. Default: %(default)s
144*760c253cSXin Li        """,
145*760c253cSXin Li    )
146*760c253cSXin Li    parser.add_argument(
147*760c253cSXin Li        "--chromeos-tree",
148*760c253cSXin Li        type=Path,
149*760c253cSXin Li        help="""
150*760c253cSXin Li        Root of a ChromeOS tree. This is optional to pass in, but doing so
151*760c253cSXin Li        unlocks extra convenience features on `--upload`. This script will try
152*760c253cSXin Li        to autodetect a tree if this isn't specified.
153*760c253cSXin Li        """,
154*760c253cSXin Li    )
155*760c253cSXin Li    parser.add_argument(
156*760c253cSXin Li        "channel",
157*760c253cSXin Li        nargs="*",
158*760c253cSXin Li        type=Channel.parse,
159*760c253cSXin Li        default=list(Channel),
160*760c253cSXin Li        help=f"""
161*760c253cSXin Li        Channel(s) to update. If none are passed, this will update all
162*760c253cSXin Li        channels. Choose from {[x.value for x in Channel]}.
163*760c253cSXin Li        """,
164*760c253cSXin Li    )
165*760c253cSXin Li    return parser
166*760c253cSXin Li
167*760c253cSXin Li
168*760c253cSXin Li@dataclasses.dataclass(frozen=True, eq=True, order=True)
169*760c253cSXin Liclass GitBranch:
170*760c253cSXin Li    """Represents a ChromeOS branch."""
171*760c253cSXin Li
172*760c253cSXin Li    remote: str
173*760c253cSXin Li    release_number: int
174*760c253cSXin Li    branch_name: str
175*760c253cSXin Li
176*760c253cSXin Li
177*760c253cSXin Lidef git_checkout(git_dir: Path, branch: GitBranch) -> None:
178*760c253cSXin Li    subprocess.run(
179*760c253cSXin Li        [
180*760c253cSXin Li            "git",
181*760c253cSXin Li            "checkout",
182*760c253cSXin Li            "--quiet",
183*760c253cSXin Li            f"{branch.remote}/{branch.branch_name}",
184*760c253cSXin Li        ],
185*760c253cSXin Li        check=True,
186*760c253cSXin Li        cwd=git_dir,
187*760c253cSXin Li        stdin=subprocess.DEVNULL,
188*760c253cSXin Li    )
189*760c253cSXin Li
190*760c253cSXin Li
191*760c253cSXin Lidef git_fetch(git_dir: Path) -> None:
192*760c253cSXin Li    subprocess.run(
193*760c253cSXin Li        ["git", "fetch"],
194*760c253cSXin Li        check=True,
195*760c253cSXin Li        cwd=git_dir,
196*760c253cSXin Li        stdin=subprocess.DEVNULL,
197*760c253cSXin Li    )
198*760c253cSXin Li
199*760c253cSXin Li
200*760c253cSXin Lidef git_rev_parse(git_dir: Path, ref_or_sha: str) -> str:
201*760c253cSXin Li    return subprocess.run(
202*760c253cSXin Li        ["git", "rev-parse", ref_or_sha],
203*760c253cSXin Li        check=True,
204*760c253cSXin Li        cwd=git_dir,
205*760c253cSXin Li        stdin=subprocess.DEVNULL,
206*760c253cSXin Li        stdout=subprocess.PIPE,
207*760c253cSXin Li        encoding="utf-8",
208*760c253cSXin Li    ).stdout.strip()
209*760c253cSXin Li
210*760c253cSXin Li
211*760c253cSXin Lidef autodetect_branches(toolchain_utils: Path) -> Dict[Channel, GitBranch]:
212*760c253cSXin Li    """Returns GitBranches for each branch type in toolchain_utils."""
213*760c253cSXin Li    stdout = subprocess.run(
214*760c253cSXin Li        [
215*760c253cSXin Li            "git",
216*760c253cSXin Li            "branch",
217*760c253cSXin Li            "-r",
218*760c253cSXin Li        ],
219*760c253cSXin Li        cwd=toolchain_utils,
220*760c253cSXin Li        check=True,
221*760c253cSXin Li        stdin=subprocess.DEVNULL,
222*760c253cSXin Li        stdout=subprocess.PIPE,
223*760c253cSXin Li        encoding="utf-8",
224*760c253cSXin Li    ).stdout
225*760c253cSXin Li
226*760c253cSXin Li    # Match "${remote}/release-R${branch_number}-${build}.B"
227*760c253cSXin Li    branch_re = re.compile(r"([^/]+)/(release-R(\d+)-\d+\.B)")
228*760c253cSXin Li    branches = []
229*760c253cSXin Li    for line in stdout.splitlines():
230*760c253cSXin Li        line = line.strip()
231*760c253cSXin Li        if m := branch_re.fullmatch(line):
232*760c253cSXin Li            remote, branch_name, branch_number = m.groups()
233*760c253cSXin Li            branches.append(GitBranch(remote, int(branch_number), branch_name))
234*760c253cSXin Li
235*760c253cSXin Li    branches.sort(key=lambda x: x.release_number)
236*760c253cSXin Li    if len(branches) < 2:
237*760c253cSXin Li        raise ValueError(
238*760c253cSXin Li            f"Expected at least two branches, but only found {len(branches)}"
239*760c253cSXin Li        )
240*760c253cSXin Li
241*760c253cSXin Li    stable = branches[-2]
242*760c253cSXin Li    beta = branches[-1]
243*760c253cSXin Li    canary = GitBranch(
244*760c253cSXin Li        remote=beta.remote,
245*760c253cSXin Li        release_number=beta.release_number + 1,
246*760c253cSXin Li        branch_name="main",
247*760c253cSXin Li    )
248*760c253cSXin Li    return {
249*760c253cSXin Li        Channel.CANARY: canary,
250*760c253cSXin Li        Channel.BETA: beta,
251*760c253cSXin Li        Channel.STABLE: stable,
252*760c253cSXin Li    }
253*760c253cSXin Li
254*760c253cSXin Li
255*760c253cSXin Li@dataclasses.dataclass(frozen=True, eq=True, order=True)
256*760c253cSXin Liclass ArchUpdateConfig:
257*760c253cSXin Li    """The AFDO update config for one architecture."""
258*760c253cSXin Li
259*760c253cSXin Li    versions_to_track: List[KernelVersion]
260*760c253cSXin Li    metadata_file: Path
261*760c253cSXin Li
262*760c253cSXin Li
263*760c253cSXin Lidef read_update_cfg_file(
264*760c253cSXin Li    toolchain_utils: Path, file_path: Path
265*760c253cSXin Li) -> Dict[Arch, ArchUpdateConfig]:
266*760c253cSXin Li    """Reads `update_kernel_afdo.cfg`."""
267*760c253cSXin Li    # These files were originally meant to be `source`d in bash, and are very
268*760c253cSXin Li    # simple. These are read from branches, so we'd need cherry-picks to go
269*760c253cSXin Li    # back and replace them with a singular format. Could be nice to move to
270*760c253cSXin Li    # JSON or something.
271*760c253cSXin Li
272*760c253cSXin Li    # Parse assignments that look like `FOO="bar"`. No escaping or variable
273*760c253cSXin Li    # expansion is supported.
274*760c253cSXin Li    kv_re = re.compile(r'^([a-zA-Z_0-9]+)="([^"]*)"(?:\s*#.*)?', re.MULTILINE)
275*760c253cSXin Li    kvs = kv_re.findall(file_path.read_text(encoding="utf-8"))
276*760c253cSXin Li    # Subtle: the regex above makes it so `kv_re.findall` returns a series of
277*760c253cSXin Li    # (variable_name, variable_value).
278*760c253cSXin Li    settings = dict(kvs)
279*760c253cSXin Li
280*760c253cSXin Li    logging.debug("Parsing cfg file gave back settings: %s", settings)
281*760c253cSXin Li    archs = (
282*760c253cSXin Li        (Arch.AMD64, "AMD"),
283*760c253cSXin Li        (Arch.ARM, "ARM"),
284*760c253cSXin Li    )
285*760c253cSXin Li
286*760c253cSXin Li    results = {}
287*760c253cSXin Li    for arch, arch_var_name in archs:
288*760c253cSXin Li        # This is a space-separated list of kernel versions.
289*760c253cSXin Li        kernel_versions = settings[f"{arch_var_name}_KVERS"]
290*760c253cSXin Li        parsed_versions = [
291*760c253cSXin Li            KernelVersion.parse(x) for x in kernel_versions.split()
292*760c253cSXin Li        ]
293*760c253cSXin Li
294*760c253cSXin Li        metadata_file = settings[f"{arch_var_name}_METADATA_FILE"]
295*760c253cSXin Li        results[arch] = ArchUpdateConfig(
296*760c253cSXin Li            versions_to_track=parsed_versions,
297*760c253cSXin Li            metadata_file=toolchain_utils / metadata_file,
298*760c253cSXin Li        )
299*760c253cSXin Li    return results
300*760c253cSXin Li
301*760c253cSXin Li
302*760c253cSXin Li@dataclasses.dataclass(frozen=True, eq=True)
303*760c253cSXin Liclass KernelGsProfile:
304*760c253cSXin Li    """Represents a kernel profile in gs://."""
305*760c253cSXin Li
306*760c253cSXin Li    release_number: int
307*760c253cSXin Li    chrome_build: str
308*760c253cSXin Li    cwp_timestamp: int
309*760c253cSXin Li    suffix: str
310*760c253cSXin Li    gs_timestamp: datetime.datetime
311*760c253cSXin Li
312*760c253cSXin Li    _FILE_NAME_PARSE_RE = re.compile(r"R(\d+)-(\d+\.\d+)-(\d+)(\..+\..+)")
313*760c253cSXin Li
314*760c253cSXin Li    @property
315*760c253cSXin Li    def file_name_no_suffix(self):
316*760c253cSXin Li        return (
317*760c253cSXin Li            f"R{self.release_number}-{self.chrome_build}-{self.cwp_timestamp}"
318*760c253cSXin Li        )
319*760c253cSXin Li
320*760c253cSXin Li    @property
321*760c253cSXin Li    def file_name(self):
322*760c253cSXin Li        return f"{self.file_name_no_suffix}{self.suffix}"
323*760c253cSXin Li
324*760c253cSXin Li    @classmethod
325*760c253cSXin Li    def from_file_name(
326*760c253cSXin Li        cls, timestamp: datetime.datetime, file_name: str
327*760c253cSXin Li    ) -> "KernelGsProfile":
328*760c253cSXin Li        m = cls._FILE_NAME_PARSE_RE.fullmatch(file_name)
329*760c253cSXin Li        if not m:
330*760c253cSXin Li            raise ValueError(f"{file_name!r} doesn't parse as a profile name")
331*760c253cSXin Li        release_number, chrome_build, cwp_timestamp, suffix = m.groups()
332*760c253cSXin Li        return cls(
333*760c253cSXin Li            release_number=int(release_number),
334*760c253cSXin Li            chrome_build=chrome_build,
335*760c253cSXin Li            cwp_timestamp=int(cwp_timestamp),
336*760c253cSXin Li            suffix=suffix,
337*760c253cSXin Li            gs_timestamp=timestamp,
338*760c253cSXin Li        )
339*760c253cSXin Li
340*760c253cSXin Li
341*760c253cSXin Lidef datetime_from_gs_time(timestamp_str: str) -> datetime.datetime:
342*760c253cSXin Li    """Parses a datetime from gs."""
343*760c253cSXin Li    return datetime.datetime.strptime(
344*760c253cSXin Li        timestamp_str, "%Y-%m-%dT%H:%M:%SZ"
345*760c253cSXin Li    ).replace(tzinfo=datetime.timezone.utc)
346*760c253cSXin Li
347*760c253cSXin Li
348*760c253cSXin Liclass KernelProfileFetcher:
349*760c253cSXin Li    """Fetches kernel profiles from gs://. Caches results."""
350*760c253cSXin Li
351*760c253cSXin Li    def __init__(self):
352*760c253cSXin Li        self._cached_results: Dict[str, List[KernelGsProfile]] = {}
353*760c253cSXin Li
354*760c253cSXin Li    @staticmethod
355*760c253cSXin Li    def _parse_gs_stdout(stdout: str) -> List[KernelGsProfile]:
356*760c253cSXin Li        line_re = re.compile(r"\s*\d+\s+(\S+T\S+)\s+(gs://.+)")
357*760c253cSXin Li        results = []
358*760c253cSXin Li        # Ignore the last line, since that's "TOTAL:"
359*760c253cSXin Li        for line in stdout.splitlines()[:-1]:
360*760c253cSXin Li            line = line.strip()
361*760c253cSXin Li            if not line:
362*760c253cSXin Li                continue
363*760c253cSXin Li            m = line_re.fullmatch(line)
364*760c253cSXin Li            if m is None:
365*760c253cSXin Li                raise ValueError(f"Unexpected line from gs: {line!r}")
366*760c253cSXin Li            timestamp_str, gs_url = m.groups()
367*760c253cSXin Li            timestamp = datetime_from_gs_time(timestamp_str)
368*760c253cSXin Li            file_name = os.path.basename(gs_url)
369*760c253cSXin Li            results.append(KernelGsProfile.from_file_name(timestamp, file_name))
370*760c253cSXin Li        return results
371*760c253cSXin Li
372*760c253cSXin Li    @classmethod
373*760c253cSXin Li    def _fetch_impl(cls, gs_url: str) -> List[KernelGsProfile]:
374*760c253cSXin Li        cmd = [
375*760c253cSXin Li            GSUTIL,
376*760c253cSXin Li            "ls",
377*760c253cSXin Li            "-l",
378*760c253cSXin Li            gs_url,
379*760c253cSXin Li        ]
380*760c253cSXin Li        result = subprocess.run(
381*760c253cSXin Li            cmd,
382*760c253cSXin Li            check=False,
383*760c253cSXin Li            stdin=subprocess.DEVNULL,
384*760c253cSXin Li            stdout=subprocess.PIPE,
385*760c253cSXin Li            stderr=subprocess.PIPE,
386*760c253cSXin Li            encoding="utf-8",
387*760c253cSXin Li        )
388*760c253cSXin Li
389*760c253cSXin Li        if result.returncode:
390*760c253cSXin Li            # If nothing could be found, gsutil will exit after printing this.
391*760c253cSXin Li            if "One or more URLs matched no objects." in result.stderr:
392*760c253cSXin Li                return []
393*760c253cSXin Li            logging.error(
394*760c253cSXin Li                "%s failed; stderr:\n%s", shlex.join(cmd), result.stderr
395*760c253cSXin Li            )
396*760c253cSXin Li            result.check_returncode()
397*760c253cSXin Li            assert False, "unreachable"
398*760c253cSXin Li
399*760c253cSXin Li        return cls._parse_gs_stdout(result.stdout)
400*760c253cSXin Li
401*760c253cSXin Li    def fetch(self, gs_url: str) -> List[KernelGsProfile]:
402*760c253cSXin Li        cached = self._cached_results.get(gs_url)
403*760c253cSXin Li        if cached is None:
404*760c253cSXin Li            logging.info("Fetching profiles from %s...", gs_url)
405*760c253cSXin Li            fetched = self._fetch_impl(gs_url)
406*760c253cSXin Li            logging.info("Found %d profiles in %s", len(fetched), gs_url)
407*760c253cSXin Li            self._cached_results[gs_url] = fetched
408*760c253cSXin Li            cached = fetched
409*760c253cSXin Li
410*760c253cSXin Li        # Create a copy to keep mutations from causing problems.
411*760c253cSXin Li        # KernelGsProfiles are frozen, at least.
412*760c253cSXin Li        return cached[:]
413*760c253cSXin Li
414*760c253cSXin Li
415*760c253cSXin Lidef find_newest_afdo_artifact(
416*760c253cSXin Li    fetcher: KernelProfileFetcher,
417*760c253cSXin Li    arch: Arch,
418*760c253cSXin Li    kernel_version: KernelVersion,
419*760c253cSXin Li    release_number: int,
420*760c253cSXin Li) -> Optional[KernelGsProfile]:
421*760c253cSXin Li    """Returns info about the latest AFDO artifact for the given parameters."""
422*760c253cSXin Li    gs_base_location = arch.cwp_gs_location
423*760c253cSXin Li    kernel_profile_dir = os.path.join(gs_base_location, str(kernel_version))
424*760c253cSXin Li    kernel_profiles = fetcher.fetch(kernel_profile_dir)
425*760c253cSXin Li    if not kernel_profiles:
426*760c253cSXin Li        logging.error(
427*760c253cSXin Li            "Failed to find any kernel profiles in %s", kernel_profile_dir
428*760c253cSXin Li        )
429*760c253cSXin Li        return None
430*760c253cSXin Li
431*760c253cSXin Li    valid_profiles = [
432*760c253cSXin Li        x for x in kernel_profiles if x.release_number == release_number
433*760c253cSXin Li    ]
434*760c253cSXin Li    if not valid_profiles:
435*760c253cSXin Li        logging.warning(
436*760c253cSXin Li            "Failed to find any M%d kernel profiles in %s",
437*760c253cSXin Li            release_number,
438*760c253cSXin Li            kernel_profile_dir,
439*760c253cSXin Li        )
440*760c253cSXin Li        return None
441*760c253cSXin Li
442*760c253cSXin Li    # We want the most recently uploaded profile, since that should correspond
443*760c253cSXin Li    # with the newest profile. If there're multiple profiles for some reason,
444*760c253cSXin Li    # choose what _should_ be a consistent tie-breaker.
445*760c253cSXin Li    return max(
446*760c253cSXin Li        valid_profiles,
447*760c253cSXin Li        key=lambda x: (x.gs_timestamp, x.cwp_timestamp, x.chrome_build),
448*760c253cSXin Li    )
449*760c253cSXin Li
450*760c253cSXin Li
451*760c253cSXin Lidef read_afdo_descriptor_file(path: Path) -> Dict[KernelVersion, str]:
452*760c253cSXin Li    """Reads the AFDO descriptor file.
453*760c253cSXin Li
454*760c253cSXin Li    "AFDO descriptor file" is jargon to refer to the actual JSON file that PUpr
455*760c253cSXin Li    monitors.
456*760c253cSXin Li    """
457*760c253cSXin Li    try:
458*760c253cSXin Li        with path.open(encoding="utf-8") as f:
459*760c253cSXin Li            raw_contents = json.load(f)
460*760c253cSXin Li    except FileNotFoundError:
461*760c253cSXin Li        return {}
462*760c253cSXin Li
463*760c253cSXin Li    # The format of this is:
464*760c253cSXin Li    # {
465*760c253cSXin Li    #   "chromeos-kernel-${major}_${minor}": {
466*760c253cSXin Li    #     "name": "${profile_gs_name}",
467*760c253cSXin Li    #   }
468*760c253cSXin Li    # }
469*760c253cSXin Li    key_re = re.compile(r"^chromeos-kernel-(\d)+_(\d+)$")
470*760c253cSXin Li    result = {}
471*760c253cSXin Li    for kernel_key, val in raw_contents.items():
472*760c253cSXin Li        m = key_re.fullmatch(kernel_key)
473*760c253cSXin Li        if not m:
474*760c253cSXin Li            raise ValueError(f"Invalid key in JSON: {kernel_key}")
475*760c253cSXin Li        major, minor = m.groups()
476*760c253cSXin Li        version = KernelVersion(major=int(major), minor=int(minor))
477*760c253cSXin Li        result[version] = val["name"]
478*760c253cSXin Li    return result
479*760c253cSXin Li
480*760c253cSXin Li
481*760c253cSXin Lidef write_afdo_descriptor_file(
482*760c253cSXin Li    path: Path, contents: Dict[KernelVersion, str]
483*760c253cSXin Li) -> bool:
484*760c253cSXin Li    """Writes the file at path with the given contents.
485*760c253cSXin Li
486*760c253cSXin Li    Returns:
487*760c253cSXin Li        True if the file was written due to changes, False otherwise.
488*760c253cSXin Li    """
489*760c253cSXin Li    contents_dict = {
490*760c253cSXin Li        f"chromeos-kernel-{k.major}_{k.minor}": {"name": gs_name}
491*760c253cSXin Li        for k, gs_name in contents.items()
492*760c253cSXin Li    }
493*760c253cSXin Li
494*760c253cSXin Li    contents_json = json.dumps(contents_dict, indent=4, sort_keys=True)
495*760c253cSXin Li    try:
496*760c253cSXin Li        existing_contents = path.read_text(encoding="utf-8")
497*760c253cSXin Li    except FileNotFoundError:
498*760c253cSXin Li        existing_contents = ""
499*760c253cSXin Li
500*760c253cSXin Li    # Compare the _textual representation_ of each of these, since things like
501*760c253cSXin Li    # formatting changes should be propagated eagerly.
502*760c253cSXin Li    if contents_json == existing_contents:
503*760c253cSXin Li        return False
504*760c253cSXin Li
505*760c253cSXin Li    tmp_path = path.with_suffix(".json.tmp")
506*760c253cSXin Li    tmp_path.write_text(contents_json, encoding="utf-8")
507*760c253cSXin Li    tmp_path.rename(path)
508*760c253cSXin Li    return True
509*760c253cSXin Li
510*760c253cSXin Li
511*760c253cSXin Li@dataclasses.dataclass
512*760c253cSXin Liclass UpdateResult:
513*760c253cSXin Li    """Result of `update_afdo_for_channel`."""
514*760c253cSXin Li
515*760c253cSXin Li    # True if changes were made to the AFDO files that map kernel versions to
516*760c253cSXin Li    # AFDO profiles.
517*760c253cSXin Li    made_changes: bool
518*760c253cSXin Li
519*760c253cSXin Li    # Whether issues were had updating one or more profiles. If this is True,
520*760c253cSXin Li    # you may expect that there will be logs about the issues already.
521*760c253cSXin Li    had_failures: bool
522*760c253cSXin Li
523*760c253cSXin Li
524*760c253cSXin Lidef fetch_and_validate_newest_afdo_artifact(
525*760c253cSXin Li    fetcher: KernelProfileFetcher,
526*760c253cSXin Li    selection_info: ProfileSelectionInfo,
527*760c253cSXin Li    arch: Arch,
528*760c253cSXin Li    kernel_version: KernelVersion,
529*760c253cSXin Li    branch: GitBranch,
530*760c253cSXin Li    channel: Channel,
531*760c253cSXin Li) -> Optional[Tuple[str, bool]]:
532*760c253cSXin Li    """Tries to update one AFDO profile on a branch.
533*760c253cSXin Li
534*760c253cSXin Li    Returns:
535*760c253cSXin Li        None if something failed, and the update couldn't be completed.
536*760c253cSXin Li        Otherwise, this returns a tuple of (profile_name, is_old). If `is_old`
537*760c253cSXin Li        is True, this function logs an error.
538*760c253cSXin Li    """
539*760c253cSXin Li    newest_artifact = find_newest_afdo_artifact(
540*760c253cSXin Li        fetcher, arch, kernel_version, branch.release_number
541*760c253cSXin Li    )
542*760c253cSXin Li    # Try an older branch if we're not on stable. We should fail harder if we
543*760c253cSXin Li    # only have old profiles on stable, though.
544*760c253cSXin Li    if newest_artifact is None and channel != Channel.STABLE:
545*760c253cSXin Li        newest_artifact = find_newest_afdo_artifact(
546*760c253cSXin Li            fetcher, arch, kernel_version, branch.release_number - 1
547*760c253cSXin Li        )
548*760c253cSXin Li
549*760c253cSXin Li    if newest_artifact is None:
550*760c253cSXin Li        logging.error(
551*760c253cSXin Li            "No new profile found for %s/%s on M%d; not updating entry",
552*760c253cSXin Li            arch,
553*760c253cSXin Li            kernel_version,
554*760c253cSXin Li            branch.release_number,
555*760c253cSXin Li        )
556*760c253cSXin Li        return None
557*760c253cSXin Li
558*760c253cSXin Li    logging.info(
559*760c253cSXin Li        "Newest profile is %s for %s/%s on M%d",
560*760c253cSXin Li        newest_artifact.file_name,
561*760c253cSXin Li        arch,
562*760c253cSXin Li        kernel_version,
563*760c253cSXin Li        branch.release_number,
564*760c253cSXin Li    )
565*760c253cSXin Li    age = selection_info.now - newest_artifact.gs_timestamp
566*760c253cSXin Li    is_old = False
567*760c253cSXin Li    if age > selection_info.max_profile_age:
568*760c253cSXin Li        is_old = True
569*760c253cSXin Li        logging.error(
570*760c253cSXin Li            "Profile %s is %s old. The configured limit is %s.",
571*760c253cSXin Li            newest_artifact.file_name,
572*760c253cSXin Li            age,
573*760c253cSXin Li            selection_info.max_profile_age,
574*760c253cSXin Li        )
575*760c253cSXin Li    return newest_artifact.file_name_no_suffix, is_old
576*760c253cSXin Li
577*760c253cSXin Li
578*760c253cSXin Lidef update_afdo_for_channel(
579*760c253cSXin Li    fetcher: KernelProfileFetcher,
580*760c253cSXin Li    toolchain_utils: Path,
581*760c253cSXin Li    selection_info: ProfileSelectionInfo,
582*760c253cSXin Li    channel: Channel,
583*760c253cSXin Li    branch: GitBranch,
584*760c253cSXin Li    skipped_versions: Dict[int, Iterable[Tuple[Arch, KernelVersion]]],
585*760c253cSXin Li) -> UpdateResult:
586*760c253cSXin Li    """Updates AFDO on the given channel."""
587*760c253cSXin Li    git_checkout(toolchain_utils, branch)
588*760c253cSXin Li    update_cfgs = read_update_cfg_file(
589*760c253cSXin Li        toolchain_utils,
590*760c253cSXin Li        toolchain_utils / "afdo_tools" / "update_kernel_afdo.cfg",
591*760c253cSXin Li    )
592*760c253cSXin Li
593*760c253cSXin Li    to_skip = skipped_versions.get(branch.release_number)
594*760c253cSXin Li    made_changes = False
595*760c253cSXin Li    had_failures = False
596*760c253cSXin Li    for arch, cfg in update_cfgs.items():
597*760c253cSXin Li        afdo_mappings = read_afdo_descriptor_file(cfg.metadata_file)
598*760c253cSXin Li        for kernel_version in cfg.versions_to_track:
599*760c253cSXin Li            if to_skip and (arch, kernel_version) in to_skip:
600*760c253cSXin Li                logging.info(
601*760c253cSXin Li                    "%s/%s on M%d is in the skip list; ignoring it.",
602*760c253cSXin Li                    arch,
603*760c253cSXin Li                    kernel_version,
604*760c253cSXin Li                    branch.release_number,
605*760c253cSXin Li                )
606*760c253cSXin Li                continue
607*760c253cSXin Li
608*760c253cSXin Li            artifact_info = fetch_and_validate_newest_afdo_artifact(
609*760c253cSXin Li                fetcher,
610*760c253cSXin Li                selection_info,
611*760c253cSXin Li                arch,
612*760c253cSXin Li                kernel_version,
613*760c253cSXin Li                branch,
614*760c253cSXin Li                channel,
615*760c253cSXin Li            )
616*760c253cSXin Li            if artifact_info is None:
617*760c253cSXin Li                # Assume that the problem was already logged.
618*760c253cSXin Li                had_failures = True
619*760c253cSXin Li                continue
620*760c253cSXin Li
621*760c253cSXin Li            newest_name, is_old = artifact_info
622*760c253cSXin Li            if is_old:
623*760c253cSXin Li                # Assume that the problem was already logged, but continue to
624*760c253cSXin Li                # land this in case it makes a difference.
625*760c253cSXin Li                had_failures = True
626*760c253cSXin Li
627*760c253cSXin Li            afdo_mappings[kernel_version] = newest_name
628*760c253cSXin Li
629*760c253cSXin Li        if write_afdo_descriptor_file(cfg.metadata_file, afdo_mappings):
630*760c253cSXin Li            made_changes = True
631*760c253cSXin Li            logging.info(
632*760c253cSXin Li                "Wrote new AFDO mappings for arch %s on M%d",
633*760c253cSXin Li                arch,
634*760c253cSXin Li                branch.release_number,
635*760c253cSXin Li            )
636*760c253cSXin Li        else:
637*760c253cSXin Li            logging.info(
638*760c253cSXin Li                "No changes to write for arch %s on M%d",
639*760c253cSXin Li                arch,
640*760c253cSXin Li                branch.release_number,
641*760c253cSXin Li            )
642*760c253cSXin Li    return UpdateResult(
643*760c253cSXin Li        made_changes=made_changes,
644*760c253cSXin Li        had_failures=had_failures,
645*760c253cSXin Li    )
646*760c253cSXin Li
647*760c253cSXin Li
648*760c253cSXin Lidef commit_new_profiles(
649*760c253cSXin Li    toolchain_utils: Path, channel: Channel, had_failures: bool
650*760c253cSXin Li):
651*760c253cSXin Li    """Runs `git commit -a` with an appropriate message."""
652*760c253cSXin Li    commit_message_lines = [
653*760c253cSXin Li        "afdo_metadata: Publish the new kernel profiles",
654*760c253cSXin Li        "",
655*760c253cSXin Li    ]
656*760c253cSXin Li
657*760c253cSXin Li    if had_failures:
658*760c253cSXin Li        commit_message_lines += (
659*760c253cSXin Li            "This brings some profiles to their newest versions. The CrOS",
660*760c253cSXin Li            "toolchain detective has been notified about the failures that",
661*760c253cSXin Li            "occurred in this update.",
662*760c253cSXin Li        )
663*760c253cSXin Li    else:
664*760c253cSXin Li        commit_message_lines.append(
665*760c253cSXin Li            "This brings all profiles to their newest versions."
666*760c253cSXin Li        )
667*760c253cSXin Li
668*760c253cSXin Li    if channel != Channel.CANARY:
669*760c253cSXin Li        commit_message_lines += (
670*760c253cSXin Li            "",
671*760c253cSXin Li            "Have PM pre-approval because this shouldn't break the release",
672*760c253cSXin Li            "branch.",
673*760c253cSXin Li        )
674*760c253cSXin Li
675*760c253cSXin Li    commit_message_lines += (
676*760c253cSXin Li        "",
677*760c253cSXin Li        "BUG=None",
678*760c253cSXin Li        "TEST=Verified in kernel-release-afdo-verify-orchestrator",
679*760c253cSXin Li    )
680*760c253cSXin Li
681*760c253cSXin Li    commit_msg = "\n".join(commit_message_lines)
682*760c253cSXin Li    subprocess.run(
683*760c253cSXin Li        [
684*760c253cSXin Li            "git",
685*760c253cSXin Li            "commit",
686*760c253cSXin Li            "--quiet",
687*760c253cSXin Li            "-a",
688*760c253cSXin Li            "-m",
689*760c253cSXin Li            commit_msg,
690*760c253cSXin Li        ],
691*760c253cSXin Li        cwd=toolchain_utils,
692*760c253cSXin Li        check=True,
693*760c253cSXin Li        stdin=subprocess.DEVNULL,
694*760c253cSXin Li    )
695*760c253cSXin Li
696*760c253cSXin Li
697*760c253cSXin Lidef upload_head_to_gerrit(
698*760c253cSXin Li    toolchain_utils: Path,
699*760c253cSXin Li    chromeos_tree: Optional[Path],
700*760c253cSXin Li    branch: GitBranch,
701*760c253cSXin Li):
702*760c253cSXin Li    """Uploads HEAD to gerrit as a CL, and sets reviewers/CCs."""
703*760c253cSXin Li    cl_ids = git_utils.upload_to_gerrit(
704*760c253cSXin Li        toolchain_utils,
705*760c253cSXin Li        branch.remote,
706*760c253cSXin Li        branch.branch_name,
707*760c253cSXin Li        CL_REVIEWERS,
708*760c253cSXin Li        CL_CC,
709*760c253cSXin Li    )
710*760c253cSXin Li
711*760c253cSXin Li    if len(cl_ids) > 1:
712*760c253cSXin Li        raise ValueError(f"Unexpected: wanted just one CL upload; got {cl_ids}")
713*760c253cSXin Li
714*760c253cSXin Li    cl_id = cl_ids[0]
715*760c253cSXin Li    logging.info("Uploaded CL http://crrev.com/c/%s successfully.", cl_id)
716*760c253cSXin Li
717*760c253cSXin Li    if chromeos_tree is None:
718*760c253cSXin Li        logging.info(
719*760c253cSXin Li            "Skipping gerrit convenience commands, since no CrOS tree was "
720*760c253cSXin Li            "specified."
721*760c253cSXin Li        )
722*760c253cSXin Li        return
723*760c253cSXin Li
724*760c253cSXin Li    git_utils.try_set_autosubmit_labels(chromeos_tree, cl_id)
725*760c253cSXin Li
726*760c253cSXin Li
727*760c253cSXin Lidef find_chromeos_tree_root(a_dir: Path) -> Optional[Path]:
728*760c253cSXin Li    for parent in a_dir.parents:
729*760c253cSXin Li        if (parent / ".repo").is_dir():
730*760c253cSXin Li            return parent
731*760c253cSXin Li    return None
732*760c253cSXin Li
733*760c253cSXin Li
734*760c253cSXin Lidef main(argv: List[str]) -> None:
735*760c253cSXin Li    my_dir = Path(__file__).resolve().parent
736*760c253cSXin Li    toolchain_utils = my_dir.parent
737*760c253cSXin Li
738*760c253cSXin Li    opts = get_parser().parse_args(argv)
739*760c253cSXin Li    logging.basicConfig(
740*760c253cSXin Li        format=">> %(asctime)s: %(levelname)s: %(filename)s:%(lineno)d: "
741*760c253cSXin Li        "%(message)s",
742*760c253cSXin Li        level=logging.DEBUG if opts.debug else logging.INFO,
743*760c253cSXin Li    )
744*760c253cSXin Li
745*760c253cSXin Li    chromeos_tree = opts.chromeos_tree
746*760c253cSXin Li    if not chromeos_tree:
747*760c253cSXin Li        chromeos_tree = find_chromeos_tree_root(my_dir)
748*760c253cSXin Li        if chromeos_tree:
749*760c253cSXin Li            logging.info("Autodetected ChromeOS tree root at %s", chromeos_tree)
750*760c253cSXin Li
751*760c253cSXin Li    if opts.fetch:
752*760c253cSXin Li        logging.info("Fetching in %s...", toolchain_utils)
753*760c253cSXin Li        git_fetch(toolchain_utils)
754*760c253cSXin Li
755*760c253cSXin Li    selection_info = ProfileSelectionInfo(
756*760c253cSXin Li        now=datetime.datetime.now(datetime.timezone.utc),
757*760c253cSXin Li        max_profile_age=datetime.timedelta(days=opts.max_age_days),
758*760c253cSXin Li    )
759*760c253cSXin Li
760*760c253cSXin Li    branches = autodetect_branches(toolchain_utils)
761*760c253cSXin Li    logging.debug("Current branches: %s", branches)
762*760c253cSXin Li
763*760c253cSXin Li    assert all(x in branches for x in Channel), "branches are missing channels?"
764*760c253cSXin Li
765*760c253cSXin Li    fetcher = KernelProfileFetcher()
766*760c253cSXin Li    had_failures = False
767*760c253cSXin Li    with git_utils.create_worktree(toolchain_utils) as worktree:
768*760c253cSXin Li        for channel in opts.channel:
769*760c253cSXin Li            branch = branches[channel]
770*760c253cSXin Li            result = update_afdo_for_channel(
771*760c253cSXin Li                fetcher,
772*760c253cSXin Li                worktree,
773*760c253cSXin Li                selection_info,
774*760c253cSXin Li                channel,
775*760c253cSXin Li                branch,
776*760c253cSXin Li                SKIPPED_VERSIONS,
777*760c253cSXin Li            )
778*760c253cSXin Li            had_failures = had_failures or result.had_failures
779*760c253cSXin Li            if not result.made_changes:
780*760c253cSXin Li                logging.info("No new updates to post on %s", channel)
781*760c253cSXin Li                continue
782*760c253cSXin Li
783*760c253cSXin Li            commit_new_profiles(worktree, channel, result.had_failures)
784*760c253cSXin Li            if opts.upload:
785*760c253cSXin Li                logging.info("New profiles were committed. Uploading...")
786*760c253cSXin Li                upload_head_to_gerrit(worktree, chromeos_tree, branch)
787*760c253cSXin Li            else:
788*760c253cSXin Li                logging.info(
789*760c253cSXin Li                    "--upload not specified. Leaving commit for %s at %s",
790*760c253cSXin Li                    channel,
791*760c253cSXin Li                    git_rev_parse(worktree, "HEAD"),
792*760c253cSXin Li                )
793*760c253cSXin Li
794*760c253cSXin Li    if had_failures:
795*760c253cSXin Li        sys.exit(
796*760c253cSXin Li            "At least one failure was encountered running this script; see "
797*760c253cSXin Li            "above logs. Most likely the things you're looking for are logged "
798*760c253cSXin Li            "at the ERROR level."
799*760c253cSXin Li        )
800*760c253cSXin Li
801*760c253cSXin Li
802*760c253cSXin Liif __name__ == "__main__":
803*760c253cSXin Li    main(sys.argv[1:])
804