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