hgext/hgmo/__init__.py (618 lines of code) (raw):
# This software may be used and distributed according to the terms of the
# GNU General Public License version 2 or any later version.
"""Provide enhancements to hg.mozilla.org
Config Options
==============
hgmo.replacebookmarks
When set, `hg pull` and other bookmark application operations will replace
local bookmarks with incoming bookmarks instead of doing the more
complicated default behavior, which includes creating diverged bookmarks.
hgmo.convertsource
When set and a changeset has a ``convert_revision`` extra data attribute,
the changeset template data will contain a link to the source revision it
was converted from.
Value is a relative or absolute path to the repo. e.g.
``/mozilla-central``.
"""
import copy
import ipaddress
import json
import os
import types
from typing import Optional, Union
from mercurial.i18n import _
from mercurial.node import bin
from mercurial.utils import (
dateutil,
)
from mercurial import (
bookmarks,
commands,
configitems,
encoding,
error,
exchange,
extensions,
hg,
phases,
pycompat,
registrar,
revset,
scmutil,
templatefilters,
templateutil,
wireprotov1server,
)
from mercurial.hgweb import (
webcommands,
webutil,
)
from mercurial.hgweb.common import (
ErrorResponse,
HTTP_NOT_FOUND,
)
OUR_DIR = os.path.dirname(__file__)
ROOT = os.path.normpath(os.path.join(OUR_DIR, "..", ".."))
with open(os.path.join(OUR_DIR, "..", "bootstrap.py")) as f:
exec(f.read())
import mozautomation.commitparser as commitparser
from mozhg.util import (
import_module,
repo_owner,
get_backoutbynode,
)
from mercurial.wireprotoserver import httpv1protocolhandler as webproto
minimumhgversion = b"4.8"
testedwith = b"4.8 4.9 5.0 5.1 5.2 5.3 5.4 5.5 5.9"
cmdtable = {}
command = registrar.command(cmdtable)
revsetpredicate = registrar.revsetpredicate()
configtable = {}
configitem = registrar.configitem(configtable)
configitem(b"mozilla", b"treeherder_repo", default=configitems.dynamicdefault)
configitem(b"mozilla", b"treestatus_base_url", default=configitems.dynamicdefault)
configitem(b"mozilla", b"git_repo_url", default=configitems.dynamicdefault)
configitem(
b"hgmo", b"automationrelevantdraftancestors", default=configitems.dynamicdefault
)
configitem(b"hgmo", b"backoutsearchlimit", default=configitems.dynamicdefault)
configitem(b"hgmo", b"convertsource", default=None)
configitem(b"hgmo", b"headdivergencemaxnodes", default=configitems.dynamicdefault)
configitem(b"hgmo", b"mozippath", default=None)
configitem(b"hgmo", b"awsippath", default=None)
configitem(b"hgmo", b"gcpippath", default=None)
configitem(b"hgmo", b"azureippath", default=None)
configitem(b"hgmo", b"pullclonebundlesmanifest", default=configitems.dynamicdefault)
configitem(b"hgmo", b"replacebookmarks", default=configitems.dynamicdefault)
configitem(b"hgmo", b"instance-data-path", default=None)
@templatefilters.templatefilter(b"mozlink")
def mozlink(text):
"""Any text. Hyperlink to Bugzilla and other detected things."""
return commitparser.add_hyperlinks(text)
def addmetadata(repo, ctx, d, onlycheap=False):
"""Add changeset metadata for hgweb templates."""
description = encoding.fromlocal(ctx.description())
def bugsgen(_context):
"""Generator for bugs list"""
for bug in commitparser.parse_bugs(description):
bug = pycompat.bytestr(bug)
yield {
b"no": bug,
b"url": b"https://bugzilla.mozilla.org/show_bug.cgi?id=%s" % bug,
}
def reviewersgen(_context):
"""Generator for reviewers list"""
for reviewer in commitparser.parse_reviewers(description):
yield {
b"name": reviewer,
b"revset": b"reviewer(%s)" % reviewer,
}
def backoutsgen(_context):
"""Generator for backouts list"""
backouts = commitparser.parse_backouts(description)
if backouts:
for node in backouts[0]:
try:
bctx = scmutil.revsymbol(repo, node)
yield {b"node": bctx.hex()}
except error.RepoLookupError:
pass
d[b"reviewers"] = templateutil.mappinggenerator(reviewersgen)
d[b"bugs"] = templateutil.mappinggenerator(bugsgen)
d[b"backsoutnodes"] = templateutil.mappinggenerator(backoutsgen)
# Repositories can define which TreeHerder repository they are associated
# with.
treeherder = repo.ui.config(b"mozilla", b"treeherder_repo")
if treeherder:
d[b"treeherderrepourl"] = (
b"https://treeherder.mozilla.org/jobs?repo=%s" % treeherder
)
d[b"treeherderrepo"] = treeherder
push = repo.pushlog.pushfromchangeset(ctx)
# Don't print Perfherder link on non-publishing repos (like Try)
# because the previous push likely has nothing to do with this
# push.
# Changeset on autoland are in the phase 'draft' until they get merged
# to mozilla-central.
if (
push
and push.nodes
and (
repo.ui.configbool(b"phases", b"publish", True)
or treeherder == b"autoland"
)
):
lastpushhead = repo[push.nodes[0]].hex()
d[b"perfherderurl"] = (
b"https://treeherder.mozilla.org/perf.html#/compare?"
b"originalProject=%s&"
b"originalRevision=%s&"
b"newProject=%s&"
b"newRevision=%s"
) % (treeherder, push.nodes[-1], treeherder, lastpushhead)
# If this changeset was converted from another one and we know which repo
# it came from, add that metadata.
convertrevision = ctx.extra().get(b"convert_revision")
if convertrevision:
sourcerepo = repo.ui.config(b"hgmo", b"convertsource")
if sourcerepo:
d[b"convertsourcepath"] = sourcerepo
d[b"convertsourcenode"] = convertrevision
# Did the push to this repo included extra data about the automated landing
# system used?
# We omit the key if it has no value so that the 'json' filter function in
# the map file will return null for the key's value. Otherwise the filter
# will return a JSON empty string, even for False-y values like None.
landingsystem = ctx.extra().get(b"moz-landing-system")
if landingsystem:
d[b"landingsystem"] = landingsystem
git_commit = ctx.extra().get(b"git_commit")
if git_commit:
d[b"git_commit"] = git_commit
git_repo_url = repo.ui.config(
b"mozilla",
b"git_repo_url",
b"https://github.com/mozilla-firefox/firefox",
)
if git_repo_url:
d[b"git_repo_url"] = git_repo_url
if onlycheap:
return
# Obtain the Gecko/app version/milestone.
#
# We could probably only do this if the repo is a known app repo (by
# looking at the initial changeset). But, path based lookup is relatively
# fast, so just do it. However, we need this in the "onlycheap"
# section because resolving manifests is relatively slow and resolving
# several on changelist pages may add seconds to page load times.
try:
fctx = repo.filectx(b"config/milestone.txt", changeid=ctx.node())
lines = fctx.data().splitlines()
lines = [l for l in lines if not l.startswith(b"#") and l.strip()]
if lines:
d[b"milestone"] = lines[0].strip()
except error.LookupError:
pass
backout_node = get_backoutbynode(b"hgmo", repo, ctx)
if backout_node is not None:
d[b"backedoutbynode"] = backout_node
def changesetentry(orig, web, ctx):
"""Wraps webutil.changesetentry to provide extra metadata."""
d = orig(web, ctx)
d = pycompat.byteskwargs(d)
addmetadata(web.repo, ctx, d)
return pycompat.strkwargs(d)
def changelistentry(orig, web, ctx):
"""Wraps webutil.changelistentry to provide extra metadata."""
d = orig(web, ctx)
addmetadata(web.repo, ctx, d, onlycheap=True)
return d
def infowebcommand(web):
"""Get information about the specified changeset(s).
This is a legacy API from before the days of Mercurial's built-in JSON
API. It is used by unidentified parts of automation. Over time these
consumers should transition to the modern/native JSON API.
"""
req = web.req
if b"node" not in req.qsparams:
return web.sendtemplate(b"error", error=b"missing parameter 'node'")
nodes = req.qsparams.getall(b"node")
csets = []
for node in nodes:
ctx = scmutil.revsymbol(web.repo, node)
csets.append(
{
b"rev": ctx.rev(),
b"node": ctx.hex(),
b"user": ctx.user(),
b"date": ctx.date(),
b"description": ctx.description(),
b"branch": ctx.branch(),
b"tags": ctx.tags(),
b"parents": [p.hex() for p in ctx.parents()],
b"children": [c.hex() for c in ctx.children()],
b"files": ctx.files(),
}
)
return web.sendtemplate(b"info", csets=templateutil.mappinglist(csets))
def headdivergencewebcommand(web):
"""Get information about divergence between this repo and a changeset.
This API was invented to be used by MozReview to obtain information about
how a repository/head has progressed/diverged since a commit was submitted
for review.
It is assumed that this is running on the canonical/mainline repository.
Changes in other repositories must be rebased onto or merged into
this repository.
"""
req = web.req
if b"node" not in req.qsparams:
return web.sendtemplate(b"error", error=b"missing parameter 'node'")
repo = web.repo
paths = set(req.qsparams.getall(b"p"))
basectx = scmutil.revsymbol(repo, req.qsparams[b"node"])
# Find how much this repo has changed since the requested changeset.
# Our heuristic is to find the descendant head with the highest revision
# number. Most (all?) repositories we care about for this API should have
# a single head per branch. And we assume the newest descendant head is
# the one we care about the most. We don't care about branches because
# if a descendant is on different branch, then the repo has likely
# transitioned to said branch.
#
# If we ever consolidate Firefox repositories, we'll need to reconsider
# this logic, especially if release repos with their extra branches/heads
# are involved.
# Specifying "start" only gives heads that are descendants of "start."
headnodes = repo.changelog.heads(start=basectx.node())
headrev = max(repo[n].rev() for n in headnodes)
headnode = repo[headrev].node()
betweennodes, outroots, outheads = repo.changelog.nodesbetween(
[basectx.node()], [headnode]
)
# nodesbetween returns base node. So prune.
betweennodes = betweennodes[1:]
commitsbehind = len(betweennodes)
# If rev 0 or a really old revision is passed in, we could DoS the server
# by having to iterate nearly all changesets. Establish a cap for number
# of changesets to examine.
maxnodes = repo.ui.configint(b"hgmo", b"headdivergencemaxnodes", 1000)
filemergesignored = False
if len(betweennodes) > maxnodes:
betweennodes = []
filemergesignored = True
filemerges = {}
for node in betweennodes:
ctx = repo[node]
files = set(ctx.files())
for p in files & paths:
filemerges.setdefault(p, []).append(ctx.hex())
return web.sendtemplate(
b"headdivergence",
commitsbehind=commitsbehind,
filemerges=filemerges,
filemergesignored=filemergesignored,
)
def automationrelevancewebcommand(web):
req = web.req
if b"node" not in req.qsparams:
return web.sendtemplate(b"error", error=b"missing parameter 'node'")
repo = web.repo
deletefields = {
b"bookmarks",
b"branch",
b"branches",
b"changelogtag",
b"child",
b"ctx",
b"inbranch",
b"instabilities",
b"obsolete",
b"parent",
b"succsandmarkers",
b"tags",
b"whyunstable",
}
csets = []
# Query an unfiltered repo because sometimes automation wants to run against
# changesets that have since become hidden. The response exposes whether the
# requested node is visible, so consumers can make intelligent decisions
# about what to do if the changeset isn't visible.
urepo = repo.unfiltered()
revs = list(urepo.revs(b"automationrelevant(%r)", req.qsparams[b"node"]))
# The pushlog extensions wraps webutil.commonentry and the way it is called
# means pushlog opens a SQLite connection on every call. This is inefficient.
# So we pre load and cache data for pushlog entries we care about.
cl = urepo.changelog
nodes = [cl.node(rev) for rev in revs]
with repo.unfiltered().pushlog.cache_data_for_nodes(nodes):
for rev in revs:
ctx = urepo[rev]
entry = webutil.changelistentry(web, ctx)
if req.qsparams.get(b"backouts"):
backout_node = get_backoutbynode(b"hgmo", repo, ctx)
if backout_node is not None:
entry[b"backedoutby"] = backout_node
# The pushnodes list is redundant with data from other changesets.
# The amount of redundant data for pushes containing N>100
# changesets can add up to megabytes in size.
try:
del entry[b"pushnodes"]
except KeyError:
pass
# Some items in changelistentry are generators, which json.dumps()
# can't handle. So we expand them.
entrycopy = copy.copy(entry)
for k, v in entrycopy.items():
# "files" is a generator that attempts to call a template.
# Don't even bother and just repopulate it.
if k == b"files":
entry[b"files"] = sorted(ctx.files())
elif k == b"allparents":
iterator = v(None, None).itermaps(ctx)
entry[b"parents"] = [p[b"node"] for p in iterator]
del entry[b"allparents"]
# These aren't interesting to us, so prune them. The
# original impetus for this was because "changelogtag"
# isn't part of the json template and adding it is non-trivial.
elif k in deletefields:
del entry[k]
elif isinstance(v, types.GeneratorType):
entry[k] = list(v)
csets.append(entry)
# Advertise whether the requested revision is visible (non-obsolete).
if csets:
visible = csets[-1][b"node"] in repo
else:
visible = None
data = {
b"changesets": templateutil.mappinglist(csets),
b"visible": visible,
}
return web.sendtemplate(b"automationrelevance", **pycompat.strkwargs(data))
def push_changed_files_webcommand(web):
"""Retrieve the list of files modified in a push."""
req = web.req
if b"node" not in req.qsparams:
return web.sendtemplate(b"error", error=b"missing parameter 'node'")
repo = web.repo
# Query an unfiltered repo because sometimes automation wants to run against
# changesets that have since become hidden. The response exposes whether the
# requested node is visible, so consumers can make intelligent decisions
# about what to do if the changeset isn't visible.
urepo = repo.unfiltered()
revs = list(urepo.revs(b"automationrelevant(%r)", req.qsparams[b"node"]))
# The pushlog extensions wraps webutil.commonentry and the way it is called
# means pushlog opens a SQLite connection on every call. This is inefficient.
# So we pre load and cache data for pushlog entries we care about.
cl = urepo.changelog
nodes = [cl.node(rev) for rev in revs]
files = []
with repo.unfiltered().pushlog.cache_data_for_nodes(nodes):
for rev in revs:
ctx = urepo[rev]
files.extend(ctx.files())
# De-duplicate and sort the files.
files = sorted(list(set(files)))
return web.sendtemplate(b"pushchangedfiles", files=files)
def isancestorwebcommand(web):
"""Determine whether a changeset is an ancestor of another."""
req = web.req
for k in (b"head", b"node"):
if k not in req.qsparams:
raise ErrorResponse(HTTP_NOT_FOUND, b"missing parameter '%s'" % k)
head = req.qsparams[b"head"]
node = req.qsparams[b"node"]
try:
headctx = scmutil.revsingle(web.repo, head)
except error.RepoLookupError:
raise ErrorResponse(HTTP_NOT_FOUND, b"unknown head revision %s" % head)
try:
testctx = scmutil.revsingle(web.repo, node)
except error.RepoLookupError:
raise ErrorResponse(HTTP_NOT_FOUND, b"unknown node revision %s" % node)
testrev = testctx.rev()
isancestor = False
for rev in web.repo.changelog.ancestors([headctx.rev()], inclusive=True):
if rev == testrev:
isancestor = True
break
return web.sendtemplate(
b"isancestor",
headnode=headctx.hex(),
testnode=testctx.hex(),
isancestor=isancestor,
)
def repoinfowebcommand(web):
group_owner = repo_owner(web.repo)
return web.sendtemplate(
b"repoinfo", archives=web.archivelist(b"tip"), groupowner=group_owner
)
@revsetpredicate(b"reviewer(REVIEWER)", safe=True)
def revset_reviewer(repo, subset, x):
"""``reviewer(REVIEWER)``
Changesets reviewed by a specific person.
"""
l = revset.getargs(x, 1, 1, b"reviewer requires one argument")
n = encoding.lower(revset.getstring(l[0], b"reviewer requires a string"))
# Do not use a matcher here because regular expressions are not safe
# for remote execution and may DoS the server.
def hasreviewer(r):
for reviewer in commitparser.parse_reviewers(repo[r].description()):
if encoding.lower(reviewer) == n:
return True
return False
return subset.filter(hasreviewer)
@revsetpredicate(b"automationrelevant(set)", safe=True)
def revset_automationrelevant(repo, subset, x):
"""``automationrelevant(set)``
Changesets relevant to scheduling in automation.
Given a revset that evaluates to a single revision, will return that
revision and any ancestors that are part of the same push unioned with
non-public ancestors.
"""
s = revset.getset(repo, revset.fullreposet(repo), x)
if len(s) > 1:
raise error.Abort(b"can only evaluate single changeset")
ctx = repo[s.first()]
revs = {ctx.rev()}
drafts = repo.ui.configbool(b"hgmo", b"automationrelevantdraftancestors", False)
# The pushlog is used to get revisions part of the same push as
# the requested revision.
pushlog = getattr(repo, "pushlog", None)
if pushlog:
push = repo.pushlog.pushfromchangeset(ctx)
for n in push.nodes:
pctx = repo[n]
if pctx.rev() <= ctx.rev() and (not drafts or pctx.phase() > phases.draft):
revs.add(pctx.rev())
# Union with non-public ancestors if configured. By default, we only
# consider changesets from the push. However, on special repositories
# (namely Try), we want changesets from previous pushes to come into
# play too.
if drafts:
for rev in repo.revs(b"::%d & not public()", ctx.rev()):
revs.add(rev)
return subset & revset.baseset(revs)
def bmupdatefromremote(orig, ui, repo, remotemarks, path, trfunc, **kwargs):
"""Custom bookmarks applicator that overwrites with remote state.
The default bookmarks merging code adds divergence. When replicating from
master to mirror, a bookmark force push could result in divergence on the
mirror during `hg pull` operations. We install our own code that replaces
the complicated merging algorithm with a simple "remote wins" version.
"""
if not ui.configbool(b"hgmo", b"replacebookmarks", False):
return orig(ui, repo, remotemarks, path, trfunc, **kwargs)
localmarks = repo._bookmarks
if localmarks == remotemarks:
return
ui.status(b"remote bookmarks changed; overwriting\n")
localmarks.clear()
for bm, node in remotemarks.items():
localmarks[bm] = bin(node)
tr = trfunc()
localmarks.recordchange(tr)
def servehgmo(orig, ui, repo, *args, **kwargs):
"""Wraps commands.serve to provide --hgmo flag."""
if kwargs.get("hgmo", False):
kwargs["style"] = b"gitweb_mozilla"
kwargs["templates"] = os.path.join(pycompat.bytestr(ROOT), b"hgtemplates")
# ui.copy() is funky. Unless we do this, extension settings get
# lost when calling hg.repository().
ui = ui.copy()
def setconfig(name, paths):
ui.setconfig(
b"extensions",
name,
os.path.join(pycompat.bytestr(ROOT), b"hgext", *paths),
)
setconfig(b"firefoxreleases", [b"firefoxreleases"])
setconfig(b"pushlog", [b"pushlog"])
setconfig(b"pushlog-feed", [b"pushlog", b"feed.py"])
ui.setconfig(b"web", b"logoimg", b"moz-logo-bw-rgb.svg")
# Since new extensions may have been flagged for loading, we need
# to obtain a new repo instance to a) trigger loading of these
# extensions b) force extensions' reposetup function to run.
repo = hg.repository(ui, repo.root)
return orig(ui, repo, *args, **kwargs)
def pull(orig, repo, remote, *args, **kwargs):
"""Wraps exchange.pull to fetch the remote clonebundles.manifest."""
res = orig(repo, remote, *args, **kwargs)
if not repo.ui.configbool(b"hgmo", b"pullclonebundlesmanifest", False):
return res
has_clonebundles = remote.capable(b"clonebundles")
if not has_clonebundles:
if repo.vfs.exists(b"clonebundles.manifest"):
repo.ui.status(_(b"deleting local clonebundles.manifest\n"))
repo.vfs.unlink(b"clonebundles.manifest")
has_cinnabarclone = remote.capable(b"cinnabarclone")
if not has_cinnabarclone:
if repo.vfs.exists(b"cinnabar.manifest"):
repo.ui.status(_(b"deleting local cinnabar.manifest\n"))
repo.vfs.unlink(b"cinnabar.manifest")
if has_clonebundles or has_cinnabarclone:
with repo.wlock():
if has_clonebundles:
repo.ui.status(_(b"pulling clonebundles manifest\n"))
manifest = remote._call(b"clonebundles")
repo.vfs.write(b"clonebundles.manifest", manifest)
if has_cinnabarclone:
repo.ui.status(_(b"pulling cinnabarclone manifest\n"))
manifest = remote._call(b"cinnabarclone")
repo.vfs.write(b"cinnabar.manifest", manifest)
return res
PACKED_V1 = b"BUNDLESPEC=none-packed1"
PACKED_V2 = b"BUNDLESPEC=none-v2;stream=v2"
def stream_clone_key(manifest_entry):
"""Key function to prioritize stream clone bundles,
preferring newer stream clones over older.
"""
if PACKED_V2 in manifest_entry:
return 0
if PACKED_V1 in manifest_entry:
return 1
return 2
def filter_manifest_for_region(manifest, region):
"""Filter a clonebundles manifest by region
The returned manifest will be sorted to prioritize clone bundles
for the specified cloud region.
"""
filtered = [l for l in manifest.data.splitlines() if region in l]
# No manifest entries for this region.
if not filtered:
return manifest
# We prioritize stream clone bundles to cloud clients because they are
# the fastest way to clone and we want our automation to be fast.
filtered = sorted(filtered, key=stream_clone_key)
# include cdn urls as a fallback (cloud bucket may only have stream bundles)
cdn = [l for l in manifest.data.splitlines() if b"cdn=true" in l]
filtered.extend(cdn)
# We got a match. Write out the filtered manifest (with a trailing newline).
filtered.append(b"")
return b"\n".join(filtered)
CLOUD_REGION_MAPPING = {
"aws": b"ec2region",
"gce": b"gceregion",
}
def cloud_region_specifier(instance_data):
"""Return the cloud region specifier that corresponds to the given
instance data object.
Instance data object format can be found at:
https://cloudinit.readthedocs.io/en/latest/topics/instancedata.html
"""
cloud_data_v1 = instance_data["v1"]
# Some of the mirrors were spun up using an ancient version of
# cloud-init. In case `cloud_name` isn't available, we should look
# for `cloud-name`.
cloud_name = cloud_data_v1.get("cloud_name")
if not cloud_name:
cloud_name = cloud_data_v1["cloud-name"]
return b"%(cloud)s=%(region)s" % {
b"cloud": CLOUD_REGION_MAPPING[cloud_name],
b"region": pycompat.bytestr(cloud_data_v1["region"]),
}
def get_current_azure_region(
azure_ip_path: bytes, source_ip: Union[ipaddress.IPv4Address, ipaddress.IPv6Address]
) -> Optional[str]:
"""Return the current Azure region if one can be found from the source IP address."""
with open(azure_ip_path, "rb") as f:
azure_ips = json.load(f)
for service in azure_ips["values"]:
properties = service["properties"]
# If we can't determine the region for this Azure service, skip it.
if "region" not in properties or not properties["region"]:
continue
for subnet in properties["addressPrefixes"]:
network = ipaddress.ip_network(subnet)
if source_ip in network:
return properties["region"]
return None
def processbundlesmanifest(orig, repo, proto, *args, **kwargs):
"""Wraps `wireprotov1server.clonebundles` and `wireprotov1server.clonebundles_2`.
We examine source IP addresses and advertise URLs for the same
AWS region if the source is in AWS.
"""
# Call original fxn wireproto.clonebundles
manifest = orig(repo, proto, *args)
if not isinstance(proto, webproto):
return manifest
# Get path for Mozilla, AWS, GCP network prefixes. Return if missing
mozpath = repo.ui.config(b"hgmo", b"mozippath")
awspath = repo.ui.config(b"hgmo", b"awsippath")
gcppath = repo.ui.config(b"hgmo", b"gcpippath")
azureippath = repo.ui.config(b"hgmo", b"azureippath")
if not awspath and not mozpath and not gcppath and not azureippath:
return manifest
# Mozilla's load balancers add a X-Cluster-Client-IP header to identify the
# actual source IP, so prefer it. And if the request comes through Fastly,
# prefer the Fastly-Client-IP header it adds so we take into account the
# actual client.
sourceip = proto._req.headers.get(
b"Fastly-Client-IP",
proto._req.headers.get(
b"X-CLUSTER-CLIENT-IP", proto._req.rawenv.get(b"REMOTE_ADDR")
),
)
if not sourceip:
return manifest
else:
try:
sourceip = ipaddress.ip_address(pycompat.unicode(pycompat.sysstr(sourceip)))
except ValueError:
# XXX return 400?
return manifest
# If the request originates from a private IP address, and we are running on
# a cloud instance, we should be serving traffic to private instances in CI.
# Grab the region from the instance_data.json object and serve the correct
# manifest accordingly
instance_data_path = repo.ui.config(b"hgmo", b"instance-data-path")
if (
instance_data_path
and sourceip.is_private
and os.path.exists(instance_data_path)
):
with open(instance_data_path, "rb") as fh:
instance_data = json.load(fh)
return filter_manifest_for_region(
manifest, cloud_region_specifier(instance_data)
)
# If the AWS IP file path is set and some line in the manifest includes an ec2 region,
# we will check if the request came from AWS to server optimized bundles.
if awspath and b"ec2region=" in manifest.data:
try:
with open(awspath, "rb") as fh:
awsdata = json.load(fh)
for ipentry in awsdata["prefixes"]:
network = ipaddress.IPv4Network(ipentry["ip_prefix"])
if sourceip not in network:
continue
region = ipentry["region"]
return filter_manifest_for_region(
manifest, b"ec2region=%s" % pycompat.bytestr(region)
)
except Exception as e:
repo.ui.log(b"hgmo", b"exception filtering AWS bundle source IPs: %s\n", e)
# If the GCP IP file path is set and some line in the manifest includes a GCE region,
# we will check if the request came from GCP to serve optimized bundles
if gcppath and b"gceregion=" in manifest.data:
try:
with open(gcppath, "rb") as f:
gcpdata = json.load(f)
for ipentry in gcpdata["prefixes"]:
if "ipv4Prefix" in ipentry:
network = ipaddress.IPv4Network(ipentry["ipv4Prefix"])
elif "ipv6Prefix" in ipentry:
network = ipaddress.IPv6Network(ipentry["ipv6Prefix"])
if sourceip not in network:
continue
region = ipentry["scope"]
return filter_manifest_for_region(
manifest, b"gceregion=%s" % pycompat.bytestr(region)
)
except Exception as e:
repo.ui.log(b"hgmo", b"exception filtering GCP bundle source IPs: %s\n", e)
if azureippath and b"azureregion=" in manifest.data:
try:
azure_region = get_current_azure_region(azureippath, sourceip)
if azure_region:
return filter_manifest_for_region(
manifest, b"azureregion=%s" % pycompat.bytestr(azure_region)
)
except Exception as e:
repo.ui.log(
b"hgmo", b"exception filtering Azure bundle source IPs: %s\n", e
)
# Determine if source IP is in a Mozilla network, as we stream results to those addresses
if mozpath:
try:
with open(mozpath, "r") as fh:
mozdata = fh.read().splitlines()
for ipentry in mozdata:
network = ipaddress.IPv4Network(
pycompat.unicode(pycompat.sysstr(ipentry))
)
# If the source IP is from a Mozilla network, prioritize stream bundles
if sourceip in network:
origlines = sorted(manifest.data.splitlines(), key=stream_clone_key)
origlines.append(b"")
return b"\n".join(origlines)
except Exception as e:
repo.ui.log(b"hgmo", b"exception filtering bundle source IPs: %s\n", e)
return manifest
return manifest
def filelog(orig, web):
"""Wraps webcommands.filelog to provide pushlog metadata to template."""
req = web.req
tmpl = web.templater(req)
# Template wrapper to add pushlog data to entries when the template is
# evaluated.
class tmplwrapper(tmpl.__class__):
def __call__(self, *args, **kwargs):
for entry in kwargs.get("entries", []):
push = web.repo.pushlog.pushfromnode(bin(entry[b"node"]))
if push:
entry[b"pushid"] = push.pushid
entry[b"pushdate"] = dateutil.makedate(push.when)
else:
entry[b"pushid"] = None
entry[b"pushdate"] = None
return super(tmplwrapper, self).__call__(*args, **kwargs)
orig_class = tmpl.__class__
try:
if hasattr(web.repo, "pushlog"):
tmpl.__class__ = tmplwrapper
web.tmpl = tmpl
return orig(web)
finally:
tmpl.__class__ = orig_class
def hgwebfastannotate(orig, req, fctx, ui):
import hgext.fastannotate.support as fasupport
diffopts = webutil.difffeatureopts(req, ui, b"annotate")
return fasupport._doannotate(fctx, diffopts=diffopts)
def extsetup(ui):
extensions.wrapfunction(exchange, "pull", pull)
extensions.wrapfunction(webutil, "changesetentry", changesetentry)
extensions.wrapfunction(webutil, "changelistentry", changelistentry)
extensions.wrapfunction(bookmarks, "updatefromremote", bmupdatefromremote)
extensions.wrapfunction(webcommands, "filelog", filelog)
# Install IP filtering for bundle URLs.
# Build-in command from core Mercurial.
extensions.wrapcommand(
wireprotov1server.commands, b"clonebundles", processbundlesmanifest
)
extensions.wrapcommand(
wireprotov1server.commands, b"clonebundles_manifest", processbundlesmanifest
)
entry = extensions.wrapcommand(commands.table, b"serve", servehgmo)
entry[1].append(
(b"", b"hgmo", False, b"Run a server configured like hg.mozilla.org")
)
webcommands.info = webcommands.webcommand(b"info")(infowebcommand)
webcommands.headdivergence = webcommands.webcommand(b"headdivergence")(
headdivergencewebcommand
)
webcommands.automationrelevance = webcommands.webcommand(b"automationrelevance")(
automationrelevancewebcommand
)
webcommands.pushchangedfiles = webcommands.webcommand(b"pushchangedfiles")(
push_changed_files_webcommand
)
webcommands.isancestor = webcommands.webcommand(b"isancestor")(isancestorwebcommand)
webcommands.repoinfo = webcommands.webcommand(b"repoinfo")(repoinfowebcommand)
def reposetup(ui, repo):
fasupport = import_module("hgext.fastannotate.support")
if not fasupport:
return
# fastannotate in Mercurial 4.8 has buggy hgweb support. We always remove
# its monkeypatch if present.
try:
extensions.unwrapfunction(webutil, "annotate", fasupport._hgwebannotate)
except ValueError:
pass
# And we install our own if fastannotate is enabled.
try:
fastannotate = extensions.find(b"fastannotate")
except KeyError:
fastannotate = None
if fastannotate and b"hgweb" in ui.configlist(b"fastannotate", b"modes"):
# Guard against recursive chaining, since we're in reposetup().
try:
extensions.unwrapfunction(webutil, "annotate", hgwebfastannotate)
except ValueError:
pass
extensions.wrapfunction(webutil, "annotate", hgwebfastannotate)