merge
authorTero Marttila <tero.marttila@aalto.fi>
Tue, 03 Mar 2015 13:07:31 +0200
changeset 728 d3cea9988848
parent 727 956fdb057cf0 (diff)
parent 706 0816716c3f66 (current diff)
child 729 5642bd98b0de
merge
README.md
etc/hosts/asdf
etc/hosts/boot.dhcp
etc/hosts/dhcp1
etc/hosts/dhcp2
etc/hosts/reverse
--- a/README.md	Mon Mar 02 21:47:08 2015 +0200
+++ b/README.md	Tue Mar 03 13:07:31 2015 +0200
@@ -271,9 +271,18 @@
 
 Use the *update* script to generate a complete set of output zonefiles:
 
-    $ ./bin/update
+    $ ./bin/update -C
+      var: apply dir
+      var/dhcp: apply dir
+      var/zones: apply dir
+      var/include-cache: apply dir
+      var/serials: apply dir
+      var/dhcp/hosts: apply dir
+      var/zones/includes: apply dir
+      var/zones/forward: apply dir
+      var/zones/reverse: apply dir
     Commit...
-    Using commit timestamp: 1425049508
+    Using commit timestamp: 1425379711
     Updating forward host zones...
       var/zones/forward/test: Generating forward hosts zone: etc/zones/forward/test
     Updating reverse host zones...
@@ -281,15 +290,131 @@
     Updating DHCP hosts...
     Copying zone includes...
     Updating zones...
-      var/serials/test: Update serial: 1425049508 <- 1425049508
-      var/zones/test: Generate zone: etc/zones/test
+      var/serials/test: Update serial:  <- 1425379711
+      var/zones/test: Generate zone: etc/zones/test @ 1425379711
     Updating DHCP confs...
     Testing zones...
     Reload zones...
       Reload zones
-            rndc: server reload successful
+     * Reloading domain name service... bind9 [ OK ] 
     Testing DHCP...
     Reload DHCP...
