diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..bee8a64b79a99590d5303307144172cfe824fbf7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +__pycache__ diff --git a/Android.bp b/Android.bp new file mode 100644 index 0000000000000000000000000000000000000000..c4f158d3ad8f2505ef88c2bb9e989281d1bcd5c1 --- /dev/null +++ b/Android.bp @@ -0,0 +1,77 @@ +// Copyright (C) 2018 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +python_binary_host { + name: "external_updater", + main: "external_updater.py", + srcs: [ + "external_updater.py", + ], + libs: [ + "external_updater_lib", + ], +} + +python_binary_host { + name: "external_updater_notifier", + main: "notifier.py", + srcs: [ + "git_utils.py", + "notifier.py", + ], +} + +python_library_host { + name: "external_updater_lib", + srcs: [ + "archive_utils.py", + "fileutils.py", + "git_updater.py", + "git_utils.py", + "github_archive_updater.py", + "metadata.proto", + "updater_utils.py", + ], + libs: [ + "python-symbol", + "libprotobuf-python", + ], + proto: { + canonical_path_from_root: false, + }, + data: [ + "update_package.sh", + ], + version: { + py2: { + enabled: false, + embedded_launcher: false, + }, + py3: { + enabled: true, + embedded_launcher: false, + }, + }, +} + +python_test_host { + name: "external_updater_test", + main: "external_updater_test.py", + srcs: [ + "external_updater_test.py", + ], + libs: [ + "external_updater_lib", + ], +} diff --git a/OWNERS b/OWNERS new file mode 100644 index 0000000000000000000000000000000000000000..da8db7ab77193420a348168f7fbe1be78f335e85 --- /dev/null +++ b/OWNERS @@ -0,0 +1,4 @@ +# Default code reviewers picked from top 3 or more developers. +# Please update this list if you find better candidates. +hhb@google.com +enh@google.com diff --git a/PREUPLOAD.cfg b/PREUPLOAD.cfg new file mode 100644 index 0000000000000000000000000000000000000000..f52b2bd167cd9dbcbd5cc64d95efc356a11f1ea9 --- /dev/null +++ b/PREUPLOAD.cfg @@ -0,0 +1,5 @@ +[Builtin Hooks] +pylint = true + +[Builtin Hooks Options] +pylint = --executable-path pylint3 ${PREUPLOAD_FILES} diff --git a/archive_utils.py b/archive_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..f0198c5a408471fbeb76b80683a9c8a37f77d66a --- /dev/null +++ b/archive_utils.py @@ -0,0 +1,130 @@ +# Copyright (C) 2018 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Functions to process archive files.""" + +import os +import tempfile +import tarfile +import urllib.parse +import zipfile + + +class ZipFileWithPermission(zipfile.ZipFile): + """Subclassing Zipfile to preserve file permission. + + See https://bugs.python.org/issue15795 + """ + + def _extract_member(self, member, targetpath, pwd): + ret_val = super()._extract_member(member, targetpath, pwd) + + if not isinstance(member, zipfile.ZipInfo): + member = self.getinfo(member) + attr = member.external_attr >> 16 + if attr != 0: + os.chmod(ret_val, attr) + return ret_val + + +def unzip(archive_path, target_path): + """Extracts zip file to a path. + + Args: + archive_path: Path to the zip file. + target_path: Path to extract files to. + """ + + with ZipFileWithPermission(archive_path) as zfile: + zfile.extractall(target_path) + + +def untar(archive_path, target_path): + """Extracts tar file to a path. + + Args: + archive_path: Path to the tar file. + target_path: Path to extract files to. + """ + + with tarfile.open(archive_path, mode='r') as tfile: + tfile.extractall(target_path) + + +ARCHIVE_TYPES = { + '.zip': unzip, + '.tar.gz': untar, + '.tar.bz2': untar, + '.tar.xz': untar, +} + + +def is_supported_archive(url): + """Checks whether the url points to a supported archive.""" + return get_extract_func(url) is not None + + +def get_extract_func(url): + """Gets the function to extract an archive. + + Args: + url: The url to the archive file. + + Returns: + A function to extract the archive. None if not found. + """ + + parsed_url = urllib.parse.urlparse(url) + filename = os.path.basename(parsed_url.path) + for ext, func in ARCHIVE_TYPES.items(): + if filename.endswith(ext): + return func + return None + + +def download_and_extract(url): + """Downloads and extracts an archive file to a temporary directory. + + Args: + url: Url to download. + + Returns: + Path to the temporary directory. + """ + + print('Downloading {}'.format(url)) + archive_file, _headers = urllib.request.urlretrieve(url) + + temporary_dir = tempfile.mkdtemp() + print('Extracting {} to {}'.format(archive_file, temporary_dir)) + get_extract_func(url)(archive_file, temporary_dir) + + return temporary_dir + + +def find_archive_root(path): + """Finds the real root of an extracted archive. + + Sometimes archives has additional layers of directories. This function tries + to guess the right 'root' path by entering all single sub-directories. + + Args: + path: Path to the extracted archive. + + Returns: + The root path we found. + """ + for root, dirs, files in os.walk(path): + if files or len(dirs) > 1: + return root + return path diff --git a/external_updater.py b/external_updater.py new file mode 100644 index 0000000000000000000000000000000000000000..5e2e944c0778b50fb917604d896e7bf102b17499 --- /dev/null +++ b/external_updater.py @@ -0,0 +1,270 @@ +# Copyright (C) 2018 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""A commandline tool to check and update packages in external/ + +Example usage: +updater.sh checkall +updater.sh update kotlinc +""" + +import argparse +import json +import os +import subprocess +import time + +from google.protobuf import text_format # pylint: disable=import-error + +from git_updater import GitUpdater +from github_archive_updater import GithubArchiveUpdater +import fileutils +import git_utils +import updater_utils + + +UPDATERS = [GithubArchiveUpdater, GitUpdater] + + +def color_string(string, color): + """Changes the color of a string when print to terminal.""" + colors = { + 'FRESH': '\x1b[32m', + 'STALE': '\x1b[31;1m', + 'ERROR': '\x1b[31m', + } + end_color = '\033[0m' + return colors[color] + string + end_color + + +def build_updater(proj_path): + """Build updater for a project specified by proj_path. + + Reads and parses METADATA file. And builds updater based on the information. + + Args: + proj_path: Absolute or relative path to the project. + + Returns: + The updater object built. None if there's any error. + """ + + proj_path = fileutils.get_absolute_project_path(proj_path) + try: + metadata = fileutils.read_metadata(proj_path) + except text_format.ParseError as err: + print('{} {}.'.format(color_string('Invalid metadata file:', 'ERROR'), + err)) + return None + + try: + updater = updater_utils.create_updater(metadata, proj_path, UPDATERS) + except ValueError: + print(color_string('No supported URL.', 'ERROR')) + return None + return updater + + +def has_new_version(updater): + """Whether an updater found a new version.""" + return updater.get_current_version() != updater.get_latest_version() + + +def _message_for_calledprocesserror(error): + return '\n'.join([error.stdout.decode('utf-8'), + error.stderr.decode('utf-8')]) + + +def check_update(proj_path): + """Checks updates for a project. Prints result on console. + + Args: + proj_path: Absolute or relative path to the project. + """ + + print( + 'Checking {}. '.format(fileutils.get_relative_project_path(proj_path)), + end='') + updater = build_updater(proj_path) + if updater is None: + return (None, 'Failed to create updater') + try: + updater.check() + if has_new_version(updater): + print(color_string(' Out of date!', 'STALE')) + else: + print(color_string(' Up to date.', 'FRESH')) + return (updater, None) + except (IOError, ValueError) as err: + print('{} {}.'.format(color_string('Failed.', 'ERROR'), + err)) + return (updater, str(err)) + except subprocess.CalledProcessError as err: + msg = _message_for_calledprocesserror(err) + print('{}\n{}'.format(msg, color_string('Failed.', 'ERROR'))) + return (updater, msg) + + +def _process_update_result(path): + res = {} + updater, err = check_update(path) + if err is not None: + res['error'] = str(err) + else: + res['current'] = updater.get_current_version() + res['latest'] = updater.get_latest_version() + return res + + +def _check_some(paths, delay): + results = {} + for path in paths: + relative_path = fileutils.get_relative_project_path(path) + results[relative_path] = _process_update_result(path) + time.sleep(delay) + return results + + +def _check_all(delay): + results = {} + for path, dirs, files in os.walk(fileutils.EXTERNAL_PATH): + dirs.sort(key=lambda d: d.lower()) + if fileutils.METADATA_FILENAME in files: + # Skip sub directories. + dirs[:] = [] + relative_path = fileutils.get_relative_project_path(path) + results[relative_path] = _process_update_result(path) + time.sleep(delay) + return results + + +def check(args): + """Handler for check command.""" + if args.all: + results = _check_all(args.delay) + else: + results = _check_some(args.paths, args.delay) + + if args.json_output is not None: + with open(args.json_output, 'w') as f: + json.dump(results, f, sort_keys=True, indent=4) + + +def update(args): + """Handler for update command.""" + try: + _do_update(args) + except subprocess.CalledProcessError as err: + msg = _message_for_calledprocesserror(err) + print( + '{}\n{}'.format( + msg, + color_string( + 'Failed to upgrade.', + 'ERROR'))) + + +TMP_BRANCH_NAME = 'tmp_auto_upgrade' + + +def _do_update(args): + updater, err = check_update(args.path) + if updater is None: + return + if not has_new_version(updater) and not args.force: + return + + full_path = fileutils.get_absolute_project_path(args.path) + if args.branch_and_commit: + git_utils.checkout(full_path, args.remote_name + '/master') + try: + git_utils.delete_branch(full_path, TMP_BRANCH_NAME) + except subprocess.CalledProcessError as err: + # Still continue if the branch doesn't exist. + pass + git_utils.start_branch(full_path, TMP_BRANCH_NAME) + + updater.update() + + if args.branch_and_commit: + msg = 'Upgrade {} to {}\n\nTest: None'.format( + args.path, updater.get_latest_version()) + git_utils.add_file(full_path, '*') + git_utils.commit(full_path, msg) + + if args.push_change: + git_utils.push(full_path, args.remote_name) + + if args.branch_and_commit: + git_utils.checkout(full_path, args.remote_name + '/master') + + +def parse_args(): + """Parses commandline arguments.""" + + parser = argparse.ArgumentParser( + description='Check updates for third party projects in external/.') + subparsers = parser.add_subparsers(dest='cmd') + subparsers.required = True + + # Creates parser for check command. + check_parser = subparsers.add_parser( + 'check', help='Check update for one project.') + check_parser.add_argument( + 'paths', nargs='*', + help='Paths of the project. ' + 'Relative paths will be resolved from external/.') + check_parser.add_argument( + '--json_output', + help='Path of a json file to write result to.') + check_parser.add_argument( + '--all', action='store_true', + help='If set, check updates for all supported projects.') + check_parser.add_argument( + '--delay', default=0, type=int, + help='Time in seconds to wait between checking two projects.') + check_parser.set_defaults(func=check) + + # Creates parser for update command. + update_parser = subparsers.add_parser('update', help='Update one project.') + update_parser.add_argument( + 'path', + help='Path of the project. ' + 'Relative paths will be resolved from external/.') + update_parser.add_argument( + '--force', + help='Run update even if there\'s no new version.', + action='store_true') + update_parser.add_argument( + '--branch_and_commit', action='store_true', + help='Starts a new branch and commit changes.') + update_parser.add_argument( + '--push_change', action='store_true', + help='Pushes change to Gerrit.') + update_parser.add_argument( + '--remote_name', default='aosp', required=False, + help='Upstream remote name.') + update_parser.set_defaults(func=update) + + return parser.parse_args() + + +def main(): + """The main entry.""" + + args = parse_args() + args.func(args) + + +if __name__ == '__main__': + main() diff --git a/external_updater_test.py b/external_updater_test.py new file mode 100644 index 0000000000000000000000000000000000000000..0c82f05fc665773721c7cfd7d9aaf666cf0c6863 --- /dev/null +++ b/external_updater_test.py @@ -0,0 +1,48 @@ +# Copyright (C) 2018 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Unit tests for external updater.""" + +import unittest + +import github_archive_updater + + +class ExternalUpdaterTest(unittest.TestCase): + """Unit tests for external updater.""" + + def test_url_selection(self): + """Tests that GithubArchiveUpdater can choose the right url.""" + prefix = "https://github.com/author/project/" + urls = [ + prefix + "releases/download/ver-1.0/ver-1.0-binary.zip", + prefix + "releases/download/ver-1.0/ver-1.0-binary.tar.gz", + prefix + "releases/download/ver-1.0/ver-1.0-src.zip", + prefix + "releases/download/ver-1.0/ver-1.0-src.tar.gz", + prefix + "archive/ver-1.0.zip", + prefix + "archive/ver-1.0.tar.gz", + ] + + previous_url = prefix + "releases/download/ver-0.9/ver-0.9-src.tar.gz" + url = github_archive_updater.choose_best_url(urls, previous_url) + expected_url = prefix + "releases/download/ver-1.0/ver-1.0-src.tar.gz" + self.assertEqual(url, expected_url) + + previous_url = prefix + "archive/ver-0.9.zip" + url = github_archive_updater.choose_best_url(urls, previous_url) + expected_url = prefix + "archive/ver-1.0.zip" + self.assertEqual(url, expected_url) + + +if __name__ == '__main__': + unittest.main() diff --git a/fileutils.py b/fileutils.py new file mode 100644 index 0000000000000000000000000000000000000000..e0a0f66f18c3d49cfd472e01d63ab63ffb3fae5c --- /dev/null +++ b/fileutils.py @@ -0,0 +1,86 @@ +# Copyright (C) 2018 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tool functions to deal with files.""" + +import datetime +import os + +from google.protobuf import text_format # pylint: disable=import-error + +import metadata_pb2 # pylint: disable=import-error + +ANDROID_TOP = os.environ.get('ANDROID_BUILD_TOP', os.getcwd()) +EXTERNAL_PATH = os.path.join(ANDROID_TOP, 'external/') + +METADATA_FILENAME = 'METADATA' + + +def get_absolute_project_path(project_path): + """Gets absolute path of a project. + + Path resolution starts from external/. + """ + return os.path.join(EXTERNAL_PATH, project_path) + + +def get_metadata_path(project_path): + """Gets the absolute path of METADATA for a project.""" + return os.path.join( + get_absolute_project_path(project_path), METADATA_FILENAME) + + +def get_relative_project_path(project_path): + """Gets the relative path of a project starting from external/.""" + project_path = get_absolute_project_path(project_path) + return os.path.relpath(project_path, EXTERNAL_PATH) + + +def read_metadata(proj_path): + """Reads and parses METADATA file for a project. + + Args: + proj_path: Path to the project. + + Returns: + Parsed MetaData proto. + + Raises: + text_format.ParseError: Occurred when the METADATA file is invalid. + FileNotFoundError: Occurred when METADATA file is not found. + """ + + with open(get_metadata_path(proj_path), 'r') as metadata_file: + metadata = metadata_file.read() + return text_format.Parse(metadata, metadata_pb2.MetaData()) + + +def write_metadata(proj_path, metadata): + """Writes updated METADATA file for a project. + + This function updates last_upgrade_date in metadata and write to the project + directory. + + Args: + proj_path: Path to the project. + metadata: The MetaData proto to write. + """ + + date = metadata.third_party.last_upgrade_date + now = datetime.datetime.now() + date.year = now.year + date.month = now.month + date.day = now.day + text_metadata = text_format.MessageToString(metadata) + with open(get_metadata_path(proj_path), 'w') as metadata_file: + metadata_file.write(text_metadata) diff --git a/git_updater.py b/git_updater.py new file mode 100644 index 0000000000000000000000000000000000000000..eee64f1c2f45d2db0ac48af43cdce0854cd53285 --- /dev/null +++ b/git_updater.py @@ -0,0 +1,117 @@ +# Copyright (C) 2018 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Module to check updates from Git upstream.""" + + +import datetime + +import fileutils +import git_utils +import metadata_pb2 # pylint: disable=import-error +import updater_utils + + +class GitUpdater(): + """Updater for Git upstream.""" + + def __init__(self, url, proj_path, metadata): + if url.type != metadata_pb2.URL.GIT: + raise ValueError('Only support GIT upstream.') + self.proj_path = proj_path + self.metadata = metadata + self.upstream_url = url + self.upstream_remote_name = None + self.android_remote_name = None + self.new_version = None + + def _setup_remote(self): + remotes = git_utils.list_remotes(self.proj_path) + for name, url in remotes.items(): + if url == self.upstream_url.value: + self.upstream_remote_name = name + + # Guess android remote name. + if '/platform/external/' in url: + self.android_remote_name = name + + if self.upstream_remote_name is None: + self.upstream_remote_name = "update_origin" + git_utils.add_remote(self.proj_path, self.upstream_remote_name, + self.upstream_url.value) + + git_utils.fetch(self.proj_path, + [self.upstream_remote_name, self.android_remote_name]) + + def check(self): + """Checks upstream and returns whether a new version is available.""" + + self._setup_remote() + if git_utils.is_commit(self.metadata.third_party.version): + # Update to remote head. + self._check_head() + else: + # Update to latest version tag. + self._check_tag() + + def get_current_version(self): + """Returns the latest version name recorded in METADATA.""" + return self.metadata.third_party.version + + def get_latest_version(self): + """Returns the latest version name in upstream.""" + return self.new_version + + def _check_tag(self): + tags = git_utils.list_remote_tags(self.proj_path, + self.upstream_remote_name) + current_ver = self.get_current_version() + self.new_version = updater_utils.get_latest_version( + current_ver, tags) + print('Current version: {}. Latest version: {}'.format( + current_ver, self.new_version), end='') + + def _check_head(self): + commits = git_utils.get_commits_ahead( + self.proj_path, self.upstream_remote_name + '/master', + self.android_remote_name + '/master') + + if not commits: + self.new_version = self.get_current_version() + return + + self.new_version = commits[0] + + commit_time = git_utils.get_commit_time(self.proj_path, commits[-1]) + time_behind = datetime.datetime.now() - commit_time + print('{} commits ({} days) behind.'.format( + len(commits), time_behind.days), end='') + + def _write_metadata(self, path): + updated_metadata = metadata_pb2.MetaData() + updated_metadata.CopyFrom(self.metadata) + updated_metadata.third_party.version = self.new_version + fileutils.write_metadata(path, updated_metadata) + + def update(self): + """Updates the package. + + Has to call check() before this function. + """ + upstream_branch = self.upstream_remote_name + '/master' + + print("Running `git merge {merge_branch}`..." + .format(merge_branch=self.new_version)) + git_utils.merge(self.proj_path, self.new_version) + self._write_metadata(self.proj_path) + git_utils.add_file(self.proj_path, 'METADATA') diff --git a/git_utils.py b/git_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..7b78b21362e619b7bcc8a766ca1d1dbb6bf64a32 --- /dev/null +++ b/git_utils.py @@ -0,0 +1,158 @@ +# Copyright (C) 2018 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the 'License'); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an 'AS IS' BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +'''Helper functions to communicate with Git.''' + +import datetime +import re +import subprocess + + +def _run(cmd, cwd, redirect=True): + """Runs a command with stdout and stderr redirected.""" + out = subprocess.PIPE if redirect else None + return subprocess.run(cmd, stdout=out, stderr=out, + check=True, cwd=cwd) + + +def fetch(proj_path, remote_names): + """Runs git fetch. + + Args: + proj_path: Path to Git repository. + remote_names: Array of string to specify remote names. + """ + _run(['git', 'fetch', '--multiple'] + remote_names, cwd=proj_path) + + +def add_remote(proj_path, name, url): + """Adds a git remote. + + Args: + proj_path: Path to Git repository. + name: Name of the new remote. + url: Url of the new remote. + """ + _run(['git', 'remote', 'add', name, url], cwd=proj_path) + + +def list_remotes(proj_path): + """Lists all Git remotes. + + Args: + proj_path: Path to Git repository. + + Returns: + A dict from remote name to remote url. + """ + out = _run(['git', 'remote', '-v'], proj_path) + lines = out.stdout.decode('utf-8').splitlines() + return dict([line.split()[0:2] for line in lines]) + + +def get_commits_ahead(proj_path, branch, base_branch): + """Lists commits in `branch` but not `base_branch`.""" + out = _run(['git', 'rev-list', '--left-only', '--ancestry-path', + '{}...{}'.format(branch, base_branch)], + proj_path) + return out.stdout.decode('utf-8').splitlines() + + +def get_commit_time(proj_path, commit): + """Gets commit time of one commit.""" + out = _run(['git', 'show', '-s', '--format=%ct', commit], cwd=proj_path) + return datetime.datetime.fromtimestamp(int(out.stdout)) + + +def list_remote_branches(proj_path, remote_name): + """Lists all branches for a remote.""" + out = _run(['git', 'branch', '-r'], cwd=proj_path) + lines = out.stdout.decode('utf-8').splitlines() + stripped = [line.strip() for line in lines] + remote_path = remote_name + '/' + remote_path_len = len(remote_path) + return [line[remote_path_len:] for line in stripped + if line.startswith(remote_path)] + + +def _parse_remote_tag(line): + tag_prefix = 'refs/tags/' + tag_suffix = '^{}' + try: + line = line[line.index(tag_prefix):] + except ValueError: + return None + line = line[len(tag_prefix):] + if line.endswith(tag_suffix): + line = line[:-len(tag_suffix)] + return line + + +def list_remote_tags(proj_path, remote_name): + """Lists all tags for a remote.""" + out = _run(['git', "ls-remote", "--tags", remote_name], + cwd=proj_path) + lines = out.stdout.decode('utf-8').splitlines() + tags = [_parse_remote_tag(line) for line in lines] + return list(set(tags)) + + +COMMIT_PATTERN = r'^[a-f0-9]{40}$' +COMMIT_RE = re.compile(COMMIT_PATTERN) + + +def is_commit(commit): + """Whether a string looks like a SHA1 hash.""" + return bool(COMMIT_RE.match(commit)) + + +def merge(proj_path, branch): + """Merges a branch.""" + try: + out = _run(['git', 'merge', branch, '--no-commit'], + cwd=proj_path) + except subprocess.CalledProcessError: + # Merge failed. Error is already written to console. + subprocess.run(['git', 'merge', '--abort'], cwd=proj_path) + raise + + +def add_file(proj_path, file_name): + """Stages a file.""" + _run(['git', 'add', file_name], cwd=proj_path) + + +def delete_branch(proj_path, branch_name): + """Force delete a branch.""" + _run(['git', 'branch', '-D', branch_name], cwd=proj_path) + + +def start_branch(proj_path, branch_name): + """Starts a new repo branch.""" + _run(['repo', 'start', branch_name], cwd=proj_path) + + +def commit(proj_path, message): + """Commits changes.""" + _run(['git', 'commit', '-m', message], cwd=proj_path) + + +def checkout(proj_path, branch_name): + """Checkouts a branch.""" + _run(['git', 'checkout', branch_name], cwd=proj_path) + + +def push(proj_path, remote_name): + """Pushes change to remote.""" + return _run(['git', 'push', remote_name, 'HEAD:refs/for/master'], + cwd=proj_path, redirect=False) diff --git a/github_archive_updater.py b/github_archive_updater.py new file mode 100644 index 0000000000000000000000000000000000000000..f177b54b8cdc750b785147d06dc442ade2c883f3 --- /dev/null +++ b/github_archive_updater.py @@ -0,0 +1,174 @@ +# Copyright (C) 2018 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Module to update packages from GitHub archive.""" + + +import json +import re +import urllib.request + +import archive_utils +import fileutils +import git_utils +import metadata_pb2 # pylint: disable=import-error +import updater_utils + +GITHUB_URL_PATTERN = (r'^https:\/\/github.com\/([-\w]+)\/([-\w]+)\/' + + r'(releases\/download\/|archive\/)') +GITHUB_URL_RE = re.compile(GITHUB_URL_PATTERN) + + +def _edit_distance(str1, str2): + prev = list(range(0, len(str2) + 1)) + for i, chr1 in enumerate(str1): + cur = [i + 1] + for j, chr2 in enumerate(str2): + if chr1 == chr2: + cur.append(prev[j]) + else: + cur.append(min(prev[j + 1], prev[j], cur[j]) + 1) + prev = cur + return prev[len(str2)] + + +def choose_best_url(urls, previous_url): + """Returns the best url to download from a list of candidate urls. + + This function calculates similarity between previous url and each of new + urls. And returns the one best matches previous url. + + Similarity is measured by editing distance. + + Args: + urls: Array of candidate urls. + previous_url: String of the url used previously. + + Returns: + One url from `urls`. + """ + return min(urls, default=None, + key=lambda url: _edit_distance( + url, previous_url)) + + +class GithubArchiveUpdater(): + """Updater for archives from GitHub. + + This updater supports release archives in GitHub. Version is determined by + release name in GitHub. + """ + + VERSION_FIELD = 'tag_name' + + def __init__(self, url, proj_path, metadata): + self.proj_path = proj_path + self.metadata = metadata + self.old_url = url + self.owner = None + self.repo = None + self.new_version = None + self.new_url = None + self._parse_url(url) + + def _parse_url(self, url): + if url.type != metadata_pb2.URL.ARCHIVE: + raise ValueError('Only archive url from Github is supported.') + match = GITHUB_URL_RE.match(url.value) + if match is None: + raise ValueError('Url format is not supported.') + try: + self.owner, self.repo = match.group(1, 2) + except IndexError: + raise ValueError('Url format is not supported.') + + def _fetch_latest_version(self): + """Checks upstream and gets the latest release tag.""" + + url = 'https://api.github.com/repos/{}/{}/releases/latest'.format( + self.owner, self.repo) + with urllib.request.urlopen(url) as request: + data = json.loads(request.read().decode()) + self.new_version = data[self.VERSION_FIELD] + + supported_assets = [ + a['browser_download_url'] for a in data['assets'] + if archive_utils.is_supported_archive(a['browser_download_url'])] + + # Adds source code urls. + supported_assets.append( + 'https://github.com/{}/{}/archive/{}.tar.gz'.format( + self.owner, self.repo, data.get('tag_name'))) + supported_assets.append( + 'https://github.com/{}/{}/archive/{}.zip'.format( + self.owner, self.repo, data.get('tag_name'))) + + self.new_url = choose_best_url(supported_assets, self.old_url.value) + + def _fetch_latest_commit(self): + """Checks upstream and gets the latest commit to master.""" + + url = 'https://api.github.com/repos/{}/{}/commits/master'.format( + self.owner, self.repo) + with urllib.request.urlopen(url) as request: + data = json.loads(request.read().decode()) + self.new_version = data['sha'] + self.new_url = 'https://github.com/{}/{}/archive/{}.zip'.format( + self.owner, self.repo, self.new_version) + + def get_current_version(self): + """Returns the latest version name recorded in METADATA.""" + return self.metadata.third_party.version + + def get_latest_version(self): + """Returns the latest version name in upstream.""" + return self.new_version + + def _write_metadata(self, url, path): + updated_metadata = metadata_pb2.MetaData() + updated_metadata.CopyFrom(self.metadata) + updated_metadata.third_party.version = self.new_version + for metadata_url in updated_metadata.third_party.url: + if metadata_url == self.old_url: + metadata_url.value = url + fileutils.write_metadata(path, updated_metadata) + + def check(self): + """Checks update for package. + + Returns True if a new version is available. + """ + current = self.get_current_version() + if git_utils.is_commit(current): + self._fetch_latest_commit() + else: + self._fetch_latest_version() + print('Current version: {}. Latest version: {}'.format( + current, self.new_version), end='') + + def update(self): + """Updates the package. + + Has to call check() before this function. + """ + temporary_dir = None + try: + temporary_dir = archive_utils.download_and_extract(self.new_url) + package_dir = archive_utils.find_archive_root(temporary_dir) + self._write_metadata(self.new_url, package_dir) + updater_utils.replace_package(package_dir, self.proj_path) + finally: + # Don't remove the temporary directory, or it'll be impossible + # to debug the failure... + # shutil.rmtree(temporary_dir, ignore_errors=True) + urllib.request.urlcleanup() diff --git a/metadata.proto b/metadata.proto new file mode 100644 index 0000000000000000000000000000000000000000..2362c2e2a5d34085eb0ab0b385315b29974ccfd4 --- /dev/null +++ b/metadata.proto @@ -0,0 +1,69 @@ +// copyright (C) 2018 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// A proto definition used to parse METADATA file in third party projects. + +// This proto will only contain fields and values the updater cares about. +// It is not intended to be the formal definition of METADATA file. + +syntax = "proto3"; + +package external_updater; + +message MetaData { + string name = 1; + string description = 3; + ThirdPartyMetaData third_party = 13; +} + +enum LicenseType { + UNKNOWN = 0; + BY_EXCEPTION_ONLY = 1; + NOTICE = 2; + PERMISSIVE = 3; + RECIPROCAL = 4; + RESTRICTED_IF_STATICALLY_LINKED = 5; + RESTRICTED = 6; + UNENCUMBERED = 7; +} + +message ThirdPartyMetaData { + repeated URL url = 1; + string version = 2; + LicenseType license_type = 4; + Date last_upgrade_date = 10; +} + +message URL { + enum Type { + UNKNOWN = 0; + HOMEPAGE = 1; + ARCHIVE = 2; + GIT = 3; + SVN = 7; + HG = 8; + DARCS = 9; + OTHER = 11; + } + + Type type = 1; + + string value = 2; +} + +message Date { + int32 year = 1; + int32 month = 2; + int32 day = 3; +} diff --git a/notifier.py b/notifier.py new file mode 100644 index 0000000000000000000000000000000000000000..455a5b99f095a3dd8c0909ba864c26dc6e8a663a --- /dev/null +++ b/notifier.py @@ -0,0 +1,181 @@ +# Copyright (C) 2018 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Send notification email if new version is found. + +Example usage: +external_updater_notifier \ + --history ~/updater/history \ + --generate_change \ + --recipients xxx@xxx.xxx \ + googletest +""" + +from datetime import timedelta, datetime +import argparse +import json +import os +import re +import subprocess +import time + +import git_utils + +def parse_args(): + """Parses commandline arguments.""" + + parser = argparse.ArgumentParser( + description='Check updates for third party projects in external/.') + parser.add_argument( + '--history', + help='Path of history file. If doesn' + 't exist, a new one will be created.') + parser.add_argument( + '--recipients', + help='Comma separated recipients of notification email.') + parser.add_argument( + '--generate_change', + help='If set, an upgrade change will be uploaded to Gerrit.', + action='store_true', required=False) + parser.add_argument( + 'paths', nargs='*', + help='Paths of the project.') + parser.add_argument( + '--all', action='store_true', + help='Checks all projects.') + + return parser.parse_args() + + +CHANGE_URL_PATTERN = r'(https:\/\/[^\s]*android-review[^\s]*) Upgrade' +CHANGE_URL_RE = re.compile(CHANGE_URL_PATTERN) + + +def _send_email(proj, latest_ver, recipient, upgrade_log): + print('Sending email for {}: {}'.format(proj, latest_ver)) + msg = "New version: {}".format(latest_ver) + match = CHANGE_URL_RE.search(upgrade_log) + if match is not None: + msg += '\n\nAn upgrade change is generated at:\n{}'.format( + match.group(1)) + + msg += '\n\n' + msg += upgrade_log + + subprocess.run(['sendgmr', '--to=' + recipient, + '--subject=' + proj], check=True, + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + input=msg, encoding='ascii') + + +NOTIFIED_TIME_KEY_NAME = 'latest_notified_time' + + +def _should_notify(latest_ver, proj_history): + if latest_ver in proj_history: + # Processed this version before. + return False + + timestamp = proj_history.get(NOTIFIED_TIME_KEY_NAME, 0) + time_diff = datetime.today() - datetime.fromtimestamp(timestamp) + if git_utils.is_commit(latest_ver) and time_diff <= timedelta(days=30): + return False + + return True + + +def _process_results(args, history, results): + for proj, res in results.items(): + if 'latest' not in res: + continue + latest_ver = res['latest'] + current_ver = res['current'] + if latest_ver == current_ver: + continue + proj_history = history.setdefault(proj, {}) + if _should_notify(latest_ver, proj_history): + upgrade_log = _upgrade(proj) if args.generate_change else "" + try: + _send_email(proj, latest_ver, args.recipients, upgrade_log) + proj_history[latest_ver] = int(time.time()) + proj_history[NOTIFIED_TIME_KEY_NAME] = int(time.time()) + except subprocess.CalledProcessError as err: + msg = """Failed to send email for {} ({}). +stdout: {} +stderr: {}""".format(proj, latest_ver, err.stdout, err.stderr) + print(msg) + + +RESULT_FILE_PATH = '/tmp/update_check_result.json' + + +def send_notification(args): + """Compare results and send notification.""" + results = {} + with open(RESULT_FILE_PATH, 'r') as f: + results = json.load(f) + history = {} + try: + with open(args.history, 'r') as f: + history = json.load(f) + except FileNotFoundError: + pass + + _process_results(args, history, results) + + with open(args.history, 'w') as f: + json.dump(history, f, sort_keys=True, indent=4) + + +def _upgrade(proj): + out = subprocess.run(['out/soong/host/linux-x86/bin/external_updater', + 'update', '--branch_and_commit', '--push_change', + proj], + stdout=subprocess.PIPE, stderr=subprocess.PIPE, + cwd=os.environ['ANDROID_BUILD_TOP']) + stdout = out.stdout.decode('utf-8') + stderr = out.stderr.decode('utf-8') + return """ +==================== +| Debug Info | +==================== +-=-=-=-=stdout=-=-=-=- +{} + +-=-=-=-=stderr=-=-=-=- +{} +""".format(stdout, stderr) + + +def _check_updates(args): + params = ['out/soong/host/linux-x86/bin/external_updater', + 'check', '--json_output', RESULT_FILE_PATH, + '--delay', '30'] + if args.all: + params.append('--all') + else: + params += args.paths + + subprocess.run(params, cwd=os.environ['ANDROID_BUILD_TOP']) + + +def main(): + """The main entry.""" + + args = parse_args() + _check_updates(args) + send_notification(args) + + +if __name__ == '__main__': + main() diff --git a/update_package.sh b/update_package.sh new file mode 100644 index 0000000000000000000000000000000000000000..d7cfa42b3a535f4f83a5dab782f299e99f627ca0 --- /dev/null +++ b/update_package.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# +# Copyright (C) 2007 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# This script is used by external_updater to replace a package. Don't +# invoke directly. + +set -e + +tmp_dir=$1 +external_dir=$2 + +echo "Entering $tmp_dir..." +cd $tmp_dir + +function CopyIfPresent() { + if [ -e $external_dir/$1 ]; then + cp -a -n $external_dir/$1 . + fi +} + +echo "Copying preserved files..." +CopyIfPresent "Android.bp" +CopyIfPresent "Android.mk" +CopyIfPresent "CleanSpec.mk" +CopyIfPresent "LICENSE" +CopyIfPresent "NOTICE" +cp -a -f -n $external_dir/MODULE_LICENSE_* . +CopyIfPresent "METADATA" +CopyIfPresent ".git" +CopyIfPresent ".gitignore" +CopyIfPresent "patches" +CopyIfPresent "post_update.sh" +CopyIfPresent "OWNERS" + +echo "Applying patches..." +for p in $tmp_dir/patches/*.diff +do + [ -e "$p" ] || continue + echo "Applying $p..." + patch -p1 -d $tmp_dir < $p; +done + +if [ -f $tmp_dir/post_update.sh ] +then + echo "Running post update script" + $tmp_dir/post_update.sh $tmp_dir $external_dir +fi + +echo "Swapping old and new..." +rm -rf $external_dir +mv $tmp_dir $external_dir + +exit 0 diff --git a/updater.sh b/updater.sh new file mode 100755 index 0000000000000000000000000000000000000000..b8078c640f2adf05bdba8f3da51301744e0a51be --- /dev/null +++ b/updater.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# +# Copyright (C) 2007 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -e + +cd $(dirname "$0")/../.. +source build/envsetup.sh +lunch aosp_arm-eng +mmma tools/external_updater +out/soong/host/linux-x86/bin/external_updater $@ diff --git a/updater_utils.py b/updater_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..a08846cad73af4a5316c3c325594c2a623216125 --- /dev/null +++ b/updater_utils.py @@ -0,0 +1,103 @@ +# Copyright (C) 2018 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Helper functions for updaters.""" + +import os +import re +import subprocess +import sys + + +def create_updater(metadata, proj_path, updaters): + """Creates corresponding updater object for a project. + + Args: + metadata: Parsed proto for METADATA file. + proj_path: Absolute path for the project. + + Returns: + An updater object. + + Raises: + ValueError: Occurred when there's no updater for all urls. + """ + for url in metadata.third_party.url: + for updater in updaters: + try: + return updater(url, proj_path, metadata) + except ValueError: + pass + + raise ValueError('No supported URL.') + + +def replace_package(source_dir, target_dir): + """Invokes a shell script to prepare and update a project. + + Args: + source_dir: Path to the new downloaded and extracted package. + target_dir: The path to the project in Android source tree. + """ + + print('Updating {} using {}.'.format(target_dir, source_dir)) + script_path = os.path.join( + os.path.dirname( + sys.argv[0]), + 'update_package.sh') + subprocess.check_call(['bash', script_path, source_dir, target_dir]) + +VERSION_SPLITTER_PATTERN = r'[\.\-_]' +VERSION_PATTERN = (r'^(?P<prefix>[^\d]*)' + + r'(?P<version>\d+(' + VERSION_SPLITTER_PATTERN + r'\d+)*)' + + r'(?P<suffix>.*)$') +VERSION_RE = re.compile(VERSION_PATTERN) +VERSION_SPLITTER_RE = re.compile(VERSION_SPLITTER_PATTERN) + + +def _parse_version(version): + match = VERSION_RE.match(version) + if match is None: + raise ValueError('Invalid version.') + try: + return match.group('prefix', 'version', 'suffix') + except IndexError: + raise ValueError('Invalid version.') + + +def _match_and_get_version(prefix, suffix, version): + try: + version_prefix, version, version_suffix = _parse_version(version) + except ValueError: + return [] + + right_format = (version_prefix == prefix and version_suffix == suffix) + + return [right_format] + [int(v) for v in VERSION_SPLITTER_RE.split(version)] + + +def get_latest_version(current_version, version_list): + """Gets the latest version name from a list of versions. + + The new version must have the same prefix and suffix with old version. + If no matched version is newer, current version name will be returned. + """ + prefix, _, suffix = _parse_version(current_version) + + latest = max(version_list, + key=lambda ver: _match_and_get_version( + prefix, suffix, ver), + default=[]) + if not latest: + raise ValueError('No matching version.') + return latest