8d1257f75e69ece85166da88fbb027f93ae1bb14
[matthijs/upstream/backupninja.git] / src / backupninja.in
1 #!@BASH@
2 # -*- mode: sh; sh-basic-offset: 3; indent-tabs-mode: nil; -*-
3 #
4 #                          |\_
5 # B A C K U P N I N J A   /()/
6 #                         `\|
7 #
8 # Copyright (C) 2004-05 riseup.net -- property is theft.
9 #
10 # This program is free software; you can redistribute it and/or modify
11 # it under the terms of the GNU General Public License as published by
12 # the Free Software Foundation; either version 2 of the License, or
13 # (at your option) any later version.
14 #
15 # This program is distributed in the hope that it will be useful,
16 # but WITHOUT ANY WARRANTY; without even the implied warranty of
17 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 # GNU General Public License for more details.
19 #
20
21 #####################################################
22 ## FUNCTIONS
23
24 function setupcolors () {
25         BLUE="\033[34;01m"
26         GREEN="\033[32;01m"
27         YELLOW="\033[33;01m"
28         PURPLE="\033[35;01m"
29         RED="\033[31;01m"
30         OFF="\033[0m"
31         CYAN="\033[36;01m"
32         COLORS=($BLUE $GREEN $YELLOW $RED $PURPLE)
33 }
34
35 function colorize () {
36         if [ "$usecolors" == "yes" ]; then
37                 local typestr=`echo "$@" | sed 's/\(^[^:]*\).*$/\1/'`
38                 [ "$typestr" == "Debug" ] && type=0
39                 [ "$typestr" == "Info" ] && type=1
40                 [ "$typestr" == "Warning" ] && type=2
41                 [ "$typestr" == "Error" ] && type=3
42                 [ "$typestr" == "Fatal" ] && type=4
43                 color=${COLORS[$type]}
44                 endcolor=$OFF
45                 echo -e "$color$@$endcolor"
46         else
47                 echo -e "$@"
48         fi
49 }
50
51 # We have the following message levels:
52 # 0 - debug - blue
53 # 1 - normal messages - green
54 # 2 - warnings - yellow
55 # 3 - errors - red
56 # 4 - fatal - purple
57 # First variable passed is the error level, all others are printed
58
59 # if 1, echo out all warnings, errors, or fatal
60 # used to capture output from handlers
61 echo_debug_msg=0
62
63 usecolors=yes
64
65 function printmsg() {
66         [ ${#@} -gt 1 ] || return
67
68         type=$1
69         shift
70         if [ $type == 100 ]; then
71                 typestr=`echo "$@" | sed 's/\(^[^:]*\).*$/\1/'`
72                 [ "$typestr" == "Debug" ] && type=0
73                 [ "$typestr" == "Info" ] && type=1
74                 [ "$typestr" == "Warning" ] && type=2
75                 [ "$typestr" == "Error" ] && type=3
76                 [ "$typestr" == "Fatal" ] && type=4
77                 typestr=""
78         else
79                 types=(Debug Info Warning Error Fatal)
80                 typestr="${types[$type]}: "
81         fi
82         
83         print=$[4-type]
84         
85         if [ $echo_debug_msg == 1 ]; then
86                 echo -e "$typestr$@" >&2
87         elif [ $debug ]; then
88                 colorize "$typestr$@" >&2
89         fi
90         
91         if [ $print -lt $loglevel ]; then
92                 logmsg "$typestr$@"
93         fi
94 }
95
96 function logmsg() {
97         if [ -w "$logfile" ]; then
98                 echo -e `date "+%h %d %H:%M:%S"` "$@" >> $logfile
99         fi
100 }
101
102 function passthru() {
103         printmsg 100 "$@"
104 }
105 function debug() {
106         printmsg 0 "$@"
107 }
108 function info() {
109         printmsg 1 "$@"
110 }
111 function warning() {
112         printmsg 2 "$@"
113 }
114 function error() {
115         printmsg 3 "$@" 
116 }
117 function fatal() {
118         printmsg 4 "$@"
119         exit 2
120 }
121
122 msgcount=0
123 function msg {
124         messages[$msgcount]=$1
125         let "msgcount += 1"
126 }
127
128 #
129 # enforces very strict permissions on configuration file $file.
130 #
131
132 function check_perms() {
133    local file=$1
134    debug "check_perms $file"
135    local perms
136    local owners
137
138    perms=($(stat -L --format='%A' $file))
139    debug "perms: $perms"
140    local gperm=${perms:4:3}
141    debug "gperm: $gperm"
142    local wperm=${perms:7:3}
143    debug "wperm: $wperm"
144
145    owners=($(stat -L --format='%g %G %u %U' $file))
146    local gid=${owners[0]}
147    local group=${owners[1]}
148    local owner=${owners[2]}
149
150    if [ "$owner" != 0 ]; then
151       echo "Configuration files must be owned by root! Dying on file $file"
152       fatal "Configuration files must be owned by root! Dying on file $file"
153    fi
154    
155    if [ "$wperm" != '---' ]; then
156       echo "Configuration files must not be world writable/readable! Dying on file $file"
157       fatal "Configuration files must not be world writable/readable! Dying on file $file"
158    fi
159
160    if [ "$gperm" != '---' ]; then
161       case "$admingroup" in
162          $gid|$group) :;;
163
164          *)
165            if [ "$gid" != 0 ]; then
166               echo "Configuration files must not be writable/readable by group $group! Use the admingroup option in backupninja.conf. Dying on file $file"
167               fatal "Configuration files must not be writable/readable by group $group! Use the admingroup option in backupninja.conf. Dying on file $file"
168            fi
169          ;;
170          esac
171    fi
172 }
173
174 # simple lowercase function
175 function tolower() {
176         echo "$1" | tr [:upper:] [:lower:]
177 }
178
179 # simple to integer function
180 function toint() {
181         echo "$1" | tr -d [:alpha:] 
182 }
183
184 #
185 # function isnow(): returns 1 if the time/day passed as $1 matches
186 # the current time/day.
187 #
188 # format is <day> at <time>:
189 #   sunday at 16
190 #   8th at 01
191 #   everyday at 22
192 #
193
194 # we grab the current time once, since processing
195 # all the configs might take more than an hour.
196 nowtime=`date +%H`
197 nowday=`date +%d`
198 nowdayofweek=`date +%A`
199 nowdayofweek=`tolower "$nowdayofweek"`
200
201 function isnow() {
202         local when="$1"
203         set -- $when
204         whendayofweek=$1; at=$2; whentime=$3;
205         whenday=`toint "$whendayofweek"`
206         whendayofweek=`tolower "$whendayofweek"`
207         whentime=`echo "$whentime" | sed 's/:[0-9][0-9]$//' | sed -r 's/^([0-9])$/0\1/'`
208
209         if [ "$whendayofweek" == "everyday" -o "$whendayofweek" == "daily" ]; then
210                 whendayofweek=$nowdayofweek
211         fi
212
213         if [ "$whenday" == "" ]; then
214                 if [ "$whendayofweek" != "$nowdayofweek" ]; then
215                         whendayofweek=${whendayofweek%s}
216                         if [ "$whendayofweek" != "$nowdayofweek" ]; then
217                                 return 0
218                         fi
219                 fi
220         elif [ "$whenday" != "$nowday" ]; then
221                 return 0
222         fi
223
224         [ "$at" == "at" ] || return 0
225         [ "$whentime" == "$nowtime" ] || return 0
226
227         return 1
228 }
229
230 function usage() {
231         cat << EOF
232 $0 usage:
233 This script allows you to coordinate system backup by dropping a few
234 simple configuration files into @CFGDIR@/backup.d/. Typically, this
235 script is run hourly from cron.
236
237 The following options are available:
238 -h, --help           This usage message
239 -d, --debug          Run in debug mode, where all log messages are
240                      output to the current shell.
241 -f, --conffile FILE  Use FILE for the main configuration instead
242                      of @CFGDIR@/backupninja.conf
243 -t, --test           Test run mode. This will test if the backup
244                      could run, without actually preforming any
245                      backups. For example, it will attempt to authenticate
246                      or test that ssh keys are set correctly.
247 -n, --now            Perform actions now, instead of when they might
248                      be scheduled. No output will be created unless also
249                      run with -d.
250     --run FILE       Execute the specified action file and then exit.    
251                      Also puts backupninja in debug mode.
252                      
253 When in debug mode, output to the console will be colored:
254 EOF
255         debug=1
256         debug   "Debugging info (when run with -d)"
257         info    "Informational messages (verbosity level 4)"
258         warning "Warnings (verbosity level 3 and up)"
259         error   "Errors (verbosity level 2 and up)"
260         fatal   "Fatal, halting errors (always shown)"
261 }
262
263 ##
264 ## this function handles the running of a backup action
265 ##
266 ## these globals are modified:
267 ## fatals, errors, warnings, actions_run, errormsg
268 ##
269
270 function process_action() {
271         local file="$1"
272         local suffix="$2"
273         local run="no"
274         setfile $file
275
276         # skip over this config if "when" option
277         # is not set to the current time.
278         getconf when "$defaultwhen"
279         if [ "$processnow" == 1 ]; then
280                 info ">>>> starting action $file (because of --now)"
281                 run="yes"
282         elif [ "$when" == "hourly" ]; then
283                 info ">>>> starting action $file (because 'when = hourly')"
284                 run="yes"
285         else
286                 IFS=$'\t\n'
287                 for w in $when; do
288                         IFS=$' \t\n'
289                         isnow "$w"
290                         ret=$?
291                         IFS=$'\t\n'
292                         if [ $ret == 0 ]; then
293                                 debug "skipping $file because it is not $w"
294                         else
295                                 info ">>>> starting action $file (because it is $w)"
296                                 run="yes"
297                         fi
298                 done
299                 IFS=$' \t\n'
300         fi
301         debug $run
302         [ "$run" == "no" ] && return
303         
304         let "actions_run += 1"
305
306         # call the handler:
307         local bufferfile=`maketemp backupninja.buffer`
308         echo "" > $bufferfile
309         echo_debug_msg=1
310         (
311                 . $scriptdirectory/$suffix $file
312         ) 2>&1 | (
313                 while read a; do
314                         echo $a >> $bufferfile
315                         [ $debug ] && colorize "$a"
316                 done
317         )
318         retcode=$?
319         # ^^^^^^^^ we have a problem! we can't grab the return code "$?". grrr.
320         echo_debug_msg=0
321
322         _warnings=`cat $bufferfile | grep "^Warning: " | wc -l`
323         _errors=`cat $bufferfile | grep "^Error: " | wc -l`
324         _fatals=`cat $bufferfile | grep "^Fatal: " | wc -l`
325         
326         ret=`grep "\(^Warning: \|^Error: \|^Fatal: \)" $bufferfile`
327         rm $bufferfile
328         if [ $_fatals != 0 ]; then
329                 msg "*failed* -- $file"
330                 errormsg="$errormsg\n== fatal errors from $file ==\n\n$ret\n"
331                 passthru "Fatal: <<<< finished action $file: FAILED"
332         elif [ $_errors != 0 ]; then
333                 msg "*error* -- $file"
334                 errormsg="$errormsg\n== errors from $file ==\n\n$ret\n"
335                 error "<<<< finished action $file: ERROR"
336         elif [ $_warnings != 0 ]; then
337                 msg "*warning* -- $file"
338                 errormsg="$errormsg\n== warnings from $file ==\n\n$ret\n"
339                 warning "<<<< finished action $file: WARNING"
340         else
341                 msg "success -- $file"
342                 info "<<<< finished action $file: SUCCESS"
343         fi
344
345         let "fatals += _fatals"
346         let "errors += _errors"
347         let "warnings += _warnings"     
348 }
349
350 #####################################################
351 ## MAIN
352
353 setupcolors
354 conffile="@CFGDIR@/backupninja.conf"
355 loglevel=3
356
357 ## process command line options
358
359 while [ $# -ge 1 ]; do
360         case $1 in
361                 -h|--help) usage;;
362                 -d|--debug) debug=1;;
363                 -t|--test) test=1;debug=1;;
364                 -n|--now) processnow=1;;
365                 -f|--conffile)
366                         if [ -f $2 ]; then
367                                 conffile=$2
368                         else
369                                 echo "-f|--conffile option must be followed by an existing filename"
370                                 fatal "-f|--conffile option must be followed by an existing filename"
371                                 usage
372                         fi
373                         # we shift here to avoid processing the file path 
374                         shift
375                         ;;
376                 --run)
377                         debug=1
378                         if [ -f $2 ]; then
379                                 singlerun=$2
380                                 processnow=1
381                         else
382                                 echo "--run option must be fallowed by a backupninja action file"
383                                 fatal "--run option must be fallowed by a backupninja action file"
384                                 usage
385                         fi
386                         shift
387                         ;;
388                 *)
389                         debug=1
390                         echo "Unknown option $1"
391                         fatal "Unknown option $1"
392                         usage
393                         exit
394                         ;;
395         esac
396         shift
397 done                                                                                                                                                                                                            
398
399 #if [ $debug ]; then
400 #       usercolors=yes
401 #fi
402
403 ## Load and confirm basic configuration values
404
405 # bootstrap
406 if [ ! -r "$conffile" ]; then
407         echo "Configuration file $conffile not found." 
408         fatal "Configuration file $conffile not found."
409 fi
410
411 # find $libdirectory
412 libdirectory=`grep '^libdirectory' $conffile | awk '{print $3}'`
413 if [ -z "$libdirectory" ]; then
414         if [ -d "@libdir@" ]; then
415            libdirectory="@libdir@"
416         else
417            echo "Could not find entry 'libdirectory' in $conffile." 
418            fatal "Could not find entry 'libdirectory' in $conffile." 
419         fi
420 else
421         if [ ! -d "$libdirectory" ]; then
422            echo "Lib directory $libdirectory not found." 
423            fatal "Lib directory $libdirectory not found." 
424         fi
425 fi
426
427 # include shared functions
428 . $libdirectory/tools
429 . $libdirectory/vserver
430
431 setfile $conffile
432
433 # get global config options (second param is the default)
434 getconf configdirectory @CFGDIR@/backup.d
435 getconf scriptdirectory @datadir@
436 getconf reportemail
437 getconf reportspace
438 getconf reportsuccess yes
439 getconf reportwarning yes
440 getconf loglevel 3
441 getconf when "Everyday at 01:00"
442 defaultwhen=$when
443 getconf logfile @localstatedir@/log/backupninja.log
444 getconf usecolors "yes"
445 getconf SLAPCAT /usr/sbin/slapcat
446 getconf LDAPSEARCH /usr/bin/ldapsearch
447 getconf RDIFFBACKUP /usr/bin/rdiff-backup
448 getconf MYSQL /usr/bin/mysql
449 getconf MYSQLHOTCOPY /usr/bin/mysqlhotcopy
450 getconf MYSQLDUMP /usr/bin/mysqldump
451 getconf PGSQLDUMP /usr/bin/pg_dump
452 getconf PGSQLDUMPALL /usr/bin/pg_dumpall
453 getconf PGSQLUSER postgres
454 getconf GZIP /bin/gzip
455 getconf RSYNC /usr/bin/rsync
456 getconf admingroup root
457
458 # initialize vservers support
459 # (get config variables and check real vservers availability)
460 init_vservers nodialog
461
462 if [ ! -d "$configdirectory" ]; then
463         echo "Configuration directory '$configdirectory' not found."
464         fatal "Configuration directory '$configdirectory' not found."
465 fi
466
467 [ -f "$logfile" ] || touch $logfile
468
469 if [ "$UID" != "0" ]; then
470         echo "`basename $0` can only be run as root"
471         exit 1
472 fi
473
474 ## Process each configuration file
475
476 # by default, don't make files which are world or group readable.
477 umask 077
478
479 # these globals are set by process_action()
480 fatals=0
481 errors=0
482 warnings=0
483 actions_run=0
484 errormsg=""
485
486 if [ "$singlerun" ]; then
487         files=$singlerun
488 else
489         files=`find $configdirectory -follow -mindepth 1 -maxdepth 1 -type f ! -name '.*.swp' | sort -n`
490
491         if [ -z "$files" ]; then
492                 fatal "No backup actions configured in '$configdirectory', run ninjahelper!"
493         fi
494 fi
495
496 for file in $files; do
497         [ -f "$file" ] || continue
498
499         check_perms ${file%/*} # check containing dir
500         check_perms $file
501         suffix="${file##*.}"
502         base=`basename $file`
503         if [ "${base:0:1}" == "0" -o "$suffix" == "disabled" ]; then
504                 info "Skipping $file"
505                 continue
506         fi
507
508         if [ -e "$scriptdirectory/$suffix" ]; then
509                 process_action $file $suffix
510         else
511                 error "Can't process file '$file': no handler script for suffix '$suffix'"
512                 msg "*missing handler* -- $file"
513         fi
514 done
515
516 ## mail the messages to the report address
517
518 if [ $actions_run == 0 ]; then doit=0
519 elif [ "$reportemail" == "" ]; then doit=0
520 elif [ $fatals != 0 ]; then doit=1
521 elif [ $errors != 0 ]; then doit=1
522 elif [ "$reportsuccess" == "yes" ]; then doit=1
523 elif [ "$reportwarning" == "yes" -a $warnings != 0 ]; then doit=1
524 else doit=0
525 fi
526
527 if [ $doit == 1 ]; then
528         debug "send report to $reportemail"
529         hostname=`hostname`
530         [ $warnings == 0 ] || subject="WARNING"
531         [ $errors == 0 ] || subject="ERROR"
532         [ $fatals == 0 ] || subject="FAILED"
533         
534         {
535                 for ((i=0; i < ${#messages[@]} ; i++)); do
536                         echo ${messages[$i]}
537                 done
538                 echo -e "$errormsg"
539                 if [ "$reportspace" == "yes" ]; then
540                         previous=""
541                         for i in $(ls "$configdirectory"); do
542                         backuploc=$(grep ^directory "$configdirectory"/"$i" | awk '{print $3}')
543                         if [ "$backuploc" != "$previous" ]; then
544                                 mountdev=$(mount | grep "$backuploc" | awk '{print $1}')
545                                 df -h "$mountdev"
546                                 previous="$backuploc"
547                                 fi
548                         done
549                 fi
550         } | mail -s "backupninja: $hostname $subject" $reportemail
551 fi
552
553 if [ $actions_run != 0 ]; then
554         info "FINISHED: $actions_run actions run. $fatals fatal. $errors error. $warnings warning."
555 fi