+      Reload DHCP
+    isc-dhcp-server stop/waiting
+    isc-dhcp-server start/running, process 32581
+
+The update script tracks hostfile/zonefile dependencies, and only updates the necessary output files:
+
+    $ touch etc/hosts/test.d/foo && ./bin/update -C
+    Commit...
+    Using commit timestamp: 1425379801
+    Updating forward host zones...
+      var/zones/forward/test: Generating forward hosts zone: etc/zones/forward/test
+    Updating reverse host zones...
+      var/zones/reverse/192.0.2: Generating reverse hosts zone: etc/zones/reverse/192.0.2
+    Updating DHCP hosts...
+    Copying zone includes...
+    Updating zones...
+      var/serials/test: Update serial: 1425379801 <- 1425379801
+      var/zones/test: Generate zone: etc/zones/test @ 1425379801
+    Updating DHCP confs...
+    Testing zones...
+    Reload zones...
+      Reload zones
+     * Reloading domain name service... bind9 ...done.
+    Testing DHCP...
+    Reload DHCP...
+      Reload DHCP
+    isc-dhcp-server stop/waiting
+    isc-dhcp-server start/running, process 775
+
+Use `-n` to enable noop mode and preview changes before updating:
+
+    sed -i s/quux/quux2/ etc/hosts/asdf.test && ./bin/update -C -n
+    Commit...
+      /home/tjmartti/pvl/pvl-hosts: skip commit
+    Using local unix time for uncommited changes: 1425380558
+    Updating forward host zones...
+      var/zones/forward/test: Generating forward hosts zone: etc/zones/forward/test
+            --- var/zones/forward/test      2015-03-03 12:55:53.480735624 +0200
+            +++ var/zones/forward/test.new  2015-03-03 13:02:38.708732551 +0200
+            @@ -2,2 +2,2 @@
+             bar                               A     192.0.2.2
+            -quux.asdf                         A     192.0.2.5
+            +quux22.asdf                       A     192.0.2.5
+    Updating reverse host zones...
+      var/zones/reverse/192.0.2: Generating reverse hosts zone: etc/zones/reverse/192.0.2
+            --- var/zones/reverse/192.0.2   2015-03-03 12:55:53.596735623 +0200
+            +++ var/zones/reverse/192.0.2.new       2015-03-03 13:02:38.832732550 +0200
+            @@ -2,2 +2,2 @@
+             2                                 PTR   bar.test.
+            -5                                 PTR   quux.asdf.test.
+            +5                                 PTR   quux22.asdf.test.
+    Updating DHCP hosts...
+    Copying zone includes...
+    Updating zones...
+    Updating DHCP confs...
+    Testing zones...
+    Reload zones...
+      Skip reload zones
+    Testing DHCP...
+    Reload DHCP...
+      Skip reload DHCP
+
+Note that noop mode does not yet handle dependency chains, i.e. you will not see which zones get updated serials without also using `-F`, which force-updates all output files regardless of dependency states.
+
+Finally, the default operation mode of update is to commit any changes, and update the zones using the commit timestamp as a serial. Use the `-p` flag to show output diffs as with `-n`:
+
+    $ sed -i s/quux/quux2/ etc/hosts/asdf.test && ./bin/update -m "rename quux to quux2" -p
+    Commit...
+      /home/tjmartti/pvl/pvl-hosts: commit: rename quux to quux2
+        diff -r 8790e1e28661 etc/hosts/asdf.test
+        --- a/etc/hosts/asdf.test   Tue Mar 03 13:05:13 2015 +0200
+        +++ b/etc/hosts/asdf.test   Tue Mar 03 13:06:03 2015 +0200
+        @@ -1,2 +1,2 @@
+        -[quux]
+        +[quux2]
+             ip  = 192.0.2.5
+    Using commit timestamp: 1425380763
+    Updating forward host zones...
+      var/zones/forward/test: Generating forward hosts zone: etc/zones/forward/test
+            --- var/zones/forward/test      2015-03-03 13:04:09.556731909 +0200
+            +++ var/zones/forward/test.new  2015-03-03 13:06:04.260731122 +0200
+            @@ -2,2 +2,2 @@
+             bar                               A     192.0.2.2
+            -quux22.asdf                       A     192.0.2.5
+            +quux2.asdf                        A     192.0.2.5
+    Updating reverse host zones...
+      var/zones/reverse/192.0.2: Generating reverse hosts zone: etc/zones/reverse/192.0.2
+            --- var/zones/reverse/192.0.2   2015-03-03 13:04:09.684731908 +0200
+            +++ var/zones/reverse/192.0.2.new       2015-03-03 13:06:04.384731122 +0200
+            @@ -2,2 +2,2 @@
+             2                                 PTR   bar.test.
+            -5                                 PTR   quux22.asdf.test.
+            +5                                 PTR   quux2.asdf.test.
+    Updating DHCP hosts...
+    Copying zone includes...
+    Updating zones...
+      var/serials/test: Update serial: 1425380649 <- 1425380763
+      var/zones/test: Generate zone: etc/zones/test @ 1425380763
+            --- var/zones/test      2015-03-03 13:04:09.812731907 +0200
+            +++ var/zones/test.new  2015-03-03 13:06:04.512731121 +0200
+            @@ -1,3 +1,3 @@
+             $TTL   3600
+            -@                                 SOA   foo.test. hostmaster.test. 1425380649 1d 5m 10d 300
+            +@                                 SOA   foo.test. hostmaster.test. 1425380763 1d 5m 10d 300
+                                               NS    foo
+    Updating DHCP confs...
+    Testing zones...
+    Reload zones...
+      Reload zones
+     * Reloading domain name service... bind9 [ OK ] 
+    Testing DHCP...
+    Reload DHCP...
+      Reload DHCP
+    isc-dhcp-server stop/waiting
+    isc-dhcp-server start/running, process 2839
+
 
 ## Output zone files
 
--- a/bin/pvl.dns-includes	Mon Mar 02 21:47:08 2015 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,39 +0,0 @@
-#!/usr/bin/env python
-
-"""
-    Extract a list of $INCLUDE paths from a zone file.
-"""
-
-import logging; log = logging.getLogger('pvl.dns-includes')
-import optparse
-import pvl.args
-import pvl.dns
-import pvl.dns.process
-
-def main (argv):
-    parser = optparse.OptionParser(main.__doc__)
-    parser.add_option_group(pvl.args.parser(parser))
-    parser.add_option_group(pvl.dns.process.optparser(parser))
-
-    parser.add_option('--include-path',         metavar='PATH',
-            help="Rewrite includes to given absolute path")
-
-    # input
-    options, args = pvl.args.parse(parser, argv)
-    
-    # process
-    zone = list(pvl.dns.process.apply_zone(options, args))
-
-    if options.include_path:
-        log.info("Set zone include path: %s", options.include_path)
-
-        zone = list(pvl.dns.process.zone_includes_path(zone, options.include_path))
-
-    for include in pvl.dns.process.zone_includes(zone):
-        # output include path only
-        print include
-
-    return 0
-
-if __name__ == '__main__':
-    pvl.args.main(main)
--- a/bin/pvl.dns-process	Mon Mar 02 21:47:08 2015 +0200
+++ b/bin/pvl.dns-process	Tue Mar 03 13:07:31 2015 +0200
@@ -22,12 +22,22 @@
 
     parser.add_option('--include-path',         metavar='PATH',
             help="Rewrite includes to given absolute path")
