1#!/usr/bin/env python3 2# Copyright 2023 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"""Automatically maintains the rust-bootstrap package. 7 8This script is responsible for: 9 - uploading new rust-bootstrap prebuilts 10 - adding new versions of rust-bootstrap to keep up with dev-lang/rust 11 - removing versions of rust-bootstrap that are no longer depended on by 12 dev-lang/rust 13 14It's capable of (and intended to primarily be used for) uploading CLs to do 15these things on its own, so it can easily be regularly run by Chrotomation. 16""" 17 18import argparse 19import collections 20import dataclasses 21import functools 22import glob 23import logging 24import os 25from pathlib import Path 26import re 27import subprocess 28import sys 29import textwrap 30from typing import Dict, Iterable, List, Optional, Tuple, Union 31 32import copy_rust_bootstrap 33 34 35# The bug to tag in all commit messages. 36TRACKING_BUG = "b:315473495" 37 38# Reviewers for all CLs uploaded. 39DEFAULT_CL_REVIEWERS = ( 40 "[email protected]", 41 "[email protected]", 42) 43 44 45@dataclasses.dataclass(frozen=True, eq=True, order=True) 46class EbuildVersion: 47 """Represents an ebuild version, simplified for rust-bootstrap versions. 48 49 "Simplified," means that no `_pre`/etc suffixes have to be accounted for. 50 """ 51 52 major: int 53 minor: int 54 patch: int 55 rev: int 56 57 def prior_minor_version(self) -> "EbuildVersion": 58 """Returns an EbuildVersion with just the major/minor from this one.""" 59 return dataclasses.replace(self, minor=self.minor - 1) 60 61 def major_minor_only(self) -> "EbuildVersion": 62 """Returns an EbuildVersion with just the major/minor from this one.""" 63 if not self.rev and not self.patch: 64 return self 65 return EbuildVersion( 66 major=self.major, 67 minor=self.minor, 68 patch=0, 69 rev=0, 70 ) 71 72 def without_rev(self) -> "EbuildVersion": 73 if not self.rev: 74 return self 75 return dataclasses.replace(self, rev=0) 76 77 def __str__(self): 78 result = f"{self.major}.{self.minor}.{self.patch}" 79 if self.rev: 80 result += f"-r{self.rev}" 81 return result 82 83 84def find_raw_bootstrap_sequence_lines( 85 ebuild_lines: List[str], 86) -> Tuple[int, int]: 87 """Returns the start/end lines of RUSTC_RAW_FULL_BOOTSTRAP_SEQUENCE.""" 88 for i, line in enumerate(ebuild_lines): 89 if line.startswith("RUSTC_RAW_FULL_BOOTSTRAP_SEQUENCE=("): 90 start = i 91 break 92 else: 93 raise ValueError("No bootstrap sequence start found in text") 94 95 for i, line in enumerate(ebuild_lines[i + 1 :], i + 1): 96 if line.rstrip() == ")": 97 return start, i 98 raise ValueError("No bootstrap sequence end found in text") 99 100 101def read_bootstrap_sequence_from_ebuild( 102 rust_bootstrap_ebuild: Path, 103) -> List[EbuildVersion]: 104 """Returns a list of EbuildVersions from the given ebuild.""" 105 ebuild_lines = rust_bootstrap_ebuild.read_text( 106 encoding="utf-8" 107 ).splitlines() 108 start, end = find_raw_bootstrap_sequence_lines(ebuild_lines) 109 results = [] 110 for line in ebuild_lines[start + 1 : end]: 111 # Ignore comments. 112 line = line.split("#", 1)[0].strip() 113 if not line: 114 continue 115 assert len(line.split()) == 1, f"Unexpected line: {line!r}" 116 results.append(parse_raw_ebuild_version(line.strip())) 117 return results 118 119 120def version_listed_in_bootstrap_sequence( 121 ebuild: Path, rust_bootstrap_version: EbuildVersion 122) -> bool: 123 ebuild_lines = ebuild.read_text(encoding="utf-8").splitlines() 124 start, end = find_raw_bootstrap_sequence_lines(ebuild_lines) 125 str_version = str(rust_bootstrap_version.without_rev()) 126 return any( 127 line.strip() == str_version for line in ebuild_lines[start + 1 : end] 128 ) 129 130 131@functools.lru_cache(1) 132def fetch_most_recent_sdk_version() -> str: 133 """Fetches the most recent official SDK version from gs://.""" 134 latest_file_loc = "gs://chromiumos-sdk/cros-sdk-latest.conf" 135 sdk_latest_file = subprocess.run( 136 ["gsutil", "cat", latest_file_loc], 137 check=True, 138 encoding="utf-8", 139 stdin=subprocess.DEVNULL, 140 stdout=subprocess.PIPE, 141 ).stdout.strip() 142 143 latest_sdk_re = re.compile(r'^LATEST_SDK="([0-9\.]+)"$') 144 for line in sdk_latest_file.splitlines(): 145 m = latest_sdk_re.match(line) 146 if m: 147 latest_version = m.group(1) 148 logging.info("Detected latest SDK version: %r", latest_version) 149 return latest_version 150 raise ValueError(f"Could not find LATEST_SDK in {latest_file_loc}") 151 152 153def find_rust_bootstrap_prebuilt(version: EbuildVersion) -> Optional[str]: 154 """Returns a URL to a prebuilt for `version` of rust-bootstrap.""" 155 # Searching chroot-* is generally unsafe, because some uploads might 156 # include SDK artifacts built by CQ+1 runs, so just use the most recent 157 # verified SDK version. 158 sdk_version = fetch_most_recent_sdk_version() 159 160 # Search for all rust-bootstrap versions rather than specifically 161 # `version`, since gsutil will exit(1) if no matches are found. exit(1) is 162 # desirable if _no rust boostrap artifacts at all exist_, but substantially 163 # less so if this function seeks to just `return False`. 164 gs_glob = ( 165 f"gs://chromeos-prebuilt/board/amd64-host/chroot-{sdk_version}" 166 "/packages/dev-lang/rust-bootstrap-*tbz2" 167 ) 168 169 logging.info("Searching %s for rust-bootstrap version %s", gs_glob, version) 170 results = subprocess.run( 171 ["gsutil", "ls", gs_glob], 172 check=True, 173 encoding="utf-8", 174 stdin=subprocess.DEVNULL, 175 stdout=subprocess.PIPE, 176 ).stdout.strip() 177 178 binpkg_name_re = re.compile( 179 r"rust-bootstrap-" + re.escape(str(version)) + r"\.tbz2$" 180 ) 181 result_lines = results.splitlines() 182 for line in result_lines: 183 result = line.strip() 184 if binpkg_name_re.search(result): 185 logging.info("Found rust-bootstrap prebuilt: %s", result) 186 return result 187 logging.info("Skipped rust-bootstrap prebuilt: %s", result) 188 189 logging.info( 190 "No rust-bootstrap for %s found (regex: %s); options: %s", 191 version, 192 binpkg_name_re, 193 result_lines, 194 ) 195 return None 196 197 198def parse_raw_ebuild_version(raw_ebuild_version: str) -> EbuildVersion: 199 """Parses an ebuild version without the ${PN} prefix or .ebuild suffix. 200 201 >>> parse_raw_ebuild_version("1.70.0-r2") 202 EbuildVersion(major=1, minor=70, patch=0, rev=2) 203 """ 204 version_re = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:-r(\d+))?") 205 m = version_re.match(raw_ebuild_version) 206 if not m: 207 raise ValueError(f"Version {raw_ebuild_version} can't be recognized.") 208 209 major, minor, patch, rev_str = m.groups() 210 rev = 0 if not rev_str else int(rev_str) 211 return EbuildVersion( 212 major=int(major), minor=int(minor), patch=int(patch), rev=rev 213 ) 214 215 216def parse_ebuild_version(ebuild_name: str) -> EbuildVersion: 217 """Parses the version from an ebuild. 218 219 Raises: 220 ValueError if the `ebuild_name` doesn't contain a parseable version. 221 Notably, version suffixes like `_pre`, `_beta`, etc are unexpected in 222 Rust-y ebuilds, so they're not handled here. 223 224 >>> parse_ebuild_version("rust-bootstrap-1.70.0-r2.ebuild") 225 EbuildVersion(major=1, minor=70, patch=0, rev=2) 226 """ 227 version_re = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:-r(\d+))?\.ebuild$") 228 m = version_re.search(ebuild_name) 229 if not m: 230 raise ValueError(f"Ebuild {ebuild_name} has no obvious version") 231 232 major, minor, patch, rev_str = m.groups() 233 rev = 0 if not rev_str else int(rev_str) 234 return EbuildVersion( 235 major=int(major), minor=int(minor), patch=int(patch), rev=rev 236 ) 237 238 239def collect_ebuilds_by_version( 240 ebuild_dir: Path, 241) -> List[Tuple[EbuildVersion, Path]]: 242 """Returns the latest ebuilds grouped by version.without_rev. 243 244 Result is always sorted by version, latest versions are last. 245 """ 246 ebuilds = ebuild_dir.glob("*.ebuild") 247 versioned_ebuilds: Dict[EbuildVersion, Tuple[EbuildVersion, Path]] = {} 248 for ebuild in ebuilds: 249 version = parse_ebuild_version(ebuild.name) 250 version_no_rev = version.without_rev() 251 other = versioned_ebuilds.get(version_no_rev) 252 this_is_newer = other is None or other[0] < version 253 if this_is_newer: 254 versioned_ebuilds[version_no_rev] = (version, ebuild) 255 256 return sorted(versioned_ebuilds.values()) 257 258 259def maybe_copy_prebuilt_to_localmirror( 260 copy_rust_bootstrap_script: Path, prebuilt_gs_path: str, dry_run: bool 261) -> bool: 262 upload_to = copy_rust_bootstrap.determine_target_path(prebuilt_gs_path) 263 result = subprocess.run( 264 ["gsutil", "ls", upload_to], 265 check=False, 266 encoding="utf-8", 267 stdin=subprocess.DEVNULL, 268 stdout=subprocess.DEVNULL, 269 stderr=subprocess.DEVNULL, 270 ) 271 272 if not result.returncode: 273 logging.info("Artifact at %s already exists", upload_to) 274 return False 275 276 cmd: List[Union[Path, str]] = [ 277 copy_rust_bootstrap_script, 278 prebuilt_gs_path, 279 ] 280 281 if dry_run: 282 cmd.append("--dry-run") 283 284 subprocess.run( 285 cmd, 286 check=True, 287 stdin=subprocess.DEVNULL, 288 ) 289 return True 290 291 292def add_version_to_bootstrap_sequence( 293 ebuild: Path, version: EbuildVersion, dry_run: bool 294): 295 ebuild_lines = ebuild.read_text(encoding="utf-8").splitlines(keepends=True) 296 _, end = find_raw_bootstrap_sequence_lines(ebuild_lines) 297 # `end` is the final paren. Since we _need_ prebuilts for all preceding 298 # versions, always put this a line before the end. 299 ebuild_lines.insert(end, f"\t{version}\n") 300 if not dry_run: 301 ebuild.write_text("".join(ebuild_lines), encoding="utf-8") 302 303 304def is_ebuild_linked_to_in_dir(root_ebuild_path: Path) -> bool: 305 """Returns whether symlinks point to `root_ebuild_path`. 306 307 The only directory checked is the directory that contains 308 `root_ebuild_path`. 309 """ 310 assert ( 311 root_ebuild_path.is_absolute() 312 ), f"{root_ebuild_path} should be an absolute path." 313 in_dir = root_ebuild_path.parent 314 for ebuild in in_dir.glob("*.ebuild"): 315 if ebuild == root_ebuild_path or not ebuild.is_symlink(): 316 continue 317 318 points_to = Path(os.path.normpath(in_dir / os.readlink(ebuild))) 319 if points_to == root_ebuild_path: 320 return True 321 return False 322 323 324def uprev_ebuild(ebuild: Path, version: EbuildVersion, dry_run: bool) -> Path: 325 assert ebuild.is_absolute(), f"{ebuild} should be an absolute path." 326 327 new_version = dataclasses.replace(version, rev=version.rev + 1) 328 new_ebuild = ebuild.parent / f"rust-bootstrap-{new_version}.ebuild" 329 if dry_run: 330 logging.info( 331 "Skipping rename of %s -> %s; dry-run specified", ebuild, new_ebuild 332 ) 333 return new_ebuild 334 335 # This condition tries to follow CrOS best practices. Namely: 336 # - If the ebuild is a symlink, move it. 337 # - Otherwise, if the ebuild is a normal file, symlink to it as long as 338 # it has no revision. 339 # 340 # Since rust-bootstrap's functionality relies heavily on `${PV}`, it's 341 # completely expected for cross-${PV} symlinks to exist. 342 uprev_via_rename = ( 343 version.rev != 0 or ebuild.is_symlink() 344 ) and not is_ebuild_linked_to_in_dir(ebuild) 345 346 if uprev_via_rename: 347 logging.info("Moving %s -> %s", ebuild, new_ebuild) 348 ebuild.rename(new_ebuild) 349 else: 350 logging.info("Symlinking %s to %s", new_ebuild, ebuild) 351 new_ebuild.symlink_to(ebuild.relative_to(ebuild.parent)) 352 return new_ebuild 353 354 355def update_ebuild_manifest(rust_bootstrap_ebuild: Path): 356 subprocess.run( 357 ["ebuild", rust_bootstrap_ebuild, "manifest"], 358 check=True, 359 stdin=subprocess.DEVNULL, 360 ) 361 362 363def commit_all_changes( 364 git_dir: Path, rust_bootstrap_dir: Path, commit_message: str 365): 366 subprocess.run( 367 ["git", "add", rust_bootstrap_dir.relative_to(git_dir)], 368 cwd=git_dir, 369 check=True, 370 stdin=subprocess.DEVNULL, 371 ) 372 subprocess.run( 373 ["git", "commit", "-m", commit_message], 374 cwd=git_dir, 375 check=True, 376 stdin=subprocess.DEVNULL, 377 ) 378 379 380def scrape_git_push_cl_id_strs(git_push_output: str) -> List[str]: 381 id_regex = re.compile( 382 r"^remote:\s+https://chromium-review\S+/\+/(\d+)\s", re.MULTILINE 383 ) 384 results = id_regex.findall(git_push_output) 385 if not results: 386 raise ValueError( 387 f"Found 0 matches of {id_regex} in {git_push_output!r}; expected " 388 "at least 1." 389 ) 390 return results 391 392 393def upload_changes(git_dir: Path): 394 logging.info("Uploading changes") 395 result = subprocess.run( 396 ["git", "push", "cros", "HEAD:refs/for/main"], 397 check=True, 398 cwd=git_dir, 399 encoding="utf-8", 400 stdin=subprocess.DEVNULL, 401 stdout=subprocess.PIPE, 402 stderr=subprocess.STDOUT, 403 ) 404 # Print this in case anyone's looking at the output. 405 print(result.stdout, end=None) 406 result.check_returncode() 407 408 cl_ids = scrape_git_push_cl_id_strs(result.stdout) 409 logging.info( 410 "Uploaded %s successfully!", [f"crrev.com/c/{x}" for x in cl_ids] 411 ) 412 for cl_id in cl_ids: 413 gerrit_commands = ( 414 ["gerrit", "label-v", cl_id, "1"], 415 ["gerrit", "label-cq", cl_id, "1"], 416 ["gerrit", "label-as", cl_id, "1"], 417 ["gerrit", "reviewers", cl_id] + list(DEFAULT_CL_REVIEWERS), 418 ["gerrit", "ready", cl_id], 419 ) 420 for command in gerrit_commands: 421 logging.info("Running gerrit command: %s", command) 422 subprocess.run( 423 command, 424 check=True, 425 stdin=subprocess.DEVNULL, 426 ) 427 428 429def maybe_add_newest_prebuilts( 430 copy_rust_bootstrap_script: Path, 431 chromiumos_overlay: Path, 432 rust_bootstrap_dir: Path, 433 dry_run: bool, 434) -> bool: 435 """Ensures that prebuilts in rust-bootstrap ebuilds are up-to-date. 436 437 If dry_run is True, no changes will be made on disk. Otherwise, changes 438 will be committed to git locally. 439 440 Returns: 441 True if changes were made (or would've been made, in the case of 442 dry_run being True). False otherwise. 443 """ 444 # A list of (version, maybe_prebuilt_location). 445 versions_updated: List[Tuple[EbuildVersion, Optional[str]]] = [] 446 for version, ebuild in collect_ebuilds_by_version(rust_bootstrap_dir): 447 logging.info("Inspecting %s...", ebuild) 448 if version.without_rev() in read_bootstrap_sequence_from_ebuild(ebuild): 449 logging.info("Prebuilt already exists for %s.", ebuild) 450 continue 451 452 logging.info("Prebuilt isn't in ebuild; checking remotely.") 453 prebuilt = find_rust_bootstrap_prebuilt(version) 454 if not prebuilt: 455 # `find_rust_bootstrap_prebuilt` handles logging in this case. 456 continue 457 458 # Houston, we have prebuilt. 459 uploaded = maybe_copy_prebuilt_to_localmirror( 460 copy_rust_bootstrap_script, prebuilt, dry_run 461 ) 462 add_version_to_bootstrap_sequence(ebuild, version, dry_run) 463 uprevved_ebuild = uprev_ebuild(ebuild, version, dry_run) 464 versions_updated.append((version, prebuilt if uploaded else None)) 465 466 if not versions_updated: 467 logging.info("No updates made; exiting cleanly.") 468 return False 469 470 if dry_run: 471 logging.info("Dry-run specified; quit.") 472 return True 473 474 # Just pick an arbitrary ebuild to run `ebuild ... manifest` on; it always 475 # updates for all ebuilds in the same package. 476 update_ebuild_manifest(uprevved_ebuild) 477 478 pretty_artifact_lines = [] 479 for version, maybe_gs_path in versions_updated: 480 if maybe_gs_path: 481 pretty_artifact_lines.append( 482 f"- rust-bootstrap-{version.without_rev()} => {maybe_gs_path}" 483 ) 484 else: 485 pretty_artifact_lines.append( 486 f"- rust-bootstrap-{version.without_rev()} was already on " 487 "localmirror" 488 ) 489 490 pretty_artifacts = "\n".join(pretty_artifact_lines) 491 492 logging.info("Committing changes.") 493 commit_all_changes( 494 chromiumos_overlay, 495 rust_bootstrap_dir, 496 commit_message=textwrap.dedent( 497 f"""\ 498 rust-bootstrap: use prebuilts 499 500 This CL used the following rust-bootstrap artifacts: 501 {pretty_artifacts} 502 503 BUG={TRACKING_BUG} 504 TEST=CQ 505 """ 506 ), 507 ) 508 return True 509 510 511def rust_dir_from_rust_bootstrap(rust_bootstrap_dir: Path) -> Path: 512 """Derives dev-lang/rust's dir from dev-lang/rust-bootstrap's dir.""" 513 return rust_bootstrap_dir.parent / "rust" 514 515 516class MissingRustBootstrapPrebuiltError(Exception): 517 """Raised when rust-bootstrap can't be landed due to a missing prebuilt.""" 518 519 520def maybe_add_new_rust_bootstrap_version( 521 chromiumos_overlay: Path, 522 rust_bootstrap_dir: Path, 523 dry_run: bool, 524 commit: bool = True, 525) -> bool: 526 """Ensures that there's a rust-bootstrap-${N} ebuild matching rust-${N}. 527 528 Args: 529 chromiumos_overlay: Path to chromiumos-overlay. 530 rust_bootstrap_dir: Path to rust-bootstrap's directory. 531 dry_run: if True, don't commit to git or write changes to disk. 532 Otherwise, write changes to disk. 533 commit: if True, commit changes to git. This value is meaningless if 534 `dry_run` is True. 535 536 Returns: 537 True if changes were made (or would've been made, in the case of 538 dry_run being True). False otherwise. 539 540 Raises: 541 MissingRustBootstrapPrebuiltError if the creation of a new 542 rust-bootstrap ebuild wouldn't be buildable, since there's no 543 rust-bootstrap prebuilt of the prior version for it to sync. 544 """ 545 # These are always returned in sorted error, so taking the last is the same 546 # as `max()`. 547 ( 548 newest_bootstrap_version, 549 newest_bootstrap_ebuild, 550 ) = collect_ebuilds_by_version(rust_bootstrap_dir)[-1] 551 552 logging.info( 553 "Detected newest rust-bootstrap version: %s", newest_bootstrap_version 554 ) 555 556 rust_dir = rust_dir_from_rust_bootstrap(rust_bootstrap_dir) 557 newest_rust_version, _ = collect_ebuilds_by_version(rust_dir)[-1] 558 logging.info("Detected newest rust version: %s", newest_rust_version) 559 560 # Generally speaking, we don't care about keeping up with new patch 561 # releases for rust-bootstrap. It's OK to _initially land_ e.g., 562 # rust-bootstrap-1.73.1, but upgrades from rust-bootstrap-1.73.0 to 563 # rust-bootstrap-1.73.1 are rare, and have added complexity, so should be 564 # done manually. Hence, only check for major/minor version inequality. 565 if ( 566 newest_rust_version.major_minor_only() 567 <= newest_bootstrap_version.major_minor_only() 568 ): 569 logging.info("No missing rust-bootstrap versions detected.") 570 return False 571 572 available_prebuilts = read_bootstrap_sequence_from_ebuild( 573 newest_bootstrap_ebuild 574 ) 575 need_prebuilt = newest_rust_version.major_minor_only().prior_minor_version() 576 577 if all(x.major_minor_only() != need_prebuilt for x in available_prebuilts): 578 raise MissingRustBootstrapPrebuiltError( 579 f"want version {need_prebuilt}; " 580 f"available versions: {available_prebuilts}" 581 ) 582 583 # Ensure the rust-bootstrap ebuild we're landing is a regular file. This 584 # makes cleanup of the old files trivial, since they're dead symlinks. 585 prior_ebuild_resolved = newest_bootstrap_ebuild.resolve() 586 new_ebuild = ( 587 rust_bootstrap_dir 588 / f"rust-bootstrap-{newest_rust_version.without_rev()}.ebuild" 589 ) 590 if dry_run: 591 logging.info("Would move %s to %s.", prior_ebuild_resolved, new_ebuild) 592 return True 593 594 logging.info( 595 "Moving %s to %s, and creating symlink at the old location", 596 prior_ebuild_resolved, 597 new_ebuild, 598 ) 599 prior_ebuild_resolved.rename(new_ebuild) 600 prior_ebuild_resolved.symlink_to(new_ebuild.relative_to(rust_bootstrap_dir)) 601 602 update_ebuild_manifest(new_ebuild) 603 if commit: 604 newest_no_rev = newest_rust_version.without_rev() 605 commit_all_changes( 606 chromiumos_overlay, 607 rust_bootstrap_dir, 608 commit_message=textwrap.dedent( 609 f"""\ 610 rust-bootstrap: add version {newest_no_rev} 611 612 Rust is now at {newest_no_rev}; add a rust-bootstrap version so 613 prebuilts can be generated early. 614 615 BUG={TRACKING_BUG} 616 TEST=CQ 617 """ 618 ), 619 ) 620 return True 621 622 623class OldEbuildIsLinkedToError(Exception): 624 """Raised when a would-be-removed ebuild has symlinks to it.""" 625 626 627def find_external_links_to_files_in_dir( 628 in_dir: Path, files: Iterable[Path] 629) -> Dict[Path, List[Path]]: 630 """Returns all symlinks to `files` in `in_dir`, excluding from `files`. 631 632 Essentially, if this returns an empty dict, nothing in `in_dir` symlinks to 633 any of `files`, _except potentially_ things in `files`. 634 """ 635 files_set = {x.absolute() for x in files} 636 linked_to = collections.defaultdict(list) 637 for f in in_dir.iterdir(): 638 if f not in files_set and f.is_symlink(): 639 target = f.parent / os.readlink(f) 640 if target in files_set: 641 linked_to[target].append(f) 642 return linked_to 643 644 645def maybe_delete_old_rust_bootstrap_ebuilds( 646 chromiumos_overlay: Path, 647 rust_bootstrap_dir: Path, 648 dry_run: bool, 649 commit: bool = True, 650) -> bool: 651 """Deletes versions of rust-bootstrap ebuilds that seem unneeded. 652 653 "Unneeded", in this case, is specifically only referencing whether 654 dev-lang/rust (or similar) obviously relies on the ebuild, or whether it's 655 likely that a future version of dev-lang/rust will rely on it. 656 657 Args: 658 chromiumos_overlay: Path to chromiumos-overlay. 659 rust_bootstrap_dir: Path to rust-bootstrap's directory. 660 dry_run: if True, don't commit to git or write changes to disk. 661 Otherwise, write changes to disk. 662 commit: if True, commit changes to git. This value is meaningless if 663 `dry_run` is True. 664 665 Returns: 666 True if changes were made (or would've been made, in the case of 667 dry_run being True). False otherwise. 668 669 Raises: 670 OldEbuildIsLinkedToError if the deletion of an ebuild was blocked by 671 other ebuilds linking to it. It's still 'needed' in this case, but with 672 some human intervention, it can be removed. 673 """ 674 rust_bootstrap_versions = collect_ebuilds_by_version(rust_bootstrap_dir) 675 logging.info( 676 "Current rust-bootstrap versions: %s", 677 [x for x, _ in rust_bootstrap_versions], 678 ) 679 rust_versions = collect_ebuilds_by_version( 680 rust_dir_from_rust_bootstrap(rust_bootstrap_dir) 681 ) 682 # rust_versions is sorted, so taking the last is the same as max(). 683 newest_rust_version = rust_versions[-1][0].major_minor_only() 684 need_rust_bootstrap_versions = { 685 rust_ver.major_minor_only().prior_minor_version() 686 for rust_ver, _ in rust_versions 687 } 688 logging.info( 689 "Needed rust-bootstrap versions (major/minor only): %s", 690 sorted(need_rust_bootstrap_versions), 691 ) 692 693 discardable_bootstrap_versions = [ 694 (version, ebuild) 695 for version, ebuild in rust_bootstrap_versions 696 # Don't remove newer rust-bootstrap versions, since they're likely to 697 # be needed in the future (& may have been generated by this very 698 # script). 699 if version.major_minor_only() not in need_rust_bootstrap_versions 700 and version.major_minor_only() < newest_rust_version 701 ] 702 703 if not discardable_bootstrap_versions: 704 logging.info("No versions of rust-bootstrap are unneeded.") 705 return False 706 707 discardable_ebuilds = [] 708 for version, ebuild in discardable_bootstrap_versions: 709 # We may have other files with the same version at different revisions. 710 # Include those, as well. 711 escaped_ver = glob.escape(str(version.without_rev())) 712 discardable = list( 713 ebuild.parent.glob(f"rust-bootstrap-{escaped_ver}*.ebuild") 714 ) 715 assert ebuild in discardable, discardable 716 discardable_ebuilds += discardable 717 718 # We can end up in a case where rust-bootstrap versions are unneeded, but 719 # the ebuild is still required. For example, consider a case where 720 # rust-bootstrap-1.73.0.ebuild is considered 'old', but 721 # rust-bootstrap-1.74.0.ebuild is required. If rust-bootstrap-1.74.0.ebuild 722 # is a symlink to rust-bootstrap-1.73.0.ebuild, the 'old' ebuild can't be 723 # deleted until rust-bootstrap-1.74.0.ebuild is fixed up. 724 # 725 # These cases are expected to be rare (this script should never push 726 # changes that gets us into this case, but human edits can), but uploading 727 # obviously-broken changes isn't a great UX. Opt to detect these and 728 # `raise` on them, since repairing can get complicated in instances where 729 # symlinks link to symlinks, etc. 730 has_links = find_external_links_to_files_in_dir( 731 rust_bootstrap_dir, discardable_ebuilds 732 ) 733 if has_links: 734 raise OldEbuildIsLinkedToError(str(has_links)) 735 736 logging.info("Plan to remove ebuilds: %s", discardable_ebuilds) 737 if dry_run: 738 logging.info("Dry-run specified; removal skipped.") 739 return True 740 741 for ebuild in discardable_ebuilds: 742 ebuild.unlink() 743 744 remaining_ebuild = next( 745 ebuild 746 for _, ebuild in rust_bootstrap_versions 747 if ebuild not in discardable_ebuilds 748 ) 749 update_ebuild_manifest(remaining_ebuild) 750 if commit: 751 many = len(discardable_ebuilds) > 1 752 message_lines = [ 753 "Rust has moved on in ChromeOS, so", 754 "these ebuilds are" if many else "this ebuild is", 755 "no longer needed.", 756 ] 757 message = textwrap.fill("\n".join(message_lines)) 758 commit_all_changes( 759 chromiumos_overlay, 760 rust_bootstrap_dir, 761 commit_message=textwrap.dedent( 762 f"""\ 763 rust-bootstrap: remove unused ebuild{"s" if many else ""} 764 765 {message} 766 767 BUG={TRACKING_BUG} 768 TEST=CQ 769 """ 770 ), 771 ) 772 return True 773 774 775def main(argv: List[str]): 776 logging.basicConfig( 777 format=">> %(asctime)s: %(levelname)s: %(filename)s:%(lineno)d: " 778 "%(message)s", 779 level=logging.INFO, 780 ) 781 782 my_dir = Path(__file__).parent.resolve() 783 parser = argparse.ArgumentParser( 784 description=__doc__, 785 formatter_class=argparse.RawDescriptionHelpFormatter, 786 ) 787 parser.add_argument( 788 "--chromiumos-overlay", 789 type=Path, 790 default=my_dir.parent.parent / "chromiumos-overlay", 791 ) 792 parser.add_argument( 793 "action", 794 choices=("dry-run", "commit", "upload"), 795 help=""" 796 What to do. `dry-run` makes no changes, `commit` commits changes 797 locally, and `upload` commits changes and uploads the result to Gerrit, 798 and sets a few labels for convenience (reviewers, CQ+1, etc). 799 """, 800 ) 801 opts = parser.parse_args(argv) 802 803 if opts.action == "dry-run": 804 dry_run = True 805 upload = False 806 elif opts.action == "commit": 807 dry_run = False 808 upload = False 809 else: 810 assert opts.action == "upload" 811 dry_run = False 812 upload = True 813 814 rust_bootstrap_dir = opts.chromiumos_overlay / "dev-lang/rust-bootstrap" 815 copy_rust_bootstrap_script = my_dir / "copy_rust_bootstrap.py" 816 817 had_recoverable_error = False 818 # Ensure prebuilts are up to date first, since it allows 819 # `ensure_newest_rust_bootstrap_ebuild_exists` to succeed in edge cases. 820 made_changes = maybe_add_newest_prebuilts( 821 copy_rust_bootstrap_script, 822 opts.chromiumos_overlay, 823 rust_bootstrap_dir, 824 dry_run, 825 ) 826 827 try: 828 made_changes |= maybe_add_new_rust_bootstrap_version( 829 opts.chromiumos_overlay, rust_bootstrap_dir, dry_run 830 ) 831 except MissingRustBootstrapPrebuiltError: 832 logging.exception( 833 "Ensuring newest rust-bootstrap ebuild exists failed." 834 ) 835 had_recoverable_error = True 836 837 try: 838 made_changes |= maybe_delete_old_rust_bootstrap_ebuilds( 839 opts.chromiumos_overlay, rust_bootstrap_dir, dry_run 840 ) 841 except OldEbuildIsLinkedToError: 842 logging.exception("An old ebuild is linked to; can't remove it") 843 had_recoverable_error = True 844 845 if upload: 846 if made_changes: 847 upload_changes(opts.chromiumos_overlay) 848 logging.info("Changes uploaded successfully.") 849 else: 850 logging.info("No changes were made; uploading skipped.") 851 852 if had_recoverable_error: 853 sys.exit("Exiting uncleanly due to above error(s).") 854 855 856if __name__ == "__main__": 857 main(sys.argv[1:]) 858