merge dns-new branch into default
authorTero Marttila <terom@paivola.fi>
Sat, 21 Dec 2013 22:43:38 +0200
changeset 97 bfdf1633b2a1
parent 80 b58236f9ea7b (current diff)
parent 96 bed4765fc56f (diff)
child 98 a3734856e0fa
merge dns-new branch into default
bin/check-dhcp-hosts
bin/expand-zone
bin/process-zone
bin/update-serial
lib/update.logging
--- a/.hgignore	Fri May 10 00:05:25 2013 +0300
+++ b/.hgignore	Sat Dec 21 22:43:38 2013 +0200
@@ -1,13 +1,9 @@
-# temp
-\.sw[op]$
-\.pyc$
+syntax:glob
 
-# output
-^zones/
-^var/
+.*.swo
+.*.swp
 
-# data
-^settings/
+etc/
+opt/
+var/
 
-# stuff
-^old/
--- a/bin/check-dhcp-hosts	Fri May 10 00:05:25 2013 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,201 +0,0 @@
-#!/usr/bin/env python
-
-"""
-    Go through a dhcp conf file looking for fixed-address stanzas, and make sure that they are valid.
-"""
-
-__version__ = '0.0.1-dev'
-
-import optparse
-import codecs
-import logging
-
-import socket
-
-log = logging.getLogger('main')
-
-# command-line options, global state
-options = None
-
-def parse_options (argv) :
-    """
-        Parse command-line arguments.
-    """
-
-    prog = argv[0]
-
-    parser = optparse.OptionParser(
-            prog        = prog,
-            usage       = '%prog: [options]',
-            version     = __version__,
-
-            # module docstring
-            description = __doc__,
-    )
-
-    # logging
-    general = optparse.OptionGroup(parser, "General Options")
-
-    general.add_option('-q', '--quiet',     dest='loglevel', action='store_const', const=logging.ERROR, help="Less output")
-    general.add_option('-v', '--verbose',   dest='loglevel', action='store_const', const=logging.INFO,  help="More output")
-    general.add_option('-D', '--debug',     dest='loglevel', action='store_const', const=logging.DEBUG, help="Even more output")
-
-    parser.add_option_group(general)
-
-    # input/output
-    parser.add_option('-c', '--input-charset',  metavar='CHARSET',  default='utf-8', 
-            help="Encoding used for input files")
-
-    # 
-    parser.add_option('--doctest',              action='store_true',
-            help="Run module doctests")
-
-    # defaults
-    parser.set_defaults(
-        loglevel            = logging.WARN,
-    )
-    
-    # parse
-    options, args = parser.parse_args(argv[1:])
-
-    # configure
-    logging.basicConfig(
-        format  = prog + ': %(name)s: %(levelname)s %(funcName)s : %(message)s',
-        level   = options.loglevel,
-    )
-
-    return options, args
-
-def parse_fixedaddrs (file) :
-    """
-        Go through lines in given .conf file, looking for fixed-address stanzas.
-    """
-
-    filename = file.name
-
-    for lineno, line in enumerate(file) :
-        # comments?
-        if '#' in line :
-            line, comment = line.split('#', 1)
-
-        else :
-            comment = None
-
-        # whitespace
-        line = line.strip()
-
-        if not line :
-            # empty
-            continue
-       
-        # grep
-        if 'fixed-address' in line :
-            # great parsing :)
-            fixedaddr = line.replace('fixed-address', '').replace(';', '').strip()
-
-            log.debug("%s:%d: %s: %s", filename, lineno, fixedaddr, line)
-        
-            yield lineno, fixedaddr
-
-def resolve_addr (addr, af=socket.AF_INET, socktype=socket.SOCK_STREAM) :
-    """
-        Resolve given address for given AF_INET, returning a list of resolved addresses.
-
-        Raises an Exception if failed.
-
-        >>> resolve_addr('127.0.0.1')
-        ['127.0.0.1']
-    """
-
-    if not addr :
-        raise Exception("Empty addr: %r", addr)
-
-    # resolve
-    result = socket.getaddrinfo(addr, None, af, socktype)
-   
-    #log.debug("%s: %s", addr, result)
-
-    # addresses
-    addrs = list(sorted(set(sockaddr[0] for family, socktype, proto, canonname, sockaddr in result)))
-
-    return addrs
-
-def check_file_hosts (file) :
-    """
-        Check all fixed-address parameters in given file.
-    """
-
-    filename = file.name
-    fail = 0
-
-    for lineno, addr in parse_fixedaddrs(file) :
-        # lookup
-        try :
-            resolved = resolve_addr(addr)
-
-        except Exception as e:
-            log.warning("%s:%d: failed to resolve: %s: %s", filename, lineno, addr, e)
-            fail += 1
-
-        else :
-            log.debug("%s:%d: %s: %r", filename, lineno, addr, resolved)
-
-    return fail
-
-def open_file (path, mode, charset) :
-    """
-        Open unicode-enabled file from path, with - using stdio.
-    """
-
-    if path == '-' :
-        # use stdin/out based on mode
-        stream, func = {
-            'r':    (sys.stdin, codecs.getreader),
-            'w':    (sys.stdout, codecs.getwriter),
-        }[mode[0]]
-
-        # wrap
-        return func(charset)(stream)
-
-    else :
-        # open
-        return codecs.open(path, mode, charset)
-
-def main (argv) :
-    global options
-    
-    options, args = parse_options(argv)
-
-    if options.doctest :
-        import doctest
-        fail, total = doctest.testmod()
-        return fail
-
-    if args :
-        # open files
-        input_files = [open_file(path, 'r', options.input_charset) for path in args]
-
-    else :
-        # default to stdout
-        input_files = [open_file('-', 'r', options.input_charset)]
-   
-    # process zone data
-    for file in input_files :
-        log.info("Reading zone: %s", file)
-    
-        fail = check_file_hosts(file)
-
-        if fail :
-            log.warn("DHCP hosts check failed: %d", fail)
-            return 2
-
-        else :
-            log.info("DHCP hosts check OK")
-
-    return 0
-
-if __name__ == '__main__':
-    import sys
-
-    sys.exit(main(sys.argv))
-
--- a/bin/expand-zone	Fri May 10 00:05:25 2013 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,170 +0,0 @@
-#!/usr/bin/env python
-# vim: set ft=python :
-
-"""
-    Process zonefiles with template expansions.
-"""
-
-__version__ = '0.0.1-dev'
-
-import optparse
-import codecs
-import os.path
-from datetime import datetime
-import logging
-
-log = logging.getLogger()
-
-# command-line options, global state
-options = None
-
-def parse_options (argv) :
-    """
-        Parse command-line arguments.
-    """
-
-    parser = optparse.OptionParser(
-            prog        = argv[0],
-            usage       = '%prog: [options]',
-            version     = __version__,
-
-            # module docstring
-            description = __doc__,
-    )
-
-    # logging
-    general = optparse.OptionGroup(parser, "General Options")
-
-    general.add_option('-q', '--quiet',     dest='loglevel', action='store_const', const=logging.ERROR, help="Less output")
-    general.add_option('-v', '--verbose',   dest='loglevel', action='store_const', const=logging.INFO,  help="More output")
-    general.add_option('-D', '--debug',     dest='loglevel', action='store_const', const=logging.DEBUG, help="Even more output")
-
-    parser.add_option_group(general)
-
-    parser.add_option('-c', '--input-charset',  metavar='CHARSET',  default='utf-8', 
-            help="Encoding used for input files")
-
-    parser.add_option('-o', '--output',         metavar='FILE',     default='-',
-            help="Write to output file; default stdout")
-
-    parser.add_option('--output-charset',       metavar='CHARSET',  default='utf-8', 
-            help="Encoding used for output files")
-
-    parser.add_option('--expand',               metavar='NAME=VALUE', action='append',
-            help="Expand given template variable in zone")
-
-    parser.add_option('--serial',               metavar='FILE',
-            help="Read/expand serial from given .serial file")
-
-    # defaults
-    parser.set_defaults(
-        loglevel            = logging.WARN,
-        expand              = [],
-    )
-    
-    # parse
-    options, args = parser.parse_args(argv[1:])
-
-    # configure
-    logging.basicConfig(
-        format  = '%(processName)s: %(name)s: %(levelname)s %(funcName)s : %(message)s',
-        level   = options.loglevel,
-    )
-
-    return options, args
-
-def process_file (file, expansions) :
-    """
-        Process file, expanding lines.
-    """
-
-    for line in file :
-        line = line.format(**expansions)
-
-        yield line
-
-def write_lines (file, lines, suffix='\n') :
-    for line in lines :
-        file.write(line + suffix)
-
-def open_file (path, mode, charset) :
-    """
-        Open unicode-enabled file from path, with - using stdio.
-    """
-
-    if path == '-' :
-        # use stdin/out based on mode
-        stream, func = {
-            'r':    (sys.stdin, codecs.getreader),
-            'w':    (sys.stdout, codecs.getwriter),
-        }[mode[0]]
-
-        # wrap
-        return func(charset)(stream)
-
-    else :
-        # open
-        return codecs.open(path, mode, charset)
-
-def process_serial (path) :
-    """
-        Use serial number from given file.
-
-        Returns the new serial as a string.
-    """
-
-    if not os.path.exists(path) :
-        raise Exception("Given --serial does not exist: %s" % path)
-
-    return open(path).read().strip()
-
-def parse_expand (expand) :
-    """
-        Parse an --expand foo=bar to (key, value)
-    """
-
-    key, value = expand.split('=', 1)
-
-    return key, value
-
-def main (argv) :
-    global options
-    
-    options, args = parse_options(argv)
-
-    # expands
-    expand = dict(parse_expand(expand) for expand in options.expand)
-
-    # serial?
-    if options.serial :
-        serial = process_serial(options.serial)
-
-        expand['serial'] = serial
-
-    # input
-    if args :
-        # open files
-        input_files = [open_file(path, 'r', options.input_charset) for path in args]
-
-    else :
-        # default to stdout
-        input_files = [open_file('-', 'r', options.input_charset)]
-   
-    # process
-    lines = []
-
-    for file in input_files :
-        log.info("Reading zone: %s", file)
-
-        lines += list(process_file(file, expand))
-
-    # output
-    output = open_file(options.output, 'w', options.output_charset)
-    write_lines(output, lines, suffix='')
-
-    return 0
-
-if __name__ == '__main__':
-    import sys
-
-    sys.exit(main(sys.argv))
--- a/bin/process-zone	Fri May 10 00:05:25 2013 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,833 +0,0 @@
-#!/usr/bin/env python
-
-"""
-    Process zonefiles.
-"""
-
-__version__ = '0.0.1-dev'
-
-import optparse
-import codecs
-from datetime import datetime
-import logging
-
-import ipaddr
-
-log = logging.getLogger('main')
-
-# command-line options, global state
-options = None
-
-def parse_options (argv) :
-    """
-        Parse command-line arguments.
-    """
-
-    prog = argv[0]
-
-    parser = optparse.OptionParser(
-            prog        = prog,
-            usage       = '%prog: [options]',
-            version     = __version__,
-
-            # module docstring
-            description = __doc__,
-    )
-
-    # logging
-    general = optparse.OptionGroup(parser, "General Options")
-
-    general.add_option('-q', '--quiet',     dest='loglevel', action='store_const', const=logging.ERROR, help="Less output")
-    general.add_option('-v', '--verbose',   dest='loglevel', action='store_const', const=logging.INFO,  help="More output")
-    general.add_option('-D', '--debug',     dest='loglevel', action='store_const', const=logging.DEBUG, help="Even more output")
-
-    parser.add_option_group(general)
-
-    # input/output
-    parser.add_option('-c', '--input-charset',  metavar='CHARSET',  default='utf-8', 
-            help="Encoding used for input files")
-
-    parser.add_option('-o', '--output',         metavar='FILE',     default='-',
-            help="Write to output file; default stdout")
-
-    parser.add_option('--output-charset',       metavar='CHARSET',  default='utf-8', 
-            help="Encoding used for output files")
-
-    # check stage
-    parser.add_option('--check-hosts',          action='store_true',
-            help="Check that host/IPs are unique. Use --quiet to silence warnings, and test exit status")
-
-    parser.add_option('--check-exempt',         metavar='HOST', action='append',
-            help="Allow given names to have multiple records")
-
-    # meta stage
-    parser.add_option('--meta-zone',            action='store_true',
-            help="Generate host metadata zone; requires --input-line-date")
-
-    parser.add_option('--meta-ignore',          metavar='HOST', action='append',
-            help="Ignore given hostnames in metadata output")
-
-    parser.add_option('--input-line-date',      action='store_true',
-            help="Parse timestamp prefix from each input line (e.g. `hg blame | ...`)")
-
-    # forward stage
-    parser.add_option('--forward-zone',         action='store_true', 
-            help="Generate forward zone")
-
-    parser.add_option('--forward-txt',          action='store_true',
-            help="Generate TXT records for forward zone")
-
-    parser.add_option('--forward-mx',           metavar='MX',
-            help="Generate MX records for forward zone")
-
-    # reverse stage
-    parser.add_option('--reverse-domain',       metavar='DOMAIN',
-            help="Domain to use for hosts in reverse zone")
-
-    parser.add_option('--reverse-zone',         metavar='NET',
-            help="Generate forward zone for given subnet (x.z.y | a:b:c:d)")
-
-    # 
-    parser.add_option('--doctest',              action='store_true',
-            help="Run module doctests")
-
-    # defaults
-    parser.set_defaults(
-        loglevel            = logging.WARN,
-
-        # XXX: combine
-        check_exempt        = [],
-        meta_ignore         = [],
-    )
-    
-    # parse
-    options, args = parser.parse_args(argv[1:])
-
-    # configure
-    logging.basicConfig(
-        format  = prog + ': %(name)s: %(levelname)s %(funcName)s : %(message)s',
-        level   = options.loglevel,
-    )
-
-    return options, args
-
-class ZoneError (Exception) :
-    pass
-
-class ZoneLineError (ZoneError) :
-    """
-        ZoneLine-related error
-    """
-
-    def __init__ (self, line, msg, *args, **kwargs) :
-        super(ZoneLineError, self).__init__("%s: %s" % (line, msg.format(*args, **kwargs)))
-
-class ZoneLine (object) :
-    """
-        A line in a zonefile.
-    """
-
-    file = None
-    lineno = None
-
-    # data
-    indent = None # was the line indented?
-    data = None
-    parts = None # split line fields
-
-    # optional
-    timestamp = None
-    comment = None
-
-    PARSE_DATETIME_FORMAT = '%Y-%m-%d'
-
-    @classmethod
-    def parse (cls, file, lineno, line, line_timestamp_prefix=False) :
-        """
-            Parse out given line and build.
-        """
-
-        log.debug("parse: %s:%d: %s", file, lineno, line)
-
-        ts = None
-
-        if line_timestamp_prefix :
-            if ': ' not in line :
-                raise ZoneError("%s:%d: Missing timestamp prefix: %s" % (file, lineno, line))
-
-            # split prefix
-            prefix, line = line.split(': ', 1)
-
-            # parse it out
-            ts = datetime.strptime(prefix, cls.PARSE_DATETIME_FORMAT)
-
-            log.debug("  ts=%r", ts)
-
-        # was line indented?
-        indent = line.startswith(' ') or line.startswith('\t')
-        
-        # strip
-        line = line.strip()
-        
-        log.debug("  indent=%r, line=%r", indent, line)
-
-        # parse comment out?
-        if ';' in line :
-            line, comment = line.split(';', 1)
-
-            line = line.strip()
-            comment = comment.strip()
-
-        else :
-            line = line.strip()
-            comment = None
-        
-        log.debug("  line=%r, comment=%r", line, comment)
-
-        # parse fields
-        if '"' in line :
-            pre, data, post = line.split('"', 2)
-            parts = pre.split() + [data] + post.split()
-           
-        else :
-            parts = line.split()
-
-        log.debug("  parts=%r", parts)
-
-        # build
-        return cls(file, lineno, indent, line, parts, timestamp=ts, comment=comment)
-
-    def __init__ (self, file, lineno, indent, data, parts, timestamp=None, comment=None) :
-        self.file = file
-        self.lineno = lineno
-
-        self.indent = indent
-        self.data = data
-        self.parts = parts
-
-        self.timestamp = timestamp
-        self.comment = comment
-
-    def __str__ (self) :
-        return "{file}:{lineno}".format(file=self.file, lineno=self.lineno)
-
-class ZoneRecord (object) :
-    """
-        A record from a zonefile.
-    """
-
-    # the underlying line
-    line = None
-
-    # possible $ORIGIN context
-    origin = None
-
-    # record fields
-    name = None
-    type = None
-
-    # list of data fields
-    data = None
-
-    # optional
-    ttl = None
-    cls = None
-
-    @classmethod
-    def parse (cls, line, parts=None, origin=None) :
-        """
-            Parse from ZoneLine. Returns None if there is no record on the line..
-        """
-
-        if parts is None :
-            parts = list(line.parts)
-
-        if not parts :
-            # skip
-            return
-        
-        # XXX: indented lines keep name from previous record
-        if line.indent :
-            name = None
-
-        else :
-            name = parts.pop(0)
-        
-        log.debug("  name=%r, origin=%r", name, origin)
-
-        if len(parts) < 2 :
-            raise ZoneLineError(line, "Too few parts to parse: {0!r}", line.data)
-
-        # parse ttl/cls/type
-        ttl = _cls = None
-
-        if parts and parts[0][0].isdigit() :
-            ttl = parts.pop(0)
-
-        if parts and parts[0].upper() in ('IN', 'CH') :
-            _cls = parts.pop(0)
-
-        # always have type
-        type = parts.pop(0)
-
-        # remaining parts are data
-        data = parts
-
-        log.debug("  ttl=%r, cls=%r, type=%r, data=%r", ttl, _cls, type, data)
-
-        return cls(name, type, data,
-            origin  = origin,
-            ttl     = ttl,
-            cls     = _cls,
-            line    = line,
-        )
-
-    def __init__ (self, name, type, data, origin=None, ttl=None, cls=None, line=None, comment=None) :
-        self.name = name
-        self.type = type
-        self.data = data
-        
-        self.ttl = ttl
-        self.cls = cls
-        
-        self.origin = origin
-        self.line = line
-
-        # XXX: within line
-        self._comment = comment
-
-    def build_line (self) :
-        """
-            Construct a zonefile-format line..."
-        """
-
-        # XXX: comment?
-        if self._comment :
-            comment = '\t; ' + self._comment
-        else :
-            comment = ''
-            
-        return u"{name:25} {ttl:4} {cls:2} {type:5} {data}{comment}".format(
-                name    = self.name or '',
-                ttl     = self.ttl or '',
-                cls     = self.cls or '',
-                type    = self.type,
-                data    = ' '.join(unicode(data) for data in self.data),
-                comment = comment,
-        )
-
-    def __str__ (self) :
-        return ' '.join((self.name or '', self.type, ' '.join(self.data)))
-
-class TXTRecord (ZoneRecord) :
-    """
-        TXT record.
-    """
-
-    def __init__ (self, name, text, **opts) :
-        return super(TXTRecord, self).__init__(name, 'TXT', 
-            [u'"{0}"'.format(text.replace('"', '\\"'))], 
-            **opts
-        )
-
-class OffsetValue (object) :
-    def __init__ (self, value) :
-        self.value = value
-
-    def __getitem__ (self, offset) :
-        value = self.value + offset
-
-        #log.debug("OffsetValue: %d[%d] -> %d", self.value, offset, value)
-
-        return value
-
-def parse_generate_field (line, field) :
-    """
-        Parse a $GENERATE lhs/rhs field:
-            $
-            ${<offset>[,<width>[,<base>]]}
-            \$
-            $$
-
-        Returns a wrapper that builds the field-value when called with the index.
-        
-        >>> parse_generate_field(None, "foo")(1)
-        'foo'
-        >>> parse_generate_field(None, "foo-$")(1)
-        'foo-1'
-        >>> parse_generate_field(None, "foo-$$")(1)
-        'foo-$'
-        >>> parse_generate_field(None, "\$")(1)
-        '$'
-        >>> parse_generate_field(None, "10.0.0.${100}")(1)
-        '10.0.0.101'
-        >>> parse_generate_field(None, "foo-${0,2,d}")(1)
-        'foo-01'
-
-    """
-
-    input = field
-    expr = []
-
-    while '$' in field :
-        # defaults
-        offset = 0
-        width = 0
-        base = 'd'
-        escape = False
-
-        # different forms
-        if '${' in field :
-            pre, body = field.split('${', 1)
-            body, post = body.split('}', 1)
-
-            # parse body
-            parts = body.split(',')
-
-            # offset
-            offset = int(parts.pop(0))
-
-            # width
-            if parts :
-                width = int(parts.pop(0))
-
-            # base
-            if parts :
-                base = parts.pop(0)
-            
-            if parts:
-                # fail
-                raise ZoneLineError(line, "extra data in ${...} body: {0!r}", parts)
-
-        elif '$$' in field :
-            pre, post = field.split('$$', 1)
-            escape = True
-
-        elif '\\$' in field :
-            pre, post = field.split('\\$', 1)
-            escape = True
-
-        else :
-            pre, post = field.split('$', 1)
-        
-        expr.append(pre)
-
-        if escape :
-            expr.append('$')
-
-        else :
-            # meta-format
-            fmt = '{value[%d]:0%d%s}' % (offset, width, base)
-
-            log.debug("field=%r -> pre=%r, fmt=%r, post=%r", field, expr, fmt, post)
-
-            expr.append(fmt)
-
-        field = post
-
-    # final
-    if field :
-        expr.append(field)
-    
-    # combine
-    expr = ''.join(expr)
-
-    log.debug("%s: %s", input, expr)
-
-    # processed
-    def value_func (value) :
-        # magic wrapper to implement offsets
-        return expr.format(value=OffsetValue(value))
-    
-    return value_func
-
-def process_generate (line, origin, parts) :
-    """
-        Process a 
-            $GENERATE <start>-<stop>[/<step>] lhs [ttl] [class] type rhs [comment]
-        directive into a series of ZoneResource's.
-    """
-
-    range = parts.pop(0)
-
-    # parse range
-    if '/' in range :
-        range, step = range.split('/')
-        step = int(step)
-    else :
-        step = 1
-
-    start, stop = range.split('-')
-    start = int(start)
-    stop = int(stop)
-
-    log.debug("  range: start=%r, stop=%r, step=%r", start, stop, step)
-
-    # inclusive
-    range = xrange(start, stop + 1, step)
-
-    lhs_func = parse_generate_field(line, parts.pop(0))
-    rhs_func = parse_generate_field(line, parts.pop(-1))
-    body = parts
-
-    for i in range :
-        # build
-        parts = [lhs_func(i)] + body + [rhs_func(i)]
-
-        log.debug(" %03d: %r", i, parts)
-
-        # parse
-        yield ZoneRecord.parse(line, parts=parts, origin=origin)
-
-def parse_zone_records (file, origin=None, **opts) :
-    """
-        Parse ZoneRecord items from the given zonefile, ignoring non-record lines.
-    """
-
-    ttl = None
-
-    skip_multiline = False
-    
-    for lineno, raw_line in enumerate(file) :
-        # parse comment
-        if ';' in raw_line :
-            line, comment = raw_line.split(';', 1)
-        else :
-            line = raw_line
-            comment = None
-
-        # XXX: handle multi-line statements...
-        # start
-        if '(' in line :
-            skip_multiline = True
-            
-            log.warn("%s:%d: Start of multi-line statement: %s", file.name, lineno, raw_line)
-
-        # end?
-        if ')' in line :
-            skip_multiline = False
-            
-            log.warn("%s:%d: End of multi-line statement: %s", file.name, lineno, raw_line)
-            
-            continue
-
-        elif skip_multiline :
-            log.warn("%s:%d: Multi-line statement: %s", file.name, lineno, raw_line)
-
-            continue
-        
-        # parse
-        line = ZoneLine.parse(file.name, lineno, raw_line, **opts)
-
-        if not line.data :
-            log.debug("%s: skip empty line: %s", line, raw_line)
-
-            continue
-
-        elif line.data.startswith('$') :
-            # control record
-            type = line.parts[0]
-
-            if type == '$ORIGIN':
-                # update
-                origin = line.parts[1]
-                
-                log.info("%s: origin: %s", line, origin)
-            
-            elif type == '$GENERATE':
-                # process...
-                log.info("%s: generate: %s", line, line.parts)
-
-                for record in process_generate(line, origin, line.parts[1:]) :
-                    yield record
-
-            else :
-                log.warning("%s: skip control record: %s", line, line.data)
-            
-            # XXX: passthrough!
-            continue
-
-        # normal record?
-        record = ZoneRecord.parse(line, origin=origin)
-
-        if record :
-            yield record
-
-        else :
-            # unknown
-            log.warning("%s: skip unknown line: %s", line, line.data)
-
-def check_zone_hosts (zone, whitelist=None, whitelist_types=set(['TXT'])) :
-    """
-        Parse host/IP pairs from the zone, and verify that they are unique.
-
-        As an exception, names listed in the given whitelist may have multiple IPs.
-    """
-
-    by_name = {}
-    by_ip = {}
-
-    fail = None
-
-    last_name = None
-
-    for r in zone :
-        name = r.name or last_name
-
-        name = (r.origin, name)
-
-        # name
-        if r.type not in whitelist_types :
-            if name not in by_name :
-                by_name[name] = r
-
-            elif r.name in whitelist :
-                log.debug("Duplicate whitelist entry: %s", r)
-
-            else :
-                # fail!
-                log.warn("%s: Duplicate name: %s <-> %s", r.line, r, by_name[name])
-                fail = True
-
-        # ip
-        if r.type == 'A' :
-            ip, = r.data
-
-            if ip not in by_ip :
-                by_ip[ip] = r
-
-            else :
-                # fail!
-                log.warn("%s: Duplicate IP: %s <-> %s", r.line, r, by_ip[ip])
-                fail = True
-
-    return fail
-
-def process_zone_forwards (zone, txt=False, mx=False) :
-    """
-        Process zone data -> forward zone data.
-    """
-
-    for r in zone :
-        yield r
-
-        if r.type == 'A' :
-            if txt :
-                # comment?
-                comment = r.line.comment
-
-                if comment :
-                    yield TXTRecord(None, comment, ttl=r.ttl)
-
-           
-            # XXX: RP, do we need it?
-
-            if mx :
-                # XXX: is this a good idea?
-                yield ZoneRecord(None, 'MX', [10, mx], ttl=r.ttl)
-
-def process_zone_meta (zone, ignore=None) :
-    """
-        Process zone metadata -> output.
-    """
-    
-    TIMESTAMP_FORMAT='%Y/%m/%d'
-    
-    for r in zone :
-        if ignore and r.name in ignore :
-            # skip
-            log.debug("Ignore record: %s", r)
-            continue
-
-        # for hosts..
-        if r.type == 'A' :
-            # timestamp?
-            timestamp = r.line.timestamp
-
-            if timestamp :
-                yield TXTRecord(r.name, timestamp.strftime(TIMESTAMP_FORMAT), ttl=r.ttl)
-     
-def reverse_ipv4 (ip) :
-    """
-        Return in-addr.arpa reverse for given IPv4 prefix.
-    """
-
-    # parse
-    octets = tuple(int(part) for part in ip.split('.'))
-
-    for octet in octets :
-        assert 0 <= octet <= 255
-
-    return '.'.join([str(octet) for octet in reversed(octets)] + ['in-addr', 'arpa'])
-
-def reverse_ipv6 (ip6) :
-    """
-        Return ip6.arpa reverse for given IPv6 prefix.
-    """
-
-    parts = [int(part, 16) for part in ip6.split(':')]
-    parts = ['{0:04x}'.format(part) for part in parts]
-    parts = ''.join(parts)
-
-    return '.'.join(tuple(reversed(parts)) + ( 'ip6', 'arpa'))
-
-def fqdn (*parts) :
-    fqdn = '.'.join(parts)
-    
-    # we may be given an fqdn in parts
-    if not fqdn.endswith('.') :
-        fqdn += '.'
-    
-    return fqdn
-
-def process_zone_reverse (zone, origin, domain) :
-    """
-        Process zone data -> reverse zone data.
-    """
-
-    name = None
-
-    for r in zone :
-        # keep name from previous..
-        if r.name :
-            name = r.name
-
-        if r.type == 'A' :
-            ip, = r.data
-            ptr = reverse_ipv4(ip)
-
-        elif r.type == 'AAAA' :
-            ip, = r.data
-            ptr = reverse_ipv6(ip)
-            
-        else :
-            continue
-
-        # verify
-        if zone and ptr.endswith(origin) :
-            ptr = ptr[:-(len(origin) + 1)]
-
-        else :
-            log.warning("Reverse does not match zone origin, skipping: (%s) -> %s <-> %s", ip, ptr, origin)
-            continue
-
-        # domain to use
-        host_domain = r.origin or domain
-        host_fqdn = fqdn(name, host_domain)
-
-        yield ZoneRecord(ptr, 'PTR', [host_fqdn])
-
-def write_zone_records (file, zone) :
-    for r in zone :
-        file.write(r.build_line() + u'\n')
-
-def open_file (path, mode, charset) :
-    """
-        Open unicode-enabled file from path, with - using stdio.
-    """
-
-    if path == '-' :
-        # use stdin/out based on mode
-        stream, func = {
-            'r':    (sys.stdin, codecs.getreader),
-            'w':    (sys.stdout, codecs.getwriter),
-        }[mode[0]]
-
-        # wrap
-        return func(charset)(stream)
-
-    else :
-        # open
-        return codecs.open(path, mode, charset)
-
-def main (argv) :
-    global options
-    
-    options, args = parse_options(argv)
-
-    if options.doctest :
-        import doctest
-        fail, total = doctest.testmod()
-        return fail
-
-    if args :
-        # open files
-        input_files = [open_file(path, 'r', options.input_charset) for path in args]
-
-    else :
-        # default to stdout
-        input_files = [open_file('-', 'r', options.input_charset)]
-   
-    # process zone data
-    zone = []
-
-    for file in input_files :
-        log.info("Reading zone: %s", file)
-
-        zone += list(parse_zone_records(file, 
-            line_timestamp_prefix   = options.input_line_date,
-        ))
-
-    # check?
-    if options.check_hosts :
-        whitelist = set(options.check_exempt)
-
-        log.debug("checking hosts; whitelist=%r", whitelist)
-
-        if check_zone_hosts(zone, whitelist=whitelist) :
-            log.warn("Hosts check failed")
-            return 2
-
-        else :
-            log.info("Hosts check OK")
-
-    # output file
-    output = open_file(options.output, 'w', options.output_charset)
-
-    if options.forward_zone :
-        log.info("Write forward zone: %s", output)
-
-        zone = list(process_zone_forwards(zone, txt=options.forward_txt, mx=options.forward_mx))
-
-    elif options.meta_zone :
-        log.info("Write metadata zone: %s", output)
-
-        if not options.input_line_date :
-            log.error("--meta-zone requires --input-line-date")
-            return 1
-
-        zone = list(process_zone_meta(zone, ignore=set(options.meta_ignore)))
-
-    elif options.reverse_zone :
-        if ':' in options.reverse_zone :
-            # IPv6
-            origin = reverse_ipv6(options.reverse_zone)
-
-        else :
-            # IPv4
-            origin = reverse_ipv4(options.reverse_zone)
-
-        domain = options.reverse_domain
-
-        if not domain :
-            log.error("--reverse-zone requires --reverse-domain")
-            return 1
-
-        zone = list(process_zone_reverse(zone, origin=origin, domain=domain))
-
-    elif options.check_hosts :
-        # we only did that, done
-        return 0
-
-    else :
-        log.warn("Nothing to do")
-        return 1
-
-    write_zone_records(output, zone)
-
-    return 0
-
-if __name__ == '__main__':
-    import sys
-
-    sys.exit(main(sys.argv))
--- a/bin/update	Fri May 10 00:05:25 2013 +0300
+++ b/bin/update	Sat Dec 21 22:43:38 2013 +0200
@@ -1,283 +1,131 @@
 #!/bin/bash
 # vim: set ft=sh :
 
