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