#! /bin/bash
# -*- mode: sh; sh-basic-offset: 4; indent-tabs-mode: nil; -*-
#
#  metche: reducing root bus factor
#  Copyright (C) 2004-2006 boum.org collective - property is theft !
#
#  This program is free software; you can redistribute it and/or modify
#  it under the terms of the GNU General Public License as published by
#  the Free Software Foundation; either version 2 of the License, or
#  (at your option) any later version.
#
#  This program is distributed in the hope that it will be useful,
#  but WITHOUT ANY WARRANTY; without even the implied warranty of
#  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#  GNU General Public License for more details.
#
#  You should have received a copy of the GNU General Public License along
#  with this program; if not, write to the Free Software Foundation, Inc.,
#  59 Temple Place - Suite 330, Boston, MA  02111-1307, USA.
#

set -e
shopt -s nullglob

###
### Auxiliary functions
###

display_usage() {
    ( echo "Usage: `basename $0` list" 
      echo "       `basename $0` report" \
           "[{stable|testing|unstable}-YYYYMMDDHHMM]"
      echo "       `basename $0` cron"
      echo "       `basename $0` stabilize [testing-YYYYMMDDHHMM]"
      echo ""
    ) >&2
}

fatal() {
    echo -e "$@" >&2
    exit 2
}

executable_not_found() {
    local executable="$1"
    local software="$2"
    local option="$3"

    fatal "$executable not found. Please install $software or turn $option off."
}

debug() {
    [ "$DEBUG" != yes ] || echo -e "debug: $@" >&2
}

email() {
    debug "email $@"
    local subject="$_MAIL_SUBJECT : $1"
    if [ $ENCRYPT_EMAIL = "yes" ]; then
        LC_ALL="$LOCALE" gpg --batch --armor --encrypt \
                             --recipient "$EMAIL_ADDRESS" |
            LC_ALL="$LOCALE" mutt -s "$subject" "$EMAIL_ADDRESS"
    else
        LC_ALL="$LOCALE" mutt -s "$subject" "$EMAIL_ADDRESS"
    fi
}

###
### Configuration
###

DEBUG="yes"
WATCHED_DIR="/etc"
BACKUP_DIR="/var/lib/metche"
# if set, activate single changelog mode
#CHANGELOG_FILE="/root/Changelog"
# if set, activate multiple changelogs mode
#CHANGELOG_DIR="/root/changelogs"
DO_PACKAGES="no"
DO_DETAILS="no"
TESTING_TIME="60"
STABLE_TIME="3"
EMAIL_ADDRESS="root@`hostname -f`"
ENCRYPT_EMAIL="no"
EXCLUDES="*.swp #* *~ *.key ifstate adjtime ld.so.cache shadow* \
          blkid.tab* aumixrc net.enable mtab \
          vdirbase run.rev vdir run.rev"
LOCALE="C"

_MAIL_SUBJECT="`hostname -f` - changes report"
_NO_DEBIAN_PACKAGES_CHANGE="No change in Debian packages state."
_NO_CHANGE="No change."

MAIN_HEADER="
     c h a n g e s   r e p o r t
     ---------------------------

"

CHANGELOGS_HEADER="

Changelogs
==========

"

FILES_HEADER="

Changed files
=============

"

DEBIAN_PACKAGES_HEADER="

Changes in Debian packages
==========================

"

FILES_DETAILS_HEADER="

Details for changed files
=========================

"

if [ "$1" = "-h" ]; then
    if [ -f /etc/metche/$2.conf ]; then
        . /etc/metche/$2.conf
        CMD="$3"
        MILESTONE="$4"
    else
        display_usage
        fatal "Config file /etc/metche/$2.conf does not exist."
    fi
elif [ -f /etc/metche.conf ]; then
    . /etc/metche.conf
    CMD="$1"
    MILESTONE="$2"
else
    display_usage
    fatal "Config file not found."
fi

PATH="/bin:/usr/bin"
unset LC_ALL
unset LC_CTYPE
unset LANGUAGE
unset LANG
umask 077

test -d "$WATCHED_DIR" || fatal "WATCHED_DIR ($WATCHED_DIR) does not exist."
test -d "$BACKUP_DIR"  || fatal "BACKUP_DIR ($BACKUP_DIR) does not exist."
test -z "$TAR_OPTS"    || fatal "TAR_OPTS is deprecated, use EXCLUDES instead."

