ask before making backup
[sitarba.git] / shbackup
index 0137b9d25356f71ae9b3165f80c66457487bafc3..17bd3318208b8ce01e6363c68a628e15a80a55a7 100755 (executable)
--- a/shbackup
+++ b/shbackup
@@ -11,13 +11,18 @@ import random, re
 
 Mode = ["full", "incr", "diff"]
 
-Epoch = { \
+RealEpoch = { \
         "hour" : datetime.timedelta(0, 3600), \
         "day" : datetime.timedelta(1), \
         "week" : datetime.timedelta(7), \
         "month" : datetime.timedelta(30), \
         "year" : datetime.timedelta(365) }
 
+Epoch = dict(RealEpoch, **{ \
+        "sporadic" : datetime.timedelta(0,0) \
+        })
+
+
 class Backup:
     """A single backup has a date, an epoch and a mode."""
 
@@ -26,11 +31,31 @@ class Backup:
         self.epoch = epoch
         self.mode = mode
 
+    @staticmethod
+    def fromDirName(dirname):
+            [strdate, strtime, epoch, mode] = dirname.split("-")
+
+            if not epoch in Epoch.keys():
+                raise ValueError("Invalid epoch: " + epoch)
+
+            if not mode in Mode:
+                raise ValueError("Invalid mode: " + mode)
+
+            date = datetime.datetime(int(strdate[0:4]),
+                    int(strdate[4:6]), int(strdate[6:8]),\
+                    int(strtime[0:2]), int(strtime[2:4]))
+
+            return Backup(date, epoch, mode)
+
     def __str__(self):
         return "[date: " + self.date.ctime() + \
                 ", epoch: " + self.epoch + \
                 ", mode: " + self.mode + "]"
 
+    def colAlignedString(self):
+        return "%16s    %8s    %4s" % ( \
+                self.date.strftime("%Y-%m-%d %H:%M"), self.epoch, self.mode)
+
     @staticmethod
     def getDirName(date, epoch, mode):
         """Get directory name of backup by given properties."""
@@ -47,9 +72,11 @@ class Backup:
 class Config:
     """Encapsules the configuration for the backup program."""
 
-    class ReadException(Exception):
+    class ReadError(RuntimeError):
         """An exception raised when reading configurations."""
-        pass
+        def __init__(self, value):
+            self.value = value
+            self.message = value
 
     class FileSet:
         """A fileset has a name and a list of directories."""
@@ -60,7 +87,7 @@ class Config:
         def __str__(self):
             return "[name: " + self.name + ", dirs: " + str(self.dirs) + "]"
 
-    formats = ["tar.gz", "tar.bz2", "tar.xz" ]
+    formats = ["tar", "tar.gz", "tar.bz2", "tar.xz" ]
 
     # Filename where checksum of config is saved
     checksumfn = "checksum"
@@ -68,8 +95,8 @@ class Config:
     def __init__(self):
         self.directory = "/media/backup"
         self.format = self.formats[0]
-        self.epochkeeps = { k : 0 for k in Epoch.keys() }
-        self.epochmodes = { k : "full" for k in Epoch.keys() }
+        self.epochkeeps = { k : 0 for k in RealEpoch.keys() }
+        self.epochmodes = { k : "full" for k in RealEpoch.keys() }
         self.exclpatterns = []
         self.sets = []
         self.checksum = None
@@ -87,45 +114,50 @@ class Config:
         """Read configuration from file"""
 
         if not os.path.isfile(filename):
-            raise Config.ReadException("No file '" + filename + "'.")
+            raise Config.ReadError("Cannot read config file '" + filename + "'.")
 
         config = configparser.RawConfigParser()
         config.read(filename)
 
         for reqsec in ["destination"]:
             if not config.has_section(reqsec):
-                raise Config.ReadException("Section '" + reqsec + "' is missing.")
+                raise Config.ReadError("Section '" + reqsec + "' is missing.")
 
         self.directory = config.get("destination", "directory")
+        if not os.path.isdir(self.directory):
+            raise Config.ReadError("Directory '{0}' does not exist.".format(self.directory))
 
         self.format = config.get("destination", "format")
         if not self.format in Config.formats:
-            raise Config.ReadException("Invalid 'format' given.")
+            raise Config.ReadError("Invalid 'format' given.")
 
 
         if config.has_section("history"):
             for opt in config.options("history"):
                 if opt.startswith("keep"):
                     epoch = opt[4:]
-                    if not epoch in Epoch.keys():
-                        raise Config.ReadException("Invalid option 'keep" + epoch + "'.")
-                    self.epochkeeps[epoch] = int(config.getint("history", opt))
+                    if not epoch in RealEpoch.keys():
+                        raise Config.ReadError("Invalid option 'keep" + epoch + "'.")
+                    try:
+                        self.epochkeeps[epoch] = int(config.getint("history", opt))
+                    except ValueError:
+                        raise Config.ReadError("Invalid integer given for '" + opt + "'.")
                 elif opt.startswith("mode"):
                     epoch = opt[4:]
-                    if not epoch in Epoch.keys():
-                        raise Config.ReadException("Invalid option 'mode" + epoch + "'.")
+                    if not epoch in RealEpoch.keys():
+                        raise Config.ReadError("Invalid option 'mode" + epoch + "'.")
                     self.epochmodes[epoch] = config.get("history", opt)
                     if not self.epochmodes[epoch] in Mode:
-                        raise Config.ReadException("Invalid mode given.")
+                        raise Config.ReadError("Invalid mode given.")
                 else:
-                    raise Config.ReadException("Invalid option '" + opt + "'.")
-    
+                    raise Config.ReadError("Invalid option '" + opt + "'.")
+
         if config.has_section("input"):
             for opt in config.options("input"):
                 if opt.startswith("exclude"):
                     self.exclpatterns += [ config.get("input", opt) ]
                 else:
-                    raise Config.ReadException("Invalid option '" + opt + "'.")
+                    raise Config.ReadError("Invalid option '" + opt + "'.")
 
         for sec in config.sections():
             if sec in ["destination", "history", "input"]:
@@ -136,12 +168,12 @@ class Config:
 
                 for opt in config.options(sec):
                     if not opt.startswith("dir"):
-                        raise Config.ReadException("Unknown option '" + opt + "'.")
+                        raise Config.ReadError("Unknown option '" + opt + "'.")
                     else:
                         dirs += [config.get(sec, opt)]
                 self.sets += [Config.FileSet(name, dirs)]
             else:
-                raise Config.ReadException("Unknown section '" + sec + "'.")
+                raise Config.ReadError("Unknown section '" + sec + "'.")
 
         # Compute checksum of config file
         m = hashlib.sha1()
@@ -163,38 +195,29 @@ class Config:
 class BackupManager:
     """List and create backups"""
 
-    def __init__(self, conffn):
+    def __init__(self, conffn, alwaysyes):
         self.conf = Config()
+        self.alwaysyes = alwaysyes
         self.conf.read(conffn)
 
 
     def listAllDirs(self):
         """List all dirs in destination directory"""
-        
+
         # Get all entries
         basedir = self.conf.directory
         dirs = os.listdir(basedir)
         # Filter directories
         return [ d for d in dirs if os.path.isdir(os.path.join(basedir, d)) ]
 
+
     def listOldBackups(self):
         """Returns a list of old backups."""
 
         backups = []
 
         for entry in [ b for b in self.listAllDirs() if Backup.isBackupDir(b) ]:
-            [strdate, strtime, epoch, mode] = entry.split("-")
-
-            if not epoch in Epoch.keys():
-                raise ValueError("Invalid epoch: " + epoch)
-
-            if not mode in Mode:
-                raise ValueError("Invalid mode: " + mode)
-
-            date = datetime.datetime(int(strdate[0:4]),
-                    int(strdate[4:6]), int(strdate[6:8]),\
-                    int(strtime[0:2]), int(strtime[2:4]))
-            backups += [ Backup(date, epoch, mode) ]
+            backups += [ Backup.fromDirName(entry) ]
 
         return backups
 
@@ -204,7 +227,7 @@ class BackupManager:
 
         # Find the longest epoch for which we would like the make a backup
         latest = datetime.datetime(1900, 1, 1)
-        for timespan, e in reversed(sorted( [ (Epoch[e], e) for e in Epoch ] )):
+        for timespan, e in reversed(sorted( [ (Epoch[e], e) for e in RealEpoch ] )):
             # We make backups of that epoch
             if self.conf.epochkeeps[e] == 0:
                 continue
@@ -237,33 +260,17 @@ class BackupManager:
 
         if since != None:
             taropts += ["-N", since.strftime("%Y-%m-%d %H:%M:%S")]
-        
+
         for pat in self.conf.exclpatterns:
             taropts += ["--exclude", pat]
 
         tarargs = [tarpath] + taropts + ["-f", fsfn] + fileset.dirs
-        print("tarargs: ", tarargs)
-        tarp = subprocess.Popen( tarargs, \
-                stdout=subprocess.PIPE, stderr=subprocess.PIPE)
-  
-        while tarp.poll():
-            l = tarp.stdout.readline()
-            if len(l) > 0:
-                print(l.decode(), end="")
-            l = tarp.stderr.readline()
-            if len(l) > 0:
-                print(l.decode(), end="")
-
-        for l in tarp.stdout.readlines():
-            print(l.decode(), end="")
-
-        for l in tarp.stderr.readlines():
-            print(l.decode(), end="")
+        #print("tarargs: ", tarargs)
+        tarp = subprocess.Popen( tarargs )
 
         rett = tarp.wait()
         if rett != 0:
             print(tarpath + " returned with exit status " + str(rett) + ":")
-            print( tarp.stderr.read().decode() )
 
 
     def backup(self, epoch=None, mode=None):
@@ -290,7 +297,7 @@ class BackupManager:
 
         # No old full backups existing
         if mode != "full" and len(oldfullbackups)==0:
-            print("No full backups existing. Making a full backup.")        
+            print("No full backups existing. Making a full backup.")
 
         # Checksum changed -> self.config file changed
         if self.conf.checksum != self.conf.lastchecksum:
@@ -298,23 +305,29 @@ class BackupManager:
             if mode != "full":
                 print("** Warning: full backup recommended!")
 
-        # Create new target directory
-        basedir = self.conf.directory
-        dirname = Backup.getDirName(now, epoch, mode)
-        tmpdirname = dirname + ("-%x" % (random.random()*2e16) )
-        targetdir = os.path.join(basedir, tmpdirname)
-        os.mkdir( targetdir )
 
         # If we have a full backup, we backup everything
         since = None
-
-        # Get latest full backup time
         if mode == "diff":
             since = sorted(oldfullbackups, key=lambda b: b.date)[-1].date
-        # Get latest backup time
         elif mode == "incr":
             since = sorted(oldbackups, key=lambda b: b.date)[-1].date
 
+        if since != None:
+            print("Making backup relative to ", since.ctime())
+
+        yesno = self.ask_user_yesno("Proceed? [Y, n] ")
+        if yesno == "n":
+            return
+
+        # Create new target directory
+        basedir = self.conf.directory
+        dirname = Backup.getDirName(now, epoch, mode)
+        tmpdirname = dirname + ("-%x" % (random.random()*2e16) )
+        targetdir = os.path.join(basedir, tmpdirname)
+        os.mkdir( targetdir )
+
+
         # Backup all file sets
         for s in self.conf.sets:
             self.backupFileSet(s, targetdir, since)
@@ -327,37 +340,59 @@ class BackupManager:
             f = open( os.path.join(basedir, self.conf.checksumfn), "w")
             f.write( self.conf.checksum )
             f.close()
-               
+
 
     def prune(self):
         """Prune old backup files"""
 
+        allDirs = self.listAllDirs()
         # Collect all directories not matching backup name
-        dirs = [ d for d in self.listAllDirs() if not Backup.isBackupDir(d) ]
+        removeDirs = [ d for d in allDirs if not Backup.isBackupDir(d) ]
 
-        # Get all directories which are outdated
+        # Get all directories which are kept
         backups = self.listOldBackups()
+        keepdirs = []
         byepoch = { e : list(sorted( [ b for b in backups if b.epoch == e ], \
-                key=lambda b : b.date, reverse=True)) for e in Epoch }
+                key=lambda b : b.date, reverse=True)) for e in RealEpoch }
         for e in byepoch:
             keep = self.conf.epochkeeps[e]
             old = byepoch[e][keep:]