+    
+    parser.add_option('--include-trace',         metavar='FILE',
+            help="Write out included files to given file")
 
     # input
     options, args = pvl.args.parse(parser, argv)
     
     # process
-    zone = list(pvl.dns.process.apply_zone(options, args))
+    if options.include_trace:
+        include_trace = [ ]
+    else:
+        include_trace = None
+
+    zone = list(pvl.dns.process.apply_zone(options, args,
+            include_trace   = include_trace,
+    ))
 
     if options.serial:
         log.info("Set zone serial: %s", options.serial)
@@ -37,7 +47,14 @@
     if options.include_path:
         log.info("Set zone include path: %s", options.include_path)
 
-        zone = list(pvl.dns.process.zone_includes_path(zone, options.include_path))
+        zone = list(pvl.dns.process.zone_includes(zone, options.include_path,
+                include_trace   = include_trace,
+        ))
+
+    if options.include_trace:
+        with pvl.args.apply_file(options.include_trace, 'w') as file:
+            for include in include_trace:
+                print >>file, include
     
     pvl.dns.process.apply_zone_output(options, zone)
 
--- a/lib/pvl/apply.sh	Mon Mar 02 21:47:08 2015 +0200
+++ b/lib/pvl/apply.sh	Tue Mar 03 13:07:31 2015 +0200
@@ -45,18 +45,18 @@
     local update=
     local out="$1"
 
-    debug "$out"
+    log_debug           "$out"
 
     if [ ${#@} -eq 1 ]; then
-        debug "  update: empty deps"
-        return 0
+        log_changed     "  update: empty deps"
+        return 2
 
     elif [ ! -e "$out" ]; then
-        debug "  update: dest missing"
+        log_changed     "  update: dest missing"
         return 2
         
     elif [ "$APPLY_FORCE" = 1 ]; then
-        debug "  update: forced"
+        log_changed     "  update: forced"
         return 2
     fi
 
@@ -64,16 +64,16 @@
     for dep in "${@:2}"; do
         # check
         if [ ! -e "$dep" ]; then
-            warn "$out: Missing source: $dep"
+            warn        "$out: Missing source: $dep"
 
         elif [ "$out" -ot "$dep" ]; then
-            debug "  update: $dep"
+            log_changed "   changed: $dep"
             return 1
         else
-            debug "  check: $dep"
+            log_debug   "  check: $dep"
         fi
     done
 
-    debug "  up-to-date"
+    log_debug           "  up-to-date"
     return 0
 }
--- a/lib/pvl/cmd.sh	Mon Mar 02 21:47:08 2015 +0200
+++ b/lib/pvl/cmd.sh	Tue Mar 03 13:07:31 2015 +0200
@@ -20,6 +20,7 @@
 
     "$@"
 }
+
 ## Execute command, prefixing its output on stdout with given indent prefix.
 #
 #   indent  "    " $cmd...
@@ -32,3 +33,12 @@
 
     return ${PIPESTATUS[0]}
 }
+
+## Execute a command as root, using sudo if required.
+function cmd_sudo {
+    if [ $UID -eq 0 ]; then
+        cmd "$@"
+    else
+        cmd sudo "$@"
+    fi
+}
--- a/lib/pvl/hosts/dhcp.sh	Mon Mar 02 21:47:08 2015 +0200
+++ b/lib/pvl/hosts/dhcp.sh	Tue Mar 03 13:07:31 2015 +0200
@@ -11,16 +11,18 @@
 function update_hosts_dhcp {
     local out="$1"
     local src="$2"
-    local srcs=($(list_tree $src))
     local msg="$out: Generating DHCP hosts: $src"
+    local include_cache=$(include_cache_path $src)
+    local srcs=($(include_cache $include_cache))
 
-    if apply_check "$out" "${srcs[@]}"; then
+    if apply_check "$out" ${srcs[@]:-}; then
         log_skip "$msg"
     else
         log_apply "$msg"
     
         apply_cmd "$out" $OPT/bin/pvl.hosts-dhcp \
             --hosts-include="$HOSTS_INCLUDE" \
+            --hosts-include-trace=$include_cache \
              "$src"
     fi
 }