if [ "$DO_PACKAGES" = "yes" ]; then
    which apt-show-versions > /dev/null ||
        executable_not_found "apt-show-versions" "it" "DO_PACKAGES"
fi

if [ "$ENCRYPT_EMAIL" = "yes" ]; then
    which gpg > /dev/null ||
        executable_not_found "gpg" "GnuPG" "ENCRPYT_EMAIL"
    gpg --batch --list-public-keys $EMAIL_ADDRESS >/dev/null 2>&1 ||
        fatal "GnuPG public key for $EMAIL_ADDRESS not found."
fi

DATE=`date "+%Y%m%d%H%M"`
WATCHED_PARENT=`dirname $WATCHED_DIR`
if [ "$WATCHED_PARENT" != '/' ]; then
    WATCHED_PARENT="$WATCHED_PARENT/"
fi

# How to use $TAR_OPTS:
#    - $TAR_OPTS should be used unquoted
#    - 'set -o noglob' has to be run before any $TAR_OPTS use
#    - 'set +o noglob' has to be run after any $TAR_OPTS use
TAR_OPTS=""
set -o noglob
for pattern in $EXCLUDES; do
    TAR_OPTS="$TAR_OPTS --exclude=$pattern"
done
set +o noglob

# How to use $FIND_OPTS:
#    - $FIND_OPTS should appear unquoted between:
#       . the (optional) target files and directories
#       . the (compulsory) action, such as -print or -exec
#    - 'set -o noglob' has to be run before any $FIND_OPTS use
#    - 'set +o noglob' has to be run after any $FIND_OPTS use
FIND_OPTS=""
set -o noglob
# DO NOT fix me: the final -or at the end of $FIND_OPTS is really needed
for pattern in $EXCLUDES; do
    FIND_OPTS="$FIND_OPTS -path */$pattern -prune -or"
done
set +o noglob

###
### Modules enabling/disabling
###

DO_CHANGELOGS="no"
if [ "$CHANGELOG_DIR" ]; then
    if [ -d "$CHANGELOG_DIR" ]; then
        DO_CHANGELOGS="dir"
    fi
elif [ -f "$CHANGELOG_FILE" ]; then
    DO_CHANGELOGS="file"
fi

# Debian packages
# Enabled/disabled by $DO_PACKAGES, initialized to "yes", can be
# overriden by the sourced conf file.


###
### A few functions to do the real work
###

# Returns 0 if, and only if, specified milestone exists.
milestone_exists() {
    local milestone="$1"
    if [ -f "${BACKUP_DIR}/${milestone}.tar.bz2" -o \
         -L "${BACKUP_DIR}/${milestone}.tar.bz2" ]; then
        return 0
    else
        return 1
    fi
}

# Echoes the given milestone's version (i.e. "stable", "testing", "unstable")
# if it has a valid version, else "none".
# The given milestone can be inexistant.
milestone_version() {
    local milestone="$1"
    local version="`echo $milestone | sed 's/-.*$//'`"
    case $version in
      stable|testing|unstable)
          echo $version;;
      *)
          echo "none";;
    esac
}

# Echoes given milestone's date.
# Symlinks (e.g.: *-latest) are dereferenced if needed.
# The given milestone can be inexistant.
milestone_date() {
    local milestone="$1"

    if [ -L "${BACKUP_DIR}/${milestone}.tar.bz2" ]; then
        milestone="`readlink ${BACKUP_DIR}/${milestone}.tar.bz2`"
    fi
    echo `basename $milestone` | sed 's/.*-//' | sed 's/\..*$//'
}

# Returns 0 if, and only if, the given milestone ($1) is the latest one
# of its type.
# The given milestone can be inexistant.
is_latest() {
    local file milestone ref_milestone ref_date ref_version
    
    ref_milestone="$1"
    ref_date="`milestone_date $ref_milestone`"
    ref_version="`milestone_version $ref_milestone`"
    for file in "${BACKUP_DIR}/${ref_version}-"*.tar.bz2; do
        milestone=`basename $file | sed 's/\.tar\.bz2$//'`
        if [ "`milestone_date $milestone`" -gt "$ref_date" ]; then
            return 1
        fi
    done
    return 0
}