-            dirs += [ Backup.getDirName(b.date, b.epoch, b.mode) for b in old]
+            removeDirs += [ Backup.getDirName(b.date, b.epoch, b.mode) for b in old]
 
-        if len(dirs) == 0:
-            print("No stale/outdated entries to remove.")
-            return
 
         print("List of stale/outdated entries:")
-        for d in dirs:
-            print("  " + d)
+        for d in allDirs:
+            if d in removeDirs:
+                print("[*]  ", end="")
+            else:
+                print("[ ]  ", end="")
+
+            if Backup.isBackupDir(d):
+                print( Backup.fromDirName(d).colAlignedString())
+            else:
+                print(d)
+
+        # 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.")
+            return
 
         basedir = self.conf.directory
-        yesno = input("Remove listed entries? [y, N] ")
+        yesno = self.ask_user_yesno("Remove entries marked by '*'? [y, N] ")
         if yesno == "y":
-            for d in dirs:
+            for d in removeDirs:
                 shutil.rmtree(os.path.join(basedir, d))
 
+    def ask_user_yesno(self, question):
+        if self.alwaysyes:
+            print(question + " y")
+            return "y"
+        else:
+            return input(question)
+
 
 def printUsage():
     """Print --help text"""
@@ -365,21 +400,22 @@ def printUsage():
     print("shbackup - a simple backup solution.")
     print("")
     print("Usage:")
