OLD | NEW |
(Empty) | |
| 1 # Copyright 2015 The Chromium Authors. All rights reserved. |
| 2 # Use of this source code is governed by a BSD-style license that can be |
| 3 # found in the LICENSE file. |
| 4 |
| 5 import distutils.util |
| 6 import logging |
| 7 import os |
| 8 import re |
| 9 import tempfile |
| 10 |
| 11 from infra.libs.gitiles import gitiles |
| 12 from infra.tools.cros_pin import checkout, execute, pinfile |
| 13 from infra.tools.cros_pin.logger import LOGGER |
| 14 |
| 15 # Ths path of the Chromite repository. |
| 16 CHROMITE_REPOSITORY = 'https://chromium.googlesource.com/chromiumos/chromite' |
| 17 |
| 18 # The number of stable release branches to build in addition to the beta |
| 19 # branch. |
| 20 DEFAULT_STABLE_COUNT = 2 |
| 21 |
| 22 # Regular expression to match release branch names. |
| 23 RELEASE_RE = re.compile(r'release-R(\d+)-.*') |
| 24 |
| 25 |
| 26 def add_argparse_options(parser): |
| 27 parser.add_argument('-d', '--dry-run', |
| 28 action='store_true', |
| 29 help="Stop short of submitting the CLs.") |
| 30 parser.add_argument('-n', '--no-verify', |
| 31 action='store_true', |
| 32 help="Don't check that the specified pin exists.") |
| 33 parser.add_argument('-C', '--checkout-path', metavar='PATH', |
| 34 help="If specified, the checkout at PATH will be used instead of a " |
| 35 "temporary one. If PATH does not exist, it will be created, and " |
| 36 "the checkout will not be cleaned up. This is intended for " |
| 37 "debugging.") |
| 38 parser.add_argument('--chromite-repository', default=CHROMITE_REPOSITORY, |
| 39 help="The Chromite repository to query (default is %(default)s).") |
| 40 parser.add_argument('-b', '--bug', |
| 41 help="Cite this BUG when creating CLs.") |
| 42 parser.add_argument('-r', '--reviewer', |
| 43 action='append', default=[], |
| 44 help="Add this reviewer to the uploaded CL. If no reviewers are " |
| 45 "specified, someone from the OWNERS file will be chosen.") |
| 46 parser.add_argument('-m', '--commit-message', |
| 47 help="Use this commit message instead of an auto-generated one.") |
| 48 parser.add_argument('--no-commit', dest='commit', action='store_false', |
| 49 help="Don't automatically mark generated CLs for commit queue.") |
| 50 |
| 51 subparsers = parser.add_subparsers(help='CrOS Pin Subcommands') |
| 52 |
| 53 # Subcommand: update |
| 54 subp = subparsers.add_parser('update', |
| 55 help=subcommand_update.__doc__) |
| 56 subp.add_argument('-t', '--target', |
| 57 choices=["existing", "external", "internal", "both"], default='existing', |
| 58 help="Specifies which pin repositories to update. 'existing' (default) " |
| 59 "updates all existing named pins. 'external', 'internal', and " |
| 60 "'both' indicate that the pin should be updated in the external " |
| 61 "and/or internal pin repositories, and should be added if not " |
| 62 "currently present. Use these with caution!") |
| 63 subp.add_argument('name', |
| 64 help="The name of the pin to update.") |
| 65 subp.add_argument('version', nargs='?', |
| 66 help="The new commit hash for the pin. If empty, probe for tip-of-tree " |
| 67 "of the branch sharing the pin's name.") |
| 68 subp.set_defaults(func=subcommand_update) |
| 69 |
| 70 # Subcommand: add-release |
| 71 subp = subparsers.add_parser('add-release', |
| 72 help=subcommand_add_release.__doc__) |
| 73 subp.add_argument('--stable-count', metavar='COUNT', |
| 74 type=int, default=DEFAULT_STABLE_COUNT, |
| 75 help="Specifies the number of stable branches to preserve. (default is " |
| 76 "%(default)s). The youngest COUNT release branch pins beyond the " |
| 77 "newest will be preserved as stable branches, and any additional " |
| 78 "release branches will be removed from the pins.") |
| 79 subp.add_argument('branch', |
| 80 help='The name of the release branch. Must begin with "release-R#".') |
| 81 subp.add_argument('version', nargs='?', |
| 82 help="The commit hash for the branch. If empty, use the branch's " |
| 83 "tip-of-tree commit.") |
| 84 subp.set_defaults(func=subcommand_add_release) |
| 85 |
| 86 |
| 87 def checkout_for_args(args): |
| 88 """A contextmanager that supplies the Checkout configured in args. |
| 89 |
| 90 The Checkout's teardown() method will be invoked on cleanup. |
| 91 |
| 92 Args: |
| 93 args (argparse.Options): Parsed option list. |
| 94 """ |
| 95 return checkout.Checkout.use( |
| 96 path=args.checkout_path) |
| 97 |
| 98 def pinfile_editor_from_args(args, c): |
| 99 return pinfile.Editor( |
| 100 c.path, |
| 101 gitiles.Repository(args.chromite_repository), |
| 102 validate=not args.no_verify) |
| 103 |
| 104 |
| 105 def logging_verbosity(): |
| 106 count = 0 |
| 107 if LOGGER.level >= logging.INFO: |
| 108 count += 1 |
| 109 if LOGGER.level >= logging.DEBUG: |
| 110 count += 1 |
| 111 return ['-v'] * count |
| 112 |
| 113 |
| 114 def get_release_version(v): |
| 115 m = RELEASE_RE.match(v) |
| 116 if not m: |
| 117 return None |
| 118 return int(m.group(1)) |
| 119 |
| 120 |
| 121 def subcommand_update(args): |
| 122 """Update a single Chromite pin.""" |
| 123 create = (args.target != 'existing') |
| 124 target_pins = [] |
| 125 if args.target in ('external', 'both', 'existing'): |
| 126 target_pins.append(pinfile.EXTERNAL) |
| 127 if args.target in ('internal', 'both', 'existing'): |
| 128 target_pins.append(pinfile.INTERNAL) |
| 129 |
| 130 with checkout_for_args(args) as c: |
| 131 pfe = pinfile_editor_from_args(args, c) |
| 132 tracker = UpdateTracker.from_args(args, c) |
| 133 |
| 134 for pin in target_pins: |
| 135 logging.debug('Updating target pin [%s]', pin) |
| 136 |
| 137 # Update |
| 138 pf = pfe.load(pin) |
| 139 update = pf.update(args.name, version=args.version, create=create) |
| 140 if not update: |
| 141 LOGGER.debug('Did not update pins for [%s]', pin) |
| 142 continue |
| 143 tracker.add(pin, update) |
| 144 |
| 145 LOGGER.debug('Updated pin set: %s', update) |
| 146 if not tracker: |
| 147 LOGGER.error('No pins were updated.') |
| 148 return 1 |
| 149 |
| 150 # Regenerate slave pools for affected masters. |
| 151 tracker.update() |
| 152 for i in tracker.issues: |
| 153 LOGGER.warning('Created Issue: %s', i) |
| 154 return 0 |
| 155 |
| 156 |
| 157 def subcommand_add_release(args): |
| 158 """Add a new release branch to the list of pins.""" |
| 159 with checkout_for_args(args) as c: |
| 160 pfe = pinfile_editor_from_args(args, c) |
| 161 tracker = UpdateTracker.from_args(args, c) |
| 162 |
| 163 add_release = (get_release_version(args.branch), args.branch) |
| 164 if add_release[0] is None: |
| 165 raise ValueError("Invalid release branch: [%s]" % (args.branch,)) |
| 166 |
| 167 # Build a list of releases and their versions. |
| 168 pf = pfe.load(pinfile.INTERNAL) |
| 169 releases = [add_release] |
| 170 for name, _ in pf.iterpins(): |
| 171 v = get_release_version(name) |
| 172 if v == add_release[0]: |
| 173 LOGGER.error('Release [%s] (%d) is already pinned.', |
| 174 add_release[1], add_release[0]) |
| 175 return 1 |
| 176 |
| 177 if v is not None: |
| 178 releases.append((v, name)) |
| 179 releases.sort(reverse=True) |
| 180 |
| 181 # Shave off the top [stable_count+1] releases. |
| 182 count = args.stable_count+1 |
| 183 releases, deleted = releases[:count], releases[count:] |
| 184 if add_release not in releases: |
| 185 raise ValueError("Updated releases do not include added (%s):\n%s" % ( |
| 186 add_release[1], '\n'.join(r[1] for r in releases))) |
| 187 |
| 188 # Set the new releases. |
| 189 tracker.add(pinfile.INTERNAL, pf.update(add_release[1], create=True)) |
| 190 for _, r in deleted: |
| 191 tracker.add(pinfile.INTERNAL, pf.remove(r)) |
| 192 |
| 193 if not tracker: |
| 194 LOGGER.error('No pins were updated.') |
| 195 return 1 |
| 196 |
| 197 # Regenerate slave pools for affected masters. |
| 198 tracker.update() |
| 199 LOGGER.warning('Created issues:\n%s', '\n'.join(tracker.issues)) |
| 200 return 0 |
| 201 |
| 202 |
| 203 class SlavePoolUpdateError(Exception): |
| 204 pass |
| 205 |
| 206 |
| 207 class UpdateTracker(object): |
| 208 |
| 209 RUNIT_PY = ('build', 'scripts', 'tools', 'runit.py') |
| 210 SLAVE_ALLOC_UPDATE = ('build', 'scripts', 'tools', 'slave_alloc_update.py') |
| 211 |
| 212 RE_ISSUE_CREATED = re.compile(r'^Issue created. URL: (.+)$') |
| 213 |
| 214 def __init__(self, c, cq=False, bug=None, reviewers=None, dry_run=True): |
| 215 self._c = c |
| 216 self._cq = cq |
| 217 self._bug = bug |
| 218 self._reviewers = reviewers |
| 219 self._dry_run = dry_run |
| 220 |
| 221 self._updated = {} |
| 222 self._issues = set() |
| 223 |
| 224 @classmethod |
| 225 def from_args(cls, args, c): |
| 226 return cls( |
| 227 c, |
| 228 cq=args.commit, |
| 229 bug=args.bug, |
| 230 reviewers=args.reviewer, |
| 231 dry_run=args.dry_run) |
| 232 |
| 233 def __nonzero__(self): |
| 234 return bool(self._updated) |
| 235 |
| 236 @property |
| 237 def issues(self): |
| 238 return sorted(self._issues) |
| 239 |
| 240 def add(self, pin, update): |
| 241 self._updated.setdefault(pin, {})[update.name] = (update.fr, update.to) |
| 242 |
| 243 def update(self): |
| 244 LOGGER.info('Updating repositories: %s', self._updated) |
| 245 affected_masters = set() |
| 246 for pin in self._updated.iterkeys(): |
| 247 affected_masters.update(pin.masters) |
| 248 |
| 249 failed_slave_pool_masters = [] |
| 250 for m in sorted(affected_masters): |
| 251 try: |
| 252 self._regenerate_slave_pool(m) |
| 253 except SlavePoolUpdateError: |
| 254 failed_slave_pool_masters.append(m) |
| 255 if failed_slave_pool_masters: |
| 256 LOGGER.error('Failed to update slave pools for %s. You may need to ' |
| 257 'add additional slaves the pool(s).', |
| 258 failed_slave_pool_masters) |
| 259 raise SlavePoolUpdateError("Failed to update slave pools.") |
| 260 |
| 261 # Upload CLs for the affected repositories. |
| 262 for pin, updates in self._updated.iteritems(): |
| 263 self._upload_patch( |
| 264 self._c.subpath(*pin.base), |
| 265 self._generate_commit_message(updates)) |
| 266 |
| 267 def _regenerate_slave_pool(self, master): |
| 268 LOGGER.debug('Regenerating slave pool for: %s', master) |
| 269 cmd = [ |
| 270 os.path.join(*self.RUNIT_PY), |
| 271 os.path.join(*self.SLAVE_ALLOC_UPDATE), |
| 272 ] |
| 273 cmd += logging_verbosity() |
| 274 cmd.append(master) |
| 275 |
| 276 rv, stdout = execute.call(cmd, cwd=self._c.path) |
| 277 if rv != 0: |
| 278 LOGGER.exception('Failed to update slaves for master [%s] (%d):\n%s', |
| 279 master, rv, stdout) |
| 280 raise SlavePoolUpdateError() |
| 281 |
| 282 |
| 283 def _upload_patch(self, repo_path, commit_msg): |
| 284 # Check if the Git repository actually has changes. |
| 285 diff_args = ['git', 'diff', '--no-ext-diff', '--exit-code'] |
| 286 if not LOGGER.isEnabledFor(logging.DEBUG): |
| 287 diff_args.append('--quiet') |
| 288 rv, diff = execute.call(diff_args, cwd=repo_path) |
| 289 LOGGER.debug('Diff for [%s]:\n%s', repo_path, diff) |
| 290 if rv == 0: |
| 291 LOGGER.warning('No changes in repository; refusing to commit.') |
| 292 return |
| 293 |
| 294 LOGGER.debug('Creating commit in [%s] with message:\n%s', |
| 295 repo_path, commit_msg) |
| 296 execute.check_call( |
| 297 ['git', 'checkout', '-b', '_cros_pin', '--track'], |
| 298 cwd=repo_path) |
| 299 execute.check_call( |
| 300 ['git', 'commit', '--all', '--message', commit_msg], |
| 301 cwd=repo_path) |
| 302 |
| 303 LOGGER.debug('Uploading CL!') |
| 304 args = [ |
| 305 'git', 'cl', 'upload', |
| 306 '--bypass-hooks', # The CQ will take care of them! |
| 307 '-t', commit_msg, |
| 308 '-m', 'Auto-generated by `%s`' % (__name__,), |
| 309 '-f', |
| 310 ] |
| 311 if self._cq: |
| 312 print 'Commit? [Y/n]:', |
| 313 input_string = raw_input() |
| 314 if input_string != '' and not distutils.util.strtobool(input_string): |
| 315 LOGGER.info('User opted not to commit; aborting.') |
| 316 return |
| 317 args.append('--use-commit-queue') |
| 318 if not self._reviewers: |
| 319 args.append('--tbr-owners') |
| 320 |
| 321 output = execute.check_call(args, cwd=repo_path, dry_run=self._dry_run) |
| 322 issue = None |
| 323 for line in output.splitlines(): |
| 324 match = self.RE_ISSUE_CREATED.match(line) |
| 325 if match: |
| 326 issue = match.group(1) |
| 327 LOGGER.debug('Extracted issue from output: %s', issue) |
| 328 self._issues.add(issue) |
| 329 break |
| 330 else: |
| 331 LOGGER.warning("Unable to extract issue from patch submission.") |
| 332 |
| 333 def _generate_commit_message(self, updates): |
| 334 lines = [ |
| 335 'CrOS: Update Chromite pin.', |
| 336 '', |
| 337 'Update ChromeOS Chromite pins.' |
| 338 ] |
| 339 for name, update in updates.iteritems(): |
| 340 if not update: |
| 341 continue |
| 342 |
| 343 fr, to = update |
| 344 lines.append('- [%s]' % (name,)) |
| 345 if fr: |
| 346 if to: |
| 347 # Update from one commit to another. |
| 348 lines.extend([ |
| 349 ' %s =>' % (fr,), |
| 350 ' %s' % (to,), |
| 351 ]) |
| 352 else: |
| 353 # Added new pin. |
| 354 lines.append(' - Deleted (was %s)' % (fr,)) |
| 355 elif to: |
| 356 # Deleted a pin. |
| 357 lines.append(' - Added => %s' % (to,)) |
| 358 lines.append('') |
| 359 |
| 360 if self._bug: |
| 361 lines.append('BUG=%s' % (self._bug,)) |
| 362 if self._reviewers: |
| 363 lines.append('TBR=%s' % (', '.join(self._reviewers))) |
| 364 return '\n'.join(lines) |
OLD | NEW |