Setup and use of Mercurial repository on Fedora

The following is a documentation HOWTO on setting up Mercurial repository on Fedora with HTTPS + LDAP.

Please read the full documentation before you try it!

Pre-requisites

  # yum install httpd mod_python mod_ssl mercurial mercurial-hgk

Create repository directory

  # mkdir /var/hg/repos

Create test repository

  # cd /var/hg/repos
  # mkdir hgtest; cd hgtest
  # hg init
  # echo 'Hello world' > README
  # hg add README
  # hg commit -m 'This is the first change to my test repository'
  # chown -R apache:apache .

Create configuration files

Create .hgrc at /var/hg/repos/hgtest/.hg/:

[web]
contact = Qvantel
description = testing Mercurial web
style = gitweb
allow_push = *
push_ssl = false

Create modpython_gateway.py at /var/hg/repos/:

"""
WSGI wrapper for mod_python. Requires Python 2.2 or greater.


Example httpd.conf section for a CherryPy app called "mcontrol":

<Location /mcontrol>
    SetHandler python-program
    PythonFixupHandler mcontrol.cherry::startup
    PythonHandler modpython_gateway::handler
    PythonOption wsgi.application cherrypy._cpwsgi::wsgiApp
</Location>

Some WSGI implementations assume that the SCRIPT_NAME environ variable will
always be equal to "the root URL of the app"; Apache probably won't act as
you expect in that case. You can add another PythonOption directive to tell
modpython_gateway to force that behavior:

    PythonOption SCRIPT_NAME /mcontrol

Some WSGI applications need to be cleaned up when Apache exits. You can
register a cleanup handler with yet another PythonOption directive:

    PythonOption wsgi.cleanup module::function

The module.function will be called with no arguments on server shutdown,
once for each child process or thread.
"""

import traceback

from mod_python import apache


class InputWrapper(object):

    def __init__(self, req):
        self.req = req

    def close(self):
        pass

    def read(self, size=-1):
        return self.req.read(size)

    def readline(self, size=-1):
        return self.req.readline(size)

    def readlines(self, hint=-1):
        return self.req.readlines(hint)

    def __iter__(self):
        line = self.readline()
        while line:
            yield line
            # Notice this won't prefetch the next line; it only
            # gets called if the generator is resumed.
            line = self.readline()


class ErrorWrapper(object):

    def __init__(self, req):
        self.req = req

    def flush(self):
        pass

    def write(self, msg):
        self.req.log_error(msg)

    def writelines(self, seq):
        self.write(''.join(seq))


bad_value = ("You must provide a PythonOption '%s', either 'on' or 'off', "
             "when running a version of mod_python < 3.1")


class Handler:

    def __init__(self, req):
        self.started = False

        options = req.get_options()

        # Threading and forking
        try:
            q = apache.mpm_query
            threaded = q(apache.AP_MPMQ_IS_THREADED)
            forked = q(apache.AP_MPMQ_IS_FORKED)
        except AttributeError:
            threaded = options.get('multithread', '').lower()
            if threaded == 'on':
                threaded = True
            elif threaded == 'off':
                threaded = False
            else:
                raise ValueError(bad_value % "multithread")

            forked = options.get('multiprocess', '').lower()
            if forked == 'on':
                forked = True
            elif forked == 'off':
                forked = False
            else:
                raise ValueError(bad_value % "multiprocess")

        env = self.environ = dict(apache.build_cgi_env(req))

        if 'SCRIPT_NAME' in options:
            # Override SCRIPT_NAME and PATH_INFO if requested.
            env['SCRIPT_NAME'] = options['SCRIPT_NAME']
            env['PATH_INFO'] = req.uri[len(options['SCRIPT_NAME']):]

        env['wsgi.input'] = InputWrapper(req)
        env['wsgi.errors'] = ErrorWrapper(req)
        env['wsgi.version'] = (1,0)
        env['wsgi.run_once'] = False
        if env.get("HTTPS") in ('yes', 'on', '1'):
            env['wsgi.url_scheme'] = 'https'
        else:
            env['wsgi.url_scheme'] = 'http'
        env['wsgi.multithread']  = threaded
        env['wsgi.multiprocess'] = forked

        self.request = req

    def run(self, application):
        try:
            result = application(self.environ, self.start_response)
            for data in result:
                self.write(data)
            if not self.started:
                self.request.set_content_length(0)
            if hasattr(result, 'close'):
                result.close()
        except:
            traceback.print_exc(None, self.environ['wsgi.errors'])
            if not self.started:
                self.request.status = 500
                self.request.content_type = 'text/plain'
                data = "A server error occurred. Please contact the administrator."
                self.request.set_content_length(len(data))
                self.request.write(data)

    def start_response(self, status, headers, exc_info=None):
        if exc_info:
            try:
                if self.started:
                    raise exc_info[0], exc_info[1], exc_info[2]
            finally:
                exc_info = None

        self.request.status = int(status[:3])

        for key, val in headers:
            if key.lower() == 'content-length':
                self.request.set_content_length(int(val))
            elif key.lower() == 'content-type':
                self.request.content_type = val
            else:
                self.request.headers_out.add(key, val)

        return self.write

    def write(self, data):
        if not self.started:
            self.started = True
        self.request.write(data)


