aboutsummaryrefslogtreecommitdiff
path: root/rust_tools
diff options
context:
space:
mode:
Diffstat (limited to 'rust_tools')
-rwxr-xr-xrust_tools/rust_uprev.py673
-rwxr-xr-xrust_tools/rust_uprev_test.py455
-rwxr-xr-xrust_tools/rust_watch.py351
-rwxr-xr-xrust_tools/rust_watch_test.py202
4 files changed, 1681 insertions, 0 deletions
diff --git a/rust_tools/rust_uprev.py b/rust_tools/rust_uprev.py
new file mode 100755
index 00000000..3c0ad012
--- /dev/null
+++ b/rust_tools/rust_uprev.py
@@ -0,0 +1,673 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Copyright 2020 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Tool to automatically generate a new Rust uprev CL.
+
+This tool is intended to automatically generate a CL to uprev Rust to a
+newer version in Chrome OS, including creating a new Rust version or
+removing an old version. It's based on
+src/third_party/chromiumos-overlay/dev-lang/rust/UPGRADE.md. When using
+the tool, the progress can be saved to a JSON file, so the user can resume
+the process after a failing step is fixed. Example usage to create a new
+version:
+
+1. (inside chroot) $ ./rust_tools/rust_uprev.py
+ --state_file /tmp/state-file.json
+ create --rust_version 1.45.0
+2. Step "compile rust" failed due to the patches can't apply to new version
+3. Manually fix the patches
+4. Execute the command in step 1 again.
+5. Iterate 1-4 for each failed step until the tool passes.
+
+Replace `create --rust_version 1.45.0` with `remove --rust_version 1.43.0`
+if you want to remove all 1.43.0 related stuff in the same CL. Remember to
+use a different state file if you choose to run different subcommands.
+
+If you want a hammer that can do everything for you, use the subcommand
+`roll`. It can create a Rust uprev CL with `create` and `remove` and upload
+the CL to chromium code review.
+
+See `--help` for all available options.
+"""
+
+# pylint: disable=cros-logging-import
+
+import argparse
+import pathlib
+import json
+import logging
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+from typing import Any, Callable, Dict, List, NamedTuple, Optional, T, Tuple
+
+from llvm_tools import chroot, git
+RUST_PATH = '/mnt/host/source/src/third_party/chromiumos-overlay/dev-lang/rust'
+
+
+def get_command_output(command: List[str], *args, **kwargs) -> str:
+ return subprocess.check_output(
+ command, encoding='utf-8', *args, **kwargs).strip()
+
+
+class RustVersion(NamedTuple):
+ """NamedTuple represents a Rust version"""
+ major: int
+ minor: int
+ patch: int
+
+ def __str__(self):
+ return f'{self.major}.{self.minor}.{self.patch}'
+
+ @staticmethod
+ def parse_from_ebuild(ebuild_name: str) -> 'RustVersion':
+ input_re = re.compile(r'^rust-'
+ r'(?P<major>\d+)\.'
+ r'(?P<minor>\d+)\.'
+ r'(?P<patch>\d+)'
+ r'(:?-r\d+)?'
+ r'\.ebuild$')
+ m = input_re.match(ebuild_name)
+ assert m, f'failed to parse {ebuild_name!r}'
+ return RustVersion(
+ int(m.group('major')), int(m.group('minor')), int(m.group('patch')))
+
+ @staticmethod
+ def parse(x: str) -> 'RustVersion':
+ input_re = re.compile(r'^(?:rust-)?'
+ r'(?P<major>\d+)\.'
+ r'(?P<minor>\d+)\.'
+ r'(?P<patch>\d+)'
+ r'(?:.ebuild)?$')
+ m = input_re.match(x)
+ assert m, f'failed to parse {x!r}'
+ return RustVersion(
+ int(m.group('major')), int(m.group('minor')), int(m.group('patch')))
+
+
+def parse_commandline_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument(
+ '--state_file',
+ required=True,
+ help='A state file to hold previous completed steps. If the file '
+ 'exists, it needs to be used together with --continue or --restart. '
+ 'If not exist (do not use --continue in this case), we will create a '
+ 'file for you.',
+ )
+ parser.add_argument(
+ '--restart',
+ action='store_true',
+ help='Restart from the first step. Ignore the completed steps in '
+ 'the state file',
+ )
+ parser.add_argument(
+ '--continue',
+ dest='cont',
+ action='store_true',
+ help='Continue the steps from the state file',
+ )
+
+ create_parser_template = argparse.ArgumentParser(add_help=False)
+ create_parser_template.add_argument(
+ '--template',
+ type=RustVersion.parse,
+ default=None,
+ help='A template to use for creating a Rust uprev from, in the form '
+ 'a.b.c The ebuild has to exist in the chroot. If not specified, the '
+ 'tool will use the current Rust version in the chroot as template.',
+ )
+ create_parser_template.add_argument(
+ '--skip_compile',
+ action='store_true',
+ help='Skip compiling rust to test the tool. Only for testing',
+ )
+
+ subparsers = parser.add_subparsers(dest='subparser_name')
+ subparser_names = []
+ subparser_names.append('create')
+ create_parser = subparsers.add_parser(
+ 'create',
+ parents=[create_parser_template],
+ help='Create changes uprevs Rust to a new version',
+ )
+ create_parser.add_argument(
+ '--rust_version',
+ type=RustVersion.parse,
+ required=True,
+ help='Rust version to uprev to, in the form a.b.c',
+ )
+
+ subparser_names.append('remove')
+ remove_parser = subparsers.add_parser(
+ 'remove',
+ help='Clean up old Rust version from chroot',
+ )
+ remove_parser.add_argument(
+ '--rust_version',
+ type=RustVersion.parse,
+ default=None,
+ help='Rust version to remove, in the form a.b.c If not '
+ 'specified, the tool will remove the oldest version in the chroot',
+ )
+
+ subparser_names.append('roll')
+ roll_parser = subparsers.add_parser(
+ 'roll',
+ parents=[create_parser_template],
+ help='A command can create and upload a Rust uprev CL, including '
+ 'preparing the repo, creating new Rust uprev, deleting old uprev, '
+ 'and upload a CL to crrev.',
+ )
+ roll_parser.add_argument(
+ '--uprev',
+ type=RustVersion.parse,
+ required=True,
+ help='Rust version to uprev to, in the form a.b.c',
+ )
+ roll_parser.add_argument(
+ '--remove',
+ type=RustVersion.parse,
+ default=None,
+ help='Rust version to remove, in the form a.b.c If not '
+ 'specified, the tool will remove the oldest version in the chroot',
+ )
+ roll_parser.add_argument(
+ '--skip_cross_compiler',
+ action='store_true',
+ help='Skip updating cross-compiler in the chroot',
+ )
+ roll_parser.add_argument(
+ '--no_upload',
+ action='store_true',
+ help='If specified, the tool will not upload the CL for review',
+ )
+
+ args = parser.parse_args()
+ if args.subparser_name not in subparser_names:
+ parser.error('one of %s must be specified' % subparser_names)
+
+ if args.cont and args.restart:
+ parser.error('Please select either --continue or --restart')
+
+ if os.path.exists(args.state_file):
+ if not args.cont and not args.restart:
+ parser.error('State file exists, so you should either --continue '
+ 'or --restart')
+ if args.cont and not os.path.exists(args.state_file):
+ parser.error('Indicate --continue but the state file does not exist')
+
+ if args.restart and os.path.exists(args.state_file):
+ os.remove(args.state_file)
+
+ return args
+
+
+def parse_stage0_file(new_version: RustVersion) -> Tuple[str, str, str]:
+ # Find stage0 date, rustc and cargo
+ stage0_file = get_command_output([
+ 'curl', '-f', 'https://raw.githubusercontent.com/rust-lang/rust/'
+ f'{new_version}/src/stage0.txt'
+ ])
+ regexp = re.compile(r'date:\s*(?P<date>\d+-\d+-\d+)\s+'
+ r'rustc:\s*(?P<rustc>\d+\.\d+\.\d+)\s+'
+ r'cargo:\s*(?P<cargo>\d+\.\d+\.\d+)')
+ m = regexp.search(stage0_file)
+ assert m, 'failed to parse stage0.txt file'
+ stage0_date, stage0_rustc, stage0_cargo = m.groups()
+ logging.info('Found stage0 file has date: %s, rustc: %s, cargo: %s',
+ stage0_date, stage0_rustc, stage0_cargo)
+ return stage0_date, stage0_rustc, stage0_cargo
+
+
+def prepare_uprev(rust_version: RustVersion, template: Optional[RustVersion]
+ ) -> Optional[Tuple[RustVersion, str]]:
+ if template is None:
+ ebuild_path = get_command_output(['equery', 'w', 'rust'])
+ ebuild_name = os.path.basename(ebuild_path)
+ template_version = RustVersion.parse_from_ebuild(ebuild_name)
+ else:
+ ebuild_path = find_ebuild_for_rust_version(template)
+ template_version = template
+
+ if rust_version <= template_version:
+ logging.info(
+ 'Requested version %s is not newer than the template version %s.',
+ rust_version, template_version)
+ return None
+
+ logging.info('Template Rust version is %s (ebuild: %r)', template_version,
+ ebuild_path)
+ return template_version, ebuild_path
+
+
+def copy_patches(template_version: RustVersion,
+ new_version: RustVersion) -> None:
+ patch_path = os.path.join(RUST_PATH, 'files')
+ for f in os.listdir(patch_path):
+ if f'rust-{template_version}' not in f:
+ continue
+ logging.info('Rename patch %s to new version', f)
+ new_name = f.replace(str(template_version), str(new_version))
+ shutil.copyfile(
+ os.path.join(patch_path, f),
+ os.path.join(patch_path, new_name),
+ )
+
+ subprocess.check_call(['git', 'add', f'files/rust-{new_version}-*.patch'],
+ cwd=RUST_PATH)
+
+
+def create_ebuild(template_ebuild: str, new_version: RustVersion) -> str:
+ shutil.copyfile(template_ebuild,
+ os.path.join(RUST_PATH, f'rust-{new_version}.ebuild'))
+ subprocess.check_call(['git', 'add', f'rust-{new_version}.ebuild'],
+ cwd=RUST_PATH)
+ return os.path.join(RUST_PATH, f'rust-{new_version}.ebuild')
+
+
+def update_ebuild(ebuild_file: str, stage0_info: Tuple[str, str, str]) -> None:
+ stage0_date, stage0_rustc, stage0_cargo = stage0_info
+ with open(ebuild_file, encoding='utf-8') as f:
+ contents = f.read()
+ # Update STAGE0_DATE in the ebuild
+ stage0_date_re = re.compile(r'STAGE0_DATE="(\d+-\d+-\d+)"')
+ if not stage0_date_re.search(contents):
+ raise RuntimeError('STAGE0_DATE not found in rust ebuild')
+ new_contents = stage0_date_re.sub(f'STAGE0_DATE="{stage0_date}"', contents)
+
+ # Update STAGE0_VERSION in the ebuild
+ stage0_rustc_re = re.compile(r'STAGE0_VERSION="[^"]*"')
+ if not stage0_rustc_re.search(new_contents):
+ raise RuntimeError('STAGE0_VERSION not found in rust ebuild')
+ new_contents = stage0_rustc_re.sub(f'STAGE0_VERSION="{stage0_rustc}"',
+ new_contents)
+
+ # Update STAGE0_VERSION_CARGO in the ebuild
+ stage0_cargo_re = re.compile(r'STAGE0_VERSION_CARGO="[^"]*"')
+ if not stage0_cargo_re.search(new_contents):
+ raise RuntimeError('STAGE0_VERSION_CARGO not found in rust ebuild')
+ new_contents = stage0_cargo_re.sub(f'STAGE0_VERSION_CARGO="{stage0_cargo}"',
+ new_contents)
+ with open(ebuild_file, 'w', encoding='utf-8') as f:
+ f.write(new_contents)
+ logging.info(
+ 'Rust ebuild file has STAGE0_DATE, STAGE0_VERSION, STAGE0_VERSION_CARGO '
+ 'updated to %s, %s, %s respectively', stage0_date, stage0_rustc,
+ stage0_cargo)
+
+
+def flip_mirror_in_ebuild(ebuild_file: str, add: bool) -> None:
+ restrict_re = re.compile(
+ r'(?P<before>RESTRICT=")(?P<values>"[^"]*"|.*)(?P<after>")')
+ with open(ebuild_file, encoding='utf-8') as f:
+ contents = f.read()
+ m = restrict_re.search(contents)
+ assert m, 'failed to find RESTRICT variable in Rust ebuild'
+ values = m.group('values')
+ if add:
+ if 'mirror' in values:
+ return
+ values += ' mirror'
+ else:
+ if 'mirror' not in values:
+ return
+ values = values.replace(' mirror', '')
+ new_contents = restrict_re.sub(r'\g<before>%s\g<after>' % values, contents)
+ with open(ebuild_file, 'w', encoding='utf-8') as f:
+ f.write(new_contents)
+
+
+def rust_ebuild_actions(actions: List[str], sudo: bool = False) -> None:
+ ebuild_path_inchroot = get_command_output(['equery', 'w', 'rust'])
+ cmd = ['ebuild', ebuild_path_inchroot] + actions
+ if sudo:
+ cmd = ['sudo'] + cmd
+ subprocess.check_call(cmd)
+
+
+def update_manifest(ebuild_file: str) -> None:
+ logging.info('Added "mirror" to RESTRICT to Rust ebuild')
+ flip_mirror_in_ebuild(ebuild_file, add=True)
+ rust_ebuild_actions(['manifest'])
+ logging.info('Removed "mirror" to RESTRICT from Rust ebuild')
+ flip_mirror_in_ebuild(ebuild_file, add=False)
+
+
+def update_rust_packages(rust_version: RustVersion, add: bool) -> None:
+ package_file = os.path.join(
+ RUST_PATH, '../../profiles/targets/chromeos/package.provided')
+ with open(package_file, encoding='utf-8') as f:
+ contents = f.read()
+ if add:
+ rust_packages_re = re.compile(r'dev-lang/rust-(\d+\.\d+\.\d+)')
+ rust_packages = rust_packages_re.findall(contents)
+ # Assume all the rust packages are in alphabetical order, so insert the new
+ # version to the place after the last rust_packages
+ new_str = f'dev-lang/rust-{rust_version}'
+ new_contents = contents.replace(rust_packages[-1],
+ f'{rust_packages[-1]}\n{new_str}')
+ logging.info('%s has been inserted into package.provided', new_str)
+ else:
+ old_str = f'dev-lang/rust-{rust_version}\n'
+ assert old_str in contents, f'{old_str!r} not found in package.provided'
+ new_contents = contents.replace(old_str, '')
+ logging.info('%s has been removed from package.provided', old_str)
+
+ with open(package_file, 'w', encoding='utf-8') as f:
+ f.write(new_contents)
+
+
+def update_virtual_rust(template_version: RustVersion,
+ new_version: RustVersion) -> None:
+ virtual_rust_dir = os.path.join(RUST_PATH, '../../virtual/rust')
+ assert os.path.exists(virtual_rust_dir)
+ shutil.copyfile(
+ os.path.join(virtual_rust_dir, f'rust-{template_version}.ebuild'),
+ os.path.join(virtual_rust_dir, f'rust-{new_version}.ebuild'))
+ subprocess.check_call(['git', 'add', f'rust-{new_version}.ebuild'],
+ cwd=virtual_rust_dir)
+
+
+def upload_single_tarball(rust_url: str, tarfile_name: str,
+ tempdir: str) -> None:
+ rust_src = f'{rust_url}/{tarfile_name}'
+ gsutil_location = f'gs://chromeos-localmirror/distfiles/{tarfile_name}'
+
+ missing_file = subprocess.call(
+ ['gsutil', 'ls', gsutil_location],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL,
+ )
+ if not missing_file:
+ logging.info('Rust artifact at %s already exists; skipping download',
+ gsutil_location)
+ return
+
+ logging.info('Downloading Rust artifact from %s', rust_src)
+
+ # Download Rust's source
+ rust_file = os.path.join(tempdir, tarfile_name)
+ subprocess.check_call(['curl', '-f', '-o', rust_file, rust_src])
+
+ # Verify the signature of the source
+ sig_file = os.path.join(tempdir, 'rustc_sig.asc')
+ subprocess.check_call(['curl', '-f', '-o', sig_file, f'{rust_src}.asc'])
+ try:
+ subprocess.check_output(['gpg', '--verify', sig_file, rust_file],
+ encoding='utf-8',
+ stderr=subprocess.STDOUT)
+ except subprocess.CalledProcessError as e:
+ if "gpg: Can't check signature" not in e.output:
+ raise RuntimeError(f'Failed to execute `gpg --verify`, {e.output}')
+
+ # If it fails to verify the signature, try import rustc key, and retry.
+ keys = get_command_output(
+ ['curl', '-f', 'https://keybase.io/rust/pgp_keys.asc'])
+ subprocess.run(['gpg', '--import'],
+ input=keys,
+ encoding='utf-8',
+ check=True)
+ subprocess.check_call(['gpg', '--verify', sig_file, rust_file])
+
+ # Since we are using `-n` to skip an item if it already exists, there's no
+ # need to check if the file exists on GS bucket or not.
+ subprocess.check_call(
+ ['gsutil', 'cp', '-n', '-a', 'public-read', rust_file, gsutil_location])
+
+
+def upload_to_localmirror(tempdir: str, rust_version: RustVersion,
+ stage0_info: Tuple[str, str, str]) -> None:
+ stage0_date, stage0_rustc, stage0_cargo = stage0_info
+ rust_url = 'https://static.rust-lang.org/dist'
+ # Upload rustc source
+ upload_single_tarball(
+ rust_url,
+ f'rustc-{rust_version}-src.tar.gz',
+ tempdir,
+ )
+ # Upload stage0 toolchain
+ upload_single_tarball(
+ f'{rust_url}/{stage0_date}',
+ f'rust-std-{stage0_rustc}-x86_64-unknown-linux-gnu.tar.gz',
+ tempdir,
+ )
+ # Upload stage0 source
+ upload_single_tarball(
+ rust_url,
+ f'rustc-{stage0_rustc}-x86_64-unknown-linux-gnu.tar.gz',
+ tempdir,
+ )
+ # Upload stage0 cargo
+ upload_single_tarball(
+ rust_url,
+ f'cargo-{stage0_cargo}-x86_64-unknown-linux-gnu.tar.gz',
+ tempdir,
+ )
+
+
+def perform_step(state_file: pathlib.Path,
+ tmp_state_file: pathlib.Path,
+ completed_steps: Dict[str, Any],
+ step_name: str,
+ step_fn: Callable[[], T],
+ result_from_json: Optional[Callable[[Any], T]] = None,
+ result_to_json: Optional[Callable[[T], Any]] = None) -> T:
+ if step_name in completed_steps:
+ logging.info('Skipping previously completed step %s', step_name)
+ if result_from_json:
+ return result_from_json(completed_steps[step_name])
+ return completed_steps[step_name]
+
+ logging.info('Running step %s', step_name)
+ val = step_fn()
+ logging.info('Step %s complete', step_name)
+ if result_to_json:
+ completed_steps[step_name] = result_to_json(val)
+ else:
+ completed_steps[step_name] = val
+
+ with tmp_state_file.open('w', encoding='utf-8') as f:
+ json.dump(completed_steps, f, indent=4)
+ tmp_state_file.rename(state_file)
+ return val
+
+
+def prepare_uprev_from_json(obj: Any) -> Optional[Tuple[RustVersion, str]]:
+ if not obj:
+ return None
+ version, ebuild_path = obj
+ return RustVersion(*version), ebuild_path
+
+
+def create_rust_uprev(rust_version: RustVersion,
+ maybe_template_version: Optional[RustVersion],
+ skip_compile: bool, run_step: Callable[[], T]) -> None:
+ stage0_info = run_step(
+ 'parse stage0 file', lambda: parse_stage0_file(rust_version))
+ template_version, template_ebuild = run_step(
+ 'prepare uprev',
+ lambda: prepare_uprev(rust_version, maybe_template_version),
+ result_from_json=prepare_uprev_from_json,
+ )
+ if template_ebuild is None:
+ return
+
+ run_step('copy patches', lambda: copy_patches(template_version, rust_version))
+ ebuild_file = run_step(
+ 'create ebuild', lambda: create_ebuild(template_ebuild, rust_version))
+ run_step('update ebuild', lambda: update_ebuild(ebuild_file, stage0_info))
+ with tempfile.TemporaryDirectory(dir='/tmp') as tempdir:
+ run_step('upload_to_localmirror', lambda: upload_to_localmirror(
+ tempdir, rust_version, stage0_info))
+ run_step('update manifest to add new version', lambda: update_manifest(
+ ebuild_file))
+ if not skip_compile:
+ run_step('emerge rust', lambda: subprocess.check_call(
+ ['sudo', 'emerge', 'dev-lang/rust']))
+ run_step('insert version into rust packages', lambda: update_rust_packages(
+ rust_version, add=True))
+ run_step('upgrade virtual/rust', lambda: update_virtual_rust(
+ template_version, rust_version))
+
+
+def find_rust_versions_in_chroot() -> List[Tuple[RustVersion, str]]:
+ return [(RustVersion.parse_from_ebuild(x), os.path.join(RUST_PATH, x))
+ for x in os.listdir(RUST_PATH)
+ if x.endswith('.ebuild')]
+
+
+def find_oldest_rust_version_in_chroot() -> Tuple[RustVersion, str]:
+ rust_versions = find_rust_versions_in_chroot()
+ if len(rust_versions) <= 1:
+ raise RuntimeError('Expect to find more than one Rust versions')
+ return min(rust_versions)
+
+
+def find_ebuild_for_rust_version(version: RustVersion) -> str:
+ rust_ebuilds = [
+ ebuild for x, ebuild in find_rust_versions_in_chroot() if x == version
+ ]
+ if not rust_ebuilds:
+ raise ValueError(f'No Rust ebuilds found matching {version}')
+ if len(rust_ebuilds) > 1:
+ raise ValueError(f'Multiple Rust ebuilds found matching {version}: '
+ f'{rust_ebuilds}')
+ return rust_ebuilds[0]
+
+
+def remove_files(filename: str, path: str) -> None:
+ subprocess.check_call(['git', 'rm', filename], cwd=path)
+
+
+def remove_rust_uprev(rust_version: Optional[RustVersion],
+ run_step: Callable[[], T]) -> None:
+
+ def find_desired_rust_version():
+ if rust_version:
+ return rust_version, find_ebuild_for_rust_version(rust_version)
+ return find_oldest_rust_version_in_chroot()
+
+ delete_version, delete_ebuild = run_step(
+ 'find rust version to delete',
+ find_desired_rust_version,
+ result_from_json=prepare_uprev_from_json,
+ )
+ run_step(
+ 'remove patches', lambda: remove_files(
+ f'files/rust-{delete_version}-*.patch', RUST_PATH))
+ run_step('remove ebuild', lambda: remove_files(delete_ebuild, RUST_PATH))
+ ebuild_file = get_command_output(['equery', 'w', 'rust'])
+ run_step('update manifest to delete old version', lambda: update_manifest(
+ ebuild_file))
+ run_step('remove version from rust packages', lambda: update_rust_packages(
+ delete_version, add=False))
+ run_step(
+ 'remove virtual/rust', lambda: remove_files(
+ f'rust-{delete_version}.ebuild',
+ os.path.join(RUST_PATH, '../../virtual/rust')))
+
+
+def create_new_repo(rust_version: RustVersion) -> None:
+ output = get_command_output(['git', 'status', '--porcelain'], cwd=RUST_PATH)
+ if output:
+ raise RuntimeError(
+ f'{RUST_PATH} has uncommitted changes, please either discard them '
+ 'or commit them.')
+ git.CreateBranch(RUST_PATH, f'rust-to-{rust_version}')
+
+
+def build_cross_compiler() -> None:
+ # Get target triples in ebuild
+ rust_ebuild = get_command_output(['equery', 'w', 'rust'])
+ with open(rust_ebuild, encoding='utf-8') as f:
+ contents = f.read()
+
+ target_triples_re = re.compile(r'RUSTC_TARGET_TRIPLES=\(([^)]+)\)')
+ m = target_triples_re.search(contents)
+ assert m, 'RUST_TARGET_TRIPLES not found in rust ebuild'
+ target_triples = m.group(1).strip().split('\n')
+
+ compiler_targets_to_install = [
+ target.strip() for target in target_triples if 'cros-' in target
+ ]
+ for target in target_triples:
+ if 'cros-' not in target:
+ continue
+ target = target.strip()
+
+ # We also always need arm-none-eabi, though it's not mentioned in
+ # RUSTC_TARGET_TRIPLES.
+ compiler_targets_to_install.append('arm-none-eabi')
+
+ logging.info('Emerging cross compilers %s', compiler_targets_to_install)
+ subprocess.check_call(
+ ['sudo', 'emerge', '-j', '-G'] +
+ [f'cross-{target}/gcc' for target in compiler_targets_to_install])
+
+
+def create_new_commit(rust_version: RustVersion) -> None:
+ subprocess.check_call(['git', 'add', '-A'], cwd=RUST_PATH)
+ messages = [
+ f'[DO NOT SUBMIT] dev-lang/rust: upgrade to Rust {rust_version}',
+ '',
+ 'This CL is created by rust_uprev tool automatically.'
+ '',
+ 'BUG=None',
+ 'TEST=Use CQ to test the new Rust version',
+ ]
+ git.UploadChanges(RUST_PATH, f'rust-to-{rust_version}', messages)
+
+
+def main() -> None:
+ if not chroot.InChroot():
+ raise RuntimeError('This script must be executed inside chroot')
+
+ logging.basicConfig(level=logging.INFO)
+
+ args = parse_commandline_args()
+
+ state_file = pathlib.Path(args.state_file)
+ tmp_state_file = state_file.with_suffix('.tmp')
+
+ try:
+ with state_file.open(encoding='utf-8') as f:
+ completed_steps = json.load(f)
+ except FileNotFoundError:
+ completed_steps = {}
+
+ def run_step(
+ step_name: str,
+ step_fn: Callable[[], T],
+ result_from_json: Optional[Callable[[Any], T]] = None,
+ result_to_json: Optional[Callable[[T], Any]] = None,
+ ) -> T:
+ return perform_step(state_file, tmp_state_file, completed_steps, step_name,
+ step_fn, result_from_json, result_to_json)
+
+ if args.subparser_name == 'create':
+ create_rust_uprev(args.rust_version, args.template, args.skip_compile,
+ run_step)
+ elif args.subparser_name == 'remove':
+ remove_rust_uprev(args.rust_version, run_step)
+ else:
+ # If you have added more subparser_name, please also add the handlers above
+ assert args.subparser_name == 'roll'
+ run_step('create new repo', lambda: create_new_repo(args.uprev))
+ if not args.skip_cross_compiler:
+ run_step('build cross compiler', build_cross_compiler)
+ create_rust_uprev(args.uprev, args.template, args.skip_compile, run_step)
+ remove_rust_uprev(args.remove, run_step)
+ if not args.no_upload:
+ run_step('create rust uprev CL', lambda: create_new_commit(args.uprev))
+
+
+if __name__ == '__main__':
+ sys.exit(main())
diff --git a/rust_tools/rust_uprev_test.py b/rust_tools/rust_uprev_test.py
new file mode 100755
index 00000000..fc506004
--- /dev/null
+++ b/rust_tools/rust_uprev_test.py
@@ -0,0 +1,455 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Copyright 2020 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Tests for rust_uprev.py"""
+
+# pylint: disable=cros-logging-import
+import os
+import shutil
+import subprocess
+import unittest
+from unittest import mock
+
+from llvm_tools import git
+
+import rust_uprev
+
+
+class RustVersionTest(unittest.TestCase):
+ """Tests for RustVersion class"""
+
+ def test_str(self):
+ obj = rust_uprev.RustVersion(major=1, minor=2, patch=3)
+ self.assertEqual(str(obj), '1.2.3')
+
+ def test_parse_version_only(self):
+ expected = rust_uprev.RustVersion(major=1, minor=2, patch=3)
+ actual = rust_uprev.RustVersion.parse('1.2.3')
+ self.assertEqual(expected, actual)
+
+ def test_parse_ebuild_name(self):
+ expected = rust_uprev.RustVersion(major=1, minor=2, patch=3)
+ actual = rust_uprev.RustVersion.parse_from_ebuild('rust-1.2.3.ebuild')
+ self.assertEqual(expected, actual)
+
+ actual = rust_uprev.RustVersion.parse_from_ebuild('rust-1.2.3-r1.ebuild')
+ self.assertEqual(expected, actual)
+
+ def test_parse_fail(self):
+ with self.assertRaises(AssertionError) as context:
+ rust_uprev.RustVersion.parse('invalid-rust-1.2.3')
+ self.assertEqual("failed to parse 'invalid-rust-1.2.3'",
+ str(context.exception))
+
+
+class PrepareUprevTest(unittest.TestCase):
+ """Tests for prepare_uprev step in rust_uprev"""
+
+ def setUp(self):
+ self.version_old = rust_uprev.RustVersion(1, 2, 3)
+ self.version_new = rust_uprev.RustVersion(1, 3, 5)
+
+ @mock.patch.object(
+ rust_uprev,
+ 'find_ebuild_for_rust_version',
+ return_value='/path/to/ebuild')
+ @mock.patch.object(rust_uprev, 'get_command_output')
+ def test_success_with_template(self, mock_command, mock_find_ebuild):
+ expected = (self.version_old, '/path/to/ebuild')
+ actual = rust_uprev.prepare_uprev(
+ rust_version=self.version_new, template=self.version_old)
+ self.assertEqual(expected, actual)
+ mock_command.assert_not_called()
+
+ @mock.patch.object(
+ rust_uprev,
+ 'find_ebuild_for_rust_version',
+ return_value='/path/to/ebuild')
+ @mock.patch.object(rust_uprev, 'get_command_output')
+ def test_return_none_with_template_larger_than_input(self, mock_command,
+ _mock_find_ebuild):
+ ret = rust_uprev.prepare_uprev(
+ rust_version=self.version_old, template=self.version_new)
+ self.assertIsNone(ret)
+ mock_command.assert_not_called()
+
+ @mock.patch.object(os.path, 'exists')
+ @mock.patch.object(rust_uprev, 'get_command_output')
+ def test_success_without_template(self, mock_command, mock_exists):
+ rust_ebuild_path = f'/path/to/rust/rust-{self.version_old}-r3.ebuild'
+ mock_command.return_value = rust_ebuild_path
+ expected = (self.version_old, rust_ebuild_path)
+ actual = rust_uprev.prepare_uprev(
+ rust_version=self.version_new, template=None)
+ self.assertEqual(expected, actual)
+ mock_command.assert_called_once_with(['equery', 'w', 'rust'])
+ mock_exists.assert_not_called()
+
+ @mock.patch.object(os.path, 'exists')
+ @mock.patch.object(rust_uprev, 'get_command_output')
+ def test_return_none_with_ebuild_larger_than_input(self, mock_command,
+ mock_exists):
+ mock_command.return_value = f'/path/to/rust/rust-{self.version_new}.ebuild'
+ ret = rust_uprev.prepare_uprev(rust_version=self.version_old, template=None)
+ self.assertIsNone(ret)
+ mock_exists.assert_not_called()
+
+ def test_prepare_uprev_from_json(self):
+ ebuild_path = '/path/to/the/ebuild'
+ json_result = (list(self.version_new), ebuild_path)
+ expected = (self.version_new, ebuild_path)
+ actual = rust_uprev.prepare_uprev_from_json(json_result)
+ self.assertEqual(expected, actual)
+
+
+class UpdateEbuildTest(unittest.TestCase):
+ """Tests for update_ebuild step in rust_uprev"""
+ ebuild_file_before = """
+ STAGE0_DATE="2019-01-01"
+ STAGE0_VERSION="any.random.(number)"
+ STAGE0_VERSION_CARGO="0.0.0"
+ """
+ ebuild_file_after = """
+ STAGE0_DATE="2020-01-01"
+ STAGE0_VERSION="1.1.1"
+ STAGE0_VERSION_CARGO="0.1.0"
+ """
+
+ def test_success(self):
+ mock_open = mock.mock_open(read_data=self.ebuild_file_before)
+ ebuild_file = '/path/to/rust/rust-1.3.5.ebuild'
+ with mock.patch('builtins.open', mock_open):
+ rust_uprev.update_ebuild(ebuild_file, ('2020-01-01', '1.1.1', '0.1.0'))
+ mock_open.return_value.__enter__().write.assert_called_once_with(
+ self.ebuild_file_after)
+
+ def test_fail_when_ebuild_misses_a_variable(self):
+ ebuild_file = 'STAGE0_DATE="2019-01-01"'
+ mock_open = mock.mock_open(read_data=ebuild_file)
+ ebuild_file = '/path/to/rust/rust-1.3.5.ebuild'
+ with mock.patch('builtins.open', mock_open):
+ with self.assertRaises(RuntimeError) as context:
+ rust_uprev.update_ebuild(ebuild_file, ('2020-01-01', '1.1.1', '0.1.0'))
+ self.assertEqual('STAGE0_VERSION not found in rust ebuild',
+ str(context.exception))
+
+
+class UpdateManifestTest(unittest.TestCase):
+ """Tests for update_manifest step in rust_uprev"""
+
+ # pylint: disable=protected-access
+ def _run_test_flip_mirror(self, before, after, add, expect_write):
+ mock_open = mock.mock_open(read_data=f'RESTRICT="{before}"')
+ with mock.patch('builtins.open', mock_open):
+ rust_uprev.flip_mirror_in_ebuild('', add=add)
+ if expect_write:
+ mock_open.return_value.__enter__().write.assert_called_once_with(
+ f'RESTRICT="{after}"')
+
+ def test_add_mirror_in_ebuild(self):
+ self._run_test_flip_mirror(
+ before='variable1 variable2',
+ after='variable1 variable2 mirror',
+ add=True,
+ expect_write=True)
+
+ def test_remove_mirror_in_ebuild(self):
+ self._run_test_flip_mirror(
+ before='variable1 variable2 mirror',
+ after='variable1 variable2',
+ add=False,
+ expect_write=True)
+
+ def test_add_mirror_when_exists(self):
+ self._run_test_flip_mirror(
+ before='variable1 variable2 mirror',
+ after='variable1 variable2 mirror',
+ add=True,
+ expect_write=False)
+
+ def test_remove_mirror_when_not_exists(self):
+ self._run_test_flip_mirror(
+ before='variable1 variable2',
+ after='variable1 variable2',
+ add=False,
+ expect_write=False)
+
+ @mock.patch.object(rust_uprev, 'flip_mirror_in_ebuild')
+ @mock.patch.object(rust_uprev, 'rust_ebuild_actions')
+ def test_update_manifest(self, mock_run, mock_flip):
+ ebuild_file = '/path/to/rust/rust-1.1.1.ebuild'
+ rust_uprev.update_manifest(ebuild_file)
+ mock_run.assert_called_once_with(['manifest'])
+ mock_flip.assert_has_calls(
+ [mock.call(ebuild_file, add=True),
+ mock.call(ebuild_file, add=False)])
+
+
+class UpdateRustPackagesTests(unittest.TestCase):
+ """Tests for update_rust_packages step."""
+
+ def setUp(self):
+ self.old_version = rust_uprev.RustVersion(1, 1, 0)
+ self.current_version = rust_uprev.RustVersion(1, 2, 3)
+ self.new_version = rust_uprev.RustVersion(1, 3, 5)
+ self.ebuild_file = os.path.join(rust_uprev.RUST_PATH,
+ 'rust-{self.new_version}.ebuild')
+
+ def test_add_new_rust_packages(self):
+ package_before = (f'dev-lang/rust-{self.old_version}\n'
+ f'dev-lang/rust-{self.current_version}')
+ package_after = (f'dev-lang/rust-{self.old_version}\n'
+ f'dev-lang/rust-{self.current_version}\n'
+ f'dev-lang/rust-{self.new_version}')
+ mock_open = mock.mock_open(read_data=package_before)
+ with mock.patch('builtins.open', mock_open):
+ rust_uprev.update_rust_packages(self.new_version, add=True)
+ mock_open.return_value.__enter__().write.assert_called_once_with(
+ package_after)
+
+ def test_remove_old_rust_packages(self):
+ package_before = (f'dev-lang/rust-{self.old_version}\n'
+ f'dev-lang/rust-{self.current_version}\n'
+ f'dev-lang/rust-{self.new_version}')
+ package_after = (f'dev-lang/rust-{self.current_version}\n'
+ f'dev-lang/rust-{self.new_version}')
+ mock_open = mock.mock_open(read_data=package_before)
+ with mock.patch('builtins.open', mock_open):
+ rust_uprev.update_rust_packages(self.old_version, add=False)
+ mock_open.return_value.__enter__().write.assert_called_once_with(
+ package_after)
+
+
+class UploadToLocalmirrorTests(unittest.TestCase):
+ """Tests for upload_to_localmirror"""
+
+ def setUp(self):
+ self.tempdir = '/tmp/any/dir'
+ self.new_version = rust_uprev.RustVersion(1, 3, 5)
+ self.rust_url = 'https://static.rust-lang.org/dist'
+ self.tarfile_name = f'rustc-{self.new_version}-src.tar.gz'
+ self.rust_src = f'https://static.rust-lang.org/dist/{self.tarfile_name}'
+ self.gsurl = f'gs://chromeos-localmirror/distfiles/{self.tarfile_name}'
+ self.rust_file = os.path.join(self.tempdir, self.tarfile_name)
+ self.sig_file = os.path.join(self.tempdir, 'rustc_sig.asc')
+
+ @mock.patch.object(subprocess, 'call', return_value=1)
+ @mock.patch.object(subprocess, 'check_call')
+ @mock.patch.object(subprocess, 'check_output')
+ @mock.patch.object(subprocess, 'run')
+ def test_pass_without_retry(self, mock_run, mock_output, mock_call,
+ mock_raw_call):
+ rust_uprev.upload_single_tarball(self.rust_url, self.tarfile_name,
+ self.tempdir)
+ mock_output.assert_called_once_with(
+ ['gpg', '--verify', self.sig_file, self.rust_file],
+ encoding='utf-8',
+ stderr=subprocess.STDOUT)
+ mock_raw_call.assert_has_calls([
+ mock.call(['gsutil', 'ls', self.gsurl],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL)
+ ])
+ mock_call.assert_has_calls([
+ mock.call(['curl', '-f', '-o', self.rust_file, self.rust_src]),
+ mock.call(['curl', '-f', '-o', self.sig_file, f'{self.rust_src}.asc']),
+ mock.call([
+ 'gsutil', 'cp', '-n', '-a', 'public-read', self.rust_file,
+ self.gsurl
+ ])
+ ])
+ mock_run.assert_not_called()
+
+ @mock.patch.object(subprocess, 'call')
+ @mock.patch.object(subprocess, 'check_call')
+ @mock.patch.object(subprocess, 'check_output')
+ @mock.patch.object(subprocess, 'run')
+ @mock.patch.object(rust_uprev, 'get_command_output')
+ def test_pass_with_retry(self, mock_output, mock_run, mock_check, mock_call,
+ mock_raw_call):
+ mock_check.side_effect = subprocess.CalledProcessError(
+ returncode=2, cmd=None, output="gpg: Can't check signature")
+ mock_output.return_value = 'some_gpg_keys'
+ rust_uprev.upload_single_tarball(self.rust_url, self.tarfile_name,
+ self.tempdir)
+ mock_check.assert_called_once_with(
+ ['gpg', '--verify', self.sig_file, self.rust_file],
+ encoding='utf-8',
+ stderr=subprocess.STDOUT)
+ mock_output.assert_called_once_with(
+ ['curl', '-f', 'https://keybase.io/rust/pgp_keys.asc'])
+ mock_run.assert_called_once_with(['gpg', '--import'],
+ input='some_gpg_keys',
+ encoding='utf-8',
+ check=True)
+ mock_raw_call.assert_has_calls([
+ mock.call(['gsutil', 'ls', self.gsurl],
+ stdout=subprocess.DEVNULL,
+ stderr=subprocess.DEVNULL)
+ ])
+ mock_call.assert_has_calls([
+ mock.call(['curl', '-f', '-o', self.rust_file, self.rust_src]),
+ mock.call(['curl', '-f', '-o', self.sig_file, f'{self.rust_src}.asc']),
+ mock.call(['gpg', '--verify', self.sig_file, self.rust_file]),
+ mock.call([
+ 'gsutil', 'cp', '-n', '-a', 'public-read', self.rust_file,
+ self.gsurl
+ ])
+ ])
+
+ @mock.patch.object(rust_uprev, 'upload_single_tarball')
+ def test_upload_to_mirror(self, mock_upload):
+ stage0_info = '2020-01-01', '1.1.1', '0.1.0'
+ rust_uprev.upload_to_localmirror(self.tempdir, self.new_version,
+ stage0_info)
+ mock_upload.assert_has_calls([
+ mock.call(self.rust_url, f'rustc-{self.new_version}-src.tar.gz',
+ self.tempdir),
+ mock.call(f'{self.rust_url}/{stage0_info[0]}',
+ f'rust-std-{stage0_info[1]}-x86_64-unknown-linux-gnu.tar.gz',
+ self.tempdir),
+ mock.call(self.rust_url,
+ f'rustc-{stage0_info[1]}-x86_64-unknown-linux-gnu.tar.gz',
+ self.tempdir),
+ mock.call(self.rust_url,
+ f'cargo-{stage0_info[2]}-x86_64-unknown-linux-gnu.tar.gz',
+ self.tempdir),
+ ])
+
+
+class RustUprevOtherStagesTests(unittest.TestCase):
+ """Tests for other steps in rust_uprev"""
+
+ def setUp(self):
+ self.old_version = rust_uprev.RustVersion(1, 1, 0)
+ self.current_version = rust_uprev.RustVersion(1, 2, 3)
+ self.new_version = rust_uprev.RustVersion(1, 3, 5)
+ self.ebuild_file = os.path.join(rust_uprev.RUST_PATH,
+ 'rust-{self.new_version}.ebuild')
+
+ @mock.patch.object(rust_uprev, 'get_command_output')
+ def test_parse_stage0_file(self, mock_get):
+ stage0_file = """
+ unrelated stuff before
+ date: 2020-01-01
+ rustc: 1.1.1
+ cargo: 0.1.0
+ unrelated stuff after
+ """
+ mock_get.return_value = stage0_file
+ expected = '2020-01-01', '1.1.1', '0.1.0'
+ rust_version = rust_uprev.RustVersion(1, 2, 3)
+ actual = rust_uprev.parse_stage0_file(rust_version)
+ self.assertEqual(expected, actual)
+ mock_get.assert_called_once_with([
+ 'curl', '-f', 'https://raw.githubusercontent.com/rust-lang/rust/'
+ f'{rust_version}/src/stage0.txt'
+ ])
+
+ @mock.patch.object(shutil, 'copyfile')
+ @mock.patch.object(os, 'listdir')
+ @mock.patch.object(subprocess, 'check_call')
+ def test_copy_patches(self, mock_call, mock_ls, mock_copy):
+ mock_ls.return_value = [
+ f'rust-{self.old_version}-patch-1.patch',
+ f'rust-{self.old_version}-patch-2-old.patch',
+ f'rust-{self.current_version}-patch-1.patch',
+ f'rust-{self.current_version}-patch-2-new.patch'
+ ]
+ rust_uprev.copy_patches(self.current_version, self.new_version)
+ mock_copy.assert_has_calls([
+ mock.call(
+ os.path.join(rust_uprev.RUST_PATH, 'files',
+ f'rust-{self.current_version}-patch-1.patch'),
+ os.path.join(rust_uprev.RUST_PATH, 'files',
+ f'rust-{self.new_version}-patch-1.patch'),
+ ),
+ mock.call(
+ os.path.join(rust_uprev.RUST_PATH, 'files',
+ f'rust-{self.current_version}-patch-2-new.patch'),
+ os.path.join(rust_uprev.RUST_PATH, 'files',
+ f'rust-{self.new_version}-patch-2-new.patch'))
+ ])
+ mock_call.assert_called_once_with(
+ ['git', 'add', f'files/rust-{self.new_version}-*.patch'],
+ cwd=rust_uprev.RUST_PATH)
+
+ @mock.patch.object(shutil, 'copyfile')
+ @mock.patch.object(subprocess, 'check_call')
+ def test_create_ebuild(self, mock_call, mock_copy):
+ template_ebuild = f'/path/to/rust-{self.current_version}-r2.ebuild'
+ rust_uprev.create_ebuild(template_ebuild, self.new_version)
+ mock_copy.assert_called_once_with(
+ template_ebuild,
+ os.path.join(rust_uprev.RUST_PATH, f'rust-{self.new_version}.ebuild'))
+ mock_call.assert_called_once_with(
+ ['git', 'add', f'rust-{self.new_version}.ebuild'],
+ cwd=rust_uprev.RUST_PATH)
+
+ @mock.patch.object(os.path, 'exists', return_value=True)
+ @mock.patch.object(shutil, 'copyfile')
+ @mock.patch.object(subprocess, 'check_call')
+ def test_update_virtual_rust(self, mock_call, mock_copy, mock_exists):
+ virtual_rust_dir = os.path.join(rust_uprev.RUST_PATH, '../../virtual/rust')
+ rust_uprev.update_virtual_rust(self.current_version, self.new_version)
+ mock_call.assert_called_once_with(
+ ['git', 'add', f'rust-{self.new_version}.ebuild'], cwd=virtual_rust_dir)
+ mock_copy.assert_called_once_with(
+ os.path.join(virtual_rust_dir, f'rust-{self.current_version}.ebuild'),
+ os.path.join(virtual_rust_dir, f'rust-{self.new_version}.ebuild'))
+ mock_exists.assert_called_once_with(virtual_rust_dir)
+
+ @mock.patch.object(os, 'listdir')
+ def test_find_oldest_rust_version_in_chroot_pass(self, mock_ls):
+ oldest_version_name = f'rust-{self.old_version}.ebuild'
+ mock_ls.return_value = [
+ oldest_version_name, f'rust-{self.current_version}.ebuild',
+ f'rust-{self.new_version}.ebuild'
+ ]
+ actual = rust_uprev.find_oldest_rust_version_in_chroot()
+ expected = (self.old_version,
+ os.path.join(rust_uprev.RUST_PATH, oldest_version_name))
+ self.assertEqual(expected, actual)
+
+ @mock.patch.object(os, 'listdir')
+ def test_find_oldest_rust_version_in_chroot_fail_with_only_one_ebuild(
+ self, mock_ls):
+ mock_ls.return_value = [f'rust-{self.new_version}.ebuild']
+ with self.assertRaises(RuntimeError) as context:
+ rust_uprev.find_oldest_rust_version_in_chroot()
+ self.assertEqual('Expect to find more than one Rust versions',
+ str(context.exception))
+
+ @mock.patch.object(rust_uprev, 'get_command_output')
+ @mock.patch.object(git, 'CreateBranch')
+ def test_create_new_repo(self, mock_branch, mock_output):
+ mock_output.return_value = ''
+ rust_uprev.create_new_repo(self.new_version)
+ mock_branch.assert_called_once_with(rust_uprev.RUST_PATH,
+ f'rust-to-{self.new_version}')
+
+ @mock.patch.object(rust_uprev, 'get_command_output')
+ @mock.patch.object(subprocess, 'check_call')
+ def test_build_cross_compiler(self, mock_call, mock_output):
+ mock_output.return_value = f'rust-{self.new_version}.ebuild'
+ cros_targets = [
+ 'x86_64-cros-linux-gnu',
+ 'armv7a-cros-linux-gnueabihf',
+ 'aarch64-cros-linux-gnu',
+ ]
+ all_triples = ['x86_64-pc-linux-gnu'] + cros_targets
+ rust_ebuild = 'RUSTC_TARGET_TRIPLES=(' + '\n\t'.join(all_triples) + ')'
+ mock_open = mock.mock_open(read_data=rust_ebuild)
+ with mock.patch('builtins.open', mock_open):
+ rust_uprev.build_cross_compiler()
+
+ mock_call.assert_called_once_with(
+ ['sudo', 'emerge', '-j', '-G'] +
+ [f'cross-{x}/gcc' for x in cros_targets + ['arm-none-eabi']])
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/rust_tools/rust_watch.py b/rust_tools/rust_watch.py
new file mode 100755
index 00000000..b9ad7b82
--- /dev/null
+++ b/rust_tools/rust_watch.py
@@ -0,0 +1,351 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Copyright 2020 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Checks for various upstream events with the Rust toolchain.
+
+Sends an email if something interesting (probably) happened.
+"""
+
+# pylint: disable=cros-logging-import
+
+import argparse
+import itertools
+import json
+import logging
+import pathlib
+import re
+import shutil
+import subprocess
+import sys
+import time
+from typing import Any, Dict, Iterable, List, Optional, Tuple, NamedTuple
+
+from cros_utils import email_sender
+from cros_utils import tiny_render
+
+
+def gentoo_sha_to_link(sha: str) -> str:
+ """Gets a URL to a webpage that shows the Gentoo commit at `sha`."""
+ return f'https://gitweb.gentoo.org/repo/gentoo.git/commit?id={sha}'
+
+
+def send_email(subject: str, body: List[tiny_render.Piece]) -> None:
+ """Sends an email with the given title and body to... whoever cares."""
+ email_sender.EmailSender().SendX20Email(
+ subject=subject,
+ identifier='rust-watch',
+ well_known_recipients=['cros-team'],
+ text_body=tiny_render.render_text_pieces(body),
+ html_body=tiny_render.render_html_pieces(body),
+ )
+
+
+class RustReleaseVersion(NamedTuple):
+ """Represents a version of Rust's stable compiler."""
+ major: int
+ minor: int
+ patch: int
+
+ @staticmethod
+ def from_string(version_string: str) -> 'RustReleaseVersion':
+ m = re.match(r'(\d+)\.(\d+)\.(\d+)', version_string)
+ if not m:
+ raise ValueError(f"{version_string!r} isn't a valid version string")
+ return RustReleaseVersion(*[int(x) for x in m.groups()])
+
+ def __str__(self) -> str:
+ return f'{self.major}.{self.minor}.{self.patch}'
+
+ def to_json(self) -> str:
+ return str(self)
+
+ @staticmethod
+ def from_json(s: str) -> 'RustReleaseVersion':
+ return RustReleaseVersion.from_string(s)
+
+
+class State(NamedTuple):
+ """State that we keep around from run to run."""
+ # The last Rust release tag that we've seen.
+ last_seen_release: RustReleaseVersion
+
+ # We track Gentoo's upstream Rust ebuild. This is the last SHA we've seen
+ # that updates it.
+ last_gentoo_sha: str
+
+ def to_json(self) -> Dict[str, Any]:
+ return {
+ 'last_seen_release': self.last_seen_release.to_json(),
+ 'last_gentoo_sha': self.last_gentoo_sha,
+ }
+
+ @staticmethod
+ def from_json(s: Dict[str, Any]) -> 'State':
+ return State(
+ last_seen_release=RustReleaseVersion.from_json(s['last_seen_release']),
+ last_gentoo_sha=s['last_gentoo_sha'],
+ )
+
+
+def parse_release_tags(lines: Iterable[str]) -> Iterable[RustReleaseVersion]:
+ """Parses `git ls-remote --tags` output into Rust stable release versions."""
+ refs_tags = 'refs/tags/'
+ for line in lines:
+ _sha, tag = line.split(None, 1)
+ tag = tag.strip()
+ # Each tag has an associated 'refs/tags/name^{}', which is the actual
+ # object that the tag points to. That's irrelevant to us.
+ if tag.endswith('^{}'):
+ continue
+
+ if not tag.startswith(refs_tags):
+ continue
+
+ short_tag = tag[len(refs_tags):]
+ # There are a few old versioning schemes. Ignore them.
+ if short_tag.startswith('0.') or short_tag.startswith('release-'):
+ continue
+ yield RustReleaseVersion.from_string(short_tag)
+
+
+def fetch_most_recent_release() -> RustReleaseVersion:
+ """Fetches the most recent stable `rustc` version."""
+ result = subprocess.run(
+ ['git', 'ls-remote', '--tags', 'https://github.com/rust-lang/rust'],
+ check=True,
+ stdin=None,
+ capture_output=True,
+ encoding='utf-8',
+ )
+ tag_lines = result.stdout.strip().splitlines()
+ return max(parse_release_tags(tag_lines))
+
+
+class GitCommit(NamedTuple):
+ """Represents a single git commit."""
+ sha: str
+ subject: str
+
+
+def update_git_repo(git_dir: pathlib.Path) -> None:
+ """Updates the repo at `git_dir`, retrying a few times on failure."""
+ for i in itertools.count(start=1):
+ result = subprocess.run(
+ ['git', 'fetch', 'origin'],
+ check=False,
+ cwd=str(git_dir),
+ stdin=None,
+ )
+
+ if not result.returncode:
+ break
+
+ if i == 5:
+ # 5 attempts is too many. Something else may be wrong.
+ result.check_returncode()
+
+ sleep_time = 60 * i
+ logging.error("Failed updating gentoo's repo; will try again in %ds...",
+ sleep_time)
+ time.sleep(sleep_time)
+
+
+def get_new_gentoo_commits(git_dir: pathlib.Path,
+ most_recent_sha: str) -> List[GitCommit]:
+ """Gets commits to dev-lang/rust since `most_recent_sha`.
+
+ Older commits come earlier in the returned list.
+ """
+ commits = subprocess.run(
+ [
+ 'git',
+ 'log',
+ '--format=%H %s',
+ f'{most_recent_sha}..origin/master',
+ '--',
+ 'dev-lang/rust',
+ ],
+ capture_output=True,
+ check=False,
+ cwd=str(git_dir),
+ encoding='utf-8',
+ )
+
+ if commits.returncode:
+ logging.error('Error getting new gentoo commits; stderr:\n%s',
+ commits.stderr)
+ commits.check_returncode()
+
+ results = []
+ for line in commits.stdout.strip().splitlines():
+ sha, subject = line.strip().split(None, 1)
+ results.append(GitCommit(sha=sha, subject=subject))
+
+ # `git log` outputs things in newest -> oldest order.
+ results.reverse()
+ return results
+
+
+def setup_gentoo_git_repo(git_dir: pathlib.Path) -> str:
+ """Sets up a gentoo git repo at the given directory. Returns HEAD."""
+ subprocess.run(
+ [
+ 'git', 'clone', 'https://anongit.gentoo.org/git/repo/gentoo.git',
+ str(git_dir)
+ ],
+ stdin=None,
+ check=True,
+ )
+
+ head_rev = subprocess.run(
+ ['git', 'rev-parse', 'HEAD'],
+ cwd=str(git_dir),
+ check=True,
+ stdin=None,
+ capture_output=True,
+ encoding='utf-8',
+ )
+ return head_rev.stdout.strip()
+
+
+def read_state(state_file: pathlib.Path) -> State:
+ """Reads state from the given file."""
+ with state_file.open(encoding='utf-8') as f:
+ return State.from_json(json.load(f))
+
+
+def atomically_write_state(state_file: pathlib.Path, state: State) -> None:
+ """Writes state to the given file."""
+ temp_file = pathlib.Path(str(state_file) + '.new')
+ with temp_file.open('w', encoding='utf-8') as f:
+ json.dump(state.to_json(), f)
+ temp_file.rename(state_file)
+
+
+def maybe_compose_email(old_state: State, newest_release: RustReleaseVersion,
+ new_gentoo_commits: List[GitCommit]
+ ) -> Optional[Tuple[str, List[tiny_render.Piece]]]:
+ """Creates an email given our new state, if doing so is appropriate."""
+ subject_pieces = []
+ body_pieces = []
+
+ if newest_release > old_state.last_seen_release:
+ subject_pieces.append('new rustc release detected')
+ body_pieces.append(f'Rustc tag for v{newest_release} was found.')
+
+ if new_gentoo_commits:
+ # Separate the sections a bit for prettier output.
+ if body_pieces:
+ body_pieces += [tiny_render.line_break, tiny_render.line_break]
+
+ if len(new_gentoo_commits) == 1:
+ subject_pieces.append('new rust ebuild commit detected')
+ body_pieces.append('commit:')
+ else:
+ subject_pieces.append('new rust ebuild commits detected')
+ body_pieces.append('commits (newest first):')
+
+ commit_lines = []
+ for commit in new_gentoo_commits:
+ commit_lines.append([
+ tiny_render.Link(
+ gentoo_sha_to_link(commit.sha),
+ commit.sha[:12],
+ ),
+ f': {commit.subject}',
+ ])
+
+ body_pieces.append(tiny_render.UnorderedList(commit_lines))
+
+ if not subject_pieces:
+ return None
+
+ subject = '[rust-watch] ' + '; '.join(subject_pieces)
+ return subject, body_pieces
+
+
+def main(argv: List[str]) -> None:
+ logging.basicConfig(level=logging.INFO)
+
+ parser = argparse.ArgumentParser(
+ description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument(
+ '--state_dir', required=True, help='Directory to store state in.')
+ parser.add_argument(
+ '--skip_email', action='store_true', help="Don't send an email.")
+ parser.add_argument(
+ '--skip_state_update',
+ action='store_true',
+ help="Don't update the state file. Doesn't apply to initial setup.")
+ opts = parser.parse_args(argv)
+
+ state_dir = pathlib.Path(opts.state_dir)
+ state_file = state_dir / 'state.json'
+ gentoo_subdir = state_dir / 'upstream-gentoo'
+ if not state_file.exists():
+ logging.info("state_dir isn't fully set up; doing that now.")
+
+ # Could be in a partially set-up state.
+ if state_dir.exists():
+ logging.info('incomplete state_dir detected; removing.')
+ shutil.rmtree(str(state_dir))
+
+ state_dir.mkdir(parents=True)
+ most_recent_release = fetch_most_recent_release()
+ most_recent_gentoo_commit = setup_gentoo_git_repo(gentoo_subdir)
+ atomically_write_state(
+ state_file,
+ State(
+ last_seen_release=most_recent_release,
+ last_gentoo_sha=most_recent_gentoo_commit,
+ ),
+ )
+ # Running through this _should_ be a nop, but do it anyway. Should make any
+ # bugs more obvious on the first run of the script.
+
+ prior_state = read_state(state_file)
+ logging.info('Last state was %r', prior_state)
+
+ most_recent_release = fetch_most_recent_release()
+ logging.info('Most recent Rust release is %s', most_recent_release)
+
+ logging.info('Fetching new commits from Gentoo')
+ update_git_repo(gentoo_subdir)
+ new_commits = get_new_gentoo_commits(gentoo_subdir,
+ prior_state.last_gentoo_sha)
+ logging.info('New commits: %r', new_commits)
+
+ maybe_email = maybe_compose_email(prior_state, most_recent_release,
+ new_commits)
+
+ if maybe_email is None:
+ logging.info('No updates to send')
+ else:
+ title, body = maybe_email
+ if opts.skip_email:
+ logging.info('Skipping sending email with title %r and contents\n%s',
+ title, tiny_render.render_html_pieces(body))
+ else:
+ logging.info('Sending email')
+ send_email(title, body)
+
+ if opts.skip_state_update:
+ logging.info('Skipping state update, as requested')
+ return
+
+ newest_sha = (
+ new_commits[-1].sha if new_commits else prior_state.last_gentoo_sha)
+ atomically_write_state(
+ state_file,
+ State(
+ last_seen_release=most_recent_release,
+ last_gentoo_sha=newest_sha,
+ ),
+ )
+
+
+if __name__ == '__main__':
+ sys.exit(main(sys.argv[1:]))
diff --git a/rust_tools/rust_watch_test.py b/rust_tools/rust_watch_test.py
new file mode 100755
index 00000000..97d111fc
--- /dev/null
+++ b/rust_tools/rust_watch_test.py
@@ -0,0 +1,202 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+# Copyright 2020 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Tests for rust_watch.py."""
+
+# pylint: disable=cros-logging-import
+
+import logging
+import pathlib
+import subprocess
+import time
+import unittest
+import unittest.mock
+
+import rust_watch
+from cros_utils import tiny_render
+
+
+class Test(unittest.TestCase):
+ """Tests."""
+
+ def _silence_logs(self):
+ """Silences all log output until the end of the current test."""
+
+ def should_log(_record):
+ return 0
+
+ logger = logging.root
+ logger.addFilter(should_log)
+ self.addCleanup(logger.removeFilter, should_log)
+
+ def test_release_version_parsing(self):
+ self.assertEqual(
+ rust_watch.RustReleaseVersion.from_string('1.2.3'),
+ rust_watch.RustReleaseVersion(1, 2, 3),
+ )
+
+ def test_release_version_json_round_trips(self):
+ ver = rust_watch.RustReleaseVersion(1, 2, 3)
+ self.assertEqual(
+ rust_watch.RustReleaseVersion.from_json(ver.to_json()), ver)
+
+ def test_state_json_round_trips(self):
+ state = rust_watch.State(
+ last_seen_release=rust_watch.RustReleaseVersion(1, 2, 3),
+ last_gentoo_sha='abc123',
+ )
+
+ self.assertEqual(rust_watch.State.from_json(state.to_json()), state)
+
+ @unittest.mock.patch.object(subprocess, 'run')
+ @unittest.mock.patch.object(time, 'sleep')
+ def test_update_git_repo_tries_again_on_failure(self, sleep_mock, run_mock):
+ self._silence_logs()
+
+ oh_no_error = ValueError('oh no')
+
+ def check_returncode():
+ raise oh_no_error
+
+ run_call_count = 0
+
+ def run_sideeffect(*_args, **_kwargs):
+ nonlocal run_call_count
+ run_call_count += 1
+ result = unittest.mock.Mock()
+ result.returncode = 1
+ result.check_returncode = check_returncode
+ return result
+
+ run_mock.side_effect = run_sideeffect
+
+ with self.assertRaises(ValueError) as raised:
+ rust_watch.update_git_repo(pathlib.Path('/does/not/exist/at/all'))
+
+ self.assertIs(raised.exception, oh_no_error)
+ self.assertEqual(run_call_count, 5)
+
+ sleep_timings = [unittest.mock.call(60 * i) for i in range(1, 5)]
+ self.assertEqual(sleep_mock.mock_calls, sleep_timings)
+
+ @unittest.mock.patch.object(subprocess, 'run')
+ def test_get_new_gentoo_commits_functions(self, run_mock):
+ returned = unittest.mock.Mock()
+ returned.returncode = 0
+ returned.stdout = '\n'.join((
+ 'abc123 newer commit',
+ 'abcdef and an older commit',
+ ))
+ run_mock.return_value = returned
+ results = rust_watch.get_new_gentoo_commits(
+ pathlib.Path('/does/not/exist/at/all'), 'defabc')
+ self.assertEqual(results, [
+ rust_watch.GitCommit('abcdef', 'and an older commit'),
+ rust_watch.GitCommit('abc123', 'newer commit'),
+ ])
+
+ def test_compose_email_on_a_new_release(self):
+ new_release = rust_watch.maybe_compose_email(
+ old_state=rust_watch.State(
+ last_seen_release=rust_watch.RustReleaseVersion(1, 0, 0),
+ last_gentoo_sha='',
+ ),
+ newest_release=rust_watch.RustReleaseVersion(1, 1, 0),
+ new_gentoo_commits=[],
+ )
+
+ self.assertEqual(new_release, ('[rust-watch] new rustc release detected',
+ ['Rustc tag for v1.1.0 was found.']))
+
+ def test_compose_email_on_a_new_gentoo_commit(self):
+ sha_a = 'a' * 40
+ new_commit = rust_watch.maybe_compose_email(
+ old_state=rust_watch.State(
+ last_seen_release=rust_watch.RustReleaseVersion(1, 0, 0),
+ last_gentoo_sha='',
+ ),
+ newest_release=rust_watch.RustReleaseVersion(1, 0, 0),
+ new_gentoo_commits=[
+ rust_watch.GitCommit(
+ sha=sha_a,
+ subject='summary_a',
+ ),
+ ],
+ )
+
+ self.assertEqual(new_commit,
+ ('[rust-watch] new rust ebuild commit detected', [
+ 'commit:',
+ tiny_render.UnorderedList([
+ [
+ tiny_render.Link(
+ rust_watch.gentoo_sha_to_link(sha_a),
+ sha_a[:12],
+ ),
+ ': summary_a',
+ ],
+ ])
+ ]))
+
+ def test_compose_email_on_multiple_events(self):
+ sha_a = 'a' * 40
+ new_commit_and_release = rust_watch.maybe_compose_email(
+ old_state=rust_watch.State(
+ last_seen_release=rust_watch.RustReleaseVersion(1, 0, 0),
+ last_gentoo_sha='',
+ ),
+ newest_release=rust_watch.RustReleaseVersion(1, 1, 0),
+ new_gentoo_commits=[
+ rust_watch.GitCommit(
+ sha=sha_a,
+ subject='summary_a',
+ ),
+ ],
+ )
+
+ self.assertEqual(
+ new_commit_and_release,
+ ('[rust-watch] new rustc release detected; new rust ebuild commit '
+ 'detected', [
+ 'Rustc tag for v1.1.0 was found.',
+ tiny_render.line_break,
+ tiny_render.line_break,
+ 'commit:',
+ tiny_render.UnorderedList([
+ [
+ tiny_render.Link(
+ rust_watch.gentoo_sha_to_link(sha_a),
+ sha_a[:12],
+ ),
+ ': summary_a',
+ ],
+ ]),
+ ]))
+
+ def test_compose_email_composes_nothing_when_no_new_updates_exist(self):
+ self.assertIsNone(
+ rust_watch.maybe_compose_email(
+ old_state=rust_watch.State(
+ last_seen_release=rust_watch.RustReleaseVersion(1, 0, 0),
+ last_gentoo_sha='',
+ ),
+ newest_release=rust_watch.RustReleaseVersion(1, 0, 0),
+ new_gentoo_commits=[],
+ ))
+
+ self.assertIsNone(
+ rust_watch.maybe_compose_email(
+ old_state=rust_watch.State(
+ last_seen_release=rust_watch.RustReleaseVersion(1, 1, 0),
+ last_gentoo_sha='',
+ ),
+ newest_release=rust_watch.RustReleaseVersion(1, 0, 0),
+ new_gentoo_commits=[],
+ ))
+
+
+if __name__ == '__main__':
+ unittest.main()