@@ -55,7 +57,7 @@
    
     log_check "Checking DHCP: $DHCP_CONF" 
 
-    test_cmd "$conf" \
+    test_cmd "$DHCP_CONF" \
         "$DHCP_SBIN" -cf "$DHCP_CONF" -t
 }
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/lib/pvl/hosts/include_cache.sh	Tue Mar 03 13:07:31 2015 +0200
@@ -0,0 +1,26 @@
+INCLUDE_CACHE='var/include-cache'
+
+## Normalize a given source path for use in the include-cache
+function include_cache_path {
+    local src="$1"
+    local cache="$src"
+
+    cache="${cache//_/__}"
+    cache="${cache//\//_}"
+
+    echo "$INCLUDE_CACHE/$cache"
+}
+
+## Read the include cache for given path
+function include_cache {
+    local cache="$1"
+
+    if [ ! -e "$cache" ]; then
+        debug "missing: $cache"
+
+        return 0
+    fi
+    
+    cat $cache
+}
+
--- a/lib/pvl/hosts/update.sh	Mon Mar 02 21:47:08 2015 +0200
+++ b/lib/pvl/hosts/update.sh	Tue Mar 03 13:07:31 2015 +0200
@@ -4,6 +4,7 @@
 UPDATE_RELOAD=
 UPDATE_INCLUDES=
 
+. $LIB/pvl/hosts/include_cache.sh
 . $LIB/pvl/hosts/dhcp.sh
 . $LIB/pvl/hosts/zone.sh
 
@@ -97,15 +98,7 @@
 
     log "Updating zones..."
     for zone in $(list_files etc/zones); do
-        update_zone_includes "var/include-cache/$zone" "etc/zones/$zone"
-
-        zone_includes=$(cat "var/include-cache/$zone")
-
-        update_zone_serial "var/serials/$zone" $serial \
-            "etc/zones/$zone" $zone_includes
-
-        update_zone "var/zones/$zone" "etc/zones/$zone" "var/serials/$zone" \
-            $zone_includes
+        update_zone -s "$serial" "var/zones/$zone" "etc/zones/$zone" "var/serials/$zone"
     done
 
     log "Updating DHCP confs..."
--- a/lib/pvl/hosts/zone.sh	Mon Mar 02 21:47:08 2015 +0200
+++ b/lib/pvl/hosts/zone.sh	Tue Mar 03 13:07:31 2015 +0200
@@ -5,10 +5,7 @@
 ZONES_INCLUDE="$SRV/var/zones"
 
 NAMED_CHECKZONE=/usr/sbin/named-checkzone
-
-RNDC=/usr/sbin/rndc
-RNDC_KEY=/etc/bind/rndc.key
-
+NAMED_SERVICE=bind9
 
 ## Generate forward zone from hosts hosts using pvl.hosts-forward
 #
@@ -16,16 +13,18 @@
 function update_hosts_forward {
     local out="$1"
     local src="$2"
-    local srcs=($(list_tree $src))
     local msg="$out: Generating forward hosts zone: $src"
+    local include_cache=$(include_cache_path $src)
+    local srcs=($(include_cache $include_cache))
 
-    if apply_check "$out" "${srcs[@]}"; then
+    if apply_check "$out" ${srcs[@]:-}; then
         log_skip "$msg"
     else
         log_apply "$msg"
     
         apply_cmd "$out" $OPT/bin/pvl.hosts-forward \
-            --hosts-include="$HOSTS_INCLUDE" \
+            --hosts-include=$HOSTS_INCLUDE \
+            --hosts-include-trace=$include_cache \
              "$src"
     fi
 }
@@ -36,20 +35,26 @@
 function update_hosts_reverse {
     local out="$1"
     local src="$2"
-    local srcs=($(list_tree $src))
     local msg="$out: Generating reverse hosts zone: $src"
+    local include_cache=$(include_cache_path $src)
+    local srcs=($(include_cache $include_cache))
 
-    if apply_check "$out" "${srcs[@]}"; then
+    if apply_check "$out" ${srcs[@]:-}; then
         log_skip "$msg"
     else
         log_apply "$msg"
     
         apply_cmd "$out" $OPT/bin/pvl.hosts-reverse \
             --hosts-include="$HOSTS_INCLUDE" \
+            --hosts-include-trace=$include_cache \
              "$src"
     fi
 }
 
