f9f700719f4bc8c950fea2099062c57dbd0ce68a
2 """Stefan Huber's simplistic backup solution."""
13 """Encapsules the configuration for the backup program."""
15 class ReadException(Exception):
16 """An exception raised when reading configurations."""
20 """A fileset has a name and a list of directories."""
21 def __init__(self
, name
, dirs
):
26 return "[name: " + self
.name
+ ", dirs: " + str(self
.dirs
) + "]"
28 formats
= ["tar.gz", "tar.bz2", "tar.xz" ]
30 # Filename where checksum of config is saved
31 checksumfn
= "checksum"
34 self
.directory
= "/media/backup"
35 self
.format
= self
.formats
[0]
36 self
.epochkeeps
= { k
: 0 for k
in Epoch
.keys() }
37 self
.epochmodes
= { k
: "full" for k
in Epoch
.keys() }
38 self
.exclpatterns
= []
41 self
.lastchecksum
= None
44 return "[directory: " + self
.directory
+ \
45 ", format: " + self
.format
+ \
46 ", keeps: " + str(self
.epochkeeps
) + \
47 ", modes: " + str(self
.epochmodes
) + \
48 ", exclpatterns: " + str(self
.exclpatterns
) + \
49 ", sets: " + str([str(s
) for s
in self
.sets
]) + "]"
51 def read(self
, filename
):
52 """Read configuration from file"""
54 if not os
.path
.isfile(filename
):
55 raise Config
.ReadException("No file '" + filename
+ "'.")
57 config
= configparser
.RawConfigParser()
60 for reqsec
in ["destination"]:
61 if not config
.has_section(reqsec
):
62 raise Config
.ReadException("Section '" + reqsec
+ "' is missing.")
64 self
.directory
= config
.get("destination", "directory")
66 self
.format
= config
.get("destination", "format")
67 if not self
.format
in Config
.formats
:
68 raise Config
.ReadException("Invalid 'format' given.")
71 if config
.has_section("history"):
72 for opt
in config
.options("history"):
73 if opt
.startswith("keep"):
75 if not epoch
in Epoch
.keys():
76 raise Config
.ReadException("Invalid option 'keep" + epoch
+ "'.")
77 self
.epochkeeps
[epoch
] = int(config
.getint("history", opt
))
78 elif opt
.startswith("mode"):
80 if not epoch
in Epoch
.keys():
81 raise Config
.ReadException("Invalid option 'mode" + epoch
+ "'.")
82 self
.epochmodes
[epoch
] = config
.get("history", opt
)
83 if not self
.epochmodes
[epoch
] in Mode
:
84 raise Config
.ReadException("Invalid mode given.")
86 raise Config
.ReadException("Invalid option '" + opt
+ "'.")
88 if config
.has_section("input"):
89 for opt
in config
.options("input"):
90 if opt
.startswith("exclude"):
91 self
.exclpatterns
+= [ config
.get("input", opt
) ]
93 raise Config
.ReadException("Invalid option '" + opt
+ "'.")
95 for sec
in config
.sections():
96 if sec
in ["destination", "history", "input"]:
98 elif sec
.startswith("set "):
99 name
= sec
[4:].strip()
102 for opt
in config
.options(sec
):
103 if not opt
.startswith("dir"):
104 raise Config
.ReadException("Unknown option '" + opt
+ "'.")
106 dirs
+= [config
.get(sec
, opt
)]
107 self
.sets
+= [Config
.FileSet(name
, dirs
)]
109 raise Config
.ReadException("Unknown section '" + sec
+ "'.")
111 # Compute checksum of config file
113 f
= open(filename
, 'rb')
116 self
.checksum
= m
.hexdigest()
121 f
= open(os
.path
.join(self
.directory
, self
.checksumfn
), 'r')
122 self
.lastchecksum
= f
.read().strip()
125 self
.lastchecksum
= None
128 Mode
= ["full", "incr", "diff"]
130 Epoch
= { "hour" : datetime
.timedelta(0, 3600), \
131 "day" : datetime
.timedelta(1), \
132 "week" : datetime
.timedelta(7), \
133 "month" : datetime
.timedelta(30), \
134 "year" : datetime
.timedelta(365) }
137 """A single backup has a date, an epoch and a mode."""
139 def __init__(self
, date
, epoch
, mode
):
145 return "[date: " + self
.date
.ctime() + \
146 ", epoch: " + self
.epoch
+ \
147 ", mode: " + self
.mode
+ "]"
150 def getDirName(date
, epoch
, mode
):
151 """Get directory name of backup by given properties."""
152 return date
.strftime("%Y%m%d-%H%M") + "-" + epoch
+ "-" + mode
155 def isBackupDir(dirname
):
156 """Is directory a backup directory?"""
157 p
= re
.compile(r
'^\d\d\d\d\d\d\d\d-\d\d\d\d-\w+-\w+$')
158 return p
.match(dirname
)
162 """List and create backups"""
164 def __init__(self
, conffn
):
166 self
.conf
.read(conffn
)
169 def listAllDirs(self
):
170 """List all dirs in destination directory"""
173 basedir
= self
.conf
.directory
174 dirs
= os
.listdir(basedir
)
176 return [ d
for d
in dirs
if os
.path
.isdir(os
.path
.join(basedir
, d
)) ]
178 def listOldBackups(self
):
179 """Returns a list of old backups."""
183 for entry
in [ b
for b
in self
.listAllDirs() if Backup
.isBackupDir(b
) ]:
184 [strdate
, strtime
, epoch
, mode
] = entry
.split("-")
186 if not epoch
in Epoch
.keys():
187 raise ValueError("Invalid epoch: " + epoch
)
190 raise ValueError("Invalid mode: " + mode
)
192 date
= datetime
.datetime(int(strdate
[0:4]),
193 int(strdate
[4:6]), int(strdate
[6:8]),\
194 int(strtime
[0:2]), int(strtime
[2:4]))
195 backups
+= [ Backup(date
, epoch
, mode
) ]
200 def getDesiredEpoch(self
, backups
, now
):
201 """Get desired epoch based on self.configuration and list of old backups"""
203 # Find the longest epoch for which we would like the make a backup
204 latest
= datetime
.datetime(1900, 1, 1)
205 for timespan
, e
in reversed(sorted( [ (Epoch
[e
], e
) for e
in Epoch
] )):
206 # We make backups of that epoch
207 if self
.conf
.epochkeeps
[e
] == 0:
210 # Get backups of that epoch
211 byepoch
= list(sorted( [ b
for b
in backups
if b
.epoch
==e
], \
212 key
=lambda b
: b
.date
))
214 # If there are any, determine the latest
216 latest
= max(latest
, byepoch
[-1].date
)
218 # the latest backup is too old
219 if now
-latest
> timespan
:
222 # No backup is to be made
227 def backupFileSet(self
, fileset
, targetdir
, since
=None):
228 """Create an archive for given fileset at given target directory."""
230 print("Running file set: " + fileset
.name
)
232 fsfn
= os
.path
.join(targetdir
, fileset
.name
) + "." + self
.conf
.format
237 taropts
+= ["-N", since
.strftime("%Y-%m-%d %H:%M:%S")]
239 for pat
in self
.conf
.exclpatterns
:
240 taropts
+= ["--exclude", pat
]
242 tarargs
= [tarpath
] + taropts
+ ["-f", fsfn
] + fileset
.dirs
243 print("tarargs: ", tarargs
)
244 tarp
= subprocess
.Popen( tarargs
, \
245 stdout
=subprocess
.PIPE
, stderr
=subprocess
.PIPE
)
248 l
= tarp
.stdout
.readline()
250 print(l
.decode(), end
="")
251 l
= tarp
.stderr
.readline()
253 print(l
.decode(), end
="")
255 for l
in tarp
.stdout
.readlines():
256 print(l
.decode(), end
="")
258 for l
in tarp
.stderr
.readlines():
259 print(l
.decode(), end
="")
263 print(tarpath
+ " returned with exit status " + str(rett
) + ":")
264 print( tarp
.stderr
.read().decode() )
268 """Make a new backup, if necessary"""
270 now
= datetime
.datetime
.now()
271 oldbackups
= self
.listOldBackups()
272 epoch
= self
.getDesiredEpoch(oldbackups
, now
)
275 print("No backup planned.")
280 mode
= self
.conf
.epochmodes
[epoch
]
281 print("Making a backup. Epoch: " + epoch
+ ", mode: " + mode
)
283 oldfullbackups
= [ b
for b
in oldbackups
if b
.mode
=="full" ]
285 # No old full backups existing
286 if mode
!= "full" and len(oldfullbackups
)==0:
287 print("No full backups existing. Making a full backup.")
289 # Checksum changed -> self.config file changed
290 if self
.conf
.checksum
!= self
.conf
.lastchecksum
:
291 print("Config file changed since last time.")
293 print("** Warning: full backup recommended!")
295 # Create new target directory
296 basedir
= self
.conf
.directory
297 dirname
= Backup
.getDirName(now
, epoch
, mode
)
298 tmpdirname
= dirname
+ ("-%x" % (random
.random()*2e16
) )
299 targetdir
= os
.path
.join(basedir
, tmpdirname
)
300 os
.mkdir( targetdir
)
302 # If we have a full backup, we backup everything
305 # Get latest full backup time
307 since
= sorted(oldfullbackups
, key
=lambda b
: b
.date
)[-1].date
308 # Get latest backup time
310 since
= sorted(oldbackups
, key
=lambda b
: b
.date
)[-1].date
312 # Backup all file sets
313 for s
in self
.conf
.sets
:
314 self
.backupFileSet(s
, targetdir
, since
)
316 # Rename backup directory to final name
317 os
.rename( targetdir
, os
.path
.join(basedir
, dirname
) )
319 # We made a full backup -- recall checksum of config
321 f
= open( os
.path
.join(basedir
, self
.conf
.checksumfn
), "w")
322 f
.write( self
.conf
.checksum
)
327 """Prune old backup files"""
329 # Collect all directories not matching backup name
330 dirs
= [ d
for d
in self
.listAllDirs() if not Backup
.isBackupDir(d
) ]
332 # Get all directories which are outdated
333 backups
= self
.listOldBackups()
334 byepoch
= { e
: list(sorted( [ b
for b
in backups
if b
.epoch
== e
], \
335 key
=lambda b
: b
.date
, reverse
=True)) for e
in Epoch
}
337 keep
= self
.conf
.epochkeeps
[e
]
338 old
= byepoch
[e
][keep
:]
339 dirs
+= [ Backup
.getDirName(b
.date
, b
.epoch
, b
.mode
) for b
in old
]
342 print("No stale/outdated entries to remove.")
345 print("List of stale/outdated entries:")
349 basedir
= self
.conf
.directory
350 yesno
= input("Remove listed entries? [y, N] ")
353 shutil
.rmtree(os
.path
.join(basedir
, d
))
357 """Print --help text"""
359 print("shbackup - a simple backup solution.")
362 print(" " + sys
.argv
[0] + " [-C <configfile>")
363 print(" " + sys
.argv
[0] + " --help")
366 print(" -C <configfile> default: /etc/shbackup.conf")
369 if __name__
== "__main__":
371 conffn
= "/etc/shbackup.conf"
374 while i
< len(sys
.argv
)-1:
378 if opt
in ["-h", "--help"]:
382 elif opt
in ["-C", "--config"]:
388 print("Unknown option: " + opt
)
392 man
= BackupManager(conffn
)
396 except Config
.ReadException
as e
:
397 print("Error reading config file: ", end
="")