# This will save an archive of the watched directory with the given prefix
save_files() {
    debug "  - save_files $@"
    set -o noglob
    tar jcf "$BACKUP_DIR/$1-$DATE".tar.bz2 \
        -C "$WATCHED_PARENT" $TAR_OPTS `basename "$WATCHED_DIR"`
    set +o noglob
    ln -sf "$1-$DATE".tar.bz2 "$BACKUP_DIR/$1"-latest.tar.bz2
}

# This will save packages list with the given prefix
save_packages() {
    debug "  - save_packages $@"
    apt-show-versions -i
    apt-show-versions |
        sort > "$BACKUP_DIR/$1-$DATE".packages
    ln -sf "$1-$DATE".packages "$BACKUP_DIR/$1"-latest.packages
}

# This will save Changelogs with the given prefix
save_changelogs() {
    debug "  - save_changelogs $@"
    local changelog domain file

    if [ "$DO_CHANGELOGS" = "dir" ]; then
        for file in "$CHANGELOG_DIR"/*/Changelog; do
            changelog="${file##$CHANGELOG_DIR/}"
            domain="${changelog%%/Changelog}"
            cat "$file" > "$BACKUP_DIR/$1-$DATE.$domain.Changelog"
            ln -sf "$1-$DATE.$domain.Changelog" \
                "$BACKUP_DIR/$1-latest.$domain.Changelog"
        done
    elif [ "$DO_CHANGELOGS" = "file" ]; then
        cat "$CHANGELOG_FILE" > "$BACKUP_DIR/$1-$DATE.Changelog"
        ln -sf "$1-$DATE.Changelog" "$BACKUP_DIR/$1-latest.Changelog"
    fi
}

# Save whatever reflect the current state with the given prefix
save_state() {
    debug "save_state $@"
    save_files "$1"
    [ $DO_PACKAGES = "no" ] || save_packages "$1"
    [ $DO_CHANGELOGS = "no" ] || save_changelogs "$1"
}

# Report changes against given version to standard output
report_changes() {
    debug "report_changes $@"    
    local tmp tmpdir changelog domain diff tar_diff diff_diff
    local files old new tmp_packages file

    # File to store results
    tmp=`mktemp -q`
    # We need to diff against given version, so extract it
    tmpdir=`mktemp -d -q`
    tar jxf "$BACKUP_DIR/$1".tar.bz2 -C "$tmpdir"

    echo "$MAIN_HEADER" >> "$tmp"

    if [ $DO_CHANGELOGS = "dir" ]; then
        echo "$CHANGELOGS_HEADER" >> "$tmp"
        for file in "$CHANGELOG_DIR"/*/Changelog; do
            changelog="${file##$CHANGELOG_DIR/}"
            domain="${changelog%%/Changelog}"
            diff=`LC_ALL=$LOCALE \
                  diff -wEbBN "$BACKUP_DIR/$1.$domain.Changelog" \
                              "$file"` ||
                # diff returns false when files differ
                (echo "$domain:" ; echo "$diff" |
                     grep -v '^[0-9-]\|^\\') >> "$tmp"
        done
    fi
    if [ $DO_CHANGELOGS = "file" ]; then
        echo "$CHANGELOGS_HEADER" >> "$tmp"
        diff=`LC_ALL=$LOCALE \
              diff -wEbBN "$BACKUP_DIR/$1.Changelog" "$CHANGELOG_FILE"` ||
            # diff returns false when files differ
            (echo "$diff" | grep -v '^[0-9-]\|^\\') >> "$tmp"
    fi

    echo "$FILES_HEADER" >> "$tmp"

    # Find differences with tar
    set -o noglob
    tar_diff=$(tar jdf "$BACKUP_DIR/$1".tar.bz2 \
                   -C "$WATCHED_PARENT" $TAR_OPTS 2>&1 |
       # transform:
       #   etc/issue: Gid differs   -> etc/issue
       #   tar: etc/irssi.conf: ... -> etc/irssi.conf
       sed -e 's/\(tar: \)\?\([^:]*\):.*/\2/')
    # Get new files
    diff_diff=$(diff -qr $TAR_OPTS "$tmpdir"/`basename "$WATCHED_DIR"` \
                                   "$WATCHED_DIR" 2>/dev/null |
       # Only in test/etc: issue -> test/etc/issue
       sed -n -e "s,^Only in $WATCHED_PARENT\([^:]*\): \(.*\),\1/\2,p")
    files="`echo "$tar_diff$diff_diff" | sort -u`"
    set +o noglob
    if [ -z "$files" ]; then
        echo "$_NO_CHANGE" >> "$tmp"
    else
        for file in $files; do
            old="$tmpdir"/"$file"
            new="$WATCHED_PARENT$file"
            if [ -e "$old" -a -e "$new" ]; then
                echo -n '< '
                ls -ld "$old" | sed -e "s;$tmpdir/;;"
                echo -n '> '
                ls -ld "$new" | sed -e "s;$WATCHED_PARENT;;"
            elif [ -e "$old" ]; then
                echo -n '- '
                ls -ld "$old" | sed -e "s;$tmpdir/;;"
            elif [ -e "$new" ]; then
                echo -n '+ '
                ls -ld "$new" | sed -e "s;$WATCHED_PARENT;;"
            fi
        done >> "$tmp"
    fi
 
    if [ "$DO_PACKAGES" = "yes" ]; then
        echo "$DEBIAN_PACKAGES_HEADER" >> "$tmp"

        tmp_packages=`mktemp -q`
        apt-show-versions -i
        apt-show-versions | sort > "$tmp_packages"
        if diff -wEbB "$BACKUP_DIR/$1".packages "$tmp_packages"; then
            echo "$_NO_DEBIAN_PACKAGES_CHANGE"
        fi | grep -v '^[0-9-]' >> "$tmp"
    fi

    if [ "$DO_DETAILS" = "yes" ]; then
        echo "$FILES_DETAILS_HEADER" >> "$tmp"

        # Just diff it!
        set -o noglob
        if (LC_ALL=$LOCALE diff -urBN $TAR_OPTS \
                --minimal "$tmpdir"/`basename "$WATCHED_DIR"` \
                "$WATCHED_DIR" 2>/dev/null); then
            echo "$_NO_CHANGE" 
        fi | grep -v '^--- \|diff ' |
             sed -e "s;^+++ $WATCHED_PARENT\([^ ]*\)    .*;+++ \1;" \
             >> "$tmp"
        set +o noglob
    fi

    # Put  on standard output
    cat "$tmp"

    # Clean temporaries
    rm -rf "$tmp" "$tmpdir"
}

