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