+## Update zone $INCLUDE file
+#
+#   update_zone_include etc/zone/includes/$zone var/zone/includes/$zone
+#
 function update_zone_include {
     local out="$1"
     local src="$2"
@@ -65,45 +70,15 @@
     fi
 }
 
-
-## Update list of zone $INCLUDEs from zone file
-#
-#   update_zone_includes var/include-cache/$zone etc/zones/$zone
+## Check if the given zone needs to be updated; update serial if so
 #
-function update_zone_includes {
-    local out="$1"
-    local src="$2"
-    local includes="$ZONES_INCLUDE"
-
-    if [ "$UPDATE_INCLUDES" = 1 ]; then
-        log_force "$out: Force zone includes: $src"
-    
-    elif apply_check "$out" "$src"; then
-        log_skip "$out: Skip zone includes: $src"
-        
-        return
-
-    elif [ "$UPDATE_INCLUDES" = 0 ]; then
-        log_noop "$out: Noop zone includes: $src"
-        
-        return
-    else
-        log_apply "$out: Update zone includes: $src"
-    fi
-
-    apply_cmd "$out" $OPT/bin/pvl.dns-includes \
-            --include-path=$ZONES_INCLUDE \
-            "$src"
-}
-
-## Update the cached .serial for the given zone, if the zone has changed:
-#
-#   update_serial var/serials/$zone $serial $deps...
+#   check_zone_serial var/serials/$zone $serial $zone_deps...
 #
 # Supports UPDATE_SERIAL=
-function update_zone_serial {
+function check_zone_serial {
     local out="$1"
     local serial="$2"
+    local srcs=(${@:3})
 
     local old=$(test -e "$out" && cat "$out" || echo '')
     
@@ -111,51 +86,66 @@
     if [ "$UPDATE_SERIAL" = 1 ]; then
         log_force "$out: Force serial $old <- $serial"
 
-    elif apply_check "$out" "${@:3}"; then
-        log_skip "$out: Skip serial: $old <- $serial"
-        
-        return
+    elif apply_check "$out" ${srcs[@]:-}; then
+        return 0
 
     elif [ "$UPDATE_SERIAL" = 0 ]; then
         log_noop "$out: Noop serial: $old <- $serial"
         
-        return
+        # fake
+        return 1
 
     else
         log_apply "$out: Update serial: $old <- $serial"
     fi
 
     echo "$serial" > $out
+        
+    return 1
 }
 
 ## Generate zone file from source using pvl.dns-process:
 #
 #   update_zone var/zones/$zone etc/zones/$zone var/serials/$zone
 #
-# Sets the SOA serial, and adjusts the $INCLUDE paths
+# Updates the SOA serial, and adjusts the $INCLUDE paths
 function update_zone {
+    local update_serial=
+
+    local OPTIND
+    while getopts 's:' opt; do case $opt in
+        s)  update_serial=$OPTARG ;;
+    esac done
+    shift $(($OPTIND - 1))
+
     local out="$1"
     local src="$2"
     local serial="$3"
-    local serial_opt=
-    local msg="$out: Generate zone: $src"
+    local zone_serial=
 
-    if [ -n "$serial" -a -f "$serial" ]; then
-        serial_opt="--serial=$(cat "$serial")"
-    elif [ "$UPDATE_SERIAL" = 0 ]; then
-        warn "$out: omit noop'd serial"
+    local msg="$out: Generate zone: $src"
+    local include_cache=$(include_cache_path $src)
+    local srcs=($(include_cache $include_cache))
+    
+    if check_zone_serial "$serial" $update_serial ${srcs[@]:-}; then
+        zone_serial=$(cat $serial)
+
+        log_skip "$out: Skip zone: $src @ $zone_serial <- $update_serial"
+
     else
-        fail "$out: missing serial: $serial"
-    fi
+        zone_serial=$(cat $serial)
 
-    if apply_check "$out" "${@:2}"; then
-        log_skip "$msg"
-    else
-        log_apply "$msg"
+        # XXX: hack to get the right diff in NOOP mode
+        if [ "$UPDATE_SERIAL" = 0 ]; then
+            zone_serial=$update_serial
+        fi
 
+        log_apply "$out: Generate zone: $src @ $zone_serial"
+    
         apply_cmd "$out" $OPT/bin/pvl.dns-process \
-                $serial_opt \
+                --serial=$zone_serial \
                 --include-path=$ZONES_INCLUDE \
+                --include-trace=$include_cache \
                 "$src"
     fi
 }
@@ -174,14 +164,9 @@
         $NAMED_CHECKZONE $origin $zone
 }
 