# Turns into stable the given testing.
# NB: argument validity is supposed to have been already checked.
stabilize_state() {
    debug "stabilize_state $@"
    local testing stable file dst

    testing="$1"
    # follow symlink if needed
    if [ -L "${BACKUP_DIR}/$testing".tar.bz2 ]; then
        testing="`readlink ${BACKUP_DIR}/${testing}.tar.bz2`"
        testing="`basename $testing | sed 's/\..*//'`"
    fi
    stable="`echo $testing | sed 's/^testing/stable/'`"
    for file in "${BACKUP_DIR}/${testing}"*; do
        dst="`echo $file | sed 's/\/testing-/\/stable-/'`"
        cp "$file" "$dst"
        # create/change stable-latest* links if, and only if,
        # it's really the latest    
        if is_latest $stable; then
            ln -sf "`basename $dst`" "${BACKUP_DIR}/`basename $dst |
                sed 's/-[0-9]*\./-latest\./'`"
        fi
    done
}

# Print watched directory and files separated by spaces
# (suitable for find)
# Note: this function needs pathname expansion, but is called from places where
#       it is disabled; that's why we need to save the pathname expansion status
#       in the beginning and reset it to end with.
print_watched_files() {
    local files
    local reset_noglob_status_cmd

    files="$WATCHED_DIR"
    reset_noglob_status_cmd="`set +o | grep 'set .o noglob'`"
    set +o noglob
    if [ "$DO_CHANGELOGS" = "dir" ]; then
        files="$files `echo "$CHANGELOG_DIR"/*/Changelog`"
    elif [ "$DO_CHANGELOGS" = "file" ]; then
        files="$files $CHANGELOG_FILE"
    fi
    $reset_noglob_status_cmd
    echo "$files"
}

# Return true if watched files has not changed since $1 minutes
no_change_since() {
    local time
    
    time="$1"
    set -o noglob
    if [ -z "$(find $(print_watched_files) $FIND_OPTS -cmin "-$time" -print | head -1)" ]; then
        set +o noglob
        return 0
    else
        set +o noglob
        return 1
    fi
}

# Return true if watched files has changed since file $1 last modification
changed_from() {
    local ref_file
    
    ref_file="$1"
    set -o noglob
    if [ "$(find $(print_watched_files) $FIND_OPTS -newer "$ref_file" -print | head -1)" ]; then
        set +o noglob
        return 0
    else
        set +o noglob
        return 1
    fi
}

###
### Main
###

# make sure we've got at least one testing and one stable
milestone_exists testing-latest || save_state "testing"
milestone_exists stable-latest || stabilize_state "testing-latest"

case "$CMD" in

    report)
        DO_DETAILS="yes"
        if [ -z "$MILESTONE" ]; then
            report_changes "testing-latest"
        elif milestone_exists "$MILESTONE"; then
            report_changes "$MILESTONE"
        else
            display_usage
            fatal "The specified state does not exist."
        fi
        ;;

    list)
        for file in "$BACKUP_DIR"/*.tar.bz2; do
            echo `basename ${file%%.tar.bz2}`
        done
        ;;

    cron)
        STABLE_TIME_MIN=`expr 24 '*' 60 '*' "$STABLE_TIME"`

        ### Algorithm
        #
        # if (no change happened for TESTING_TIME) then
        #     if (something has changed since the last testing) then
        #       send a report against last testing
        #       save a new testing state
        #       delete all saved unstable states
        #     elif (no change happened for STABLE_TIME) then
        #       if (something has changed since the last stable) then
        #           save a new stable state and notify EMAIL_ADDRESS
        #           delete all saved testing states older than STABLE_TIME
        #       fi
        #     fi
        # elif (last unstable exists) then
        #     if (something has changed since the last unstable) then
        #         save a new unstable state
        #     fi
        # else
        #     save a new unstable state
        # fi
        if no_change_since "$TESTING_TIME"; then
            debug "no change since TESTING_TIME"
            if changed_from "$BACKUP_DIR"/testing-latest.tar.bz2; then
                debug "changed from testing-latest"
                report_changes "testing-latest" | email "testing-$DATE"
                save_state "testing"
                debug "removing all saved unstable states."
                find "$BACKUP_DIR" -name 'unstable-*' -exec rm "{}" \;
            elif no_change_since "$STABLE_TIME_MIN"; then
                if changed_from "$BACKUP_DIR"/stable-latest.tar.bz2; then
                    save_state "stable"
                    echo "metche saved a new stable state : stable-${DATE}." |
                        email "stable-$DATE"
                    debug "removing all saved testing states older " \
                          "than STABLE_TIME ($STABLE_TIME)."
                    find "$BACKUP_DIR" -name 'testing-*' \
                                       -ctime +"$STABLE_TIME" -exec rm "{}" \;
                fi
            fi
        elif milestone_exists unstable-latest; then
            if changed_from "$BACKUP_DIR"/unstable-latest.tar.bz2; then
                debug "changed from unstable-latest"
                save_state "unstable"
            fi
        else
            save_state "unstable"
        fi
        ;;

    stabilize)
        if [ -z "$MILESTONE" ]; then
            stabilize_state "testing-latest"
        elif [ "`milestone_version $MILESTONE`" = "testing" -a \
               milestone_exists $MILESTONE ]; then
            stabilize_state "$MILESTONE"
        else
            display_usage
            fatal "The specified state is not an existing testing state."
        fi
        ;;

    test)
        milestone_version "stable-200507040202"
        milestone_version "testing-latest"
        milestone_version "testing-200507030047"
        milestone_version "testing-200507030047qsfd"
        milestone_date "stable-200507040202"
        milestone_date "testing-latest"
        milestone_date "testing-200507030047"
        milestone_date "testing-200507030047qsfd"
        (is_latest testing-latest && echo oui) || echo non
        (is_latest testing-200507031821 && echo oui) || echo non
        (is_latest stable-200507031831 && echo oui) || echo non
        (is_latest stable-200507040202 && echo oui) || echo non 
        ;;

    *)
        display_usage
        exit 1
        ;;
esac

# vim: et sw=4
