sapling/hgext3rd/fbconduit.py
Adam Simpkins 33b2fbc0bc fbconduit: fix error handling in gitnode() revset
Summary:
Don't let errors propagate out of the gitnode() revset.  Always report errors
in gitnode() as a translation failure, rather than letting exceptions propagate
up and crash mercurial.  The code was previously only catching internally
generated ConduitError exceptions, but it can also throw HttpError exceptions,
and the underlying httplib code can throw its own exceptions as well as
socket.error exceptions.

This also fixes the test code to use an ephemeral TCP port rather than assuming
port 8543 will always be available.  Using a fixed TCP port in test code is a
very common way to cause bogus failures if the tests are run in parallel.
(For instance, testing multiple repositories in parallel on the same build
host.)

Test Plan:
Added unit tests that check the behavior when the server returns a 500 error,
and when the server refuses the connection entirely.

Reviewers: quark, durham, rmcelroy

Reviewed By: rmcelroy

Subscribers: net-systems-diffs@fb.com, yogeshwer, mjpieters

Differential Revision: https://phabricator.intern.facebook.com/D4556871

Signature: t1:4556871:1487062142:b58d770d46c975d44933bec08cfce8acb25ff16b
2017-02-15 12:35:06 -08:00

261 lines
7.8 KiB
Python

