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 return "[date: " + self
.date
.ctime() + \
36 ", epoch: " + self
.epoch
+ \
37 ", mode: " + self
.mode
+ "]"
40 def getDirName(date
, epoch
, mode
):
41 """Get directory name of backup by given properties."""
42 return date
.strftime("%Y%m%d-%H%M") + "-" + epoch
+ "-" + mode
45 def isBackupDir(dirname
):
46 """Is directory a backup directory?"""
47 p
= re
.compile(r
'^\d\d\d\d\d\d\d\d-\d\d\d\d-\w+-\w+$')
48 return p
.match(dirname
)
53 """Encapsules the configuration for the backup program."""
55 class ReadException(Exception):
56 """An exception raised when reading configurations."""
60 """A fileset has a name and a list of directories."""
61 def __init__(self
, name
, dirs
):
66 return "[name: " + self
.name
+ ", dirs: " + str(self
.dirs
) + "]"
68 formats
= ["tar.gz", "tar.bz2", "tar.xz" ]
70 # Filename where checksum of config is saved
71 checksumfn
= "checksum"
74 self
.directory
= "/media/backup"
75 self
.format
= self
.formats
[0]
76 self
.epochkeeps
= { k
: 0 for k
in RealEpoch
.keys() }
77 self
.epochmodes
= { k
: "full" for k
in RealEpoch
.keys() }
78 self
.exclpatterns
= []
81 self
.lastchecksum
= None
84 return "[directory: " + self
.directory
+ \
85 ", format: " + self
.format
+ \
86 ", keeps: " + str(self
.epochkeeps
) + \
87 ", modes: " + str(self
.epochmodes
) + \
88 ", exclpatterns: " + str(self
.exclpatterns
) + \
89 ", sets: " + str([str(s
) for s
in self
.sets
]) + "]"
91 def read(self
, filename
):
92 """Read configuration from file"""
94 if not os
.path
.isfile(filename
):
95 raise Config
.ReadException("No file '" + filename
+ "'.")
97 config
= configparser
.RawConfigParser()
100 for reqsec
in ["destination"]:
101 if not config
.has_section(reqsec
):
102 raise Config
.ReadException("Section '" + reqsec
+ "' is missing.")
104 self
.directory
= config
.get("destination", "directory")
106 self
.format
= config
.get("destination", "format")
107 if not self
.format
in Config
.formats
:
108 raise Config
.ReadException("Invalid 'format' given.")
111 if config
.has_section("history"):
112 for opt
in config
.options("history"):
113 if opt
.startswith("keep"):
115 if not epoch
in RealEpoch
.keys():
116 raise Config
.ReadException("Invalid option 'keep" + epoch
+ "'.")
117 self
.epochkeeps
[epoch
] = int(config
.getint("history", opt
))
118 elif opt
.startswith("mode"):
120 if not epoch
in RealEpoch
.keys():
121 raise Config
.ReadException("Invalid option 'mode" + epoch
+ "'.")
122 self
.epochmodes
[epoch
] = config
.get("history", opt
)
123 if not self
.epochmodes
[epoch
] in Mode
:
124 raise Config
.ReadException("Invalid mode given.")
126 raise Config
.ReadException("Invalid option '" + opt
+ "'.")
128 if config
.has_section("input"):
129 for opt
in config
.options("input"):
130 if opt
.startswith("exclude"):
131 self
.exclpatterns
+= [ config
.get("input", opt
) ]
133 raise Config
.ReadException("Invalid option '" + opt
+ "'.")
135 for sec
in config
.sections():
136 if sec
in ["destination", "history", "input"]:
138 elif sec
.startswith("set "):
139 name
= sec
[4:].strip()
142 for opt
in config
.options(sec
):
143 if not opt
.startswith("dir"):
144 raise Config
.ReadException("Unknown option '" + opt
+ "'.")
146 dirs
+= [config
.get(sec
, opt
)]
147 self
.sets
+= [Config
.FileSet(name
, dirs
)]
149 raise Config
.ReadException("Unknown section '" + sec
+ "'.")
151 # Compute checksum of config file
153 f
= open(filename
, 'rb')
156 self
.checksum
= m
.hexdigest()
161 f
= open(os
.path
.join(self
.directory
, self
.checksumfn
), 'r')
162 self
.lastchecksum
= f
.read().strip()
165 self
.lastchecksum
= None
169 """List and create backups"""
171 def __init__(self
, conffn
):
173 self
.conf
.read(conffn
)
176 def listAllDirs(self
):
177 """List all dirs in destination directory"""
180 basedir
= self
.conf
.directory
181 dirs
= os
.listdir(basedir
)
183 return [ d
for d
in dirs
if os
.path
.isdir(os
.path
.join(basedir
, d
)) ]
186 def listOldBackups(self
):
187 """Returns a list of old backups."""
191 for entry
in [ b
for b
in self
.listAllDirs() if Backup
.isBackupDir(b
) ]:
192 [strdate
, strtime
, epoch
, mode
] = entry
.split("-")
194 if not epoch
in Epoch
.keys():
195 raise ValueError("Invalid epoch: " + epoch
)
198 raise ValueError("Invalid mode: " + mode
)
200 date
= datetime
.datetime(int(strdate
[0:4]),
201 int(strdate
[4:6]), int(strdate
[6:8]),\
202 int(strtime
[0:2]), int(strtime
[2:4]))
203 backups
+= [ Backup(date
, epoch
, mode
) ]
208 def getDesiredEpoch(self
, backups
, now
):
209 """Get desired epoch based on self.configuration and list of old backups"""
211 # Find the longest epoch for which we would like the make a backup
212 latest
= datetime
.datetime(1900, 1, 1)
213 for timespan
, e
in reversed(sorted( [ (Epoch
[e
], e
) for e
in RealEpoch
] )):
214 # We make backups of that epoch
215 if self
.conf
.epochkeeps
[e
] == 0:
218 # Get backups of that epoch
219 byepoch
= list(sorted( [ b
for b
in backups
if b
.epoch
==e
], \
220 key
=lambda b
: b
.date
))
222 # If there are any, determine the latest
224 latest
= max(latest
, byepoch
[-1].date
)
226 # the latest backup is too old
227 if now
-latest
> timespan
:
230 # No backup is to be made
235 def backupFileSet(self
, fileset
, targetdir
, since
=None):
236 """Create an archive for given fileset at given target directory."""
238 print("Running file set: " + fileset
.name
)
240 fsfn
= os
.path
.join(targetdir
, fileset
.name
) + "." + self
.conf
.format
245 taropts
+= ["-N", since
.strftime("%Y-%m-%d %H:%M:%S")]
247 for pat
in self
.conf
.exclpatterns
:
248 taropts
+= ["--exclude", pat
]
250 tarargs
= [tarpath
] + taropts
+ ["-f", fsfn
] + fileset
.dirs
251 print("tarargs: ", tarargs
)
252 tarp
= subprocess
.Popen( tarargs
, \
253 stdout
=subprocess
.PIPE
, stderr
=subprocess
.PIPE
)
256 l
= tarp
.stdout
.readline()
258 print(l
.decode(), end
="")
259 l
= tarp
.stderr
.readline()
261 print(l
.decode(), end
="")
263 for l
in tarp
.stdout
.readlines():
264 print(l
.decode(), end
="")
266 for l
in tarp
.stderr
.readlines():
267 print(l
.decode(), end
="")
271 print(tarpath
+ " returned with exit status " + str(rett
) + ":")
272 print( tarp
.stderr
.read().decode() )
275 def backup(self
, epoch
=None, mode
=None):
276 """Make a new backup, if necessary. If epoch is None then determine
277 desired epoch automatically. Use given epoch otherwise. If mode is None
278 then use mode for given epoch. Use given mode otherwise."""
280 now
= datetime
.datetime
.now()
281 oldbackups
= self
.listOldBackups()
283 # Get epoch of backup
285 epoch
= self
.getDesiredEpoch(oldbackups
, now
)
287 print("No backup planned.")
292 mode
= self
.conf
.epochmodes
[epoch
]
293 print("Making a backup. Epoch: " + epoch
+ ", mode: " + mode
)
295 oldfullbackups
= [ b
for b
in oldbackups
if b
.mode
== "full" ]
297 # No old full backups existing
298 if mode
!= "full" and len(oldfullbackups
)==0:
299 print("No full backups existing. Making a full backup.")
301 # Checksum changed -> self.config file changed
302 if self
.conf
.checksum
!= self
.conf
.lastchecksum
:
303 print("Config file changed since last time.")
305 print("** Warning: full backup recommended!")
307 # Create new target directory
308 basedir
= self
.conf
.directory
309 dirname
= Backup
.getDirName(now
, epoch
, mode
)
310 tmpdirname
= dirname
+ ("-%x" % (random
.random()*2e16
) )
311 targetdir
= os
.path
.join(basedir
, tmpdirname
)
312 os
.mkdir( targetdir
)
314 # If we have a full backup, we backup everything
317 # Get latest full backup time
319 since
= sorted(oldfullbackups
, key
=lambda b
: b
.date
)[-1].date
320 # Get latest backup time
322 since
= sorted(oldbackups
, key
=lambda b
: b
.date
)[-1].date
324 # Backup all file sets
325 for s
in self
.conf
.sets
:
326 self
.backupFileSet(s
, targetdir
, since
)
328 # Rename backup directory to final name
329 os
.rename( targetdir
, os
.path
.join(basedir
, dirname
) )
331 # We made a full backup -- recall checksum of config
333 f
= open( os
.path
.join(basedir
, self
.conf
.checksumfn
), "w")
334 f
.write( self
.conf
.checksum
)
339 """Prune old backup files"""
341 # Collect all directories not matching backup name
342 dirs
= [ d
for d
in self
.listAllDirs() if not Backup
.isBackupDir(d
) ]
344 # Get all directories which are outdated
345 backups
= self
.listOldBackups()
346 byepoch
= { e
: list(sorted( [ b
for b
in backups
if b
.epoch
== e
], \
347 key
=lambda b
: b
.date
, reverse
=True)) for e
in RealEpoch
}
349 keep
= self
.conf
.epochkeeps
[e
]
350 old
= byepoch
[e
][keep
:]
351 dirs
+= [ Backup
.getDirName(b
.date
, b
.epoch
, b
.mode
) for b
in old
]
354 print("No stale/outdated entries to remove.")
357 print("List of stale/outdated entries:")
361 basedir
= self
.conf
.directory
362 yesno
= input("Remove listed entries? [y, N] ")
365 shutil
.rmtree(os
.path
.join(basedir
, d
))
369 """Print --help text"""
371 print("shbackup - a simple backup solution.")
374 print(" " + sys
.argv
[0] + " {options} [cmd]")
375 print(" " + sys
.argv
[0] + " --help")
378 print(" backup make a new backup, if necessary")
379 print(" list list all backups (default)")
380 print(" prune prune outdated/old backups")
383 print(" -C <configfile> use given configuration file")
384 print(" default: /etc/shbackup.conf")
385 print(" -m, --mode <mode> override mode: full, diff, or incr")
386 print(" -e, --epoch <epoch> force to create backup for given epoch:")
387 print(" year, month, week, day, hour, sporadic")
388 print(" -h, --help print this usage text")
391 if __name__
== "__main__":
393 conffn
= "/etc/shbackup.conf"
399 while i
< len(sys
.argv
)-1:
403 if opt
in ["-h", "--help"]:
407 elif opt
in ["-C", "--config"]:
411 elif opt
in ["-m", "--mode"]:
415 print("Unknown mode '" + mode
+ "'.")
418 elif opt
in ["-e", "--epoch"]:
421 if not epoch
in Epoch
:
422 print("Unknown epoch '" + epoch
+ "'.")
426 elif opt
in ["backup", "list", "prune"]:
430 print("Unknown option: " + opt
)
434 man
= BackupManager(conffn
)
437 man
.backup(epoch
, mode
)
440 for b
in sorted(man
.listOldBackups(), key
=lambda b
: b
.date
):
441 print(b
.date
.strftime("%Y-%m-%d %H:%M") + \
442 "\t" + b
.epoch
+ "\t" + b
.mode
)
447 except Config
.ReadException
as e
:
448 print("Error reading config file: ", end
="")