2 """Stefan Huber's simplistic backup solution."""
12 Mode
= ["full", "incr", "diff"]
15 "hour" : datetime
.timedelta(0, 3600), \
16 "day" : datetime
.timedelta(1), \
17 "week" : datetime
.timedelta(7), \
18 "month" : datetime
.timedelta(30), \
19 "year" : datetime
.timedelta(365) }
21 Epoch
= dict(RealEpoch
, **{ \
22 "sporadic" : datetime
.timedelta(0,0) \
27 """A single backup has a date, an epoch and a mode."""
29 def __init__(self
, date
, epoch
, mode
):
35 def fromDirName(dirname
):
36 [strdate
, strtime
, epoch
, mode
] = dirname
.split("-")
38 if not epoch
in Epoch
.keys():
39 raise ValueError("Invalid epoch: " + epoch
)
42 raise ValueError("Invalid mode: " + mode
)
44 date
= datetime
.datetime(int(strdate
[0:4]),
45 int(strdate
[4:6]), int(strdate
[6:8]),\
46 int(strtime
[0:2]), int(strtime
[2:4]))
48 return Backup(date
, epoch
, mode
)
51 return "[date: " + self
.date
.ctime() + \
52 ", epoch: " + self
.epoch
+ \
53 ", mode: " + self
.mode
+ "]"
55 def colAlignedString(self
):
56 return "%16s %8s %4s" % ( \
57 self
.date
.strftime("%Y-%m-%d %H:%M"), self
.epoch
, self
.mode
)
60 def getDirName(date
, epoch
, mode
):
61 """Get directory name of backup by given properties."""
62 return date
.strftime("%Y%m%d-%H%M") + "-" + epoch
+ "-" + mode
65 def isBackupDir(dirname
):
66 """Is directory a backup directory?"""
67 p
= re
.compile(r
'^\d\d\d\d\d\d\d\d-\d\d\d\d-\w+-\w+$')
68 return p
.match(dirname
)
73 """Encapsules the configuration for the backup program."""
75 class ReadError(RuntimeError):
76 """An exception raised when reading configurations."""
77 def __init__(self
, value
):
82 """A fileset has a name and a list of directories."""
83 def __init__(self
, name
, dirs
):
88 return "[name: " + self
.name
+ ", dirs: " + str(self
.dirs
) + "]"
90 formats
= ["tar", "tar.gz", "tar.bz2", "tar.xz" ]
92 # Filename where checksum of config is saved
93 checksumfn
= "checksum"
96 self
.directory
= "/media/backup"
97 self
.format
= self
.formats
[0]
98 self
.epochkeeps
= { k
: 0 for k
in RealEpoch
.keys() }
99 self
.epochmodes
= { k
: "full" for k
in RealEpoch
.keys() }
100 self
.exclpatterns
= []
103 self
.lastchecksum
= None
106 return "[directory: " + self
.directory
+ \
107 ", format: " + self
.format
+ \
108 ", keeps: " + str(self
.epochkeeps
) + \
109 ", modes: " + str(self
.epochmodes
) + \
110 ", exclpatterns: " + str(self
.exclpatterns
) + \
111 ", sets: " + str([str(s
) for s
in self
.sets
]) + "]"
113 def read(self
, filename
):
114 """Read configuration from file"""
116 if not os
.path
.isfile(filename
):
117 raise Config
.ReadError("Cannot read config file '" + filename
+ "'.")
119 config
= configparser
.RawConfigParser()
120 config
.read(filename
)
122 for reqsec
in ["destination"]:
123 if not config
.has_section(reqsec
):
124 raise Config
.ReadError("Section '" + reqsec
+ "' is missing.")
126 self
.directory
= config
.get("destination", "directory")
127 if not os
.path
.isdir(self
.directory
):
128 raise Config
.ReadError("Directory '{0}' does not exist.".format(self
.directory
))
130 self
.format
= config
.get("destination", "format")
131 if not self
.format
in Config
.formats
:
132 raise Config
.ReadError("Invalid 'format' given.")
135 if config
.has_section("history"):
136 for opt
in config
.options("history"):
137 if opt
.startswith("keep"):
139 if not epoch
in RealEpoch
.keys():
140 raise Config
.ReadError("Invalid option 'keep" + epoch
+ "'.")
142 self
.epochkeeps
[epoch
] = int(config
.getint("history", opt
))
144 raise Config
.ReadError("Invalid integer given for '" + opt
+ "'.")
145 elif opt
.startswith("mode"):
147 if not epoch
in RealEpoch
.keys():
148 raise Config
.ReadError("Invalid option 'mode" + epoch
+ "'.")
149 self
.epochmodes
[epoch
] = config
.get("history", opt
)
150 if not self
.epochmodes
[epoch
] in Mode
:
151 raise Config
.ReadError("Invalid mode given.")
153 raise Config
.ReadError("Invalid option '" + opt
+ "'.")
155 if config
.has_section("input"):
156 for opt
in config
.options("input"):
157 if opt
.startswith("exclude"):
158 self
.exclpatterns
+= [ config
.get("input", opt
) ]
160 raise Config
.ReadError("Invalid option '" + opt
+ "'.")
162 for sec
in config
.sections():
163 if sec
in ["destination", "history", "input"]:
165 elif sec
.startswith("set "):
166 name
= sec
[4:].strip()
169 for opt
in config
.options(sec
):
170 if not opt
.startswith("dir"):
171 raise Config
.ReadError("Unknown option '" + opt
+ "'.")
173 dirs
+= [config
.get(sec
, opt
)]
174 self
.sets
+= [Config
.FileSet(name
, dirs
)]
176 raise Config
.ReadError("Unknown section '" + sec
+ "'.")
178 # Compute checksum of config file
180 f
= open(filename
, 'rb')
183 self
.checksum
= m
.hexdigest()
188 f
= open(os
.path
.join(self
.directory
, self
.checksumfn
), 'r')
189 self
.lastchecksum
= f
.read().strip()
192 self
.lastchecksum
= None
196 """List and create backups"""
198 def __init__(self
, conffn
, alwaysyes
):
200 self
.alwaysyes
= alwaysyes
201 self
.conf
.read(conffn
)
204 def listAllDirs(self
):
205 """List all dirs in destination directory"""
208 basedir
= self
.conf
.directory
209 dirs
= os
.listdir(basedir
)
211 return [ d
for d
in dirs
if os
.path
.isdir(os
.path
.join(basedir
, d
)) ]
214 def listOldBackups(self
):
215 """Returns a list of old backups."""
219 for entry
in [ b
for b
in self
.listAllDirs() if Backup
.isBackupDir(b
) ]:
220 backups
+= [ Backup
.fromDirName(entry
) ]
225 def getDesiredEpoch(self
, backups
, now
):
226 """Get desired epoch based on self.configuration and list of old backups"""
228 # Find the longest epoch for which we would like the make a backup
229 latest
= datetime
.datetime(1900, 1, 1)
230 for timespan
, e
in reversed(sorted( [ (Epoch
[e
], e
) for e
in RealEpoch
] )):
231 # We make backups of that epoch
232 if self
.conf
.epochkeeps
[e
] == 0:
235 # Get backups of that epoch
236 byepoch
= list(sorted( [ b
for b
in backups
if b
.epoch
==e
], \
237 key
=lambda b
: b
.date
))
239 # If there are any, determine the latest
241 latest
= max(latest
, byepoch
[-1].date
)
243 # the latest backup is too old
244 if now
-latest
> timespan
:
247 # No backup is to be made
252 def backupFileSet(self
, fileset
, targetdir
, log
, since
=None):
253 """Create an archive for given fileset at given target directory."""
255 print("Running file set: " + fileset
.name
)
257 fsfn
= os
.path
.join(targetdir
, fileset
.name
) + "." + self
.conf
.format
262 taropts
+= ["-N", since
.strftime("%Y-%m-%d %H:%M:%S")]
264 for pat
in self
.conf
.exclpatterns
:
265 taropts
+= ["--exclude", pat
]
267 tarargs
= [tarpath
] + taropts
+ ["-f", fsfn
] + fileset
.dirs
268 #print("tarargs: ", tarargs)
269 print("tar call: " + " ".join(tarargs
), file=log
)
270 tarp
= subprocess
.Popen( tarargs
, stderr
=subprocess
.PIPE
)
274 sys
.stderr
.write( tarp
.stderr
.read() )
275 msg
= tarpath
+ " returned with exit status " + str(rett
) + "."
280 def backup(self
, epoch
=None, mode
=None):
281 """Make a new backup, if necessary. If epoch is None then determine
282 desired epoch automatically. Use given epoch otherwise. If mode is None
283 then use mode for given epoch. Use given mode otherwise."""
285 now
= datetime
.datetime
.now()
286 oldbackups
= self
.listOldBackups()
288 # Get epoch of backup
290 epoch
= self
.getDesiredEpoch(oldbackups
, now
)
292 print("No backup planned.")
297 mode
= self
.conf
.epochmodes
[epoch
]
298 print("Making a backup. Epoch: " + epoch
+ ", mode: " + mode
)
300 oldfullbackups
= [ b
for b
in oldbackups
if b
.mode
== "full" ]
302 # No old full backups existing
303 if mode
!= "full" and len(oldfullbackups
)==0:
304 print("No full backups existing. Making a full backup.")
306 # Checksum changed -> self.config file changed
307 if self
.conf
.checksum
!= self
.conf
.lastchecksum
:
308 print("Config file changed since last time.")
310 print("** Warning: full backup recommended!")
313 # If we have a full backup, we backup everything
316 since
= sorted(oldfullbackups
, key
=lambda b
: b
.date
)[-1].date
318 since
= sorted(oldbackups
, key
=lambda b
: b
.date
)[-1].date
321 print("Making backup relative to ", since
.ctime())
323 yesno
= self
.ask_user_yesno("Proceed? [Y, n] ")
327 # Create new target directory
328 basedir
= self
.conf
.directory
329 dirname
= Backup
.getDirName(now
, epoch
, mode
)
330 tmpdirname
= dirname
+ ("-%x" % (random
.random()*2e16
) )
331 targetdir
= os
.path
.join(basedir
, tmpdirname
)
332 os
.mkdir( targetdir
)
335 log
= open(os
.path
.join(targetdir
, "log.log"), 'w')
336 print("Started: " + now
.ctime(), file=log
)
338 # Backup all file sets
339 for s
in self
.conf
.sets
:
340 self
.backupFileSet(s
, targetdir
, log
, since
)
342 print("Stopped: " + datetime
.datetime
.now().ctime(), file=log
)
345 # Rename backup directory to final name
346 os
.rename( targetdir
, os
.path
.join(basedir
, dirname
) )
348 # We made a full backup -- recall checksum of config
350 f
= open( os
.path
.join(basedir
, self
.conf
.checksumfn
), "w")
351 f
.write( self
.conf
.checksum
)
357 """Prune old backup files"""
359 allDirs
= self
.listAllDirs()
360 # Collect all directories not matching backup name
361 removeDirs
= [ d
for d
in allDirs
if not Backup
.isBackupDir(d
) ]
363 # Get all directories which are kept
364 backups
= self
.listOldBackups()
366 byepoch
= { e
: list(sorted( [ b
for b
in backups
if b
.epoch
== e
], \
367 key
=lambda b
: b
.date
, reverse
=True)) for e
in RealEpoch
}
369 keep
= self
.conf
.epochkeeps
[e
]
370 old
= byepoch
[e
][keep
:]
371 removeDirs
+= [ Backup
.getDirName(b
.date
, b
.epoch
, b
.mode
) for b
in old
]
374 print("List of stale/outdated entries:")
377 print("[*] ", end
="")
379 print("[ ] ", end
="")
381 if Backup
.isBackupDir(d
):
382 print( Backup
.fromDirName(d
).colAlignedString())
386 # Check that dirs to be removed is in list of all dirs
388 assert( d
in allDirs
)
390 if len(removeDirs
) == 0:
391 print("No stale/outdated entries to remove.")
394 basedir
= self
.conf
.directory
395 yesno
= self
.ask_user_yesno("Remove entries marked by '*'? [y, N] ")
398 shutil
.rmtree(os
.path
.join(basedir
, d
))
400 def ask_user_yesno(self
, question
):
402 print(question
+ " y")
405 return input(question
)
409 """Print --help text"""
411 print("shbackup - a simple backup solution.")
414 print(" " + sys
.argv
[0] + " {options} [cmd]")
415 print(" " + sys
.argv
[0] + " --help")
418 print(" backup make a new backup, if necessary")
419 print(" list list all backups (default)")
420 print(" prune prune outdated/old backups")
423 print(" -h, --help print this usage text")
424 print(" -c, --conf <configfile> use given configuration file")
425 print(" default: /etc/shbackup.conf")
426 print(" -e, --epoch <epoch> force to create backup for given epoch:")
427 print(" year, month, week, day, hour, sporadic")
428 print(" -m, --mode <mode> override mode: full, diff, or incr")
429 print(" -y, --yes always assume 'yes' when user is asked")
432 if __name__
== "__main__":
434 conffn
= "/etc/shbackup.conf"
441 while i
< len(sys
.argv
)-1:
445 if opt
in ["-h", "--help"]:
449 elif opt
in ["-c", "--conf"]:
453 elif opt
in ["-y", "--yes"]:
456 elif opt
in ["-m", "--mode"]:
460 print("Unknown mode '" + mode
+ "'.")
463 elif opt
in ["-e", "--epoch"]:
466 if not epoch
in Epoch
:
467 print("Unknown epoch '" + epoch
+ "'.")
471 elif opt
in ["backup", "list", "prune"]:
475 print("Unknown option: " + opt
)
479 man
= BackupManager(conffn
, yes
)
482 man
.backup(epoch
, mode
)
485 for b
in sorted(man
.listOldBackups(), key
=lambda b
: b
.date
):
486 print(b
.colAlignedString())
491 except (Config
.ReadError
, configparser
.DuplicateOptionError
) as e
:
492 print("Error reading config file: " + e
.message
)