# fbconduit.py
#
# An extension to query remote servers for extra information via conduit RPC
#
# Copyright 2015 Facebook, Inc.
from mercurial import (
error,
extensions,
node,
registrar,
revset,
templater,
)
from mercurial.i18n import _
import re
import json
from urllib import urlencode
from mercurial.util import httplib
conduit_host = None
conduit_path = None
conduit_protocol = None
connection = None
MAX_CONNECT_RETRIES = 3
class ConduitError(Exception):
pass
class HttpError(Exception):
pass
githashre = re.compile('g([0-9a-fA-F]{12,40})')
phabhashre = re.compile('^r([A-Z]+)([0-9a-f]{12,40})$')
fbsvnhash = re.compile('^r[A-Z]+(\d+)$')
def extsetup(ui):
if not conduit_config(ui):
ui.warn(_('No conduit host specified in config; disabling fbconduit\n'))
return
templater.funcs['mirrornode'] = mirrornode
revset.symbols['gitnode'] = gitnode
extensions.wrapfunction(revset, 'stringset', overridestringset)
revset.symbols['stringset'] = revset.stringset
revset.methods['string'] = revset.stringset
revset.methods['symbol'] = revset.stringset
def conduit_config(ui, host=None, path=None, protocol=None):
global conduit_host, conduit_path, conduit_protocol
conduit_host = host or ui.config('fbconduit', 'host')
conduit_path = path or ui.config('fbconduit', 'path')
conduit_protocol = protocol or ui.config('fbconduit', 'protocol')
if conduit_host is None:
return False
if conduit_protocol is None:
conduit_protocol = 'https'
return True
def call_conduit(method, **kwargs):
global connection, conduit_host, conduit_path, conduit_protocol
# start connection
if connection is None:
if conduit_protocol == 'https':
connection = httplib.HTTPSConnection(conduit_host)
elif conduit_protocol == 'http':
connection = httplib.HTTPConnection(conduit_host)
# send request
path = conduit_path + method
args = urlencode({'params': json.dumps(kwargs)})
for attempt in xrange(MAX_CONNECT_RETRIES):
try:
connection.request('POST', path, args, {'Connection': 'Keep-Alive'})
break
except httplib.HTTPException as e:
connection.connect()
else:
raise e
# read http response
response = connection.getresponse()
if response.status != 200:
raise HttpError(response.reason)
result = response.read()
# strip jsonp header and parse
assert result.startswith('for(;;);')
result = json.loads(result[8:])
# check for conduit errors
if result['error_code']:
raise ConduitError(result['error_info'])
# return RPC result
return result['result']
# don't close the connection b/c we want to avoid the connection overhead
def mirrornode(ctx, mapping, args):
'''template: find this commit in other repositories'''
reponame = mapping['repo'].ui.config('fbconduit', 'reponame')
if not reponame:
# We don't know who we are, so we can't ask for a translation
return ''
if mapping['ctx'].mutable():
# Local commits don't have translations
return ''
node = mapping['ctx'].hex()
args = [f(ctx, mapping, a) for f, a in args]
if len(args) == 1:
torepo, totype = reponame, args[0]
else:
torepo, totype = args
try:
result = call_conduit('scmquery.get.mirrored.revs',
from_repo=reponame,
from_scm='hg',
to_repo=torepo,
to_scm=totype,
revs=[node]
)
except ConduitError as e:
if 'unknown revision' not in str(e.args):
mapping['repo'].ui.warn((str(e.args) + '\n'))
return ''
return result.get(node, '')
templatekeyword = registrar.templatekeyword()
@templatekeyword("gitnode")
def showgitnode(repo, ctx, templ, **args):
"""Return the git revision corresponding to a given hg rev"""
reponame = repo.ui.config('fbconduit', 'reponame')
if not reponame:
# We don't know who we are, so we can't ask for a translation
return ''
backingrepos = repo.ui.configlist('fbconduit', 'backingrepos',
default=[reponame])
if ctx.mutable():
# Local commits don't have translations
return ''
matches = []
for backingrepo in backingrepos:
try:
result = call_conduit('scmquery.get.mirrored.revs',
from_repo=reponame,
from_scm='hg',
to_repo=backingrepo,
to_scm='git',
revs=[ctx.hex()]
)
githash = result[ctx.hex()]
if githash != "":
matches.append((backingrepo, githash))
except ConduitError:
pass
if len(matches) == 0:
return ''
elif len(backingrepos) == 1:
return matches[0][1]
else:
# in case it's not clear, the sort() is to ensure the output is in a
# deterministic order.
matches.sort()
return "; ".join(["{0}: {1}".format(*match)
for match in matches])
def gitnode(repo, subset, x):
"""``gitnode(id)``
Return the hg revision corresponding to a given git rev."""
l = revset.getargs(x, 1, 1, _("id requires one argument"))
n = revset.getstring(l[0], _("id requires a string"))
reponame = repo.ui.config('fbconduit', 'reponame')
if not reponame:
# We don't know who we are, so we can't ask for a translation
return subset.filter(lambda r: False)
backingrepos = repo.ui.configlist('fbconduit', 'backingrepos',
default=[reponame])
lasterror = None
hghash = None
for backingrepo in backingrepos:
try:
result = call_conduit('scmquery.get.mirrored.revs',
from_repo=backingrepo,
from_scm='git',
to_repo=reponame,
to_scm='hg',
revs=[n]
)
hghash = result[n]
if hghash != '':
break
except Exception as ex:
lasterror = ex
if not hghash:
if lasterror:
repo.ui.warn(("Could not translate revision {0}: {1}\n".format(
n, lasterror)))
else:
repo.ui.warn(("Could not translate revision {0}\n".format(n)))
return subset.filter(lambda r: False)
rn = repo[node.bin(hghash)].rev()
return subset.filter(lambda r: r == rn)
def overridestringset(orig, repo, subset, x):
# Is the given revset a phabricator hg hash (ie: rHGEXTaaacb34aacb34aa)
phabmatch = phabhashre.match(x)
if phabmatch:
phabrepo = phabmatch.group(1)
phabhash = phabmatch.group(2)
# The hash may be a git hash
if phabrepo in repo.ui.configlist('fbconduit', 'gitcallsigns', []):
return overridestringset(orig, repo, subset, 'g%s' % phabhash)
if phabhash in repo:
return orig(repo, subset, phabhash)
# Is the given revset a phabricator svn revision (rO11223232323232)?
svnrev = fbsvnhash.match(x)
if svnrev and not x in repo:
try:
extensions.find('hgsubversion')
meta = repo.svnmeta()
desiredrevision = int(svnrev.group(1))
# For some odd reason, the key is a tuple instead of a revision num
# The second member always seems to be None
revmapkey = (desiredrevision, None)
hghash = meta.revmap.get(revmapkey)
if hghash:
return orig(repo, subset, hghash)
except KeyError:
pass
m = githashre.match(x)
if m is not None:
githash = m.group(1)
if len(githash) == 40:
return gitnode(repo, subset, ('string', githash))
else:
raise error.Abort('git hash must be 40 characters')
return orig(repo, subset, x)