-set -ue
-
-# resolve $0 -> bin/update
-self=$0
-while [ -L $self ]; do
-    tgt=$(readlink $self)
-
-    if [ "${tgt:0:1}" == "/" ]; then
-        self=$tgt
-    else
-        self=$(dirname $self)/$tgt
-    fi
-done
-
-# root dir
-ROOT=$(dirname $(dirname $self))
-
-BIN=$ROOT/bin
-LIB=$ROOT/lib
-VAR=$ROOT/var
-
-## Data paths
-# absolute path to data files; can be changed using -d
-ROOT=$(pwd)
-
-DATA=settings
-ZONES=$VAR/zones
-SERIALS=$VAR/serials
-
-DHCP=$VAR/dhcp
-DHCP_DATA=$DATA/dhcp
-
-# global DHCP conf to test
-DHCPD=/usr/sbin/dhcpd
-DHCPD_CONF=/etc/dhcp/dhcpd.conf
-DHCPD_INIT=/etc/init.d/isc-dhcp-server
-
-# hg repo to commit
-REPO=$DATA
-
-## Settings used in lib
-# Hide files under repo in commit diff output..
-REPO_HIDE='*.serial'
-
-# data input charsets; arguments to ./bin/... python scripts
-HOSTS_FILE_ARGS='--input-charset utf-8'
-DHCP_FILE_ARGS='--input-charset utf-8'
-
-# External bins
-NAMED_CHECKZONE=/usr/sbin/named-checkzone
-
-HG=/usr/bin/hg
-HG_ARGS=(--config trusted.users=root)
-
-RNDC=/usr/sbin/rndc
-
-# Path to rndc key, must be readable to run..
-RNDC_KEY=/etc/bind/rndc.key
-
-## Library includes
-# Command-line argument handling
-source $LIB/update.args
+# Bootstrap
+if [ $0 == './update' ]; then
+    SRV=$(pwd)
+    OPT=./opt
+else
+    SRV=${SRV:-/srv/dns}
+    OPT=${SRV:-/srv/dns/opt}
+    cd $SRV
+fi
 
