1#!/usr/bin/env python3 2# Copyright 2020 The ChromiumOS Authors 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6"""Tool to automatically generate a new Rust uprev CL. 7 8This tool is intended to automatically generate a CL to uprev Rust to 9a newer version in Chrome OS, including creating a new Rust version or 10removing an old version. When using the tool, the progress can be 11saved to a JSON file, so the user can resume the process after a 12failing step is fixed. Example usage to create a new version: 13 141. (outside chroot) $ ./rust_tools/rust_uprev.py \\ 15 --state_file /tmp/rust-to-1.60.0.json \\ 16 roll --uprev 1.60.0 172. Step "compile rust" failed due to the patches can't apply to new version. 183. Manually fix the patches. 194. Execute the command in step 1 again, but add "--continue" before "roll". 205. Iterate 1-4 for each failed step until the tool passes. 21 22Besides "roll", the tool also support subcommands that perform 23various parts of an uprev. 24 25See `--help` for all available options. 26""" 27 28import argparse 29import functools 30import json 31import logging 32import os 33import pathlib 34from pathlib import Path 35import re 36import shlex 37import shutil 38import subprocess 39import threading 40import time 41from typing import ( 42 Any, 43 Callable, 44 Dict, 45 List, 46 NamedTuple, 47 Optional, 48 Protocol, 49 Sequence, 50 Tuple, 51 TypeVar, 52 Union, 53) 54import urllib.request 55 56from llvm_tools import chroot 57from llvm_tools import git 58 59 60T = TypeVar("T") 61Command = Sequence[Union[str, os.PathLike]] 62PathOrStr = Union[str, os.PathLike] 63 64 65class RunStepFn(Protocol): 66 """Protocol that corresponds to run_step's type. 67 68 This can be used as the type of a function parameter that accepts 69 run_step as its value. 70 """ 71 72 def __call__( 73 self, 74 step_name: str, 75 step_fn: Callable[[], T], 76 result_from_json: Optional[Callable[[Any], T]] = None, 77 result_to_json: Optional[Callable[[T], Any]] = None, 78 ) -> T: 79 ... 80 81 82def get_command_output(command: Command, *args, **kwargs) -> str: 83 return subprocess.check_output( 84 command, encoding="utf-8", *args, **kwargs 85 ).strip() 86 87 88def _get_source_root() -> Path: 89 """Returns the path to the chromiumos directory.""" 90 return Path(get_command_output(["repo", "--show-toplevel"])) 91 92 93SOURCE_ROOT = _get_source_root() 94EQUERY = "equery" 95GPG = "gpg" 96GSUTIL = "gsutil.py" 97MIRROR_PATH = "gs://chromeos-localmirror/distfiles" 98EBUILD_PREFIX = SOURCE_ROOT / "src/third_party/chromiumos-overlay" 99CROS_RUSTC_ECLASS = EBUILD_PREFIX / "eclass/cros-rustc.eclass" 100# Keyserver to use with GPG. Not all keyservers have Rust's signing key; 101# this must be set to a keyserver that does. 102GPG_KEYSERVER = "keyserver.ubuntu.com" 103PGO_RUST = Path( 104 "/mnt/host/source" 105 "/src/third_party/toolchain-utils/pgo_tools_rust/pgo_rust.py" 106) 107RUST_PATH = Path(EBUILD_PREFIX, "dev-lang", "rust") 108# This is the signing key used by upstream Rust as of 2023-08-09. 109# If the project switches to a different key, this will have to be updated. 110# We require the key to be updated manually so that we have an opportunity 111# to verify that the key change is legitimate. 112RUST_SIGNING_KEY = "85AB96E6FA1BE5FE" 113RUST_SRC_BASE_URI = "https://static.rust-lang.org/dist/" 114# Packages that need to be processed like dev-lang/rust. 115RUST_PACKAGES = ( 116 ("dev-lang", "rust-host"), 117 ("dev-lang", "rust"), 118) 119 120 121class SignatureVerificationError(Exception): 122 """Error that indicates verification of a downloaded file failed. 123 124 Attributes: 125 message: explanation of why the verification failed. 126 path: the path to the file whose integrity was being verified. 127 """ 128 129 def __init__(self, message: str, path: Path): 130 super(SignatureVerificationError, self).__init__() 131 self.message = message 132 self.path = path 133 134 135def get_command_output_unchecked(command: Command, *args, **kwargs) -> str: 136 # pylint: disable=subprocess-run-check 137 return subprocess.run( 138 command, 139 *args, 140 **dict( 141 { 142 "check": False, 143 "stdout": subprocess.PIPE, 144 "encoding": "utf-8", 145 }, 146 **kwargs, 147 ), 148 ).stdout.strip() 149 150 151class RustVersion(NamedTuple): 152 """NamedTuple represents a Rust version""" 153 154 major: int 155 minor: int 156 patch: int 157 158 def __str__(self): 159 return f"{self.major}.{self.minor}.{self.patch}" 160 161 @staticmethod 162 def parse_from_ebuild(ebuild_name: PathOrStr) -> "RustVersion": 163 input_re = re.compile( 164 r"^rust-" 165 r"(?P<major>\d+)\." 166 r"(?P<minor>\d+)\." 167 r"(?P<patch>\d+)" 168 r"(:?-r\d+)?" 169 r"\.ebuild$" 170 ) 171 m = input_re.match(Path(ebuild_name).name) 172 assert m, f"failed to parse {ebuild_name!r}" 173 return RustVersion( 174 int(m.group("major")), int(m.group("minor")), int(m.group("patch")) 175 ) 176 177 @staticmethod 178 def parse(x: str) -> "RustVersion": 179 input_re = re.compile( 180 r"^(?:rust-)?" 181 r"(?P<major>\d+)\." 182 r"(?P<minor>\d+)\." 183 r"(?P<patch>\d+)" 184 r"(?:.ebuild)?$" 185 ) 186 m = input_re.match(x) 187 assert m, f"failed to parse {x!r}" 188 return RustVersion( 189 int(m.group("major")), int(m.group("minor")), int(m.group("patch")) 190 ) 191 192 193class PreparedUprev(NamedTuple): 194 """Container for the information returned by prepare_uprev.""" 195 196 template_version: RustVersion 197 198 199def compute_ebuild_path(category: str, name: str, version: RustVersion) -> Path: 200 return EBUILD_PREFIX / category / name / f"{name}-{version}.ebuild" 201 202 203def compute_rustc_src_name(version: RustVersion) -> str: 204 return f"rustc-{version}-src.tar.gz" 205 206 207def find_ebuild_for_package(name: str) -> str: 208 """Returns the path to the ebuild for the named package.""" 209 return run_in_chroot( 210 [EQUERY, "w", name], 211 stdout=subprocess.PIPE, 212 ).stdout.strip() 213 214 215def find_ebuild_path( 216 directory: Path, name: str, version: Optional[RustVersion] = None 217) -> Path: 218 """Finds an ebuild in a directory. 219 220 Returns the path to the ebuild file. The match is constrained by 221 name and optionally by version, but can match any patch level. 222 E.g. "rust" version 1.3.4 can match rust-1.3.4.ebuild but also 223 rust-1.3.4-r6.ebuild. 224 225 The expectation is that there is only one matching ebuild, and 226 an assert is raised if this is not the case. However, symlinks to 227 ebuilds in the same directory are ignored, so having a 228 rust-x.y.z-rn.ebuild symlink to rust-x.y.z.ebuild is allowed. 229 """ 230 if version: 231 pattern = f"{name}-{version}*.ebuild" 232 else: 233 pattern = f"{name}-*.ebuild" 234 matches = set(directory.glob(pattern)) 235 result = [] 236 # Only count matches that are not links to other matches. 237 for m in matches: 238 try: 239 target = os.readlink(directory / m) 240 except OSError: 241 # Getting here means the match is not a symlink to one of 242 # the matching ebuilds, so add it to the result list. 243 result.append(m) 244 continue 245 if directory / target not in matches: 246 result.append(m) 247 assert len(result) == 1, result 248 return result[0] 249 250 251def get_rust_bootstrap_version(): 252 """Get the version of the current rust-bootstrap package.""" 253 bootstrap_ebuild = find_ebuild_path(rust_bootstrap_path(), "rust-bootstrap") 254 m = re.match(r"^rust-bootstrap-(\d+).(\d+).(\d+)", bootstrap_ebuild.name) 255 assert m, bootstrap_ebuild.name 256 return RustVersion(int(m.group(1)), int(m.group(2)), int(m.group(3))) 257 258 259def parse_commandline_args() -> argparse.Namespace: 260 parser = argparse.ArgumentParser( 261 description=__doc__, 262 formatter_class=argparse.RawDescriptionHelpFormatter, 263 ) 264 parser.add_argument( 265 "--state_file", 266 required=True, 267 help="A state file to hold previous completed steps. If the file " 268 "exists, it needs to be used together with --continue or --restart. " 269 "If not exist (do not use --continue in this case), we will create a " 270 "file for you.", 271 ) 272 parser.add_argument( 273 "--restart", 274 action="store_true", 275 help="Restart from the first step. Ignore the completed steps in " 276 "the state file", 277 ) 278 parser.add_argument( 279 "--continue", 280 dest="cont", 281 action="store_true", 282 help="Continue the steps from the state file", 283 ) 284 285 create_parser_template = argparse.ArgumentParser(add_help=False) 286 create_parser_template.add_argument( 287 "--template", 288 type=RustVersion.parse, 289 default=None, 290 help="A template to use for creating a Rust uprev from, in the form " 291 "a.b.c The ebuild has to exist in the chroot. If not specified, the " 292 "tool will use the current Rust version in the chroot as template.", 293 ) 294 create_parser_template.add_argument( 295 "--skip_compile", 296 action="store_true", 297 help="Skip compiling rust to test the tool. Only for testing", 298 ) 299 300 subparsers = parser.add_subparsers(dest="subparser_name") 301 subparser_names = [] 302 subparser_names.append("create") 303 create_parser = subparsers.add_parser( 304 "create", 305 parents=[create_parser_template], 306 help="Create changes uprevs Rust to a new version", 307 ) 308 create_parser.add_argument( 309 "--rust_version", 310 type=RustVersion.parse, 311 required=True, 312 help="Rust version to uprev to, in the form a.b.c", 313 ) 314 315 subparser_names.append("remove") 316 remove_parser = subparsers.add_parser( 317 "remove", 318 help="Clean up old Rust version from chroot", 319 ) 320 remove_parser.add_argument( 321 "--rust_version", 322 type=RustVersion.parse, 323 default=None, 324 help="Rust version to remove, in the form a.b.c If not " 325 "specified, the tool will remove the oldest version in the chroot", 326 ) 327 328 subparser_names.append("roll") 329 roll_parser = subparsers.add_parser( 330 "roll", 331 parents=[create_parser_template], 332 help="A command can create and upload a Rust uprev CL, including " 333 "preparing the repo, creating new Rust uprev, deleting old uprev, " 334 "and upload a CL to crrev.", 335 ) 336 roll_parser.add_argument( 337 "--uprev", 338 type=RustVersion.parse, 339 required=True, 340 help="Rust version to uprev to, in the form a.b.c", 341 ) 342 roll_parser.add_argument( 343 "--remove", 344 type=RustVersion.parse, 345 default=None, 346 help="Rust version to remove, in the form a.b.c If not " 347 "specified, the tool will remove the oldest version in the chroot", 348 ) 349 roll_parser.add_argument( 350 "--skip_cross_compiler", 351 action="store_true", 352 help="Skip updating cross-compiler in the chroot", 353 ) 354 roll_parser.add_argument( 355 "--no_upload", 356 action="store_true", 357 help="If specified, the tool will not upload the CL for review", 358 ) 359 360 args = parser.parse_args() 361 if args.subparser_name not in subparser_names: 362 parser.error("one of %s must be specified" % subparser_names) 363 364 if args.cont and args.restart: 365 parser.error("Please select either --continue or --restart") 366 367 if os.path.exists(args.state_file): 368 if not args.cont and not args.restart: 369 parser.error( 370 "State file exists, so you should either --continue " 371 "or --restart" 372 ) 373 if args.cont and not os.path.exists(args.state_file): 374 parser.error("Indicate --continue but the state file does not exist") 375 376 if args.restart and os.path.exists(args.state_file): 377 os.remove(args.state_file) 378 379 return args 380 381 382def prepare_uprev( 383 rust_version: RustVersion, template: RustVersion 384) -> Optional[PreparedUprev]: 385 ebuild_path = find_ebuild_for_rust_version(template) 386 387 if rust_version <= template: 388 logging.info( 389 "Requested version %s is not newer than the template version %s.", 390 rust_version, 391 template, 392 ) 393 return None 394 395 logging.info( 396 "Template Rust version is %s (ebuild: %s)", 397 template, 398 ebuild_path, 399 ) 400 401 return PreparedUprev(template) 402 403 404def create_ebuild( 405 category: str, 406 name: str, 407 template_version: RustVersion, 408 new_version: RustVersion, 409) -> None: 410 template_ebuild = compute_ebuild_path(category, name, template_version) 411 new_ebuild = compute_ebuild_path(category, name, new_version) 412 shutil.copyfile(template_ebuild, new_ebuild) 413 subprocess.check_call( 414 ["git", "add", new_ebuild.name], cwd=new_ebuild.parent 415 ) 416 417 418def set_include_profdata_src(ebuild_path: os.PathLike, include: bool) -> None: 419 """Changes an ebuild file to include or omit profile data from SRC_URI. 420 421 If include is True, the ebuild file will be rewritten to include 422 profile data in SRC_URI. 423 424 If include is False, the ebuild file will be rewritten to omit profile 425 data from SRC_URI. 426 """ 427 if include: 428 old = "" 429 new = "yes" 430 else: 431 old = "yes" 432 new = "" 433 contents = Path(ebuild_path).read_text(encoding="utf-8") 434 contents, subs = re.subn( 435 f"^INCLUDE_PROFDATA_IN_SRC_URI={old}$", 436 f"INCLUDE_PROFDATA_IN_SRC_URI={new}", 437 contents, 438 flags=re.MULTILINE, 439 ) 440 # We expect exactly one substitution. 441 assert subs == 1, "Failed to update INCLUDE_PROFDATA_IN_SRC_URI" 442 Path(ebuild_path).write_text(contents, encoding="utf-8") 443 444 445def update_bootstrap_version( 446 path: PathOrStr, new_bootstrap_version: RustVersion 447) -> None: 448 path = Path(path) 449 contents = path.read_text(encoding="utf-8") 450 contents, subs = re.subn( 451 r"^BOOTSTRAP_VERSION=.*$", 452 'BOOTSTRAP_VERSION="%s"' % (new_bootstrap_version,), 453 contents, 454 flags=re.MULTILINE, 455 ) 456 if not subs: 457 raise RuntimeError(f"BOOTSTRAP_VERSION not found in {path}") 458 path.write_text(contents, encoding="utf-8") 459 logging.info("Rust BOOTSTRAP_VERSION updated to %s", new_bootstrap_version) 460 461 462def ebuild_actions( 463 package: str, actions: List[str], sudo: bool = False 464) -> None: 465 ebuild_path_inchroot = find_ebuild_for_package(package) 466 cmd = ["ebuild", ebuild_path_inchroot] + actions 467 if sudo: 468 cmd = ["sudo"] + cmd 469 run_in_chroot(cmd) 470 471 472def fetch_distfile_from_mirror(name: str) -> None: 473 """Gets the named file from the local mirror. 474 475 This ensures that the file exists on the mirror, and 476 that we can read it. We overwrite any existing distfile 477 to ensure the checksums that `ebuild manifest` records 478 match the file as it exists on the mirror. 479 480 This function also attempts to verify the ACL for 481 the file (which is expected to have READER permission 482 for allUsers). We can only see the ACL if the user 483 gsutil runs with is the owner of the file. If not, 484 we get an access denied error. We also count this 485 as a success, because it means we were able to fetch 486 the file even though we don't own it. 487 """ 488 mirror_file = MIRROR_PATH + "/" + name 489 local_file = get_distdir() / name 490 cmd: Command = [GSUTIL, "cp", mirror_file, local_file] 491 logging.info("Running %r", cmd) 492 rc = subprocess.call(cmd) 493 if rc != 0: 494 logging.error( 495 """Could not fetch %s 496 497If the file does not yet exist at %s 498please download the file, verify its integrity 499with something like: 500 501curl -O https://static.rust-lang.org/dist/%s 502gpg --verify %s.asc 503 504You may need to import the signing key first, e.g.: 505 506gpg --recv-keys 85AB96E6FA1BE5FE 507 508Once you have verify the integrity of the file, upload 509it to the local mirror using gsutil cp. 510""", 511 mirror_file, 512 MIRROR_PATH, 513 name, 514 name, 515 ) 516 raise Exception(f"Could not fetch {mirror_file}") 517 # Check that the ACL allows allUsers READER access. 518 # If we get an AccessDeniedAcception here, that also 519 # counts as a success, because we were able to fetch 520 # the file as a non-owner. 521 cmd = [GSUTIL, "acl", "get", mirror_file] 522 logging.info("Running %r", cmd) 523 output = get_command_output_unchecked(cmd, stderr=subprocess.STDOUT) 524 acl_verified = False 525 if "AccessDeniedException:" in output: 526 acl_verified = True 527 else: 528 acl = json.loads(output) 529 for x in acl: 530 if x["entity"] == "allUsers" and x["role"] == "READER": 531 acl_verified = True 532 break 533 if not acl_verified: 534 logging.error("Output from acl get:\n%s", output) 535 raise Exception("Could not verify that allUsers has READER permission") 536 537 538def fetch_bootstrap_distfiles(version: RustVersion) -> None: 539 """Fetches rust-bootstrap distfiles from the local mirror 540 541 Fetches the distfiles for a rust-bootstrap ebuild to ensure they 542 are available on the mirror and the local copies are the same as 543 the ones on the mirror. 544 """ 545 fetch_distfile_from_mirror(compute_rustc_src_name(version)) 546 547 548def fetch_rust_distfiles(version: RustVersion) -> None: 549 """Fetches rust distfiles from the local mirror 550 551 Fetches the distfiles for a rust ebuild to ensure they 552 are available on the mirror and the local copies are 553 the same as the ones on the mirror. 554 """ 555 fetch_distfile_from_mirror(compute_rustc_src_name(version)) 556 557 558def fetch_rust_src_from_upstream(uri: str, local_path: Path) -> None: 559 """Fetches Rust sources from upstream. 560 561 This downloads the source distribution and the .asc file 562 containing the signatures. It then verifies that the sources 563 have the expected signature and have been signed by 564 the expected key. 565 """ 566 subprocess.run( 567 [GPG, "--keyserver", GPG_KEYSERVER, "--recv-keys", RUST_SIGNING_KEY], 568 check=True, 569 ) 570 subprocess.run( 571 [GPG, "--keyserver", GPG_KEYSERVER, "--refresh-keys", RUST_SIGNING_KEY], 572 check=True, 573 ) 574 asc_uri = uri + ".asc" 575 local_asc_path = Path(local_path.parent, local_path.name + ".asc") 576 logging.info("Fetching %s", uri) 577 urllib.request.urlretrieve(uri, local_path) 578 logging.info("%s fetched", uri) 579 580 # Raise SignatureVerificationError if we cannot get the signature. 581 try: 582 logging.info("Fetching %s", asc_uri) 583 urllib.request.urlretrieve(asc_uri, local_asc_path) 584 logging.info("%s fetched", asc_uri) 585 except Exception as e: 586 raise SignatureVerificationError( 587 f"error fetching signature file {asc_uri}", 588 local_path, 589 ) from e 590 591 # Raise SignatureVerificationError if verifying the signature 592 # failed. 593 try: 594 output = get_command_output( 595 [GPG, "--verify", "--status-fd", "1", local_asc_path] 596 ) 597 except subprocess.CalledProcessError as e: 598 raise SignatureVerificationError( 599 f"error verifying signature. GPG output:\n{e.stdout}", 600 local_path, 601 ) from e 602 603 # Raise SignatureVerificationError if the file was not signed 604 # with the expected key. 605 if f"GOODSIG {RUST_SIGNING_KEY}" not in output: 606 message = f"GOODSIG {RUST_SIGNING_KEY} not found in output" 607 if f"REVKEYSIG {RUST_SIGNING_KEY}" in output: 608 message = "signing key has been revoked" 609 elif f"EXPKEYSIG {RUST_SIGNING_KEY}" in output: 610 message = "signing key has expired" 611 elif f"EXPSIG {RUST_SIGNING_KEY}" in output: 612 message = "signature has expired" 613 raise SignatureVerificationError( 614 f"{message}. GPG output:\n{output}", 615 local_path, 616 ) 617 618 619def get_distdir() -> Path: 620 """Returns portage's distdir outside the chroot.""" 621 return SOURCE_ROOT / ".cache/distfiles" 622 623 624def mirror_has_file(name: str) -> bool: 625 """Checks if the mirror has the named file.""" 626 mirror_file = MIRROR_PATH + "/" + name 627 cmd: Command = [GSUTIL, "ls", mirror_file] 628 proc = subprocess.run( 629 cmd, 630 check=False, 631 stdout=subprocess.PIPE, 632 stderr=subprocess.STDOUT, 633 encoding="utf-8", 634 ) 635 if "URLs matched no objects" in proc.stdout: 636 return False 637 elif proc.returncode == 0: 638 return True 639 640 raise Exception( 641 "Unexpected result from gsutil ls:" 642 f" rc {proc.returncode} output:\n{proc.stdout}" 643 ) 644 645 646def mirror_rust_source(version: RustVersion) -> None: 647 """Ensures source code for a Rust version is on the local mirror. 648 649 If the source code is not found on the mirror, it is fetched 650 from upstream, its integrity is verified, and it is uploaded 651 to the mirror. 652 """ 653 filename = compute_rustc_src_name(version) 654 if mirror_has_file(filename): 655 logging.info("%s is present on the mirror", filename) 656 return 657 uri = f"{RUST_SRC_BASE_URI}{filename}" 658 local_path = get_distdir() / filename 659 mirror_path = f"{MIRROR_PATH}/{filename}" 660 fetch_rust_src_from_upstream(uri, local_path) 661 subprocess.run( 662 [GSUTIL, "cp", "-a", "public-read", local_path, mirror_path], 663 check=True, 664 ) 665 666 667def update_rust_packages( 668 pkgatom: str, rust_version: RustVersion, add: bool 669) -> None: 670 package_file = EBUILD_PREFIX.joinpath( 671 "profiles/targets/chromeos/package.provided" 672 ) 673 with open(package_file, encoding="utf-8") as f: 674 contents = f.read() 675 if add: 676 rust_packages_re = re.compile( 677 "^" + re.escape(pkgatom) + r"-\d+\.\d+\.\d+$", re.MULTILINE 678 ) 679 rust_packages = rust_packages_re.findall(contents) 680 # Assume all the rust packages are in alphabetical order, so insert 681 # the new version to the place after the last rust_packages 682 new_str = f"{pkgatom}-{rust_version}" 683 new_contents = contents.replace( 684 rust_packages[-1], f"{rust_packages[-1]}\n{new_str}" 685 ) 686 logging.info("%s has been inserted into package.provided", new_str) 687 else: 688 old_str = f"{pkgatom}-{rust_version}\n" 689 assert old_str in contents, f"{old_str!r} not found in package.provided" 690 new_contents = contents.replace(old_str, "") 691 logging.info("%s has been removed from package.provided", old_str) 692 693 with open(package_file, "w", encoding="utf-8") as f: 694 f.write(new_contents) 695 696 697def update_virtual_rust( 698 template_version: RustVersion, new_version: RustVersion 699) -> None: 700 template_ebuild = find_ebuild_path( 701 EBUILD_PREFIX.joinpath("virtual/rust"), "rust", template_version 702 ) 703 virtual_rust_dir = template_ebuild.parent 704 new_name = f"rust-{new_version}.ebuild" 705 new_ebuild = virtual_rust_dir.joinpath(new_name) 706 shutil.copyfile(template_ebuild, new_ebuild) 707 subprocess.check_call(["git", "add", new_name], cwd=virtual_rust_dir) 708 709 710def unmerge_package_if_installed(pkgatom: str) -> None: 711 """Unmerges a package if it is installed.""" 712 shpkg = shlex.quote(pkgatom) 713 run_in_chroot( 714 [ 715 "sudo", 716 "bash", 717 "-c", 718 f"! emerge --pretend --quiet --unmerge {shpkg}" 719 f" || emerge --rage-clean {shpkg}", 720 ], 721 ) 722 723 724def perform_step( 725 state_file: pathlib.Path, 726 tmp_state_file: pathlib.Path, 727 completed_steps: Dict[str, Any], 728 step_name: str, 729 step_fn: Callable[[], T], 730 result_from_json: Optional[Callable[[Any], T]] = None, 731 result_to_json: Optional[Callable[[T], Any]] = None, 732) -> T: 733 if step_name in completed_steps: 734 logging.info("Skipping previously completed step %s", step_name) 735 if result_from_json: 736 return result_from_json(completed_steps[step_name]) 737 return completed_steps[step_name] 738 739 logging.info("Running step %s", step_name) 740 val = step_fn() 741 logging.info("Step %s complete", step_name) 742 if result_to_json: 743 completed_steps[step_name] = result_to_json(val) 744 else: 745 completed_steps[step_name] = val 746 747 with tmp_state_file.open("w", encoding="utf-8") as f: 748 json.dump(completed_steps, f, indent=4) 749 tmp_state_file.rename(state_file) 750 return val 751 752 753def prepare_uprev_from_json(obj: Any) -> Optional[PreparedUprev]: 754 if not obj: 755 return None 756 version = obj[0] 757 return PreparedUprev( 758 RustVersion(*version), 759 ) 760 761 762def prepare_uprev_to_json( 763 prepared_uprev: Optional[PreparedUprev], 764) -> Optional[Tuple[RustVersion]]: 765 if prepared_uprev is None: 766 return None 767 return (prepared_uprev.template_version,) 768 769 770def create_rust_uprev( 771 rust_version: RustVersion, 772 template_version: RustVersion, 773 skip_compile: bool, 774 run_step: RunStepFn, 775) -> None: 776 prepared = run_step( 777 "prepare uprev", 778 lambda: prepare_uprev(rust_version, template_version), 779 result_from_json=prepare_uprev_from_json, 780 result_to_json=prepare_uprev_to_json, 781 ) 782 if prepared is None: 783 return 784 template_version = prepared.template_version 785 786 run_step( 787 "mirror bootstrap sources", 788 lambda: mirror_rust_source( 789 template_version, 790 ), 791 ) 792 run_step( 793 "mirror rust sources", 794 lambda: mirror_rust_source( 795 rust_version, 796 ), 797 ) 798 799 # The fetch steps will fail (on purpose) if the files they check for 800 # are not available on the mirror. To make them pass, fetch the 801 # required files yourself, verify their checksums, then upload them 802 # to the mirror. 803 run_step( 804 "fetch bootstrap distfiles", 805 lambda: fetch_bootstrap_distfiles(template_version), 806 ) 807 run_step("fetch rust distfiles", lambda: fetch_rust_distfiles(rust_version)) 808 run_step( 809 "update bootstrap version", 810 lambda: update_bootstrap_version(CROS_RUSTC_ECLASS, template_version), 811 ) 812 run_step( 813 "turn off profile data sources in cros-rustc.eclass", 814 lambda: set_include_profdata_src(CROS_RUSTC_ECLASS, include=False), 815 ) 816 817 for category, name in RUST_PACKAGES: 818 run_step( 819 f"create new {category}/{name} ebuild", 820 functools.partial( 821 create_ebuild, 822 category, 823 name, 824 template_version, 825 rust_version, 826 ), 827 ) 828 829 run_step( 830 "update dev-lang/rust-host manifest to add new version", 831 lambda: ebuild_actions("dev-lang/rust-host", ["manifest"]), 832 ) 833 834 run_step( 835 "generate profile data for rustc", 836 lambda: run_in_chroot([PGO_RUST, "generate"]), 837 # Avoid returning subprocess.CompletedProcess, which cannot be 838 # serialized to JSON. 839 result_to_json=lambda _x: None, 840 ) 841 run_step( 842 "upload profile data for rustc", 843 lambda: run_in_chroot([PGO_RUST, "upload-profdata"]), 844 # Avoid returning subprocess.CompletedProcess, which cannot be 845 # serialized to JSON. 846 result_to_json=lambda _x: None, 847 ) 848 run_step( 849 "turn on profile data sources in cros-rustc.eclass", 850 lambda: set_include_profdata_src(CROS_RUSTC_ECLASS, include=True), 851 ) 852 run_step( 853 "update dev-lang/rust-host manifest to add profile data", 854 lambda: ebuild_actions("dev-lang/rust-host", ["manifest"]), 855 ) 856 if not skip_compile: 857 run_step("build packages", lambda: rebuild_packages(rust_version)) 858 run_step( 859 "insert host version into rust packages", 860 lambda: update_rust_packages( 861 "dev-lang/rust-host", rust_version, add=True 862 ), 863 ) 864 run_step( 865 "insert target version into rust packages", 866 lambda: update_rust_packages("dev-lang/rust", rust_version, add=True), 867 ) 868 run_step( 869 "upgrade virtual/rust", 870 lambda: update_virtual_rust(template_version, rust_version), 871 ) 872 873 874def find_rust_versions() -> List[Tuple[RustVersion, Path]]: 875 """Returns (RustVersion, ebuild_path) for base versions of dev-lang/rust. 876 877 This excludes symlinks to ebuilds, so if rust-1.34.0.ebuild and 878 rust-1.34.0-r1.ebuild both exist and -r1 is a symlink to the other, 879 only rust-1.34.0.ebuild will be in the return value. 880 """ 881 return [ 882 (RustVersion.parse_from_ebuild(ebuild), ebuild) 883 for ebuild in RUST_PATH.iterdir() 884 if ebuild.suffix == ".ebuild" and not ebuild.is_symlink() 885 ] 886 887 888def find_oldest_rust_version() -> RustVersion: 889 """Returns the RustVersion of the oldest dev-lang/rust ebuild.""" 890 rust_versions = find_rust_versions() 891 if len(rust_versions) <= 1: 892 raise RuntimeError("Expect to find more than one Rust versions") 893 return min(rust_versions)[0] 894 895 896def find_ebuild_for_rust_version(version: RustVersion) -> Path: 897 """Returns the path of the ebuild for the given version of dev-lang/rust.""" 898 return find_ebuild_path(RUST_PATH, "rust", version) 899 900 901def rebuild_packages(version: RustVersion): 902 """Rebuild packages modified by this script.""" 903 # Remove all packages we modify to avoid depending on preinstalled 904 # versions. This ensures that the packages can really be built. 905 packages = [f"{category}/{name}" for category, name in RUST_PACKAGES] 906 for pkg in packages: 907 unmerge_package_if_installed(pkg) 908 # Mention only dev-lang/rust explicitly, so that others are pulled 909 # in as dependencies (letting us detect dependency errors). 910 # Packages we modify are listed in --usepkg-exclude to ensure they 911 # are built from source. 912 try: 913 run_in_chroot( 914 [ 915 "sudo", 916 "emerge", 917 "--quiet-build", 918 "--usepkg-exclude", 919 " ".join(packages), 920 f"=dev-lang/rust-{version}", 921 ], 922 ) 923 except: 924 logging.warning( 925 "Failed to build dev-lang/rust or one of its dependencies." 926 " If necessary, you can restore rust and rust-host from" 927 " binary packages:\n sudo emerge --getbinpkgonly dev-lang/rust" 928 ) 929 raise 930 931 932def remove_ebuild_version(path: PathOrStr, name: str, version: RustVersion): 933 """Remove the specified version of an ebuild. 934 935 Removes {path}/{name}-{version}.ebuild and {path}/{name}-{version}-*.ebuild 936 using git rm. 937 938 Args: 939 path: The directory in which the ebuild files are. 940 name: The name of the package (e.g. 'rust'). 941 version: The version of the ebuild to remove. 942 """ 943 path = Path(path) 944 pattern = f"{name}-{version}-*.ebuild" 945 matches = list(path.glob(pattern)) 946 ebuild = path / f"{name}-{version}.ebuild" 947 if ebuild.exists(): 948 matches.append(ebuild) 949 if not matches: 950 logging.warning( 951 "No ebuilds matching %s version %s in %r", name, version, str(path) 952 ) 953 for m in matches: 954 remove_files(m.name, path) 955 956 957def remove_files(filename: PathOrStr, path: PathOrStr) -> None: 958 subprocess.check_call(["git", "rm", filename], cwd=path) 959 960 961def remove_rust_uprev( 962 rust_version: Optional[RustVersion], 963 run_step: RunStepFn, 964) -> None: 965 def find_desired_rust_version() -> RustVersion: 966 if rust_version: 967 return rust_version 968 return find_oldest_rust_version() 969 970 def find_desired_rust_version_from_json(obj: Any) -> RustVersion: 971 return RustVersion(*obj) 972 973 delete_version = run_step( 974 "find rust version to delete", 975 find_desired_rust_version, 976 result_from_json=find_desired_rust_version_from_json, 977 ) 978 979 for category, name in RUST_PACKAGES: 980 run_step( 981 f"remove old {name} ebuild", 982 functools.partial( 983 remove_ebuild_version, 984 EBUILD_PREFIX / category / name, 985 name, 986 delete_version, 987 ), 988 ) 989 990 run_step( 991 "update dev-lang/rust-host manifest to delete old version", 992 lambda: ebuild_actions("dev-lang/rust-host", ["manifest"]), 993 ) 994 run_step( 995 "remove target version from rust packages", 996 lambda: update_rust_packages( 997 "dev-lang/rust", delete_version, add=False 998 ), 999 ) 1000 run_step( 1001 "remove host version from rust packages", 1002 lambda: update_rust_packages( 1003 "dev-lang/rust-host", delete_version, add=False 1004 ), 1005 ) 1006 run_step("remove virtual/rust", lambda: remove_virtual_rust(delete_version)) 1007 1008 1009def remove_virtual_rust(delete_version: RustVersion) -> None: 1010 remove_ebuild_version( 1011 EBUILD_PREFIX.joinpath("virtual/rust"), "rust", delete_version 1012 ) 1013 1014 1015def rust_bootstrap_path() -> Path: 1016 return EBUILD_PREFIX.joinpath("dev-lang/rust-bootstrap") 1017 1018 1019def create_new_repo(rust_version: RustVersion) -> None: 1020 output = get_command_output( 1021 ["git", "status", "--porcelain"], cwd=EBUILD_PREFIX 1022 ) 1023 if output: 1024 raise RuntimeError( 1025 f"{EBUILD_PREFIX} has uncommitted changes, please either discard " 1026 "them or commit them." 1027 ) 1028 git.CreateBranch(EBUILD_PREFIX, f"rust-to-{rust_version}") 1029 1030 1031def build_cross_compiler(template_version: RustVersion) -> None: 1032 # Get target triples in ebuild 1033 rust_ebuild = find_ebuild_path(RUST_PATH, "rust", template_version) 1034 contents = rust_ebuild.read_text(encoding="utf-8") 1035 1036 target_triples_re = re.compile(r"RUSTC_TARGET_TRIPLES=\(([^)]+)\)") 1037 m = target_triples_re.search(contents) 1038 assert m, "RUST_TARGET_TRIPLES not found in rust ebuild" 1039 target_triples = m.group(1).strip().split("\n") 1040 1041 compiler_targets_to_install = [ 1042 target.strip() for target in target_triples if "cros-" in target 1043 ] 1044 for target in target_triples: 1045 if "cros-" not in target: 1046 continue 1047 target = target.strip() 1048 1049 # We also always need arm-none-eabi, though it's not mentioned in 1050 # RUSTC_TARGET_TRIPLES. 1051 compiler_targets_to_install.append("arm-none-eabi") 1052 1053 logging.info("Emerging cross compilers %s", compiler_targets_to_install) 1054 run_in_chroot( 1055 ["sudo", "emerge", "-j", "-G"] 1056 + [f"cross-{target}/gcc" for target in compiler_targets_to_install], 1057 ) 1058 1059 1060def create_new_commit(rust_version: RustVersion) -> None: 1061 subprocess.check_call(["git", "add", "-A"], cwd=EBUILD_PREFIX) 1062 messages = [ 1063 f"[DO NOT SUBMIT] dev-lang/rust: upgrade to Rust {rust_version}", 1064 "", 1065 "This CL is created by rust_uprev tool automatically." "", 1066 "BUG=None", 1067 "TEST=Use CQ to test the new Rust version", 1068 ] 1069 branch = f"rust-to-{rust_version}" 1070 git.CommitChanges(EBUILD_PREFIX, messages) 1071 git.UploadChanges(EBUILD_PREFIX, branch) 1072 1073 1074def run_in_chroot(cmd: Command, *args, **kwargs) -> subprocess.CompletedProcess: 1075 """Runs a command in the ChromiumOS chroot. 1076 1077 This takes the same arguments as subprocess.run(). By default, 1078 it uses check=True, encoding="utf-8". If needed, these can be 1079 overridden by keyword arguments passed to run_in_chroot(). 1080 """ 1081 full_kwargs = dict( 1082 { 1083 "check": True, 1084 "encoding": "utf-8", 1085 }, 1086 **kwargs, 1087 ) 1088 full_cmd = ["cros_sdk", "--"] + list(cmd) 1089 logging.info("Running %s", shlex.join(str(x) for x in full_cmd)) 1090 # pylint: disable=subprocess-run-check 1091 # (check is actually set above; it defaults to True) 1092 return subprocess.run(full_cmd, *args, **full_kwargs) 1093 1094 1095def sudo_keepalive() -> None: 1096 """Ensures we have sudo credentials, and keeps them up-to-date. 1097 1098 Some operations (notably run_in_chroot) run sudo, which may require 1099 user interaction. To avoid blocking progress while we sit waiting 1100 for that interaction, sudo_keepalive checks that we have cached 1101 sudo credentials, gets them if necessary, then keeps them up-to-date 1102 so that the rest of the script can run without needing to get 1103 sudo credentials again. 1104 """ 1105 logging.info( 1106 "Caching sudo credentials for running commands inside the chroot" 1107 ) 1108 # Exits successfully if cached credentials exist. Otherwise, tries 1109 # created cached credentials, prompting for authentication if necessary. 1110 subprocess.run(["sudo", "true"], check=True) 1111 1112 def sudo_keepalive_loop() -> None: 1113 # Between credential refreshes, we sleep so that we don't 1114 # unnecessarily burn CPU cycles. The sleep time must be shorter 1115 # than sudo's configured cached credential expiration time, which 1116 # is 15 minutes by default. 1117 sleep_seconds = 10 * 60 1118 # So as to not keep credentials cached forever, we limit the number 1119 # of times we will refresh them. 1120 max_seconds = 16 * 3600 1121 max_refreshes = max_seconds // sleep_seconds 1122 for _x in range(max_refreshes): 1123 # Refreshes cached credentials if they exist, but never prompts 1124 # for anything. If cached credentials do not exist, this 1125 # command exits with an error. We ignore that error to keep the 1126 # loop going, so that cached credentials will be kept fresh 1127 # again once we have them (e.g. after the next cros_sdk command 1128 # successfully authenticates the user). 1129 # 1130 # The standard file descriptors are all redirected to/from 1131 # /dev/null to prevent this command from consuming any input 1132 # or mixing its output with that of the other commands rust_uprev 1133 # runs (which could be confusing, for example making it look like 1134 # errors occurred during a build when they are actually in a 1135 # separate task). 1136 # 1137 # Note: The command specifically uses "true" and not "-v", because 1138 # it turns out that "-v" actually will prompt for a password when 1139 # sudo is configured with NOPASSWD=all, even though in that case 1140 # no password is required to run actual commands. 1141 subprocess.run( 1142 ["sudo", "-n", "true"], 1143 check=False, 1144 stdin=subprocess.DEVNULL, 1145 stdout=subprocess.DEVNULL, 1146 stderr=subprocess.DEVNULL, 1147 ) 1148 time.sleep(sleep_seconds) 1149 1150 # daemon=True causes the thread to be killed when the script exits. 1151 threading.Thread(target=sudo_keepalive_loop, daemon=True).start() 1152 1153 1154def main() -> None: 1155 chroot.VerifyOutsideChroot() 1156 logging.basicConfig(level=logging.INFO) 1157 args = parse_commandline_args() 1158 state_file = pathlib.Path(args.state_file) 1159 tmp_state_file = state_file.with_suffix(".tmp") 1160 1161 try: 1162 with state_file.open(encoding="utf-8") as f: 1163 completed_steps = json.load(f) 1164 except FileNotFoundError: 1165 completed_steps = {} 1166 1167 def run_step( 1168 step_name: str, 1169 step_fn: Callable[[], T], 1170 result_from_json: Optional[Callable[[Any], T]] = None, 1171 result_to_json: Optional[Callable[[T], Any]] = None, 1172 ) -> T: 1173 return perform_step( 1174 state_file, 1175 tmp_state_file, 1176 completed_steps, 1177 step_name, 1178 step_fn, 1179 result_from_json, 1180 result_to_json, 1181 ) 1182 1183 if args.subparser_name == "create": 1184 sudo_keepalive() 1185 create_rust_uprev( 1186 args.rust_version, args.template, args.skip_compile, run_step 1187 ) 1188 elif args.subparser_name == "remove": 1189 remove_rust_uprev(args.rust_version, run_step) 1190 else: 1191 # If you have added more subparser_name, please also add the handlers 1192 # above 1193 assert args.subparser_name == "roll" 1194 1195 sudo_keepalive() 1196 # Determine the template version, if not given. 1197 template_version = args.template 1198 if template_version is None: 1199 rust_ebuild = find_ebuild_for_package("dev-lang/rust") 1200 template_version = RustVersion.parse_from_ebuild(rust_ebuild) 1201 1202 run_step("create new repo", lambda: create_new_repo(args.uprev)) 1203 if not args.skip_cross_compiler: 1204 run_step( 1205 "build cross compiler", 1206 lambda: build_cross_compiler(template_version), 1207 ) 1208 create_rust_uprev( 1209 args.uprev, template_version, args.skip_compile, run_step 1210 ) 1211 remove_rust_uprev(args.remove, run_step) 1212 prepared = prepare_uprev_from_json(completed_steps["prepare uprev"]) 1213 assert prepared is not None, "no prepared uprev decoded from JSON" 1214 if not args.no_upload: 1215 run_step( 1216 "create rust uprev CL", lambda: create_new_commit(args.uprev) 1217 ) 1218 1219 1220if __name__ == "__main__": 1221 main() 1222