| OLD | NEW |
| 1 # Copyright 2014 The Chromium Authors. All rights reserved. | 1 # Copyright 2014 The Chromium Authors. All rights reserved. |
| 2 # Use of this source code is governed by a BSD-style license that can be | 2 # Use of this source code is governed by a BSD-style license that can be |
| 3 # found in the LICENSE file. | 3 # found in the LICENSE file. |
| 4 | 4 |
| 5 """Gnumd (Git NUMber Daemon): Adds metadata to git commits as they land in | 5 """Gnumd (Git NUMber Daemon): Adds metadata to git commits as they land in |
| 6 a primary repo. | 6 a primary repo. |
| 7 | 7 |
| 8 This is a simple daemon which takes commits pushed to a pending ref, alters | 8 This is a simple daemon which takes commits pushed to a pending ref, alters |
| 9 their message with metadata, and then pushes the altered commits to a parallel | 9 their message with metadata, and then pushes the altered commits to a parallel |
| 10 ref. | 10 ref. |
| 11 """ | 11 """ |
| 12 | 12 |
| 13 import collections | 13 import collections |
| 14 import logging | 14 import logging |
| 15 import re | 15 import re |
| 16 import sys | 16 import sys |
| 17 import time | 17 import time |
| 18 | 18 |
| 19 LOGGER = logging.getLogger(__name__) | 19 LOGGER = logging.getLogger(__name__) |
| 20 | 20 |
| 21 from infra.services.gnumbd.support import git, data, util | 21 from infra.libs import git2 |
| 22 from infra.libs import types |
| 23 from infra.libs.git2 import config_ref |
| 22 | 24 |
| 23 | 25 |
| 24 DEFAULT_CONFIG_REF = 'refs/pending-config/main' | |
| 25 DEFAULT_REPO_DIR = 'gnumbd_repos' | |
| 26 FOOTER_PREFIX = 'Cr-' | 26 FOOTER_PREFIX = 'Cr-' |
| 27 COMMIT_POSITION = FOOTER_PREFIX + 'Commit-Position' | 27 COMMIT_POSITION = FOOTER_PREFIX + 'Commit-Position' |
| 28 # takes a Ref and a number | 28 # takes a Ref and a number |
| 29 FMT_COMMIT_POSITION = '{.ref}@{{#{:d}}}'.format | 29 FMT_COMMIT_POSITION = '{.ref}@{{#{:d}}}'.format |
| 30 BRANCHED_FROM = FOOTER_PREFIX + 'Branched-From' | 30 BRANCHED_FROM = FOOTER_PREFIX + 'Branched-From' |
| 31 GIT_SVN_ID = 'git-svn-id' | 31 GIT_SVN_ID = 'git-svn-id' |
| 32 | 32 |
| 33 | 33 |
| 34 ################################################################################ | 34 ################################################################################ |
| 35 # ConfigRef |
| 36 ################################################################################ |
| 37 |
| 38 class GnumbdConfigRef(config_ref.ConfigRef): |
| 39 CONVERT = { |
| 40 'interval': lambda self, val: float(val), |
| 41 'pending_tag_prefix': lambda self, val: str(val), |
| 42 'pending_ref_prefix': lambda self, val: str(val), |
| 43 'enabled_refglobs': lambda self, val: map(str, list(val)), |
| 44 } |
| 45 DEFAULTS = { |
| 46 'interval': 5.0, |
| 47 'pending_tag_prefix': 'refs/pending-tags', |
| 48 'pending_ref_prefix': 'refs/pending', |
| 49 'enabled_refglobs': [], |
| 50 } |
| 51 REF = 'refs/gnumbd-config/main' |
| 52 |
| 53 |
| 54 ################################################################################ |
| 35 # Exceptions | 55 # Exceptions |
| 36 ################################################################################ | 56 ################################################################################ |
| 37 | 57 |
| 38 class MalformedPositionFooter(Exception): | 58 class MalformedPositionFooter(Exception): |
| 39 def __init__(self, commit, header, value): | 59 def __init__(self, commit, header, value): |
| 40 super(MalformedPositionFooter, self).__init__( | 60 super(MalformedPositionFooter, self).__init__( |
| 41 'in {!r}: "{}: {}"'.format(commit, header, value)) | 61 'in {!r}: "{}: {}"'.format(commit, header, value)) |
| 42 | 62 |
| 43 | 63 |
| 44 class NoPositionData(Exception): | 64 class NoPositionData(Exception): |
| 45 def __init__(self, commit): | 65 def __init__(self, commit): |
| 46 super(NoPositionData, self).__init__( | 66 super(NoPositionData, self).__init__( |
| 47 'No {!r} or git-svn-id found for {!r}'.format(COMMIT_POSITION, commit)) | 67 'No {!r} or git-svn-id found for {!r}'.format(COMMIT_POSITION, commit)) |
| 48 | 68 |
| 49 | 69 |
| 50 ################################################################################ | 70 ################################################################################ |
| 51 # Commit Manipulation | 71 # Commit Manipulation |
| 52 ################################################################################ | 72 ################################################################################ |
| 53 | 73 |
| 54 def content_of(commit): | 74 def content_of(commit): |
| 55 """Calculates the content of |commit| such that a gnumbd-landed commit and | 75 """Calculates the content of |commit| such that a gnumbd-landed commit and |
| 56 the original commit will compare as equals. Returns the content as a | 76 the original commit will compare as equals. Returns the content as a |
| 57 data.CommitData object. | 77 git2.CommitData object. |
| 58 | 78 |
| 59 This strips out: | 79 This strips out: |
| 60 * The parent(s) | 80 * The parent(s) |
| 61 * The committer date | 81 * The committer date |
| 62 * footers beginning with 'Cr-' | 82 * footers beginning with 'Cr-' |
| 63 * the 'git-svn-id' footer. | 83 * the 'git-svn-id' footer. |
| 64 | 84 |
| 65 Stores a cached copy of the result data on the |commit| instance itself. | 85 Stores a cached copy of the result data on the |commit| instance itself. |
| 66 """ | 86 """ |
| 67 if commit is None: | 87 if commit is None: |
| 68 return git.INVALID | 88 return git2.INVALID |
| 69 | 89 |
| 70 if not hasattr(commit, '_cr_content'): | 90 if not hasattr(commit, '_cr_content'): |
| 71 d = commit.data | 91 d = commit.data |
| 72 footers = util.thaw(d.footers) | 92 footers = types.thaw(d.footers) |
| 73 footers[GIT_SVN_ID] = None | 93 footers[GIT_SVN_ID] = None |
| 74 for k in footers.keys(): | 94 for k in footers.keys(): |
| 75 if k.startswith(FOOTER_PREFIX): | 95 if k.startswith(FOOTER_PREFIX): |
| 76 footers[k] = None | 96 footers[k] = None |
| 77 commit._cr_content = d.alter( | 97 commit._cr_content = d.alter( |
| 78 parents=(), | 98 parents=(), |
| 79 committer=d.committer.alter(timestamp=data.NULL_TIMESTAMP), | 99 committer=d.committer.alter(timestamp=git2.data.NULL_TIMESTAMP), |
| 80 footers=footers) | 100 footers=footers) |
| 81 return commit._cr_content # pylint: disable=W0212 | 101 return commit._cr_content # pylint: disable=W0212 |
| 82 | 102 |
| 83 | 103 |
| 84 def get_position(commit, _position_re=re.compile('^(.*)@{#(\d*)}$')): | 104 def get_position(commit, _position_re=re.compile('^(.*)@{#(\d*)}$')): |
| 85 """Returns (ref, position number) for the given |commit|. | 105 """Returns (ref, position number) for the given |commit|. |
| 86 | 106 |
| 87 Looks for the Cr-Commit-Position footer. If that's unavailable, it falls back | 107 Looks for the Cr-Commit-Position footer. If that's unavailable, it falls back |
| 88 to the git-svn-id footer, passing back ref as None. | 108 to the git-svn-id footer, passing back ref as None. |
| 89 | 109 |
| 90 May raise the MalformedPositionFooter or NoPositionData exceptions. | 110 May raise the MalformedPositionFooter or NoPositionData exceptions. |
| 91 """ | 111 """ |
| 92 f = commit.data.footers | 112 f = commit.data.footers |
| 93 current_pos = f.get(COMMIT_POSITION) | 113 current_pos = f.get(COMMIT_POSITION) |
| 94 if current_pos: | 114 if current_pos: |
| 95 assert len(current_pos) == 1 | 115 assert len(current_pos) == 1 |
| 96 current_pos = current_pos[0] | 116 current_pos = current_pos[0] |
| 97 | 117 |
| 98 m = _position_re.match(current_pos) | 118 m = _position_re.match(current_pos) |
| 99 if not m: | 119 if not m: |
| 100 raise MalformedPositionFooter(commit, COMMIT_POSITION, current_pos) | 120 raise MalformedPositionFooter(commit, COMMIT_POSITION, current_pos) |
| 101 parent_ref = git.Ref(commit.repo, m.group(1)) | 121 parent_ref = commit.repo[m.group(1)] |
| 102 parent_num = int(m.group(2)) | 122 parent_num = int(m.group(2)) |
| 103 else: | 123 else: |
| 104 # TODO(iannucci): Remove this and rely on a manual initial commit? | 124 # TODO(iannucci): Remove this and rely on a manual initial commit? |
| 105 svn_pos = f.get(GIT_SVN_ID) | 125 svn_pos = f.get(GIT_SVN_ID) |
| 106 if not svn_pos: | 126 if not svn_pos: |
| 107 raise NoPositionData(commit) | 127 raise NoPositionData(commit) |
| 108 | 128 |
| 109 assert len(svn_pos) == 1 | 129 assert len(svn_pos) == 1 |
| 110 svn_pos = svn_pos[0] | 130 svn_pos = svn_pos[0] |
| 111 parent_ref = None | 131 parent_ref = None |
| 112 try: | 132 try: |
| 113 parent_num = int(svn_pos.split()[0].split('@')[1]) | 133 parent_num = int(svn_pos.split()[0].split('@')[1]) |
| 114 except (IndexError, ValueError): | 134 except (IndexError, ValueError): |
| 115 raise MalformedPositionFooter(commit, GIT_SVN_ID, svn_pos) | 135 raise MalformedPositionFooter(commit, GIT_SVN_ID, svn_pos) |
| 116 | 136 |
| 117 return parent_ref, parent_num | 137 return parent_ref, parent_num |
| 118 | 138 |
| 119 | 139 |
| 120 def synthesize_commit(commit, new_parent, ref, clock=time): | 140 def synthesize_commit(commit, new_parent, ref, clock=time): |
| 121 """Synthesizes a new Commit given |new_parent| and ref. | 141 """Synthesizes a new Commit given |new_parent| and ref. |
| 122 | 142 |
| 123 The new commit will contain a Cr-Commit-Position footer, and possibly | 143 The new commit will contain a Cr-Commit-Position footer, and possibly |
| 124 Cr-Branched-From footers (if commit is on a branch). | 144 Cr-Branched-From footers (if commit is on a branch). |
| 125 | 145 |
| 126 The new commit's committer date will also be updated to 'time.time()', or | 146 The new commit's committer date will also be updated to 'time.time()', or |
| 127 the new parent's date + 1, whichever is higher. This means that within a branc
h, | 147 the new parent's date + 1, whichever is higher. This means that within a branc
h, |
| 128 commit timestamps will always increase (at least from the point where this | 148 commit timestamps will always increase (at least from the point where this |
| 129 daemon went into service). | 149 daemon went into service). |
| 130 | 150 |
| 131 @type commit: git.Commit | 151 @type commit: git2.Commit |
| 132 @type new_parent: git.Commit | 152 @type new_parent: git2.Commit |
| 133 @type ref: git.Ref | 153 @type ref: git2.Ref |
| 134 @kind clock: implements .time(), used for testing determinisim. | 154 @kind clock: implements .time(), used for testing determinisim. |
| 135 """ | 155 """ |
| 136 # TODO(iannucci): See if there are any other footers we want to carry over | 156 # TODO(iannucci): See if there are any other footers we want to carry over |
| 137 # between new_parent and commit | 157 # between new_parent and commit |
| 138 footers = collections.OrderedDict() | 158 footers = collections.OrderedDict() |
| 139 parent_ref, parent_num = get_position(new_parent) | 159 parent_ref, parent_num = get_position(new_parent) |
| 140 # if parent_ref wasn't encoded, assume that the parent is on the same ref. | 160 # if parent_ref wasn't encoded, assume that the parent is on the same ref. |
| 141 if parent_ref is None: | 161 if parent_ref is None: |
| 142 parent_ref = ref | 162 parent_ref = ref |
| 143 | 163 |
| (...skipping 11 matching lines...) Expand all Loading... |
| 155 # Gerrit-landed commits. | 175 # Gerrit-landed commits. |
| 156 for key, value in commit.data.footers.iteritems(): | 176 for key, value in commit.data.footers.iteritems(): |
| 157 if key.startswith(FOOTER_PREFIX) or key == GIT_SVN_ID: | 177 if key.startswith(FOOTER_PREFIX) or key == GIT_SVN_ID: |
| 158 LOGGER.warn('Dropping key on user commit %s: %r -> %r', | 178 LOGGER.warn('Dropping key on user commit %s: %r -> %r', |
| 159 commit.hsh, key, value) | 179 commit.hsh, key, value) |
| 160 footers[key] = None | 180 footers[key] = None |
| 161 | 181 |
| 162 # Ensure that every commit has a time which is at least 1 second after its | 182 # Ensure that every commit has a time which is at least 1 second after its |
| 163 # parent, and reset the tz to UTC. | 183 # parent, and reset the tz to UTC. |
| 164 parent_time = new_parent.data.committer.timestamp.secs | 184 parent_time = new_parent.data.committer.timestamp.secs |
| 165 new_parents = [] if new_parent is git.INVALID else [new_parent.hsh] | 185 new_parents = [] if new_parent is git2.INVALID else [new_parent.hsh] |
| 166 new_committer = commit.data.committer.alter( | 186 new_committer = commit.data.committer.alter( |
| 167 timestamp=data.NULL_TIMESTAMP.alter( | 187 timestamp=git2.data.NULL_TIMESTAMP.alter( |
| 168 secs=max(int(clock.time()), parent_time + 1))) | 188 secs=max(int(clock.time()), parent_time + 1))) |
| 169 | 189 |
| 170 return commit.alter( | 190 return commit.alter( |
| 171 parents=new_parents, | 191 parents=new_parents, |
| 172 committer=new_committer, | 192 committer=new_committer, |
| 173 footers=footers, | 193 footers=footers, |
| 174 ) | 194 ) |
| 175 | 195 |
| 176 | 196 |
| 177 ################################################################################ | 197 ################################################################################ |
| (...skipping 22 matching lines...) Expand all Loading... |
| 200 | 220 |
| 201 v pending_tag | 221 v pending_tag |
| 202 A B C D E F <- pending_tip | 222 A B C D E F <- pending_tip |
| 203 A' B' C' D' E' F' <- master | 223 A' B' C' D' E' F' <- master |
| 204 | 224 |
| 205 In either case, pending_tag would be advanced, and the method would return | 225 In either case, pending_tag would be advanced, and the method would return |
| 206 the commits beteween the tag's proper position and the tip. | 226 the commits beteween the tag's proper position and the tip. |
| 207 | 227 |
| 208 Other discrepancies are errors and this method will return an empty list. | 228 Other discrepancies are errors and this method will return an empty list. |
| 209 | 229 |
| 210 @type pending_tag: git.Ref | 230 @type pending_tag: git2.Ref |
| 211 @type pending_tip: git.Ref | 231 @type pending_tip: git2.Ref |
| 212 @type real_ref: git.Ref | 232 @type real_ref: git2.Ref |
| 213 @returns [git.Commit] | 233 @returns [git2.Commit] |
| 214 """ | 234 """ |
| 215 assert pending_tag.commit != pending_tip.commit | 235 assert pending_tag.commit != pending_tip.commit |
| 216 i = 0 | 236 i = 0 |
| 217 new_commits = list(pending_tag.to(pending_tip)) | 237 new_commits = list(pending_tag.to(pending_tip)) |
| 218 if not new_commits: | 238 if not new_commits: |
| 219 LOGGER.error('%r doesn\'t match %r, but there are no new_commits?', | 239 LOGGER.error('%r doesn\'t match %r, but there are no new_commits?', |
| 220 pending_tag.ref, pending_tip.ref) | 240 pending_tag.ref, pending_tip.ref) |
| 221 return [] | 241 return [] |
| 222 | 242 |
| 223 for commit in new_commits: | 243 for commit in new_commits: |
| 224 parent = commit.parent | 244 parent = commit.parent |
| 225 if parent is git.INVALID: | 245 if parent is git2.INVALID: |
| 226 LOGGER.error('Cannot process pending merge commit %r', commit) | 246 LOGGER.error('Cannot process pending merge commit %r', commit) |
| 227 return [] | 247 return [] |
| 228 | 248 |
| 229 if content_of(parent) == content_of(real_ref.commit): | 249 if content_of(parent) == content_of(real_ref.commit): |
| 230 break | 250 break |
| 231 | 251 |
| 232 LOGGER.warn('Skipping already-processed commit on real_ref %r: %r', | 252 LOGGER.warn('Skipping already-processed commit on real_ref %r: %r', |
| 233 real_ref, commit.hsh) | 253 real_ref, commit.hsh) |
| 234 i += 1 | 254 i += 1 |
| 235 | 255 |
| (...skipping 28 matching lines...) Expand all Loading... |
| 264 v pending_tag | 284 v pending_tag |
| 265 A B C D E F <- pending_tip | 285 A B C D E F <- pending_tip |
| 266 A' B' C' <- master | 286 A' B' C' <- master |
| 267 | 287 |
| 268 This function will produce: | 288 This function will produce: |
| 269 | 289 |
| 270 v pending_tag | 290 v pending_tag |
| 271 A B C D E F <- pending_tip | 291 A B C D E F <- pending_tip |
| 272 A' B' C' D' E' F' <- master | 292 A' B' C' D' E' F' <- master |
| 273 | 293 |
| 274 @type real_ref: git.Ref | 294 @type real_ref: git2.Ref |
| 275 @type pending_tag: git.Ref | 295 @type pending_tag: git2.Ref |
| 276 @type new_commits: [git.Commit] | 296 @type new_commits: [git2.Commit] |
| 277 @kind clock: implements .time(), used for testing determinisim. | 297 @kind clock: implements .time(), used for testing determinisim. |
| 278 """ | 298 """ |
| 279 # TODO(iannucci): use push --force-with-lease to reset pending to the real | 299 # TODO(iannucci): use push --force-with-lease to reset pending to the real |
| 280 # ref? | 300 # ref? |
| 281 # TODO(iannucci): The ACL rejection message for the real ref should point | 301 # TODO(iannucci): The ACL rejection message for the real ref should point |
| 282 # users to the pending ref. | 302 # users to the pending ref. |
| 283 assert content_of(pending_tag.commit) == content_of(real_ref.commit) | 303 assert content_of(pending_tag.commit) == content_of(real_ref.commit) |
| 284 real_parent = real_ref.commit | 304 real_parent = real_ref.commit |
| 285 for commit in new_commits: | 305 for commit in new_commits: |
| 286 assert content_of(commit.parent) == content_of(real_parent) | 306 assert content_of(commit.parent) == content_of(real_parent) |
| (...skipping 13 matching lines...) Expand all Loading... |
| 300 """Execute a single pass over a fetched Repo. | 320 """Execute a single pass over a fetched Repo. |
| 301 | 321 |
| 302 Will call |process_ref| for every branch indicated by the enabled_refglobs | 322 Will call |process_ref| for every branch indicated by the enabled_refglobs |
| 303 config option. | 323 config option. |
| 304 """ | 324 """ |
| 305 pending_tag_prefix = cref['pending_tag_prefix'] | 325 pending_tag_prefix = cref['pending_tag_prefix'] |
| 306 pending_ref_prefix = cref['pending_ref_prefix'] | 326 pending_ref_prefix = cref['pending_ref_prefix'] |
| 307 enabled_refglobs = cref['enabled_refglobs'] | 327 enabled_refglobs = cref['enabled_refglobs'] |
| 308 | 328 |
| 309 def join(prefix, ref): | 329 def join(prefix, ref): |
| 310 return git.Ref(repo, '/'.join((prefix, ref.ref[len('refs/'):]))) | 330 return repo['/'.join((prefix, ref.ref[len('refs/'):]))] |
| 311 | 331 |
| 312 for refglob in enabled_refglobs: | 332 for refglob in enabled_refglobs: |
| 313 glob = join(pending_ref_prefix, git.Ref(repo, refglob)) | 333 glob = join(pending_ref_prefix, repo[refglob]) |
| 314 for pending_tip in repo.refglob(glob.ref): | 334 for pending_tip in repo.refglob(glob.ref): |
| 315 # TODO(iannucci): each real_ref could have its own thread. | 335 # TODO(iannucci): each real_ref could have its own thread. |
| 316 try: | 336 try: |
| 317 real_ref = git.Ref(repo, pending_tip.ref.replace( | 337 real_ref = git2.Ref(repo, pending_tip.ref.replace( |
| 318 pending_ref_prefix, 'refs')) | 338 pending_ref_prefix, 'refs')) |
| 319 | 339 |
| 320 if real_ref.commit is git.INVALID: | 340 if real_ref.commit is git2.INVALID: |
| 321 LOGGER.error('Missing real ref %r', real_ref) | 341 LOGGER.error('Missing real ref %r', real_ref) |
| 322 continue | 342 continue |
| 323 | 343 |
| 324 LOGGER.info('Processing %r', real_ref) | 344 LOGGER.info('Processing %r', real_ref) |
| 325 pending_tag = join(pending_tag_prefix, real_ref) | 345 pending_tag = join(pending_tag_prefix, real_ref) |
| 326 | 346 |
| 327 if pending_tag.commit is git.INVALID: | 347 if pending_tag.commit is git2.INVALID: |
| 328 LOGGER.error('Missing pending tag %r for %r', pending_tag, real_ref) | 348 LOGGER.error('Missing pending tag %r for %r', pending_tag, real_ref) |
| 329 continue | 349 continue |
| 330 | 350 |
| 331 if pending_tag.commit != pending_tip.commit: | 351 if pending_tag.commit != pending_tip.commit: |
| 332 new_commits = get_new_commits(real_ref, pending_tag, pending_tip) | 352 new_commits = get_new_commits(real_ref, pending_tag, pending_tip) |
| 333 if new_commits: | 353 if new_commits: |
| 334 process_ref(real_ref, pending_tag, new_commits, clock) | 354 process_ref(real_ref, pending_tag, new_commits, clock) |
| 335 else: | 355 else: |
| 336 if content_of(pending_tag.commit) != content_of(real_ref.commit): | 356 if content_of(pending_tag.commit) != content_of(real_ref.commit): |
| 337 LOGGER.error('%r and %r match, but %r\'s content doesn\'t match!', | 357 LOGGER.error('%r and %r match, but %r\'s content doesn\'t match!', |
| 338 pending_tag, pending_tip, real_ref) | 358 pending_tag, pending_tip, real_ref) |
| 339 else: | 359 else: |
| 340 LOGGER.info('%r is up to date', real_ref) | 360 LOGGER.info('%r is up to date', real_ref) |
| 341 except (NoPositionData, MalformedPositionFooter) as e: | 361 except (NoPositionData, MalformedPositionFooter) as e: |
| 342 LOGGER.error('%s %s', e.__class__.__name__, e) | 362 LOGGER.error('%s %s', e.__class__.__name__, e) |
| 343 except Exception: # pragma: no cover | 363 except Exception: # pragma: no cover |
| 344 LOGGER.exception('Uncaught exception while processing %r', real_ref) | 364 LOGGER.exception('Uncaught exception while processing %r', real_ref) |
| 345 | 365 |
| 346 | 366 |
| 347 def inner_loop(repo, cref, clock=time): | 367 def inner_loop(repo, cref, clock=time): |
| 348 LOGGER.debug('fetching %r', repo) | 368 LOGGER.debug('fetching %r', repo) |
| 349 repo.run('fetch', stdout=sys.stdout, stderr=sys.stderr) | 369 repo.run('fetch', stdout=sys.stdout, stderr=sys.stderr) |
| 350 cref.evaluate() | 370 cref.evaluate() |
| 351 process_repo(repo, cref, clock) | 371 process_repo(repo, cref, clock) |
| OLD | NEW |