startup = None
cleanup = None

def handler(req):
    # Run a startup function if requested.
    global startup
    if not startup:
        func = req.get_options().get('wsgi.startup')
        if func:
            module_name, object_str = func.split('::', 1)
            module = __import__(module_name, globals(), locals(), [''])
            startup = apache.resolve_object(module, object_str)
            startup(req)

    # Register a cleanup function if requested.
    global cleanup
    if not cleanup:
        func = req.get_options().get('wsgi.cleanup')
        if func:
            module_name, object_str = func.split('::', 1)
            module = __import__(module_name, globals(), locals(), [''])
            cleanup = apache.resolve_object(module, object_str)
            def cleaner(data):
                cleanup()
            try:
                # apache.register_cleanup wasn't available until 3.1.4.
                apache.register_cleanup(cleaner)
            except AttributeError:
                req.server.register_cleanup(req, cleaner)

    # Import the wsgi 'application' callable and pass it to Handler.run
    modname, objname = req.get_options()['wsgi.application'].split('::', 1)
    module = __import__(modname, globals(), locals(), [''])
    app = getattr(module, objname)
    Handler(req).run(app)

    # status was set in Handler; always return apache.OK
    return apache.OK

Create hgwebdir.py at /var/hg/repos/:

#!/usr/bin/env python
#
# $Id: hgwebdir.py 1478 2008-11-21 11:09:22Z matthew $
#
# An example CGI script to export multiple hgweb repos, edit as necessary

# adjust python path if not a system-wide install:
#import sys
#sys.path.insert(0, "/path/to/python/lib")

# Uncomment to send python tracebacks to the browser if an error occurs:
import cgitb
cgitb.enable()

# enable importing on demand to reduce startup time
# from mercurial import demandimport; demandimport.enable()
# from mercurial import demandload; demandload.enable()

# If you'd like to serve pages with UTF-8 instead of your default
# locale charset, you can do so by uncommenting the following lines.
# Note that this will cause your .hgrc files to be interpreted in
# UTF-8 and all your repo files to be displayed using UTF-8.
#
#import os
#os.environ["HGENCODING"] = "UTF-8"

from mercurial.hgweb.hgweb_mod import hgweb
from mercurial.hgweb.hgwebdir_mod import hgwebdir
from mercurial.hgweb.request import wsgiapplication

# The config file looks like this.  You can have paths to individual
# repos, collections of repos in a directory tree, or both.
#
# [paths]
# virtual/path = /real/path
# virtual/path = /real/path
#
# [collections]
# /prefix/to/strip/off = /root/of/tree/full/of/repos
#
# collections example: say directory tree /foo contains repos /foo/bar,
# /foo/quux/baz.  Give this config section:
#   [collections]
#   /foo = /foo
# Then repos will list as bar and quux/baz.
#
# Alternatively you can pass a list of ('virtual/path', '/real/path') tuples
# or use a dictionary with entries like 'virtual/path': '/real/path'

# application = hgwebdir('hgweb.config')
# wsgicgi.launch(application)

def make_web_app():
    return hgwebdir("/var/hg/hgweb.config")

def gateway(environ, start_response):
    app = wsgiapplication(make_web_app)
    return app(environ, start_response)

#
### EOF: $HeadURL: http://aventinesolutions.mine.nu/repos-nossl/config/httpd/hgwebdir.py $
#

Create /var/hg/hgweb.config as follows:

[collections]
/var/hg/repos = /var/hg/repos

Make sure /var/hg is owned by apache:

  # chown -R apache:apache /var/hg

Create /etc/httpd/conf.d/hg.conf:

