Chromium Code Reviews| OLD | NEW |
|---|---|
| 1 # Copyright 2016 The LUCI Authors. All rights reserved. | 1 # Copyright 2016 The LUCI Authors. All rights reserved. |
| 2 # Use of this source code is governed under the Apache License, Version 2.0 | 2 # Use of this source code is governed under the Apache License, Version 2.0 |
| 3 # that can be found in the LICENSE file. | 3 # that can be found in the LICENSE file. |
| 4 | 4 |
| 5 import base64 | 5 import base64 |
| 6 import functools | 6 import functools |
| 7 import httplib | |
| 7 import json | 8 import json |
| 8 import logging | 9 import logging |
| 9 import os | 10 import os |
| 10 import random | |
| 11 import shutil | 11 import shutil |
| 12 import sys | 12 import sys |
| 13 import tarfile | 13 import tarfile |
| 14 import tempfile | 14 import tempfile |
| 15 import time | 15 import time |
| 16 | 16 |
| 17 # Add third party paths. | 17 # Add third party paths. |
| 18 from . import env | 18 from . import env |
| 19 from . import requests_ssl | 19 from . import requests_ssl |
| 20 from . import util | |
| 20 from .requests_ssl import requests | 21 from .requests_ssl import requests |
| 21 | 22 |
| 22 import subprocess42 | 23 import subprocess42 |
| 23 from google.protobuf import text_format | 24 from google.protobuf import text_format |
| 24 | 25 |
| 25 from . import package_pb2 | 26 from . import package_pb2 |
| 26 | 27 |
| 27 | 28 |
| 28 class FetchError(Exception): | 29 class FetchError(Exception): |
| 29 pass | 30 pass |
| 30 | 31 |
| 31 | 32 |
| 32 class UncleanFilesystemError(FetchError): | |
| 33 pass | |
| 34 | |
| 35 | |
| 36 class FetchNotAllowedError(FetchError): | 33 class FetchNotAllowedError(FetchError): |
| 37 pass | 34 pass |
| 38 | 35 |
| 39 | 36 |
| 40 def _run_git(checkout_dir, *args): | 37 def _run_git(checkout_dir, *args): |
| 41 if sys.platform.startswith(('win', 'cygwin')): | 38 if sys.platform.startswith(('win', 'cygwin')): |
| 42 cmd = ['git.bat'] | 39 cmd = ['git.bat'] |
| 43 else: | 40 else: |
| 44 cmd = ['git'] | 41 cmd = ['git'] |
| 45 | 42 |
| 46 if checkout_dir is not None: | 43 if checkout_dir is not None: |
| 47 cmd += ['-C', checkout_dir] | 44 cmd += ['-C', checkout_dir] |
| 48 cmd += list(args) | 45 cmd += list(args) |
| 49 | 46 |
| 50 logging.info('Running: %s', cmd) | 47 logging.info('Running: %s', cmd) |
| 51 return subprocess42.check_output(cmd) | 48 return subprocess42.check_output(cmd) |
| 52 | 49 |
| 53 | 50 |
| 54 def _retry(f): | |
| 55 @functools.wraps(f) | |
| 56 def wrapper(*args, **kwargs): | |
| 57 delay = random.uniform(2, 5) | |
| 58 for _ in range(5): | |
| 59 try: | |
| 60 return f(*args, **kwargs) | |
| 61 except (requests.exceptions.RequestException, | |
| 62 subprocess42.CalledProcessError): | |
| 63 # Only retry specific errors that may be transient. | |
| 64 logging.exception('retrying') | |
| 65 time.sleep(delay) | |
| 66 delay *= 2 | |
| 67 return f(*args, **kwargs) | |
| 68 return wrapper | |
| 69 | |
| 70 | |
| 71 class Backend(object): | 51 class Backend(object): |
| 72 @property | 52 @property |
| 73 def repo_type(self): | 53 def repo_type(self): |
| 74 """Returns repo type (see package_pb2.DepSpec).""" | 54 """Returns repo type (see package_pb2.DepSpec).""" |
| 75 raise NotImplementedError() | 55 raise NotImplementedError() |
| 76 | 56 |
| 77 def branch_spec(self, branch): | 57 @staticmethod |
|
dnj
2016/09/23 00:42:46
This was a pylint failure b/c it's static in child
| |
| 58 def branch_spec(branch): | |
| 78 """Returns branch spec for given branch suitable for given git backend.""" | 59 """Returns branch spec for given branch suitable for given git backend.""" |
| 79 raise NotImplementedError() | 60 raise NotImplementedError() |
| 80 | 61 |
| 81 def checkout(self, repo, revision, checkout_dir, allow_fetch): | 62 def checkout(self, repo, revision, checkout_dir, allow_fetch): |
| 82 """Checks out given |repo| at |revision| to |checkout_dir|. | 63 """Checks out given |repo| at |revision| to |checkout_dir|. |
| 83 | 64 |
| 84 Network operations are performed only if |allow_fetch| is True. | 65 Network operations are performed only if |allow_fetch| is True. |
| 85 """ | 66 """ |
| 86 raise NotImplementedError() | 67 raise NotImplementedError() |
| 87 | 68 |
| 88 def updates(self, repo, revision, checkout_dir, allow_fetch, | 69 def updates(self, repo, revision, checkout_dir, allow_fetch, |
| 89 other_revision, paths): | 70 other_revision, paths): |
| 90 """Returns a list of revisions between |revision| and |other_revision|. | 71 """Returns a list of revisions between |revision| and |other_revision|. |
| 91 | 72 |
| 92 Network operations are performed only if |allow_fetch| is True. | 73 Network operations are performed only if |allow_fetch| is True. |
| 93 | 74 |
| 94 If |paths| is a non-empty list, the history is scoped just to these paths. | 75 If |paths| is a non-empty list, the history is scoped just to these paths. |
| 95 """ | 76 """ |
| 96 raise NotImplementedError() | 77 raise NotImplementedError() |
| 97 | 78 |
| 98 def commit_metadata(self, repo, revision, checkout_dir, allow_fetch): | 79 def commit_metadata(self, repo, revision, checkout_dir, allow_fetch): |
| 99 """Returns a dictionary of metadata about commit |revision|. | 80 """Returns a dictionary of metadata about commit |revision|. |
| 100 | 81 |
| 101 The dictionary contains the following keys: author, message. | 82 The dictionary contains the following keys: author, message. |
| 102 """ | 83 """ |
| 103 raise NotImplementedError() | 84 raise NotImplementedError() |
| 104 | 85 |
| 105 | 86 |
| 87 class UncleanFilesystemError(FetchError): | |
|
dnj
2016/09/23 00:42:46
(Moved closer to GitBackend)
| |
| 88 pass | |
| 89 | |
| 90 | |
| 91 class GitFetchError(FetchError): | |
| 92 pass | |
| 93 | |
| 94 | |
| 106 class GitBackend(Backend): | 95 class GitBackend(Backend): |
| 107 """GitBackend uses a local git checkout.""" | 96 """GitBackend uses a local git checkout.""" |
| 108 | 97 |
| 109 @property | 98 @property |
| 110 def repo_type(self): | 99 def repo_type(self): |
| 111 return package_pb2.DepSpec.GIT | 100 return package_pb2.DepSpec.GIT |
| 112 | 101 |
| 113 @staticmethod | 102 @staticmethod |
| 114 def branch_spec(branch): | 103 def branch_spec(branch): |
| 115 return 'origin/%s' % branch | 104 return 'origin/%s' % branch |
| 116 | 105 |
| 117 @_retry | 106 @util.exponential_retry(condition=lambda e: isinstance(e, GitFetchError)) |
| 118 def checkout(self, repo, revision, checkout_dir, allow_fetch): | 107 def checkout(self, repo, revision, checkout_dir, allow_fetch): |
| 119 logging.info('Freshening repository %s in %s', repo, checkout_dir) | 108 logging.info('Freshening repository %s in %s', repo, checkout_dir) |
| 120 | 109 |
| 121 if not os.path.isdir(checkout_dir): | 110 if not os.path.isdir(checkout_dir): |
| 122 if not allow_fetch: | 111 if not allow_fetch: |
| 123 raise FetchNotAllowedError( | 112 raise FetchNotAllowedError( |
| 124 'need to clone %s but fetch not allowed' % repo) | 113 'need to clone %s but fetch not allowed' % repo) |
| 125 _run_git(None, 'clone', '-q', repo, checkout_dir) | 114 _run_git(None, 'clone', '-q', repo, checkout_dir) |
| 126 elif not os.path.isdir(os.path.join(checkout_dir, '.git')): | 115 elif not os.path.isdir(os.path.join(checkout_dir, '.git')): |
| 127 raise UncleanFilesystemError( | 116 raise UncleanFilesystemError( |
| 128 '%s exists but is not a git repo' % checkout_dir) | 117 '%s exists but is not a git repo' % checkout_dir) |
| 129 | 118 |
| 130 _run_git(checkout_dir, 'config', 'remote.origin.url', repo) | 119 _run_git(checkout_dir, 'config', 'remote.origin.url', repo) |
| 131 try: | 120 try: |
| 132 _run_git(checkout_dir, 'rev-parse', '-q', '--verify', | 121 _run_git(checkout_dir, 'rev-parse', '-q', '--verify', |
| 133 '%s^{commit}' % revision) | 122 '%s^{commit}' % revision) |
| 134 except subprocess42.CalledProcessError: | 123 except subprocess42.CalledProcessError: |
| 135 if not allow_fetch: | 124 if not allow_fetch: |
| 136 raise FetchNotAllowedError( | 125 raise FetchNotAllowedError( |
| 137 'need to fetch %s but fetch not allowed' % repo) | 126 'need to fetch %s but fetch not allowed' % repo) |
| 138 _run_git(checkout_dir, 'fetch') | 127 |
| 128 # Fetch from the remote Git repository. Wrap this in a GitFetchError | |
| 129 # for exponential retry on failure. | |
| 130 try: | |
| 131 _run_git(checkout_dir, 'fetch') | |
| 132 except subprocess42.CalledProcessError as e: | |
| 133 raise GitFetchError(e.message) | |
| 134 | |
| 139 _run_git(checkout_dir, 'reset', '-q', '--hard', revision) | 135 _run_git(checkout_dir, 'reset', '-q', '--hard', revision) |
| 140 | 136 |
| 141 def updates(self, repo, revision, checkout_dir, allow_fetch, | 137 def updates(self, repo, revision, checkout_dir, allow_fetch, |
| 142 other_revision, paths): | 138 other_revision, paths): |
| 143 self.checkout(repo, revision, checkout_dir, allow_fetch) | 139 self.checkout(repo, revision, checkout_dir, allow_fetch) |
| 144 if allow_fetch: | 140 if allow_fetch: |
| 145 _run_git(checkout_dir, 'fetch') | 141 _run_git(checkout_dir, 'fetch') |
| 146 args = [ | 142 args = [ |
| 147 'rev-list', | 143 'rev-list', |
| 148 '--reverse', | 144 '--reverse', |
| 149 '%s..%s' % (revision, other_revision), | 145 '%s..%s' % (revision, other_revision), |
| 150 ] | 146 ] |
| 151 if paths: | 147 if paths: |
| 152 args.extend(['--'] + paths) | 148 args.extend(['--'] + paths) |
| 153 return filter(bool, _run_git(checkout_dir, *args).strip().split('\n')) | 149 return filter(bool, _run_git(checkout_dir, *args).strip().split('\n')) |
| 154 | 150 |
| 155 def commit_metadata(self, repo, revision, checkout_dir, allow_fetch): | 151 def commit_metadata(self, repo, revision, checkout_dir, allow_fetch): |
| 156 return { | 152 return { |
| 157 'author': _run_git(checkout_dir, 'show', '-s', '--pretty=%aE', | 153 'author': _run_git(checkout_dir, 'show', '-s', '--pretty=%aE', |
| 158 revision).strip(), | 154 revision).strip(), |
| 159 'message': _run_git(checkout_dir, 'show', '-s', '--pretty=%B', | 155 'message': _run_git(checkout_dir, 'show', '-s', '--pretty=%B', |
| 160 revision).strip(), | 156 revision).strip(), |
| 161 } | 157 } |
| 162 | 158 |
| 163 | 159 |
| 160 class GitilesFetchError(FetchError): | |
| 161 """An HTTP error that occurred during Gitiles fetching.""" | |
| 162 | |
| 163 def __init__(self, status, message): | |
| 164 super(GitilesFetchError, self).__init__( | |
| 165 'Gitiles error code (%d): %s' % (status, message)) | |
| 166 self.status = status | |
| 167 self.message = message | |
| 168 | |
| 169 @staticmethod | |
| 170 def transient(e): | |
| 171 """Returns (bool): True "e" is a GitilesFetchError with transient HTTP code. | |
|
martiniss
2016/09/23 00:49:50
nit: docs formatting
dnj
2016/09/23 01:40:55
Done.
| |
| 172 """ | |
| 173 return (isinstance(e, GitilesFetchError) and | |
| 174 e.status >= httplib.INTERNAL_SERVER_ERROR) | |
| 175 | |
| 176 | |
| 164 class GitilesBackend(Backend): | 177 class GitilesBackend(Backend): |
| 165 """GitilesBackend uses a repo served by Gitiles.""" | 178 """GitilesBackend uses a repo served by Gitiles.""" |
| 166 | 179 |
| 180 # Header at the beginning of Gerrit/Gitiles JSON API responses. | |
| 181 _GERRIT_XSRF_HEADER = ')]}\'\n' | |
| 182 | |
| 167 @property | 183 @property |
| 168 def repo_type(self): | 184 def repo_type(self): |
| 169 return package_pb2.DepSpec.GITILES | 185 return package_pb2.DepSpec.GITILES |
| 170 | 186 |
| 171 @staticmethod | 187 @staticmethod |
| 172 def branch_spec(branch): | 188 def branch_spec(branch): |
| 173 return branch | 189 return branch |
| 174 | 190 |
| 175 @_retry | |
| 176 def checkout(self, repo, revision, checkout_dir, allow_fetch): | 191 def checkout(self, repo, revision, checkout_dir, allow_fetch): |
| 177 requests_ssl.check_requests_ssl() | 192 requests_ssl.check_requests_ssl() |
| 178 logging.info('Freshening repository %s in %s', repo, checkout_dir) | 193 logging.info('Freshening repository %s in %s', repo, checkout_dir) |
| 179 | 194 |
| 180 # TODO(phajdan.jr): implement caching. | 195 # TODO(phajdan.jr): implement caching. |
| 181 if not allow_fetch: | 196 if not allow_fetch: |
| 182 raise FetchNotAllowedError( | 197 raise FetchNotAllowedError( |
| 183 'need to download %s from gitiles but fetch not allowed' % repo) | 198 'need to download %s from gitiles but fetch not allowed' % repo) |
| 184 | 199 |
| 185 revision = self._resolve_revision(repo, revision) | 200 revision = self._resolve_revision(repo, revision) |
| 186 | 201 |
| 187 shutil.rmtree(checkout_dir, ignore_errors=True) | 202 shutil.rmtree(checkout_dir, ignore_errors=True) |
| 188 | 203 |
| 189 recipes_cfg_url = '%s/+/%s/infra/config/recipes.cfg?format=TEXT' % ( | 204 recipes_cfg_url = '%s/+/%s/infra/config/recipes.cfg?format=TEXT' % ( |
| 190 repo, requests.utils.quote(revision)) | 205 repo, requests.utils.quote(revision)) |
| 191 logging.info('fetching %s' % recipes_cfg_url) | 206 recipes_cfg_text = base64.b64decode( |
|
martiniss
2016/09/23 00:49:50
Can you add something like what I did:
try:
dnj
2016/09/23 01:40:55
Does Gitiles actually return 200 w/ a UnicodeError
| |
| 192 recipes_cfg_request = requests.get(recipes_cfg_url) | 207 self._fetch_gitiles(recipes_cfg_url).text) |
| 193 recipes_cfg_text = base64.b64decode(recipes_cfg_request.text) | |
| 194 recipes_cfg_proto = package_pb2.Package() | 208 recipes_cfg_proto = package_pb2.Package() |
| 195 text_format.Merge(recipes_cfg_text, recipes_cfg_proto) | 209 text_format.Merge(recipes_cfg_text, recipes_cfg_proto) |
| 196 recipes_path_rel = recipes_cfg_proto.recipes_path | 210 recipes_path_rel = recipes_cfg_proto.recipes_path |
| 197 | 211 |
| 198 # Re-create recipes.cfg in |checkout_dir| so that the repo's recipes.py | 212 # Re-create recipes.cfg in |checkout_dir| so that the repo's recipes.py |
| 199 # can look it up. | 213 # can look it up. |
| 200 recipes_cfg_path = os.path.join( | 214 recipes_cfg_path = os.path.join( |
| 201 checkout_dir, 'infra', 'config', 'recipes.cfg') | 215 checkout_dir, 'infra', 'config', 'recipes.cfg') |
| 202 os.makedirs(os.path.dirname(recipes_cfg_path)) | 216 os.makedirs(os.path.dirname(recipes_cfg_path)) |
| 203 with open(recipes_cfg_path, 'w') as f: | 217 with open(recipes_cfg_path, 'w') as f: |
| 204 f.write(recipes_cfg_text) | 218 f.write(recipes_cfg_text) |
| 205 | 219 |
| 206 recipes_path = os.path.join(checkout_dir, recipes_path_rel) | 220 recipes_path = os.path.join(checkout_dir, recipes_path_rel) |
| 207 if not os.path.exists(recipes_path): | 221 if not os.path.exists(recipes_path): |
| 208 os.makedirs(recipes_path) | 222 os.makedirs(recipes_path) |
| 209 | 223 |
| 210 archive_url = '%s/+archive/%s/%s.tar.gz' % ( | 224 archive_url = '%s/+archive/%s/%s.tar.gz' % ( |
| 211 repo, requests.utils.quote(revision), recipes_path_rel) | 225 repo, requests.utils.quote(revision), recipes_path_rel) |
| 212 logging.info('fetching %s' % archive_url) | 226 archive_response = self._fetch_gitiles(archive_url) |
| 213 archive_request = requests.get(archive_url) | |
| 214 with tempfile.NamedTemporaryFile(delete=False) as f: | 227 with tempfile.NamedTemporaryFile(delete=False) as f: |
| 215 f.write(archive_request.content) | 228 f.write(archive_response.content) |
| 216 f.close() | 229 f.close() |
| 217 | 230 |
| 218 try: | 231 try: |
| 219 with tarfile.open(f.name) as archive_tarfile: | 232 with tarfile.open(f.name) as archive_tarfile: |
| 220 archive_tarfile.extractall(recipes_path) | 233 archive_tarfile.extractall(recipes_path) |
| 221 finally: | 234 finally: |
| 222 os.unlink(f.name) | 235 os.unlink(f.name) |
| 223 | 236 |
| 224 def updates(self, repo, revision, checkout_dir, allow_fetch, | 237 def updates(self, repo, revision, checkout_dir, allow_fetch, |
| 225 other_revision, paths): | 238 other_revision, paths): |
| (...skipping 41 matching lines...) Expand 10 before | Expand all | Expand 10 after Loading... | |
| 267 '%s/+/%s?format=JSON' % (repo, requests.utils.quote(revision))) | 280 '%s/+/%s?format=JSON' % (repo, requests.utils.quote(revision))) |
| 268 | 281 |
| 269 def _resolve_revision(self, repo, revision): | 282 def _resolve_revision(self, repo, revision): |
| 270 """Returns a git sha corresponding to given revision. | 283 """Returns a git sha corresponding to given revision. |
| 271 | 284 |
| 272 Examples of non-sha revision: origin/master, HEAD.""" | 285 Examples of non-sha revision: origin/master, HEAD.""" |
| 273 rev_json = self._revision_metadata(repo, revision) | 286 rev_json = self._revision_metadata(repo, revision) |
| 274 logging.info('resolved %s to %s', revision, rev_json['commit']) | 287 logging.info('resolved %s to %s', revision, rev_json['commit']) |
| 275 return rev_json['commit'] | 288 return rev_json['commit'] |
| 276 | 289 |
| 277 def _fetch_gitiles_json(self, url): | 290 @staticmethod |
| 291 @util.exponential_retry(condition=GitilesFetchError.transient) | |
| 292 def _fetch_gitiles(url): | |
| 293 """Fetches a remote URL and returns the response object on success.""" | |
| 294 logging.info('fetching %s' % url) | |
| 295 resp = requests.get(url) | |
| 296 if resp.status_code != httplib.OK: | |
| 297 raise GitilesFetchError(resp.status_code, resp.text) | |
| 298 return resp | |
| 299 | |
| 300 @classmethod | |
| 301 @util.exponential_retry(condition=GitilesFetchError.transient) | |
| 302 def _fetch_gitiles_json(cls, url): | |
| 278 """Fetches JSON from Gitiles and returns parsed result.""" | 303 """Fetches JSON from Gitiles and returns parsed result.""" |
| 279 logging.info('fetching %s', url) | 304 logging.info('fetching %s', url) |
| 280 raw = requests.get(url).text | 305 |
| 281 if not raw.startswith(')]}\'\n'): | 306 resp = requests.get(url) |
| 282 raise FetchError('Unexpected gitiles response: %s' % raw) | 307 if resp.status_code != httplib.OK: |
| 283 return json.loads(raw.split('\n', 1)[1]) | 308 raise GitilesFetchError(resp.status_code, resp.text) |
| 309 | |
| 310 if not resp.text.startswith(cls._GERRIT_XSRF_HEADER): | |
| 311 raise GitilesFetchError(resp.status_code, 'Missing XSRF header') | |
| 312 | |
| 313 return json.loads(resp.text[len(cls._GERRIT_XSRF_HEADER):]) | |
| OLD | NEW |