-    print("  " + sys.argv[0] + " [-C <configfile>] [cmd]")
+    print("  " + sys.argv[0] + " {options} [cmd]")
     print("  " + sys.argv[0] + " --help")
     print("")
     print("Commands:")
-    print("  backup                 make a new backup, if necessary")
-    print("  list                   list all backups")
-    print("  prune                  prune outdated/old backups")
+    print("  backup                     make a new backup, if necessary")
+    print("  list                       list all backups (default)")
+    print("  prune                      prune outdated/old backups")
     print("")
     print("Options:")
-    print("  -C <configfile>        use given configuration file")
-    print("                         default: /etc/shbackup.conf")
-    print("  -m, --mode <mode>      override mode: full, diff, or incr")
-    print("  -e, --epoch <epoch>    create backup for given epoch:")
-    print("                         year, month, week, day, hour")
-    print("  -h, --help             print this usage text")
+    print("  -h, --help                 print this usage text")
+    print("  -c, --conf <configfile>    use given configuration file")
+    print("                             default: /etc/shbackup.conf")
+    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")
 
 
 if __name__ == "__main__":
@@ -388,6 +424,7 @@ if __name__ == "__main__":
     cmd = "list"
     mode = None
     epoch = None
+    yes = False
 
     i = 0
     while i < len(sys.argv)-1:
@@ -398,10 +435,13 @@ if __name__ == "__main__":
             printUsage()
             exit(0)
 
-        elif opt in ["-C", "--config"]:
+        elif opt in ["-c", "--conf"]:
             i += 1
             conffn = sys.argv[i]
 
+        elif opt in ["-y", "--yes"]:
+            yes = True
+
         elif opt in ["-m", "--mode"]:
             i += 1
             mode = sys.argv[i]
@@ -425,24 +465,20 @@ if __name__ == "__main__":
             exit(1)
 
     try:
-        man = BackupManager(conffn)
+        man = BackupManager(conffn, yes)
 
         if cmd == "backup":
             man.backup(epoch, mode)
 
         if cmd == "list":
             for b in sorted(man.listOldBackups(), key=lambda b: b.date):
-                print(b.date.strftime("%Y-%m-%d %H:%M") + \
-                        "\t" + b.epoch + "\t" + b.mode)
+                print(b.colAlignedString())
 
         if cmd == "prune":
             man.prune()
 
-    except Config.ReadException as e:
-        print("Error reading config file: ", end="")
-        for a in e.args:
-            print(a, end=" ")
-        print()
+    except (Config.ReadError, configparser.DuplicateOptionError) as e:
+        print("Error reading config file: " + e.message)