<Location /hg>
  PythonPath "sys.path + [ '/var/hg/repos' ]"
  PythonDebug On
  SetHandler mod_python
  PythonHandler modpython_gateway::handler
  PythonOption SCRIPT_NAME /hg
  PythonOption wsgi.application hgwebdir::gateway

  AuthType Basic
  AuthBasicProvider ldap
  AuthzLDAPAuthoritative Off
  AuthName "Mercurial Repository"
  AuthLDAPBIndDN "cn=ldapusr, dc=company, dc=com"
  AuthLDAPBindPassword abcdefgh
  AuthLDAPGroupAttributeIsDN off
  AuthLDAPGroupAttribute memberUid
  AuthLDAPURL ldap://10.0.0.1:389/ou=People,dc=company,dc=com?uid
  require ldap-group cn=internal, ou=group, dc=company, dc=com
</Location>

10.0.0.1 is assumed to have the LDAP server in the above. If you want a specific LDAP group, say, services, to only have access to the hgtest repository, apart from the above you can create a project specific entry using:

<Location /hg/hgtest>
  PythonPath "sys.path + [ '/var/hg/repos' ]"
  PythonDebug On
  SetHandler mod_python
  PythonHandler modpython_gateway::handler
  PythonOption SCRIPT_NAME /hg
  PythonOption wsgi.application hgwebdir::gateway

  AuthType Basic
  AuthBasicProvider ldap
  AuthzLDAPAuthoritative Off
  AuthName "Mercurial Repository"
  AuthLDAPBIndDN "cn=ldapusr, dc=company, dc=com"
  AuthLDAPBindPassword abcdefgh
  AuthLDAPGroupAttributeIsDN off
  AuthLDAPGroupAttribute memberUid
  AuthLDAPURL ldap://10.0.0.1:389/ou=People,dc=company,dc=com?uid
  require ldap-group cn=services, ou=group, dc=company, dc=com
</Location>

Append the following to /etc/httpd/conf/httpd.conf:

<VirtualHost *:80>
        ServerName      ferrari
        DocumentRoot    /var/hg/repos
        SSLEngine       off

        Redirect        permanent / https://ferrari/

        <Directory /var/hg/repos>
                ErrorDocument   403 "Use SSL, please"
                Order           allow,deny
                Allow           from none
                Deny            from all
        </Directory>
</VirtualHost>

ferrari is the hostname of the system. Give executable permission for the script files.

  # cd /var/hg/repos
  # chmod 755 hgwebdir.py modpython_gateway.py

Firewall settings

Make sure:

Set SELINUX=disabled in /etc/selinux/config. Reboot. You can test if it is disabled from the output of:

  $ sestatus
  # iptables -F
  # chkconfig iptables off

Test

Start the server!

  # service httpd start

You can now open the Mercurial web on the browser through https://ferrari/hg/.

You can checkout code using:

  $ hg clone https://ferrari/hg/hgtest

Mail notification

Sendmail should be running by default on Fedora. You can enable e-mail notification by enabling NotifyExtension.

In each of the project repository, at /var/hg/repos/project-name/.hg/hgrc file, append the following:

[extensions]
hgext.notify =

[hooks]
# Enable either changegroup or incoming.
# changegroup will send one email for each push,
# whereas incoming sends one email per changeset.

changegroup.notify = python:hgext.notify.hook
#incoming.notify = python:hgext.notify.hook

[email]
from = your@email.address

[smtp]
host = localhost
# Optional options:
# username = joeuser
# password = secret
# port = 25
# tls = true
# local_hostname = me.example.com

# presently it is necessary to specify the baseurl for the notify
# extension to work.  It can be a dummy value if your repo isn't
# available via http
[web]
baseurl = https://ferrari/hg/hgtest

[notify]
# multiple sources can be specified as a whitespace separated list
sources = serve push pull bundle
# set this to False when you're ready for mail to start sending
test = False

# you can override the changeset template here, if you want.
# If it doesn't start with \n it may confuse the email parser.
# here's an example that makes the changeset template look more like hg log:
template = \ndetails:   {baseurl}{webroot}/rev/{node|short}\nchangeset: {rev}:{node|short}\nuser:      {author}\ndate:      {date|date}\ndescription:\n{desc}\n
# max lines of diffs to include (0=none, -1=all)
maxdiff = -1

[usersubs]
# key is subscriber email, value is comma-separated list of glob patterns
user@email.address = *

[reposubs]

Whenever you do a hg push, an e-mail will be sent to the ones mentioned in the usersubs.

Reference