-# set by do_reload_zone if zone data has actually been reloaded
-RELOAD_ZONES=
-
 ## Load update zonefiles into bind:
 #
 #   reload_zones    
-#
-# Invokes `rndc reload`, showing its output.
 function reload_zones {
     if [ "$UPDATE_RELOAD" = 1 ]; then
         log_force "Reload zones"
@@ -191,28 +176,14 @@
         
         return
     
-    elif [ ! -e "$RNDC" ]; then
-        warn "Skip with missing RNDC: $RNDC"
-        
-        return
-
-    elif [ ! -e "$RNDC_KEY" ]; then
-        warn "Skip with missing RNDC_KEY: $RNDC_KEY"
+    elif ! service_status $NAMED_SERVICE; then
+        log_skip "named not running; did not restart"
 
         return
-
-    elif [ ! -r $RNDC_KEY ]; then
-        error "Permission denied for RNDC_KEY: $RNDC_KEY"
-
-        return 1
-
+ 
     else
         log_apply "Reload zones"
     fi
 
-    cmd_indent "        rndc: " \
-        $RNDC reload
-
-    # set flag for dhcp
-    RELOAD_ZONES=1
+    service_reload $NAMED_SERVICE
 }
--- a/lib/pvl/log.sh	Mon Mar 02 21:47:08 2015 +0200
+++ b/lib/pvl/log.sh	Tue Mar 03 13:07:31 2015 +0200
@@ -8,6 +8,7 @@
 LOG_FORCE=y
 LOG_APPLY=y
 LOG_CHECK=
+LOG_CHANGED=
 LOG_NOOP=y
 LOG_SKIP=
 LOG_DEBUG=
@@ -41,6 +42,7 @@
             LOG_DIFF=
             ;;
         v)  
+            LOG_CHANGED=y
             LOG_CHECK=y
             LOG_SKIP=y
             ;;
@@ -103,6 +105,10 @@
     [ $LOG_CHECK    ] && log_color '32'     "  $*"          || true
 }
 
+function log_changed {
+    [ $LOG_CHANGED  ] && log_color '1;32'   "  $*"          || true
+}
+
 function log_noop {
     [ $LOG_NOOP     ] && log_color '2;34'   "  $*"          || true
 }
--- a/lib/pvl/service.sh	Mon Mar 02 21:47:08 2015 +0200
+++ b/lib/pvl/service.sh	Tue Mar 03 13:07:31 2015 +0200
@@ -25,8 +25,9 @@
 
 function service_status {
     local service=$1
-
-    if [ $SERVICE_TYPE = upstart ]; then
+    
+    # native upstart services don't exit with any status, but sysvinit compatibility ones do
+    if [ $SERVICE_TYPE = upstart -a -e /etc/init/$service.conf ]; then
         cmd_test service $service status | grep -q start
     else
         cmd_test service $service status > /dev/null
@@ -35,6 +36,12 @@
 
 function service_restart {
     local service=$1
+    
+    cmd_sudo service $service restart
+}
 
-    cmd service $service restart
+function service_reload {
+    local service=$1
+    
+    cmd_sudo service $service reload
 }
--- a/pvl/dhcp/config.py	Mon Mar 02 21:47:08 2015 +0200
+++ b/pvl/dhcp/config.py	Tue Mar 03 13:07:31 2015 +0200
@@ -147,7 +147,11 @@
         self.comment = comment
 
     def __str__ (self):
-        return ' '.join(self.key)
+        if self.key:
+            return ' '.join(self.key)
+        else:
+            # XXX: Item.__str__
+            return '; '.join(' '.join(str(x) for x in item) for item in self.items)
 
     def __repr__ (self):
         return "Block({self.key!r}, items={self.items!r}, blocks={self.blocks!r})".format(self=self)
--- a/pvl/dns/process.py	Mon Mar 02 21:47:08 2015 +0200
+++ b/pvl/dns/process.py	Tue Mar 03 13:07:31 2015 +0200
@@ -35,30 +35,28 @@
         else:
             yield rr
 
-def zone_includes_path (rrs, includes_path):
+def zone_includes (rrs, includes_path,
+        include_trace=None,
+):
     """
         Rewrite include paths in zones.
+
+            include_trace           - append included paths to given list
     """
 
     for rr in rrs:
         if isinstance(rr, zone.ZoneDirective) and rr.directive == 'INCLUDE':
             include_path, = rr.arguments
 
-            yield zone.ZoneDirective.INCLUDE(os.path.join(includes_path, include_path))
+            include = os.path.join(includes_path, include_path)
+
+            if include_trace is not None:
+                include_trace.append(include)
+
+            yield zone.ZoneDirective.INCLUDE(include)
         else:
             yield rr
 
-def zone_includes (rrs):
-    """
-        Extract $INCLUDE paths from zone.
-    """
-
-    for rr in rrs:
-        if isinstance(rr, pvl.dns.ZoneDirective) and rr.directive == 'INCLUDE':
-            include_path, = rr.arguments
-
-            yield include_path
-
 def apply_zone_output (options, zone):
     """
         Output given ZoneDirective/ZoneRecord items to the output file/stdout.
@@ -70,9 +68,13 @@
         file.write(unicode(item))
         file.write('\n')
 
-def apply_zone (options, args):
+def apply_zone (options, args,
+        include_trace=None,
+):
     """
         ZoneLine.load() in given zones.
+            
+            include_trace           - append included paths to given list
 
         Yields ZoneDirective/ZoneRecord items.
     """
@@ -80,6 +82,9 @@
     for file in pvl.args.apply_files(args, 'r', options.input_charset) :
         log.info("%s: reading zone", file.name)
 
+        if include_trace is not None:
+            include_trace.append(file.name)
+
         for item in zone.ZoneLine.load(file):
             yield item
 
--- a/pvl/dns/tests.py	Mon Mar 02 21:47:08 2015 +0200
+++ b/pvl/dns/tests.py	Tue Mar 03 13:07:31 2015 +0200
@@ -204,9 +204,10 @@
 
 $INCLUDE "includes/test"
 """))
