#!/usr/bin/python3
"""Stefan Huber's simplistic backup solution."""
+__version__ = "0.1"
+__author__ = "Stefan Huber"
+
import datetime
import os, shutil, sys
import configparser
import hashlib
-import subprocess
+import subprocess, fcntl, select
import random, re
+import logging
Mode = ["full", "incr", "diff"]
", mode: " + self.mode + "]"
def colAlignedString(self):
- return "%16s %8s %4s" % ( \
- self.date.strftime("%Y-%m-%d %H:%M"), self.epoch, self.mode)
+ age = datetime.datetime.now() - self.date
+ total_hours = age.total_seconds()/3600
+ if total_hours <= 48:
+ agestr = "(%s h)" % int(total_hours)
+ else:
+ agestr = "(%s d)" % age.days
+ return "%16s %7s %8s %4s" % ( \
+ self.date.strftime("%Y-%m-%d %H:%M"), agestr, self.epoch, self.mode)
@staticmethod
def getDirName(date, epoch, mode):
class BackupManager:
"""List and create backups"""
- def __init__(self, conffn, alwaysyes):
+ def __init__(self, conffn):
self.conf = Config()
- self.alwaysyes = alwaysyes
self.conf.read(conffn)
- def backupFileSet(self, fileset, targetdir, log, since=None):
+ def backupFileSet(self, fileset, targetdir, since=None):
"""Create an archive for given fileset at given target directory."""
- print("Running file set: " + fileset.name)
+ logfile = logging.getLogger('backuplog')
+ logfile.info("Running file set: " + fileset.name)
+
tarpath = "/bin/tar"
fsfn = os.path.join(targetdir, fileset.name) + "." + self.conf.format
- taropts = ["-cpva"]
+ taropts = []
+ # Add the since date, if given
if since != None:
taropts += ["-N", since.strftime("%Y-%m-%d %H:%M:%S")]
+ # Add the exclude patterns
for pat in self.conf.exclpatterns:
taropts += ["--exclude", pat]
- tarargs = [tarpath] + taropts + ["-f", fsfn] + fileset.dirs
- #print("tarargs: ", tarargs)
- print("tar call: " + " ".join(tarargs), file=log)
- tarp = subprocess.Popen( tarargs, stderr=subprocess.PIPE )
-
+ # Adding directories to backup
+ taropts += ["-C", "/"] + [ "./" + d.lstrip("/") for d in fileset.dirs]
+
+ # Launch the tar process
+ tarargs = [tarpath] + ["-cpvaf", fsfn] + taropts
+ logfile.debug("tar call: " + " ".join(tarargs))
+ tarp = subprocess.Popen( tarargs, bufsize=-1, \
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE )
+
+ # Change tarp's stdout and stderr to non-blocking
+ for s in [tarp.stdout, tarp.stderr]:
+ fd = s.fileno()
+ fl = fcntl.fcntl(fd, fcntl.F_GETFL)
+ fcntl.fcntl(fd, fcntl.F_SETFL, fl | os.O_NONBLOCK)
+
+ # Read stdout and stderr of tarp
+ errmsg = b""
+ while tarp.poll() == None:
+ rd,wr,ex = select.select([tarp.stdout, tarp.stderr], [], [], 0.05)
+ if tarp.stdout in rd:
+ logging.debug( tarp.stdout.readline()[:-1].decode() )
+ if tarp.stderr in rd:
+ errmsg += tarp.stderr.read()
+
+ # Get the remainging output of tarp
+ for l in tarp.stdout.readlines():
+ logging.debug(l.decode().rstrip())
+ errmsg += tarp.stderr.read()
+
+ # Get return code of tarp
rett = tarp.wait()
if rett != 0:
- sys.stderr.write( tarp.stderr.read() )
- msg = tarpath + " returned with exit status " + str(rett) + "."
- print(msg)
- print(msg, log)
+ for l in errmsg.decode().split("\n"):
+ logfile.error(l)
+ logfile.error(tarpath + " returned with exit status " + str(rett) + ".")
def backup(self, epoch=None, mode=None):
if epoch == None:
epoch = self.getDesiredEpoch(oldbackups, now)
if epoch == None:
- print("No backup planned.")
+ logging.info("No backup planned.")
return
# Get mode of backup
if mode == None:
mode = self.conf.epochmodes[epoch]
- print("Making a backup. Epoch: " + epoch + ", mode: " + mode)
+ logging.info("Making a backup. Epoch: " + epoch + ", mode: " + mode)
oldfullbackups = [ b for b in oldbackups if b.mode == "full" ]
# No old full backups existing
if mode != "full" and len(oldfullbackups)==0:
- print("No full backups existing. Making a full backup.")
+ logging.info("No full backups existing. Making a full backup.")
# Checksum changed -> self.config file changed
- if self.conf.checksum != self.conf.lastchecksum:
- print("Config file changed since last time.")
- if mode != "full":
- print("** Warning: full backup recommended!")
+ if self.conf.checksum != self.conf.lastchecksum and mode != "full":
+ logging.warning("Full backup recommended as config file has changed.")
# If we have a full backup, we backup everything
since = sorted(oldbackups, key=lambda b: b.date)[-1].date
if since != None:
- print("Making backup relative to ", since.ctime())
+ logging.debug("Making backup relative to " + since.ctime())
yesno = self.ask_user_yesno("Proceed? [Y, n] ")
if yesno == "n":
os.mkdir( targetdir )
- log = open(os.path.join(targetdir, "log.log"), 'w')
- print("Started: " + now.ctime(), file=log)
+ # Add file logger
+ logfile = logging.getLogger("backuplog")
+ fil = logging.FileHandler( os.path.join(targetdir, "log") )
+ fil.setLevel(logging.DEBUG)
+ logfile.addHandler(fil)
+
+ logfile.info("Started: " + now.ctime())
# Backup all file sets
for s in self.conf.sets:
- self.backupFileSet(s, targetdir, log, since)
+ self.backupFileSet(s, targetdir, since)
- print("Stopped: " + datetime.datetime.now().ctime(), file=log)
- log.close()
+ logfile.info("Stopped: " + datetime.datetime.now().ctime())
# Rename backup directory to final name
os.rename( targetdir, os.path.join(basedir, dirname) )
def prune(self):
"""Prune old backup files"""
- allDirs = self.listAllDirs()
+ allDirs = sorted(self.listAllDirs())
# Collect all directories not matching backup name
removeDirs = [ d for d in allDirs if not Backup.isBackupDir(d) ]
removeDirs += [ Backup.getDirName(b.date, b.epoch, b.mode) for b in old]
- print("List of stale/outdated entries:")
+ logging.info("List of stale/outdated entries:")
for d in allDirs:
+ msg = ""
if d in removeDirs:
- print("[*] ", end="")
+ msg = "[*] "
else:
- print("[ ] ", end="")
+ msg = "[ ] "
if Backup.isBackupDir(d):
- print( Backup.fromDirName(d).colAlignedString())
+ msg += Backup.fromDirName(d).colAlignedString()
else:
- print(d)
+ msg += d
+
+ logging.info(msg)
# Check that dirs to be removed is in list of all dirs
for d in removeDirs:
assert( d in allDirs )
if len(removeDirs) == 0:
- print("No stale/outdated entries to remove.")
+ logging.info("No stale/outdated entries to remove.")
return
basedir = self.conf.directory
yesno = self.ask_user_yesno("Remove entries marked by '*'? [y, N] ")
if yesno == "y":
for d in removeDirs:
- shutil.rmtree(os.path.join(basedir, d))
+ try:
+ shutil.rmtree(os.path.join(basedir, d))
+ except OSError as e:
+ logging.error("Error when removing '%s': %s" % (d,e.strerror) )
+
def ask_user_yesno(self, question):
- if self.alwaysyes:
- print(question + " y")
- return "y"
- else:
+ if LogConf.con.level <= logging.INFO:
return input(question)
+ else:
+ return "y"
def printUsage():
print(" -e, --epoch <epoch> force to create backup for given epoch:")
print(" year, month, week, day, hour, sporadic")
print(" -m, --mode <mode> override mode: full, diff, or incr")
- print(" -y, --yes always assume 'yes' when user is asked")
+ print(" -v, --verbose be more verbose and interact with user")
+ print(" --verbosity LEVEL set verbosity to LEVEL, which can be")
+ print(" error, warning, info, debug")
+ print(" -V, --version print version info")
+
+
+
+class LogConf:
+ """Encapsulates logging configuration"""
+
+ con = logging.StreamHandler(sys.stderr)
+
+ @classmethod
+ def setup(cls):
+ """Setup logging system"""
+ conlog = logging.getLogger()
+ conlog.setLevel(logging.DEBUG)
+
+ cls.con.setLevel(logging.WARNING)
+ conlog.addHandler(cls.con)
+
+ fillog = logging.getLogger("backuplog")
+ fillog.setLevel(logging.DEBUG)
if __name__ == "__main__":
+ LogConf.setup()
+
conffn = "/etc/shbackup.conf"
cmd = "list"
mode = None
epoch = None
- yes = False
i = 0
while i < len(sys.argv)-1:
i += 1
conffn = sys.argv[i]
- elif opt in ["-y", "--yes"]:
- yes = True
+ elif opt in ["-V", "--version"]:
+ print("shbackup " + __version__)
+ exit(0)
+
+ elif opt in ["-v", "--verbose"]:
+ LogConf.con.setLevel(logging.INFO)
+
+ elif opt in ["--verbosity"]:
+ i += 1
+ level = sys.argv[i]
+ numlevel = getattr(logging, level.upper(), None)
+ if not isinstance(numlevel, int):
+ raise ValueError('Invalid verbosity level: %s' % level)
+ LogConf.con.setLevel(numlevel)
elif opt in ["-m", "--mode"]:
i += 1
mode = sys.argv[i]
if not mode in Mode:
- print("Unknown mode '" + mode + "'.")
+ logging.error("Unknown mode '" + mode + "'.")
exit(1)
elif opt in ["-e", "--epoch"]:
i += 1
epoch = sys.argv[i]
if not epoch in Epoch:
- print("Unknown epoch '" + epoch + "'.")
+ logging.error("Unknown epoch '" + epoch + "'.")
exit(1)
cmd = opt
else:
- print("Unknown option: " + opt)
+ logging.error("Unknown option: " + opt)
exit(1)
try:
- man = BackupManager(conffn, yes)
+ man = BackupManager(conffn)
if cmd == "backup":
man.backup(epoch, mode)
man.prune()
except (Config.ReadError, configparser.DuplicateOptionError) as e:
- print("Error reading config file: " + e.message)
+ logging.error("Error reading config file: " + e.message)