blob: 3c0ad01200442590c185f58fdfa680c169074870 [file] [log] [blame]
#!/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())