-# Logging
-source $LIB/update.logging
-
-# Utility functions
-source $LIB/update.utils
-
-# Dependency-based updates
-source $LIB/update.updates
-
-# Operations; the functions called from run()
-source $LIB/update.operations
-
-## Flags
-# set by do_reload_zone if zone data has actually been reloaded
-RELOAD_ZONES=
-
-## Site settings, used as arguments to scripts
-# MX record to generate in hosts --forward-zone
-FORWARD_MX=mx0
-
-# IP network to generate reverse records for in --reverse-zone
-REVERSE_ZONE=194.197.235
-
-# Origin domain to generate reverse records for in --reverse-zone
-REVERSE_DOMAIN=paivola.fi
-
-# Views used
-VIEWS=(internal external)
-
-# Base domain zone for domains
-DOMAIN_BASE=paivola
+source lib/update
 
-# List of actual domains used; will be linked to $DOMAIN_BASE
-DOMAINS=(paivola.fi paivola.net paivola.org paivola.info paivola.mobi xn--pivl-load8j.fi)
-
-# Names of dhcp conf file names
-DHCP_CONFS=( $(list_files $DHCP_DATA *.conf) )
-
-## Operate!
-# these functions are all defined in lib/update.operations
-
-# Update $ZONES/$DHCP host-files from $DATA
-function run_hosts {
-    ## Hosts
-    # test
+function commit {
+    ## Commit
+    # pre-commit check
     log "Testing hosts..."
-        #                   data                            args...
-        check_hosts         $DATA/paivola.txt               --check-exempt ufc
+    for hosts in $(list_files etc/hosts); do
+        log_warn "TODO: check_hosts $hosts"
+    done
 
-    # update
-    log "Generating host zones..."
-        #                   hosts                           data                args...
-        update_hosts        $ZONES/hosts/paivola:internal   $DATA/paivola.txt   --forward-zone --forward-txt
-        update_hosts        $ZONES/hosts/paivola:external   $DATA/paivola.txt   --forward-zone
-        update_hosts        $ZONES/hosts/194.197.235        $DATA/paivola.txt   --reverse-zone $REVERSE_ZONE --reverse-domain $REVERSE_DOMAIN
-
-        
-        update_hosts        $ZONES/hosts/10                 $DATA/pvl.txt       --reverse-zone 10 --reverse-domain pvl -q
-        update_hosts        $ZONES/hosts/10.0               $DATA/test.pvl.txt 	--reverse-zone 10.0 --reverse-domain test.pvl -q
-        update_hosts        $ZONES/hosts/fdc4:4cef:395a     $DATA/test.pvl.txt 	--reverse-zone fdc4:4cef:395a --reverse-domain test.pvl -q
-        update_hosts        $ZONES/hosts/192.168            $DATA/pvl.txt       --reverse-zone 192.168 --reverse-domain pvl -q
-
-        # XXX: unsupported --forward-zone with pvl.txt
-        # update_hosts    $ZONES/hosts/pvl                    $DATA/pvl.txt      --forward-zone
-        copy_hosts          $ZONES/hosts/pvl                $DATA/pvl.txt
-        copy_hosts          $ZONES/hosts/test.pvl           $DATA/test.pvl.txt
+    # commit, unless noop'd
+    log "Commit..."
+        update_commit       etc
 }
 
-# Update $ZONES files
-function run_zones {
-    ## Includes
-    log "Copying zone includes..."
-        #                   view            zone                    base
-        copy_zone           includes        paivola:internal        paivola.zone.internal
-        copy_zone           includes        paivola:external        paivola.zone.external
-        copy_zone           includes        paivola.auto            paivola.zone.auto
-        copy_zone           includes        paivola.services        paivola.zone.services
-        copy_zone           includes        paivola.aux             paivola.zone.aux
-
-    ## Serials
-    log "Updating serials..."
+function update {
+    if hg_modified etc; then
+        serial=$(unix_time)
+        log_warn "Using local unix time for uncommited changes: $serial"
+    else
+        serial=$(hg_time etc)
+        log_update "Using HG commit timestamp: $serial"
+    fi
 
-        #                   zone            deps...
-        #   includes...
-        update_serial       pvl             $ZONES/hosts/pvl            $DATA/pvl.zone
-        update_serial       test.pvl        $ZONES/hosts/test.pvl       $DATA/test.pvl.zone
-        update_serial       10              $ZONES/hosts/10             $DATA/10.zone
-        update_serial       10.0            $ZONES/hosts/10.0           $DATA/10.0.zone
-        update_serial       fdc4:4cef:395a  $ZONES/hosts/fdc4:4cef:395a $DATA/fdc4:4cef:395a.zone
-        update_serial       192.168         $ZONES/hosts/192.168        $DATA/192.168.zone
+    ## Hosts
+    log "Updating forward host zones..."
+    for zone in $(list_dirs etc/hosts/forward); do
+        update_hosts_forward    "var/zones/hosts/forward/$zone"     "$zone" \
+            etc/hosts/forward/$zone/*
+    done
 
-        update_serial       paivola         $ZONES/hosts/paivola:*      $DATA/paivola.zone          \
-            $ZONES/includes/paivola:*       \
-            $ZONES/includes/paivola.*
+    log "Updating DHCP hosts..."
+    for hosts in $(list_files etc/hosts); do
+        update_hosts_dhcp       "var/dhcp/hosts/$hosts.conf"        \
+            "etc/hosts/$hosts"
+    done
 
-        update_serial       194.197.235     $ZONES/hosts/194.197.235    $DATA/194.197.235.zone          
+    log "Updating reverse host zones..."
+    for zone in $(list_dirs etc/hosts/reverse); do
+        update_hosts_reverse    "var/zones/hosts/reverse/$zone"     "$zone" \
+            etc/hosts/reverse/$zone/*
+    done
 
     ## Zones
-    log "Updating zones..."
-        #                   view        zone            base
-        update_zone         internal    pvl
-        update_zone         internal    test.pvl
-
-        update_zone         internal    10
-        update_zone         internal    10.0
-        update_zone         internal    fdc4:4cef:395a
-        update_zone         internal    192.168
-
-        update_zone         common      194.197.235
-        link_zone           internal    194.197.235
-        link_zone           external    194.197.235
+    log "Copying zone includes..."
+    for zone in $(list_files etc/zones/includes); do
+        copy                "var/zones/includes/$zone"      "etc/zones/includes/$zone"
+    done
 
-    ## Test
-    log "Testing zones..."
-        #                   view        zone            origin
-        check_zone          internal    10              10.in-addr.arpa
-        check_zone          internal    10.0            0.10.in-addr.arpa
-        check_zone          internal    fdc4:4cef:395a	a.5.9.3.f.e.c.4.4.c.d.f.ip6.arpa
-	
-        check_zone          internal    192.168         192.168.in-addr.arpa
-        check_zone          common      194.197.235     235.197.194.in-addr.arpa
+    log "Updating zone serials..."
+    for zone in $(list_files etc/zones); do
+        update_serial       "var/serials/$zone"             $serial \
+            "etc/zones/$zone" $(zone_includes var/include-cache/$zone etc/zones/$zone var/zones/)
+    done
 
-    ## Domains...
-    log "Linking domains..."
-        for view in "${VIEWS[@]}"; do
-            for zone in "${DOMAINS[@]}"; do
-                # choose input .zone to use
-                base=$(choose_zone $zone $DOMAIN_BASE)
-                
-                if [ $base != $DOMAIN_BASE ]; then
-                    # serial
-                    # XXX: not all zones use all these includes?
-                    update_serial   $base       $DATA/$base.zone    \
-                        $ZONES/hosts/paivola:*                      \
-                        $ZONES/includes/paivola:*                   \
-                        $ZONES/includes/paivola.*
-                fi
+    log "Updating zones..."
+    for zone in $(list_files etc/zones); do
+        update_zone         "var/zones/$zone"               "etc/zones/$zone"       "var/serials/$zone" \
+            $(zone_includes var/include-cache/$zone etc/zones/$zone var/zones/)
+    done
 
-                # link
-                update_zone     $view       $zone           $base
-
-                # test
-                check_zone      $view       $zone           $zone
-            done
-        done
+    log "Updating DHCP confs..."
+    for conf in $(list_files etc/dhcp); do
+        update_dhcp_conf    "var/dhcp/$conf"                "etc/dhcp/$conf"
+    done
 }
 
-# Update $DHCP files from $DATA/dhcp
-function run_dhcp {
-    log_debug "DHCP_CONFS: ${DHCP_CONFS[*]}"
-
-    log "Copying DHCP configs..."
-        for conf in "${DHCP_CONFS[@]}"; do
-            # XXX: ei toimi, koska conf:it riippuu toisistaan include:ien takia
-            # check_dhcp_conf     $conf
-
-            #                   conf               base
-            copy_dhcp_conf      $conf
-        done
+function deploy {
+    ## Check
+    log "Testing zones..."
+    for zone in $(list_files etc/zones); do
+        check_zone          "var/zones/$zone"       $zone
+    done
 
-    log "Testing dhcp..."
-        # checks the whole dhcpd.conf, with all includes..
-        check_dhcp
-}
+    log "Testing DHCP confs..."
+    for conf in var/dhcp/*.conf; do
+        check_dhcp          $conf
+    done
 
-# Runs DHCP checks, once DNS hosts have been updated
-function run_dhcp_check {
-    log "Testing dhcp hosts..."
-        for conf in "${DHCP_CONFS[@]}"; do
-            check_dhcp_hosts    $DHCP/$conf.conf
-        done
-}
-
-function run_deploy {
-    ## Reload zones
     log "Reload zones..."
         reload_zones
 
-    ## DHCP
-    run_dhcp_check
-
     log "Reload dhcp..."
         reload_dhcp
 
-    ## Commit
-    log "Commit data..."
-        commit_data
 }
 
 ## Main entry point
 function main {
-    # test tty
-    [ -t 1 ] && IS_TTY=y
-    
     parse_args "$@"
 
     ## Input dirs
-    [ -d $ROOT/$DATA ] || die "Missing data: $ROOT/$DATA"
+    for dir in etc etc/zones etc/hosts opt; do
+        [ -d $dir ] || die "Missing directory: $dir"
+    done
     
     ## Output dirs
-    for dir in $VAR $DHCP $ZONES $SERIALS; do
+    ensure_dir      var
+    for dir in var/dhcp var/zones var/include-cache var/serials; do
         ensure_dir  $dir
     done
-    
-    # sub-$ZONES
-    for dir in "common" "hosts" "includes" "${VIEWS[@]}"; do
-        ensure_dir  $ZONES/$dir
+    for dir in var/dhcp/hosts; do
+        ensure_dir  $dir
+    done
+    for dir in var/zones/includes var/zones/hosts; do
+        ensure_dir  $dir
+    done
+    for dir in var/zones/hosts/forward var/zones/hosts/reverse; do
+        ensure_dir  $dir
     done
 
     ## Go
-    run_hosts
-    run_zones
-    run_dhcp
-    run_deploy
+    commit
+    update
+    deploy
 }
 
 main "$@"
--- a/bin/update-serial	Fri May 10 00:05:25 2013 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,195 +0,0 @@
-#!/usr/bin/env python
-# vim: set ft=python :
-
-"""
-    Update zone serials.
-"""
-
-__version__ = '0.0.1-dev'
-
-import optparse
-import codecs
-import os.path
-from datetime import datetime
-import logging
-
-log = logging.getLogger('main')
-
-# command-line options, global state
-options = None
-
-def parse_options (argv) :
-    """
-        Parse command-line arguments.
-    """
-    
-    prog = argv[0]
-
-    parser = optparse.OptionParser(
-            prog        = prog,
-            usage       = '%prog: [options]',
-            version     = __version__,
-
-            # module docstring
-            description = __doc__,
-    )
-
-    # logging
-    general = optparse.OptionGroup(parser, "General Options")
-
-    general.add_option('-q', '--quiet',     dest='loglevel', action='store_const', const=logging.ERROR, help="Less output")
-    general.add_option('-v', '--verbose',   dest='loglevel', action='store_const', const=logging.INFO,  help="More output")
-    general.add_option('-D', '--debug',     dest='loglevel', action='store_const', const=logging.DEBUG, help="Even more output")
-
-    parser.add_option_group(general)
-
-    # defaults
-    parser.set_defaults(
-        loglevel            = logging.WARN,
-        expand              = [],
-    )
-    
-    # parse
-    options, args = parser.parse_args(argv[1:])
-
-    # configure
-    logging.basicConfig(
-        format  = prog + ': %(name)s: %(levelname)s %(funcName)s : %(message)s',
-        level   = options.loglevel,
-    )
-
-    return options, args
-
-# date fmt to use in serial
-DATE_FMT = '%Y%m%d'
-DATE_LEN = 8
-
-SERIAL_FMT = "{date:8}{count:02}"
-SERIAL_LEN = 10
-
-def format_serial (date, count) :
-    return SERIAL_FMT.format(date=date.strftime(DATE_FMT), count=count)
-
-def next_count (value, date, count) :
-    """
-        Return serial with next count.
-    """
-
-    count += 1
-
-    # check
-    if count > 99 :
-        serial = str(value + 1)
-
-        log.warn("Serial count rollover: %s, %s; fallback -> %s", date, count, serial)
-
-        return serial
-
-    return format_serial(date, count)
-
-def update_serial (serial) :
-    """
-        Update new serial number into given file, based on date.
-    """
-    
-    today = datetime.now().date()
-
-    # handle
-    if not serial :
-        # fresh
-        log.info("Setting initial serial: %s01", today)
-        
-        return format_serial(today, 1)
-
-    elif len(serial) != SERIAL_LEN :
-        log.warn("Invalid serial format: %s", serial)
-        
-        value = int(serial)
-        serial = str(value + 1)
-
-        log.info("Incrementing serial: %d -> %s", value, serial)
-
-        return serial
-
-    else :
-        # parse
-        value = int(serial)
-
-        try :
-            date = datetime.strptime(serial[:DATE_LEN], DATE_FMT).date()
-            count = int(serial[DATE_LEN:])
-
-        except ValueError, e :
-            # invalid date/count format?
-            log.warn("Unable to parse serial: %s: %s", serial, e)
-
-            serial = str(value + 1)
-
-            log.info("Incrementing serial: %d -> %s", value, serial)
-
-            return serial
-            
-        log.debug("old serial=%s, value=%d, date=%s, count=%s", serial, value, date, count)
-        
-    # update
-    if date < today :
-        log.info("Updating to today: %s -> %s", date, today)
-
-        # update date
-        return format_serial(today, 1)
-
-    elif date == today :
-        # keep date, update count
-        log.info("Updating today's count: %s, %s", date, count)
-        
-        # handle count rollover
-        return next_count(value, date, count)
-
-    elif date > today :
-        # keep, update count
-        serial = next_count(value, date, count)
-
-        log.warn("Serial in future; incrementing count: %s, %s -> %s", date, count, serial)
-
-        return serial
-
-    else :
-        raise Exception("Invalid serial: %s:%s", old_date, old_count)
-    
-def process_serial (path) :
-    """
-        Read old serial, generate new one, and update file.
-    """
-
-    # read
-    if os.path.exists(path) :
-        serial = open(path).read().strip()
-        log.debug("current serial: %s", serial)
-
-    else :
-        log.warn("Given .serial does not yet exist: %s", path)
-        serial = None
-
-    # update
-    serial = update_serial(serial)
-
-    # write
-    open(path, 'w').write(serial + '\n')
-
-    return serial
-
-def main (argv) :
-    global options
-    
-    options, args = parse_options(argv)
-
-    # serial files to update
-    for path in args :
-        process_serial(path)
-    
-    return 0
-
-if __name__ == '__main__':
-    import sys
-
-    sys.exit(main(sys.argv))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/update	Sat Dec 21 22:43:38 2013 +0200
@@ -0,0 +1,11 @@
+#!/bin/bash
+#
+
+## Strict errors
+set -ue
+
+## Library includes
+for lib in lib/update.*; do
+    source $lib
+done
+
--- a/lib/update.args	Fri May 10 00:05:25 2013 +0300
+++ b/lib/update.args	Sat Dec 21 22:43:38 2013 +0200
@@ -1,7 +1,7 @@
 #!/bin/bash
 # vim: set ft=sh :
 #
-# Command-line option handling
+# Command-line options
 
 # use color output?
 IS_TTY=
@@ -70,6 +70,9 @@
 
 ## Parse any command-line arguments, setting the global options vars.
 function parse_args {
+    # test tty
+    [ -t 1 ] && IS_TTY=y
+ 
     OPTIND=1
 
     while getopts 'hd:qvDVpFSsnCcm:Rr' opt "$@"; do
@@ -79,7 +82,7 @@
                 exit 0
             ;;
 
-            d)  ROOT="$OPTARG" ;;
+            d)  SRV="$OPTARG" ;;
 
             q)  
                 LOG= 
@@ -106,7 +109,7 @@
                 UPDATE_NOOP=y 
                 # implies -Sp
                 UPDATE_DIFF=y
-                SERIAL_NOUPDATE=y
+                SERIAL_NOOP=y
                 COMMIT_SKIP=y
                 RELOAD_NOOP=y
                 ;;
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/update.config	Sat Dec 21 22:43:38 2013 +0200
@@ -0,0 +1,16 @@
+# charset for files under etc/
+CHARSET='utf-8'
+
+# External bins
+NAMED_CHECKZONE=/usr/sbin/named-checkzone
+
+DHCPD=/usr/sbin/dhcpd
+DHCPD_CONF=/etc/dhcp/dhcpd.conf
+DHCPD_INIT=/etc/init.d/isc-dhcp-server
+
+HG=/usr/bin/hg
+HG_ARGS=(--config trusted.users=root)
+
+RNDC=/usr/sbin/rndc
+RNDC_KEY=/etc/bind/rndc.key
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/update.hg	Sat Dec 21 22:43:38 2013 +0200
@@ -0,0 +1,75 @@
+#!/bin/bash
+#
+# HG wrappers
+
+## Run `hg ...` within $REPO.
+function hg {
+    local repo=$1; shift
+    cmd $HG -R "$repo" "${HG_ARGS[@]}" "$@"
+}
+
+## Does the repo have local modifications?
+function hg_modified {
+    hg $1 id -i | grep -q '+'
+}
+
+## Get the date for the current commit as an unix timestamp
+function hg_time {
+    local repo=$1
+    local hg_unix=
+    local hg_tz=
+
+    local hg_date=$(hg $repo log -r . --template '{date|hgdate}')
+    local hg_unix=${hg_date% *}
+    local hg_tz=${hg_date#* }
+
+    [ -n "$hg_unix" ] || fail "failed to read hg time"
+
+    echo "$hg_unix"
+}
+
+## Show changes in repo
+#   hg_diff     [path ...]
+function hg_diff {
+    local repo=$1; shift
+    hg $repo diff "$@"
+}
+
+## Commit changes in repo, with given message:
+#
+#   hg_commit   .../etc $msg
+#
+# Automatically determines possible -u to use when running with sudo.
+function hg_commit {
+    local repo="$1"
+    local msg="$2"
+    local user_opt=
+    local msg_opt=
+
+    if [ ${SUDO_USER:-} ]; then
+        user_opt=('-u' "$SUDO_USER")
+
+    elif [ $HOME ] && [ -e $HOME/.hgrc ]; then
+        debug "using .hgrc user"
+        user_opt=( )
+
+    else
+        user_opt=('-u' "$USER")
+    fi
+    
+    if [ "$msg" ]; then
+        msg_opt=('-m' "$msg")
+    fi
+   
+    # XXX:  there's something about bash arrays that I don't like... empty arrays behave badly
+    #       mercurial does not like it if you pass it '' as an argument
+    if [ -n "${user_opt:-}" -a -n "${msg_opt:-}" ]; then
+        hg $repo commit "${user_opt[@]}" "${msg_opt[@]}"
+    elif [ -n "${user_opt:-}" ]; then
+        hg $repo commit "${user_opt[@]}"
+    elif [ -n "${msg_opt:-}" ]; then
+        hg $repo commit "${msg_opt[@]}"
+    else
+        hg $repo commit
+    fi
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/update.log	Sat Dec 21 22:43:38 2013 +0200
@@ -0,0 +1,107 @@
+#!/bin/bash
+# vim: set ft=sh :
+#
+# Logging output
+
+# Output message to stderr.
+function log_msg {
+    echo "$*" >&2
+}
+
+# Output message to stderr, optionally with given color, if TTY.
+function log_color {
+    local code=$1; shift
+
+    if [ $IS_TTY ]; then
+        echo $'\e['${code}'m'"$*"$'\e[00m' >&2
+    else
+        echo "$*" >&2
+    fi
+}
+
+## Log at various log-levels
+# plain
+function log {
+    [ $LOG          ] && log_msg            "$*"            || true
+}
+
+function log_error {
+    [ $LOG_ERROR    ] && log_color '31'     "$*"            || true
+}
+
+function log_warn {
+    [ $LOG_WARN     ] && log_color '33'     "$*"            || true
+}
+
+function log_force {
+    [ $LOG_FORCE    ] && log_color '2;33'   "  $*"          || true
+}
+
+function log_update {
+    [ $LOG_UPDATE   ] && log_color '36'     "  $*"          || true
+}
+
+function log_check {
+    [ $LOG_UPDATE   ] && log_color '37'     "  $*"          || true
+}
+
+function log_noop {
+    [ $LOG_NOOP     ] && log_color '2;34'   "  $*"          || true
+}
+
+function log_skip {
+    [ $LOG_SKIP     ] && log_color '1;34'   "  $*"          || true
+}
+
+function log_debug {
+    [ $LOG_DEBUG    ] && log_color '32'     "    $*"        || true
+}
+
+function log_cmd {
+    [ $LOG_CMD      ] && log_color '35'     "        \$ $*" || true
+}
+
+# Output stacktrace, broken.
+function log_stack {
+    local level=1
+
+    while info=$(caller $level); do
+        echo $info | read line sub file
+
+        log_msg "$file:$lineno $sub()"
+
+        level=$(($level + 1))
+    done
+}
+
+# Output calling function's name.
+function func_caller {
+    caller 1 | cut -d ' ' -f 2
+}
+
+### High-level logging output
+# Log with func_caller at log_debug
+function debug {
+    printf -v prefix "%s" $(func_caller)
+
+    log_debug "$prefix: $*"
+}
+
+function warn {
+    log_warn "$(func_caller): $*"
+}
+
+# Log with func_caller at log_error and exit, intended for internal errors...
+function fail {
+    log_error "$(func_caller): $*"
+
+    exit 2
+}
+
+# Log at log_error and exit
+function die {
+    log_error "$*"
+    exit 1
+}
+
+
--- a/lib/update.logging	Fri May 10 00:05:25 2013 +0300
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,100 +0,0 @@
-#!/bin/bash
-# vim: set ft=sh :
-#
-# Logging output
-
-# Output message to stderr.
-function log_msg {
-    echo "$*" >&2
-}
-
-# Output message to stderr, optionally with given color, if TTY.
-function log_color {
-    local code=$1; shift
-
-    if [ $IS_TTY ]; then
-        echo $'\e['${code}'m'"$*"$'\e[00m' >&2
-    else
-        echo "$*" >&2
-    fi
-}
-
-## Log at various log-levels
-
-function log_error {
-    [ $LOG_ERROR ] && log_color '31' "$*"
-}
-
-function log_warn {
-    [ $LOG_WARN ] && log_color '33' "$*" || true
-}
-
-# plain
-function log {
-    [ $LOG ] && log_msg "$*" || true
-}
-
-function log_force {
-    [ $LOG_FORCE ] && log_color '2;33' "  $*" || true
-}
-
-function log_update {
-    [ $LOG_UPDATE ] && log_color '36' "  $*" || true
-}
-
-function log_noop {
-    [ $LOG_NOOP ] && log_color '2;34' "  $*" || true
-}
-
-function log_skip {
-    [ $LOG_SKIP ] && log_color '1;34' "  $*" || true
-}
-
-function log_debug {
-    [ $LOG_DEBUG ] && log_color 32 "    $*" || true
-}
-
-function log_cmd {
-    [ $LOG_CMD ] && log_color 35 "        \$ $*" || true
-}
-
-# Output stacktrace, broken.
-function log_stack {
-    local level=1
-
-    while info=$(caller $level); do
-        echo $info | read line sub file
-
-        log_msg "$file:$lineno $sub()"
-
-        level=$(($level + 1))
-    done
-}
-
-# Output calling function's name.
-function func_caller {
-    caller 1 | cut -d ' ' -f 2
-}
-
-### High-level logging output
-# Log with func_caller at log_debug
-function debug {
-    printf -v prefix "%s" $(func_caller)
-
-    log_debug "$prefix: $*"
-}
-
-# Log with func_caller at log_error and exit, intended for internal errors...
-function fail {
-    log_error "$(func_caller): $*"
-
-    exit 2
-}
-
-# Log at log_error and exit
-function die {
-    log_error "$*"
-    exit 1
-}
-
-
--- a/lib/update.operations	Fri May 10 00:05:25 2013 +0300
+++ b/lib/update.operations	Sat Dec 21 22:43:38 2013 +0200
@@ -3,29 +3,29 @@
 #
 # Operations on zonefiles/hosts/whatever
 
-function link_generic {
-    local out=$1
-    local tgt=$2
+function link {
+    local out="$1"
+    local tgt="$2"
 
-    if check_link $out $tgt; then
+    if check_link "$out" "$tgt"; then
         log_update "Linking $out -> $tgt..."
 
-        do_link $out $tgt
+        do_link "$out" "$tgt"
 
     else
         log_skip "Linking $out -> $tgt: not changed"
     fi
 }
 
-function copy_generic {
-    local out=$1
-    local src=$2
+function copy {
+    local out="$1"
+    local src="$2"
 
-    if check_update $out $src; then
+    if check_update "$out" "$src"; then
         log_update "Copying $out <- $src..."
 
-        do_update $out \
-            cat $ROOT/$src
+        do_update "$out" \
+            cat "$src"
     else
         log_skip "Copying $out <- $src: not changed"
     fi
@@ -33,271 +33,185 @@
 
 ## Run check-command on given file, outputting results:
 #
-#   check_generic   $src    $cmd $args...
+#   check    $src    $cmd $args...
 #
-function check_generic {
-    local src=$1; shift
-    local cmd=$1; shift
+function check {
+    local src="$1"; shift
+    local cmd="$1"; shift
 
-    if cmd_test $cmd -q "$@"; then
+    if cmd_test "$cmd" -q "$@"; then
         log_skip    "Check $src: OK"
 
     else
         log_error   "  Check $src: Failed"
 
-        indent "    " $cmd "$@"
+        indent "    " "$cmd" "$@"
 
         exit 1
     fi
 }
 
-## Hosts
-## Update hosts from verbatim from input zone data:
-#
-#   copy_hosts      $ZONES/$zone    $DATA/$base
+## Generate forward zone from hosts hosts using pvl.hosts-dns:
 #
-# Writes updated zone to $zone, deps on $base.
-function copy_hosts {
-    local zone=$1
-    local base=$2
+#   update_hosts_forward out/hosts/$hosts $hosts in/hosts/$hosts
+function update_hosts_forward {
+    local out="$1"; shift
+    local domain="$1"; shift
 
-    # XXX: filenames given directly
-    local out=$zone
-    local src=$base
-
-    copy_generic $out $src
+    if check_update "$out" "$@"; then
+        log_update "Generating forward hosts zone $out @ $domain <- $@..."
+    
+        do_update "$out" $OPT/bin/pvl.hosts-dns \
+            --hosts-charset=$CHARSET \
+            --forward-zone="$domain" \
+             "$@"
+    else
+        log_skip "Generating forward hosts $out <- $@: not changed"
+    fi
 }
 
-## Generate hosts from input zone data using $BIN/process-zone:
-#
-#   update_hosts    $ZONES/$zone    $DATA/$base
+function update_hosts_dhcp {
+    local out=$1; shift
+    local src=$1; shift
+
+    if check_update $out $src "$@"; then
+        log_update "Generating DHCP hosts $out <- $src..."
+
+        do_update $out $OPT/bin/pvl.hosts-dhcp \
+            --hosts-charset=$CHARSET \
+            $src "$@"
+    else
+        log_skip "Generating DHCP hosts $out <- $src: not changed"
+    fi
+}
+
+## Generate reverse zone from hosts hosts using pvl.hosts-dns:
 #
-# Writes process-zone'd data to $zone, deps on $base.
-function update_hosts {
-    local zone=$1; shift
-    local base=$1; shift
+#   update_hosts_reverse out/hosts/$reverse $reverse in/hosts/$hosts
+function update_hosts_reverse {
+    local out="$1"; shift
+    local reverse="$1"; shift
 
-    if check_update $zone $base; then
-        log_update "Generating hosts $zone <- $base..."
-
-        do_update $zone \
-            $BIN/process-zone $HOSTS_FILE_ARGS $ROOT/$base "$@"
+    if check_update "$out" "$@"; then
+        log_update "Generating reverse hosts zone $out <- $@..."
+    
+        do_update "$out" $OPT/bin/pvl.hosts-dns \
+            --hosts-charset=$CHARSET \
+            --reverse-zone="$reverse" \
+            "$@"
     else
-        log_skip "Generating hosts $zone <- $base: not changed"
+        log_skip "Generating reverse hosts $out <- $@: not changed"
     fi
 }
 
 ## Update .serial number:
 #
-#   do_update_serial $serial
+#   do_update_serial .../serials/$zone  $serial
 #
-# Shows old/new serial on debug.
 function do_update_serial {
-    local serial=$1
+    local dst="$1"
+    local serial="$2"
 
-    # read
-    local old=$(test -e $ROOT/$serial && cat $ROOT/$serial || echo '')
-
-
-    cmd $BIN/update-serial $ROOT/$serial
-    
-    # read
-    local new=$(cat $ROOT/$serial)
-        
-    debug "  $old -> $new"
+    echo $serial > $dst
 }
 
 
-## Generate new serial for zone using $BIN/update-serial, if the zone data has changed:
+## Generate new serial for zone using pvl.dns-serial, if the zone data has changed:
 #
-#   update_serial   $zone   $deps...
+#   update_serial   .../serials/$zone   $serial     $deps...
 #
 # Supports SERIAL_FORCE/NOOP.
 # Updates $SERIALS/$zone.serial.
 function update_serial {
-    local zone=$1; shift
+    local dst="$1"; shift
+    local serial="$1"; shift
+
+    local old=$(test -e "$dst" && cat "$dst" || echo '')
     
-    local serial=$SERIALS/$zone.serial
-
     # test
     if [ $SERIAL_FORCE ]; then
-        log_force "Updating $serial: forced"
+        log_force "Updating $dst: $old <- $serial: forced"
 
-        do_update_serial $serial
+        do_update_serial "$dst" "$serial"
 
-    elif ! check_update $serial "$@"; then
-        log_skip "Updating $serial: not changed"
+    elif ! check_update "$dst" "$@"; then
+        log_skip "Updating $dst: $old <- $serial: not changed"
 
     elif [ $SERIAL_NOOP ]; then
-        log_noop "Updating $serial: skipped"
+        log_noop "Updating $dst: $old <- $serial: skipped"
 
     else
-        log_update "Updating $serial..."
+        log_update "Updating $dst: $old <- $serial"
 
-        do_update_serial $serial
+        do_update_serial "$dst" "$serial"
     fi
 }
 
-## Link serial for zone from given base-zone:
+## Generate zone file from source using pvl.dns-zone:
 #
-#   link_serial $zone $base
-function link_serial {
-    local zone=$1
-    local base=$2
-
-    local out=$SERIALS/$zone.serial
-    local tgt=$SERIALS/$base.serial
-
-    link_generic $out $tgt
-}
-
-## Update zone file verbatim from source:
-#
-#   copy_zone   $view   $zone   [$base]
-#
-# Copies changed $DATA/$base zone data to $ZONES/$view/$zone.
-function copy_zone {
-    local view=$1
-    local zone=$2
-    local base=${3:-$zone}
-
-    local out=$ZONES/$view/$zone
-    local src=$DATA/$base
-
-    copy_generic $out $src
-}
+#   update_zone out/zones/$zone in/zones/$zone var/serials/$zone
+function update_zone {
+    local out="$1"; shift
+    local src="$1"; shift
+    local serial="$1"; shift
+    local serial_opt=
 
-## Return the first zone that exists under $DATA/$name.zone
-#
-#   base=$(choose_zone $name...)
-function choose_zone {
-    # look
-    for name in "$@"; do
-        if [ $name ] && [ -e $DATA/$name.zone ]; then
-            echo $name
-            return 0
-        fi
-    done
-    
-    # failed to find
-    die "Unable to find zone in $DATA/*.zone: $@"
-}
+    if [ -n "$serial" -a -f "$serial" ]; then
+        serial_opt="--serial=$(cat "$serial")"
+    elif [ $SERIAL_NOOP ]; then
+        warn "$out: noop'd serial, omitting"
+    else
+        fail "$out: missing serial: $serial"
+    fi
 
-## Expand zone file from source using $BIN/expand-zone:
-#
-#   update_zone $view   $zone   [$base]
-#
-# Updates $ZONES/$view/$zone from $DATA/$base.{zone,serial} using $BIN/expand-zone.
-function update_zone {
-    local view=$1
-    local zone=$2
-    local base=${3:-$zone}
-
-    # zones <- data+serial file
-    local out=$ZONES/$view/$zone
-    local src=$DATA/$base.zone
-    local serial=$SERIALS/$base.serial
-
-    if check_update $out $src $serial; then
+    if check_update "$out" "$src" "$serial" "$@"; then
         log_update "Generating $out <- $src..." 
 
-        do_update $out \
-            $BIN/expand-zone $ROOT/$src \
-                --serial $ROOT/$serial              \
-                --expand zones=$(abspath $ZONES)    \
-                --expand view=$view
+        do_update "$out" $OPT/bin/pvl.dns-zone "$src" \
+                --include-path=$SRV/var/zones   \
+                $serial_opt
     else
         log_skip "Generating $out <- $src: not changed" 
     fi
 }
 
-## Link zone file to ues given shared zone.
-#
-#   link_zone   $view   $zone   [$base]
-#
-# Looks for shared zone at:
-#   $ZONES/$view/$base
-#   $ZONES/common/$base
-function link_zone {
-    local view=$1
-    local zone=$2
-    local base=${3:-$zone}
+## Generate dhcp confs from source using pvl.dhcp-conf:
+function update_dhcp_conf {
+    local out="$1"
+    local src="$2"
 
-    local out=$ZONES/$view/$zone
-    local tgt=$(choose_link $out $ZONES/$view/$base $ZONES/common/$base)
-
-    link_generic $out $tgt
-}
-
-## Link dhcp file directly from data to $DHCP
-function link_dhcp_conf {
-    local conf=$1
-    local base=${2:-$conf}
-
-    local out=$DHCP/$conf.conf
-    local tgt=$(choose_link $out $DHCP/$base.conf $DHCP_DATA/$base.conf)
-
-    link_generic $out $tgt
+    if check_update "$out" "$src"; then
+        log_update "Generating $out <- $src..."
+            
+        do_update "$out" $OPT/bin/pvl.dhcp-conf "$src" \
+            --include-path=$SRV/var/dhcp
+    else
+        log_skip "Generating $out <- $src: not changed"
+    fi
 }
 
-## Copy dhcp conf from data to $DHCP
-function copy_dhcp_conf {
-    local conf=$1
-    local base=${2:-$conf}
-
-    local out=$DHCP/$conf.conf
-    local src=$DHCP_DATA/$base.conf
-
-    copy_generic $out $src
-}
-
-## Test hosts zone for validity:
+## Test hosts zone for validity using pvl.hosts-check:
 #
-#   check_hosts     $DATA/$hosts    --check-exempt ...
-#
-# Fails if the check fails.
+#   check_hosts     .../hosts
 function check_hosts {
     local hosts=$1; shift 1
-
-    local cmd=($BIN/process-zone $HOSTS_FILE_ARGS $ROOT/$hosts --check-hosts "$@")
-
-    if "${cmd[@]}" -q; then
-        log_skip "Check $hosts: OK"
-    else
-        log_error "  Check $hosts: Failed"
-
-        indent "    " "${cmd[@]}"
-
-        exit 1
-    fi
+    
+    # TODO
+    check $hosts \
+        $OPT/bin/pvl.hosts-check $hosts
 }
 
 ## Test zone file for validity using named-checkzone:
 #
-#   check_zone      $view       $zone       $origin
-#
-# Uses the zonefile at $ZONES/$view/$zone, loading it with given initial $ORIGIN.
-# Fails if the check fails.
+#   check_zone      ..../$zone $origin
 function check_zone {
-    local view=$1
-    local zone=$2
-    local origin=$3
-
-    local src=$ZONES/$view/$zone
+    local zone=$1
+    local origin=$2
 
-    local cmd=($NAMED_CHECKZONE $origin $ROOT/$src)
+    log_check "Checking $zone @ $origin..." 
 
-    # test
-    # XXX: checkzone is very specific about the order of arguments, -q must be first
-    if cmd_test $NAMED_CHECKZONE -q $origin $ROOT/$src; then
-        log_skip "Check $src ($origin): OK"
-    else
-        log_error "  Check $src ($origin): Failed:"
-
-        indent "    " "${cmd[@]}"
-        
-        exit 1
-    fi
+    # checkzone is very specific about the order of arguments, -q must be first
+    check $zone $NAMED_CHECKZONE $origin $zone
 }
 
 ## Test DHCP configuration for validity using dhcpd -t:
@@ -308,13 +222,15 @@
 # Fails if the check fails.
 function check_dhcp {
     local conf=${1:-$DHCPD_CONF}
+    
+    log_check "Checking DHCP $conf..." 
 
     if [ ! -e $DHCPD ]; then
         log_warn "check_dhcp: dhcpd not installed, skipping: $conf"
         return 0
     fi
 
-    check_generic $conf \
+    check $conf \
         $DHCPD -cf $conf -t
 }
 
@@ -328,26 +244,11 @@
     check_dhcp $DHCP_DATA/$conf.conf
 }
 
-## Test DHCP hosts source configuration for invalid fixed-address stanzas:
-#
-#   check_dhcp_hosts    $hosts_conf
-#
-function check_dhcp_hosts {
-    local hosts=$1
-   
-    # XXX: still too unclear
-    local src=$hosts #$DHCP_DATA/$hosts.conf
+### Deploy
+# set by do_reload_zone if zone data has actually been reloaded
+RELOAD_ZONES=
 
-    # set in do_reload_zones below
-    if [ $RELOAD_ZONES ]; then
-        check_generic $src \
-            $BIN/check-dhcp-hosts $DHCP_FILE_ARGS $ROOT/$src
-    else
-        log_noop "Check $src: skipped; did not reload DNS zones"
-    fi
-}
-
-# Run rndc reload
+## Run rndc reload
 function do_reload_zones {
     # run
     indent "        rndc: " \
@@ -432,45 +333,39 @@
     fi
 }
 
-## Perform `hg commit` for $DATA
-function do_commit {
-    local msg=$1
-
-    [ $LOG_DIFF ] && indent "    " hg_diff
-
-    hg_commit "$msg"
-}
-
-
-## Commit changes in $DATA to version control:
+### Commit
+## Commit changes to version control:
 #
-#   commit_data
+#   update_commit .../etc "commit message"
 #
-# Invokes `hg commit` in the $REPO, first showing the diff.
-function commit_data {
-    local repo=$REPO
+# Invokes `hg commit`, first showing the diff.
+function update_commit {
+    local repo="$1"
     local commit_msg="$COMMIT_MSG"
 
-    local msg="Commit changes in $repo"
+    local msg="Commit changes"
 
     # operate?
     if [ $COMMIT_FORCE ]; then
-        log_force   "$msg..."
+        log_force   "$msg: $commit_msg"
 
-        do_commit "$commit_msg"
+        [ $LOG_DIFF ] && indent "    " hg_diff $repo
 
-    elif ! hg_modified; then
-        log_skip    "$msg: no changes"
+        hg_commit "$repo" "$commit_msg"
+
+    elif ! hg_modified "$repo"; then
+        log_warn    "$msg: no changes"
 
     elif [ $COMMIT_SKIP ]; then
         log_noop    "$msg: skipped"
         
         # still show diff, though
-        [ $LOG_DIFF ] && indent "    " hg_diff
+        [ $LOG_DIFF ] && indent "    " hg_diff "$repo"
     else
-        log_update  "$msg..."
+        log_update  "$msg: $commit_msg"
 
-        do_commit "$commit_msg"
+        [ $LOG_DIFF ] && indent "    " hg_diff $repo
+
+        hg_commit "$repo" "$commit_msg"
     fi
 }
-
--- a/lib/update.updates	Fri May 10 00:05:25 2013 +0300
+++ b/lib/update.updates	Sat Dec 21 22:43:38 2013 +0200
@@ -10,7 +10,7 @@
 # Returns true if the output file needs to be updated.
 function check_update {
     # target
-    local out=$1; shift
+    local out="$1"; shift
 
     debug "$out"
 
@@ -21,7 +21,7 @@
         debug "  update: unknown deps"
         update=y
 
-    elif [ ! -e $out ]; then
+    elif [ ! -e "$out" ]; then
         debug "  update: dest missing"
         update=y
         
@@ -36,10 +36,10 @@
         [ $update ] && continue
 
         # check
-        if [ ! -e $ROOT/$dep ]; then
-            fail "$out: Missing source: $dep"
+        if [ ! -e "$dep" ]; then
+            warn "$out: Missing source: $dep"
 
-        elif [ $ROOT/$out -ot $ROOT/$dep ]; then
+        elif [ "$out" -ot "$dep" ]; then
             debug "  update: $dep"
             update=y
         else
@@ -60,18 +60,18 @@
 # Writes output to a temporary .new file, optionally shows a diff of changes, and commits
 # the new version to $out (unless noop'd).
 function do_update {
-    local out=$1; shift
-    local tmp=$out.new
+    local out="$1"; shift
+    local tmp="$out.new"
 
     debug "$out"
-    cmd "$@" > $ROOT/$tmp
+    cmd "$@" > "$tmp"
 
     # compare
-    if [ -e $ROOT/$out ] && [ $UPDATE_DIFF ]; then
+    if [ -e "$out" ] && [ $UPDATE_DIFF ]; then
         debug "  changes:"
 
         # terse
-        indent "        " diff --unified=1 $ROOT/$out $ROOT/$tmp || true
+        indent "        " diff --unified=1 "$out" "$tmp" || true
     fi
     
     # deploy
@@ -79,33 +79,15 @@
         # cleanup
         debug "  no-op"
 
-        cmd rm $ROOT/$tmp
+        cmd rm "$tmp"
     else
         # commit
         debug "  deploy"
 
-        cmd mv $ROOT/$tmp $ROOT/$out
+        cmd mv "$tmp" "$out"
     fi
 }
 
-## Look for a link target:
-#
-#   find_link   $lnk    $tgt...
-#
-# Outputs the first given target to exist, skipping any that are the same as the given $lnk.
-# If no $tgt matches, outputs the last one, or '-'.
-function choose_link {
-    local lnk=$1; shift
-    local tgt=-
-
-    for tgt in "$@"; do
-        [ $tgt != $out ] && [ -e $ROOT/$tgt ] && break
-    done
-    
-    echo $tgt
-}
-
-
 ## Compare symlink to target:
 #
 #   check_link $lnk $tgt && do_link $lnk $tgt || ...
@@ -113,12 +95,12 @@
 # Tests if the symlink exists, and the target matches.
 # Fails if the target does not exist.
 function check_link {
-    local lnk=$1
-    local tgt=$2
+    local lnk="$1"
+    local tgt="$2"
 
-    [ ! -e $ROOT/$tgt ] && fail "$tgt: target does not exist"
+    [ ! -e "$tgt" ] && fail "$tgt: target does not exist"
     
-    [ ! -e $ROOT/$lnk ] || [ $(readlink $ROOT/$lnk) != $ROOT/$tgt ]
+    [ ! -e "$lnk" ] || [ $(readlink "$lnk") != "$tgt" ]
 }
 
 ## Update symlink to point to target:
@@ -126,9 +108,41 @@
 #   do_link $lnk $tgt
 #
 function do_link {
-    local lnk=$1
-    local tgt=$2
+    local lnk="$1"
+    local tgt="$2"
 
-    cmd ln -sf $ROOT/$tgt $ROOT/$lnk
+    cmd ln -sf "$tgt" "$lnk"
 }
 
+## Read include paths from file
+function read_zone_includes {
+    cmd sed -n -E 's/^\$INCLUDE\s+"(.+)"/\1/p' "$@"
+}
+
+## (cached) include paths for zone file
+function zone_includes {
+    local cache="$1"
+    local src="$2"
+    local prefix="${3:-}"
+
+    if [ ! -e "$cache" -o "$cache" -ot "$src" ]; then
+        read_zone_includes "$src" > "$cache"
+    fi
+
+    while read include; do
+        echo -n "$prefix$include "
+    done < "$cache"
+}
+
+## Search for prefix-matching includes in zone file
+function zone_includes_grep {
+    local cache="$1"
+    local src="$2"
+    local prefix="$3"
+    
+    for include in $(zone_includes $cache $src); do
+        if [ "${include#$prefix}" != "$include" ]; then
+            echo -n " ${include#$prefix}"
+        fi
+    done
+}
--- a/lib/update.utils	Fri May 10 00:05:25 2013 +0300
+++ b/lib/update.utils	Sat Dec 21 22:43:38 2013 +0200
@@ -1,9 +1,7 @@
 #!/bin/bash
-# vim: set ft=sh :
 #
 # Utility functions
 
-
 ### Command execution
 ## Execute command, possibly logging its execution.
 #
@@ -13,10 +11,9 @@
 function cmd {
     log_cmd "$@"
 
-    "$@" || die "Failed"
+    "$@" || die "Failed: $@"
 }
 
-### Command execution
 ## Execute command as a test, logging its execution at log_cmd
 #
 #   cmd_test $cmd... && ... || ...
@@ -32,11 +29,8 @@
 #   indent  "    " $cmd...
 #
 # Output is kept on stdout, exit status is that of the given command.
-# Also logs the executed command at log_cmd level..
 function indent () {
-    local indent=$1; shift
-
-    log_cmd "$@"
+    local indent="$1"; shift
 
     "$@" | sed "s/^/$indent/"
 
@@ -45,24 +39,25 @@
 
 
 ### FS utils
-# Create dir in $ROOT if not exists.
+# Create dir if not exists.
 function ensure_dir {
-    local dir=$1
+    local dir="$1"
 
-    if [ ! -d $ROOT/$dir ]; then
+    if [ ! -d "$dir" ]; then
         log_warn "Creating output dir: $dir"
-        cmd mkdir $ROOT/$dir
+        cmd mkdir "$dir"
     fi
 }
 
-## Output absolute path from $ROOT:
+## Output absolute path
 #
 #   abspath $path
 #
+# XXX: improve...?
 function abspath () {
-    local path=$1
+    local path="$1"
 
-    echo "$ROOT/$path"
+    echo "$SRV/$path"
 }
 
 ## List names of files in dir:
@@ -70,11 +65,14 @@
 #   list_files $dir $glob
 #
 function list_files {
-    local dir=$1
-    local glob=$2
+    local dir="$1"
+    local glob="${2:-*}"
     local name=
 
     for file in $dir/$glob; do
+        # only files
+        [ -f "$file" ] || continue
+
         # strip prefix
         name=${file#$dir/}
         name=${name%$glob}
@@ -83,50 +81,18 @@
     done
 }
 
-### HG wrappers
-# Run `hg ...` within $REPO.
-function hg {
-    local repo=$REPO
+## List names of dirs in dir:
+function list_dirs {
+    local dir="$1"
 
-    cmd $HG -R $ROOT/$repo "${HG_ARGS[@]}" "$@"
-}
+    for file in $dir/*; do
+        [ -d "$file" ] || continue
 
-# Does the repo have local modifications?
-function hg_modified {
-    hg id | grep -q '+'
+        echo -n "${file#$dir/} "
+    done
 }
 
-# Output possible -u flag for commit.
-function hg_user {
-    if [ ${SUDO_USER:-} ]; then
-        echo '-u' "$SUDO_USER"
-
-    elif [ $HOME ] && [ -e $HOME/.hgrc ]; then
-        debug "using .hgrc user"
-        echo ''
-
-    else
-        echo '-u' "$USER"
-    fi
+## Get current unix (utc) timestamp
+function unix_time {
+    date +'%s'
 }
-
-# Show changes in repo
-function hg_diff {
-    # just stat hidden files, but show the rest
-    hg diff --stat -I "$REPO/$REPO_HIDE"
-    hg diff -X "$REPO/$REPO_HIDE"
-}
-
-## Commit changes in repo, with given message:
-#
-#   hg_commit   $msg
-#
-function hg_commit {
-    local msg=$1
-    local user_opt=$(hg_user)
-    
-    debug "$user_opt: $msg"
-    hg commit $user_opt -m "$msg"
-}
-
-