#! /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