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