-
+        
+        include_trace = [ ]
         rrs = list(process.zone_serial(rrs, 1337))
-        rrs = list(process.zone_includes_path(rrs, '...'))
+        rrs = list(process.zone_includes(rrs, '...', include_trace))
 
         self.assertZoneEqual(rrs, [
             "$TTL 3600",
@@ -217,4 +218,7 @@
             "bar A 192.0.2.2",
             "$INCLUDE .../includes/test",
         ])
+        self.assertEqual(include_trace, [
+            '.../includes/test',
+        ])
 
--- a/pvl/hosts/__init__.py	Mon Mar 02 21:47:08 2015 +0200
+++ b/pvl/hosts/__init__.py	Tue Mar 03 13:07:31 2015 +0200
@@ -1,4 +1,4 @@
-__version__ = '0.8.0a6'
+__version__ = '0.8.0a8'
 
 from pvl.hosts.config import (
         optparser,
--- a/pvl/hosts/config.py	Mon Mar 02 21:47:08 2015 +0200
+++ b/pvl/hosts/config.py	Tue Mar 03 13:07:31 2015 +0200
@@ -22,6 +22,9 @@
 
     hosts.add_option('--hosts-include',         metavar='PATH',
             help="Optional path for hosts includes, in addition to the host config dir")
+
+    hosts.add_option('--hosts-include-trace',   metavar='FILE',
+            help="Write out all included file paths")
     
     return hosts
 
@@ -207,7 +210,7 @@
         log.info("%s: include: %s", config_path, path)
         yield path
 
-def apply_hosts_configs (options, path, name, config, parent=None, defaults={}):
+def apply_hosts_configs (options, path, name, config, parent=None, defaults={}, include_trace=None):
     """
         Load hosts from a configobj.Section (which can be the top-level ConfigObj).
 
@@ -217,6 +220,7 @@
             config          configobj.Section
             parent          parent section from included files or --hosts-domain
             defaults        hierarchial section defaults
+            include_trace   - optional list to append loaded files to
     """
     
     # items in this section
@@ -226,12 +230,15 @@
 
     # process includes?
     if 'include' in section:
-        includes = section.pop('include').split()
+        # convert from unicode
+        includes = [str(include) for include in section.pop('include').split()]
 
         includes = list(parse_config_includes(options, path, includes))
 
         # within our domain context
-        for host in apply_hosts_files(options, includes, parent=name, defaults=section):
+        for host in apply_hosts_files(options, includes, include_trace=include_trace,
+                parent=name, defaults=section
+        ):
             yield host
     else:
         includes = None
@@ -304,26 +311,38 @@
     
     return apply_hosts_configs(options, path, name, config, **opts)
 
-def apply_hosts_file (options, path, **opts):
+def apply_hosts_file (options, path, include_trace=None, **opts):
     """
         Load Hosts from a file path.
+            
+            include_trace           - optional list to append loaded files to
     """
+    
+    if include_trace is not None:
+        log.debug("%s: include trace", path)
+        include_trace.append(path)
 
     try:
         file = open(path)
     except IOError as ex:
         raise HostConfigError(path, ex.strerror)
 
-    for host in apply_hosts_config(options, file, **opts):
+    for host in apply_hosts_config(options, file, include_trace=include_trace, **opts):
         yield host
 
-def apply_hosts_directory (options, root, **opts):
+def apply_hosts_directory (options, root, include_trace=None, **opts):
     """
         Load Hosts from a directory, loading each file within the directory.
 
+            include_trace           - optional list to append loaded files to
+
         Skips .dotfiles.
     """
 
+    if include_trace is not None:
+        log.debug("%s: include trace", root)
+        include_trace.append(root)
+
     for name in sorted(os.listdir(root)):
         path = os.path.join(root, name)
 
@@ -335,7 +354,7 @@
             log.debug("%s: skip directory: %s", root, name)
             continue
 
-        for host in apply_hosts_file(options, path, **opts):
+        for host in apply_hosts_file(options, path, include_trace=include_trace, **opts):
             yield host
 
 def apply_hosts_files (options, files, **opts):
@@ -359,10 +378,16 @@
 
         Exits with status=2 if loading the confs fails.
     """
+
+    if options.hosts_include_trace:
+        log.debug("include trace")
+        include_trace = [ ]
+    else:
+        include_trace = None
     
     try:
         # load hosts from configs
-        hosts = list(apply_hosts_files(options, args))
+        hosts = list(apply_hosts_files(options, args, include_trace=include_trace))
     except HostConfigObjError as error:
         log.error("%s", error)
         log.error("\t%s", error.line_contents)
@@ -371,6 +396,11 @@
     except HostConfigError as error:
         log.error("%s", error)
         sys.exit(2)
+        
+    if options.hosts_include_trace:
+        with pvl.args.apply_file(options.hosts_include_trace, 'w') as file:
+            for include in include_trace:
+                print >>file, include
 
     # stable ordering
     return sorted(hosts, key=Host.sort_key)
--- a/pvl/hosts/dhcp.py	Mon Mar 02 21:47:08 2015 +0200
+++ b/pvl/hosts/dhcp.py	Tue Mar 03 13:07:31 2015 +0200
@@ -76,9 +76,12 @@
         extensions = host.extensions.get('dhcp', {})
 
         for block in dhcp_host(host, **extensions):
-            if block.key in blocks:
+            if not block.key:
+                # TODO: check for unique Item-Blocks
+                pass
+            elif block.key in blocks:
                 raise HostDHCPError(host, "dhcp {block} conflict with {other}; hosts on multiple networks must use unique ethernet.XXX=... naming".format(block=block, other=blocks[block.key]))
-
-            blocks[block.key] = host
+            else:
+                blocks[block.key] = host
 
             yield block
--- a/pvl/hosts/tests.py	Mon Mar 02 21:47:08 2015 +0200
+++ b/pvl/hosts/tests.py	Tue Mar 03 13:07:31 2015 +0200
@@ -18,6 +18,7 @@
                 hosts_charset   = 'utf-8',
                 hosts_domain    = None,
                 hosts_include   = None,
+                hosts_include_trace = None,
         )
 
     def assertHostEqual(self, host, host_str, attrs):
@@ -185,7 +186,13 @@
 
     def testApplyIncludePath(self):
         self.options.hosts_include = 'etc/hosts'
-        self.assertHostsEqual(config.apply_hosts_files(self.options, ['etc/zones/forward/test']), [
+        include_trace = [ ]
+
+        hosts = list(config.apply_hosts_files(self.options, ['etc/zones/forward/test'],
+            include_trace   = include_trace,
+        ))
+
+        self.assertHostsEqual(hosts, [
                 ('quux@asdf.test', dict(
                     ip          = ipaddr.IPAddress('192.0.2.5'),
                 )),
@@ -197,6 +204,15 @@
                 )),
         ])
 
+        self.assertEqual(include_trace, [
+            'etc/zones/forward/test',
+            'etc/zones/forward/test/asdf.test',
+            'etc/zones/forward/test/test',
+            'etc/hosts/test.d/',
+            'etc/hosts/test.d/bar',
+            'etc/hosts/test.d/foo',
+        ])
+
     def testApply(self):
         self.assertHostsEqual(config.apply(self.options, ['etc/hosts/example.com']), [
                 ('foo@example.com', dict(