2 """Stefan Huber's simplistic backup solution."""
5 __author__
= "Stefan Huber"
11 import subprocess
, fcntl
16 Mode
= ["full", "incr", "diff"]
19 "hour" : datetime
.timedelta(0, 3600), \
20 "day" : datetime
.timedelta(1), \
21 "week" : datetime
.timedelta(7), \
22 "month" : datetime
.timedelta(30), \
23 "year" : datetime
.timedelta(365) }
25 Epoch
= dict(RealEpoch
, **{ \
26 "sporadic" : datetime
.timedelta(0,0) \
31 """A single backup has a date, an epoch and a mode."""
33 def __init__(self
, date
, epoch
, mode
):
39 def fromDirName(dirname
):
40 [strdate
, strtime
, epoch
, mode
] = dirname
.split("-")
42 if not epoch
in Epoch
.keys():
43 raise ValueError("Invalid epoch: " + epoch
)
46 raise ValueError("Invalid mode: " + mode
)
48 date
= datetime
.datetime(int(strdate
[0:4]),
49 int(strdate
[4:6]), int(strdate
[6:8]),\
50 int(strtime
[0:2]), int(strtime
[2:4]))
52 return Backup(date
, epoch
, mode
)
55 return "[date: " + self
.date
.ctime() + \
56 ", epoch: " + self
.epoch
+ \
57 ", mode: " + self
.mode
+ "]"
59 def colAlignedString(self
):
60 return "%16s %8s %4s" % ( \
61 self
.date
.strftime("%Y-%m-%d %H:%M"), self
.epoch
, self
.mode
)
64 def getDirName(date
, epoch
, mode
):
65 """Get directory name of backup by given properties."""
66 return date
.strftime("%Y%m%d-%H%M") + "-" + epoch
+ "-" + mode
69 def isBackupDir(dirname
):
70 """Is directory a backup directory?"""
71 p
= re
.compile(r
'^\d\d\d\d\d\d\d\d-\d\d\d\d-\w+-\w+$')
72 return p
.match(dirname
)
77 """Encapsules the configuration for the backup program."""
79 class ReadError(RuntimeError):
80 """An exception raised when reading configurations."""
81 def __init__(self
, value
):
86 """A fileset has a name and a list of directories."""
87 def __init__(self
, name
, dirs
):
92 return "[name: " + self
.name
+ ", dirs: " + str(self
.dirs
) + "]"
94 formats
= ["tar", "tar.gz", "tar.bz2", "tar.xz" ]
96 # Filename where checksum of config is saved
97 checksumfn
= "checksum"
100 self
.directory
= "/media/backup"
101 self
.format
= self
.formats
[0]
102 self
.epochkeeps
= { k
: 0 for k
in RealEpoch
.keys() }
103 self
.epochmodes
= { k
: "full" for k
in RealEpoch
.keys() }
104 self
.exclpatterns
= []
107 self
.lastchecksum
= None
110 return "[directory: " + self
.directory
+ \
111 ", format: " + self
.format
+ \
112 ", keeps: " + str(self
.epochkeeps
) + \
113 ", modes: " + str(self
.epochmodes
) + \
114 ", exclpatterns: " + str(self
.exclpatterns
) + \
115 ", sets: " + str([str(s
) for s
in self
.sets
]) + "]"
117 def read(self
, filename
):
118 """Read configuration from file"""
120 if not os
.path
.isfile(filename
):
121 raise Config
.ReadError("Cannot read config file '" + filename
+ "'.")
123 config
= configparser
.RawConfigParser()
124 config
.read(filename
)
126 for reqsec
in ["destination"]:
127 if not config
.has_section(reqsec
):
128 raise Config
.ReadError("Section '" + reqsec
+ "' is missing.")
130 self
.directory
= config
.get("destination", "directory")
131 if not os
.path
.isdir(self
.directory
):
132 raise Config
.ReadError("Directory '{0}' does not exist.".format(self
.directory
))
134 self
.format
= config
.get("destination", "format")
135 if not self
.format
in Config
.formats
:
136 raise Config
.ReadError("Invalid 'format' given.")
139 if config
.has_section("history"):
140 for opt
in config
.options("history"):
141 if opt
.startswith("keep"):
143 if not epoch
in RealEpoch
.keys():
144 raise Config
.ReadError("Invalid option 'keep" + epoch
+ "'.")
146 self
.epochkeeps
[epoch
] = int(config
.getint("history", opt
))
148 raise Config
.ReadError("Invalid integer given for '" + opt
+ "'.")
149 elif opt
.startswith("mode"):
151 if not epoch
in RealEpoch
.keys():
152 raise Config
.ReadError("Invalid option 'mode" + epoch
+ "'.")
153 self
.epochmodes
[epoch
] = config
.get("history", opt
)
154 if not self
.epochmodes
[epoch
] in Mode
:
155 raise Config
.ReadError("Invalid mode given.")
157 raise Config
.ReadError("Invalid option '" + opt
+ "'.")
159 if config
.has_section("input"):
160 for opt
in config
.options("input"):
161 if opt
.startswith("exclude"):
162 self
.exclpatterns
+= [ config
.get("input", opt
) ]
164 raise Config
.ReadError("Invalid option '" + opt
+ "'.")
166 for sec
in config
.sections():
167 if sec
in ["destination", "history", "input"]:
169 elif sec
.startswith("set "):
170 name
= sec
[4:].strip()
173 for opt
in config
.options(sec
):
174 if not opt
.startswith("dir"):
175 raise Config
.ReadError("Unknown option '" + opt
+ "'.")
177 dirs
+= [config
.get(sec
, opt
)]
178 self
.sets
+= [Config
.FileSet(name
, dirs
)]
180 raise Config
.ReadError("Unknown section '" + sec
+ "'.")
182 # Compute checksum of config file
184 f
= open(filename
, 'rb')
187 self
.checksum
= m
.hexdigest()
192 f
= open(os
.path
.join(self
.directory
, self
.checksumfn
), 'r')
193 self
.lastchecksum
= f
.read().strip()
196 self
.lastchecksum
= None
200 """List and create backups"""
202 def __init__(self
, conffn
):
204 self
.conf
.read(conffn
)
207 def listAllDirs(self
):
208 """List all dirs in destination directory"""
211 basedir
= self
.conf
.directory
212 dirs
= os
.listdir(basedir
)
214 return [ d
for d
in dirs
if os
.path
.isdir(os
.path
.join(basedir
, d
)) ]
217 def listOldBackups(self
):
218 """Returns a list of old backups."""
222 for entry
in [ b
for b
in self
.listAllDirs() if Backup
.isBackupDir(b
) ]:
223 backups
+= [ Backup
.fromDirName(entry
) ]
228 def getDesiredEpoch(self
, backups
, now
):
229 """Get desired epoch based on self.configuration and list of old backups"""
231 # Find the longest epoch for which we would like the make a backup
232 latest
= datetime
.datetime(1900, 1, 1)
233 for timespan
, e
in reversed(sorted( [ (Epoch
[e
], e
) for e
in RealEpoch
] )):
234 # We make backups of that epoch
235 if self
.conf
.epochkeeps
[e
] == 0:
238 # Get backups of that epoch
239 byepoch
= list(sorted( [ b
for b
in backups
if b
.epoch
==e
], \
240 key
=lambda b
: b
.date
))
242 # If there are any, determine the latest
244 latest
= max(latest
, byepoch
[-1].date
)
246 # the latest backup is too old
247 if now
-latest
> timespan
:
250 # No backup is to be made
255 def backupFileSet(self
, fileset
, targetdir
, since
=None):
256 """Create an archive for given fileset at given target directory."""
258 logfile
= logging
.getLogger('backuplog')
259 logfile
.info("Running file set: " + fileset
.name
)
262 fsfn
= os
.path
.join(targetdir
, fileset
.name
) + "." + self
.conf
.format
267 taropts
+= ["-N", since
.strftime("%Y-%m-%d %H:%M:%S")]
269 for pat
in self
.conf
.exclpatterns
:
270 taropts
+= ["--exclude", pat
]
272 # Launch the tar process
273 tarargs
= [tarpath
] + taropts
+ ["-f", fsfn
] + fileset
.dirs
274 logfile
.debug("tar call: " + " ".join(tarargs
))
275 tarp
= subprocess
.Popen( tarargs
, bufsize
=-1, \
276 stdout
=subprocess
.PIPE
, stderr
=subprocess
.PIPE
)
278 # Change tarp's stdout and stderr to non-blocking
279 for s
in [tarp
.stdout
, tarp
.stderr
]:
281 fl
= fcntl
.fcntl(fd
, fcntl
.F_GETFL
)
282 fcntl
.fcntl(fd
, fcntl
.F_SETFL
, fl | os
.O_NONBLOCK
)
284 def readlineNonBlocking(stream
):
285 """Read a line nonblocking. Returns b'' if nothing read."""
287 return stream
.readline()
293 # Read stdout and stderr of tarp
295 while tarp
.poll() == None:
296 l
= readlineNonBlocking(tarp
.stdout
)
298 logging
.debug(l
[:-1].decode())
299 errmsg
+= readlineNonBlocking(tarp
.stderr
)
302 # Get the remainging output of tarp
303 for l
in tarp
.stdout
.readlines():
304 logging
.debug(l
.decode().rstrip())
305 errmsg
+= tarp
.stderr
.read()
307 # Get return code of tarp
310 for l
in errmsg
.split("\n"):
311 logfile
.error( l
.decode().strip().rstrip() )
312 logfile
.error(tarpath
+ " returned with exit status " + str(rett
) + ".")
315 def backup(self
, epoch
=None, mode
=None):
316 """Make a new backup, if necessary. If epoch is None then determine
317 desired epoch automatically. Use given epoch otherwise. If mode is None
318 then use mode for given epoch. Use given mode otherwise."""
320 now
= datetime
.datetime
.now()
321 oldbackups
= self
.listOldBackups()
323 # Get epoch of backup
325 epoch
= self
.getDesiredEpoch(oldbackups
, now
)
327 logging
.info("No backup planned.")
332 mode
= self
.conf
.epochmodes
[epoch
]
333 logging
.info("Making a backup. Epoch: " + epoch
+ ", mode: " + mode
)
335 oldfullbackups
= [ b
for b
in oldbackups
if b
.mode
== "full" ]
337 # No old full backups existing
338 if mode
!= "full" and len(oldfullbackups
)==0:
339 logging
.info("No full backups existing. Making a full backup.")
341 # Checksum changed -> self.config file changed
342 if self
.conf
.checksum
!= self
.conf
.lastchecksum
and mode
!= "full":
343 logging
.warning("Full backup recommended as config file has changed.")
346 # If we have a full backup, we backup everything
349 since
= sorted(oldfullbackups
, key
=lambda b
: b
.date
)[-1].date
351 since
= sorted(oldbackups
, key
=lambda b
: b
.date
)[-1].date
354 logging
.debug("Making backup relative to " + since
.ctime())
356 yesno
= self
.ask_user_yesno("Proceed? [Y, n] ")
360 # Create new target directory
361 basedir
= self
.conf
.directory
362 dirname
= Backup
.getDirName(now
, epoch
, mode
)
363 tmpdirname
= dirname
+ ("-%x" % (random
.random()*2e16
) )
364 targetdir
= os
.path
.join(basedir
, tmpdirname
)
365 os
.mkdir( targetdir
)
369 logfile
= logging
.getLogger("backuplog")
370 fil
= logging
.FileHandler( os
.path
.join(targetdir
, "log") )
371 fil
.setLevel(logging
.DEBUG
)
372 logfile
.addHandler(fil
)
374 logfile
.info("Started: " + now
.ctime())
376 # Backup all file sets
377 for s
in self
.conf
.sets
:
378 self
.backupFileSet(s
, targetdir
, since
)
380 logfile
.info("Stopped: " + datetime
.datetime
.now().ctime())
382 # Rename backup directory to final name
383 os
.rename( targetdir
, os
.path
.join(basedir
, dirname
) )
385 # We made a full backup -- recall checksum of config
387 f
= open( os
.path
.join(basedir
, self
.conf
.checksumfn
), "w")
388 f
.write( self
.conf
.checksum
)
394 """Prune old backup files"""
396 allDirs
= sorted(self
.listAllDirs())
397 # Collect all directories not matching backup name
398 removeDirs
= [ d
for d
in allDirs
if not Backup
.isBackupDir(d
) ]
400 # Get all directories which are kept
401 backups
= self
.listOldBackups()
403 byepoch
= { e
: list(sorted( [ b
for b
in backups
if b
.epoch
== e
], \
404 key
=lambda b
: b
.date
, reverse
=True)) for e
in RealEpoch
}
406 keep
= self
.conf
.epochkeeps
[e
]
407 old
= byepoch
[e
][keep
:]
408 removeDirs
+= [ Backup
.getDirName(b
.date
, b
.epoch
, b
.mode
) for b
in old
]
411 logging
.info("List of stale/outdated entries:")
419 if Backup
.isBackupDir(d
):
420 msg
+= Backup
.fromDirName(d
).colAlignedString()
426 # Check that dirs to be removed is in list of all dirs
428 assert( d
in allDirs
)
430 if len(removeDirs
) == 0:
431 logging
.info("No stale/outdated entries to remove.")
434 basedir
= self
.conf
.directory
435 yesno
= self
.ask_user_yesno("Remove entries marked by '*'? [y, N] ")
439 shutil
.rmtree(os
.path
.join(basedir
, d
))
441 logging
.error("Error when removing '%s': %s" % (d
,e
.strerror
) )
444 def ask_user_yesno(self
, question
):
445 if LogConf
.con
.level
<= logging
.INFO
:
446 return input(question
)
452 """Print --help text"""
454 print("shbackup - a simple backup solution.")
457 print(" " + sys
.argv
[0] + " {options} [cmd]")
458 print(" " + sys
.argv
[0] + " --help")
461 print(" backup make a new backup, if necessary")
462 print(" list list all backups (default)")
463 print(" prune prune outdated/old backups")
466 print(" -h, --help print this usage text")
467 print(" -c, --conf <configfile> use given configuration file")
468 print(" default: /etc/shbackup.conf")
469 print(" -e, --epoch <epoch> force to create backup for given epoch:")
470 print(" year, month, week, day, hour, sporadic")
471 print(" -m, --mode <mode> override mode: full, diff, or incr")
472 print(" -v, --verbose be more verbose and interact with user")
473 print(" --verbosity LEVEL set verbosity to LEVEL, which can be")
474 print(" error, warning, info, debug")
475 print(" -V, --version print version info")
480 """Encapsulates logging configuration"""
482 con
= logging
.StreamHandler(sys
.stderr
)
486 """Setup logging system"""
487 conlog
= logging
.getLogger()
488 conlog
.setLevel(logging
.DEBUG
)
490 cls
.con
.setLevel(logging
.WARNING
)
491 conlog
.addHandler(cls
.con
)
493 fillog
= logging
.getLogger("backuplog")
494 fillog
.setLevel(logging
.DEBUG
)
497 if __name__
== "__main__":
501 conffn
= "/etc/shbackup.conf"
507 while i
< len(sys
.argv
)-1:
511 if opt
in ["-h", "--help"]:
515 elif opt
in ["-c", "--conf"]:
519 elif opt
in ["-V", "--version"]:
520 print("shbackup " + __version__
)
523 elif opt
in ["-v", "--verbose"]:
524 LogConf
.con
.setLevel(logging
.INFO
)
526 elif opt
in ["--verbosity"]:
529 numlevel
= getattr(logging
, level
.upper(), None)
530 if not isinstance(numlevel
, int):
531 raise ValueError('Invalid verbosity level: %s' % level
)
532 LogConf
.con
.setLevel(numlevel
)
534 elif opt
in ["-m", "--mode"]:
538 logging
.error("Unknown mode '" + mode
+ "'.")
541 elif opt
in ["-e", "--epoch"]:
544 if not epoch
in Epoch
:
545 logging
.error("Unknown epoch '" + epoch
+ "'.")
549 elif opt
in ["backup", "list", "prune"]:
553 logging
.error("Unknown option: " + opt
)
557 man
= BackupManager(conffn
)
560 man
.backup(epoch
, mode
)
563 for b
in sorted(man
.listOldBackups(), key
=lambda b
: b
.date
):
564 print(b
.colAlignedString())
569 except (Config
.ReadError
, configparser
.DuplicateOptionError
) as e
:
570 logging
.error("Error reading config file: " + e
.message
)