41b24fe125686121c4a082d788546d8a66b27dc7
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"""
55 config
= configparser
.RawConfigParser()
58 for reqsec
in ["destination"]:
59 if not config
.has_section(reqsec
):
60 raise Config
.ReadException("Section '" + reqsec
+ "' is missing.")
62 self
.directory
= config
.get("destination", "directory")
64 self
.format
= config
.get("destination", "format")
65 if not self
.format
in Config
.formats
:
66 raise Config
.ReadException("Invalid 'format' given.")
69 if config
.has_section("history"):
70 for opt
in config
.options("history"):
71 if opt
.startswith("keep"):
73 if not epoch
in Epoch
.keys():
74 raise Config
.ReadException("Invalid option 'keep" + epoch
+ "'.")
75 self
.epochkeeps
[epoch
] = int(config
.getint("history", opt
))
76 elif opt
.startswith("mode"):
78 if not epoch
in Epoch
.keys():
79 raise Config
.ReadException("Invalid option 'mode" + epoch
+ "'.")
80 self
.epochmodes
[epoch
] = config
.get("history", opt
)
81 if not self
.epochmodes
[epoch
] in Mode
:
82 raise Config
.ReadException("Invalid mode given.")
84 raise Config
.ReadException("Invalid option '" + opt
+ "'.")
86 if config
.has_section("input"):
87 for opt
in config
.options("input"):
88 if opt
.startswith("exclude"):
89 self
.exclpatterns
+= [ config
.get("input", opt
) ]
91 raise Config
.ReadException("Invalid option '" + opt
+ "'.")
93 for sec
in config
.sections():
94 if sec
in ["destination", "history", "input"]:
96 elif sec
.startswith("set "):
97 name
= sec
[4:].strip()
100 for opt
in config
.options(sec
):
101 if not opt
.startswith("dir"):
102 raise Config
.ReadException("Unknown option '" + opt
+ "'.")
104 dirs
+= [config
.get(sec
, opt
)]
105 self
.sets
+= [Config
.FileSet(name
, dirs
)]
107 raise Config
.ReadException("Unknown section '" + sec
+ "'.")
109 # Compute checksum of config file
111 f
= open(filename
, 'rb')
114 self
.checksum
= m
.hexdigest()
119 f
= open(os
.path
.join(self
.directory
, self
.checksumfn
), 'r')
120 self
.lastchecksum
= f
.read().strip()
123 self
.lastchecksum
= None
126 Mode
= ["full", "incr", "diff"]
128 Epoch
= { "hour" : datetime
.timedelta(0, 3600), \
129 "day" : datetime
.timedelta(1), \
130 "week" : datetime
.timedelta(7), \
131 "month" : datetime
.timedelta(30), \
132 "year" : datetime
.timedelta(365) }
135 """A single backup has a date, an epoch and a mode."""
137 def __init__(self
, date
, epoch
, mode
):
143 return "[date: " + self
.date
.ctime() + \
144 ", epoch: " + self
.epoch
+ \
145 ", mode: " + self
.mode
+ "]"
148 def getDirName(date
, epoch
, mode
):
149 """Get directory name of backup by given properties."""
150 return date
.strftime("%Y%m%d-%H%M") + "-" + epoch
+ "-" + mode
153 def isBackupDir(dirname
):
154 """Is directory a backup directory?"""
155 p
= re
.compile(r
'^\d\d\d\d\d\d\d\d-\d\d\d\d-\w+-\w+$')
156 return p
.match(dirname
)
160 """List and create backups"""
162 def __init__(self
, conffn
):
164 self
.conf
.read(conffn
)
167 def listAllDirs(self
):
168 """List all dirs in destination directory"""
171 basedir
= self
.conf
.directory
172 dirs
= os
.listdir(basedir
)
174 return [ d
for d
in dirs
if os
.path
.isdir(os
.path
.join(basedir
, d
)) ]
176 def listOldBackups(self
):
177 """Returns a list of old backups."""
181 for entry
in [ b
for b
in self
.listAllDirs() if Backup
.isBackupDir(b
) ]:
182 [strdate
, strtime
, epoch
, mode
] = entry
.split("-")
184 if not epoch
in Epoch
.keys():
185 raise ValueError("Invalid epoch: " + epoch
)
188 raise ValueError("Invalid mode: " + mode
)
190 date
= datetime
.datetime(int(strdate
[0:4]),
191 int(strdate
[4:6]), int(strdate
[6:8]),\
192 int(strtime
[0:2]), int(strtime
[2:4]))
193 backups
+= [ Backup(date
, epoch
, mode
) ]
198 def getDesiredEpoch(self
, backups
, now
):
199 """Get desired epoch based on self.configuration and list of old backups"""
201 # Find the longest epoch for which we would like the make a backup
202 latest
= datetime
.datetime(1900, 1, 1)
203 for timespan
, e
in reversed(sorted( [ (Epoch
[e
], e
) for e
in Epoch
] )):
204 # We make backups of that epoch
205 if self
.conf
.epochkeeps
[e
] == 0:
208 # Get backups of that epoch
209 byepoch
= list(sorted( [ b
for b
in backups
if b
.epoch
==e
], \
210 key
=lambda b
: b
.date
))
212 # If there are any, determine the latest
214 latest
= max(latest
, byepoch
[-1].date
)
216 # the latest backup is too old
217 if now
-latest
> timespan
:
220 # No backup is to be made
225 def backupFileSet(self
, fileset
, targetdir
, since
=None):
226 """Create an archive for given fileset at given target directory."""
228 print("Running file set: " + fileset
.name
)
230 fsfn
= os
.path
.join(targetdir
, fileset
.name
) + "." + self
.conf
.format
235 taropts
+= ["-N", since
.strftime("%Y-%m-%d %H:%M:%S")]
237 for pat
in self
.conf
.exclpatterns
:
238 taropts
+= ["--exclude", pat
]
240 tarargs
= [tarpath
] + taropts
+ ["-f", fsfn
] + fileset
.dirs
241 print("tarargs: ", tarargs
)
242 tarp
= subprocess
.Popen( tarargs
, \
243 stdout
=subprocess
.PIPE
, stderr
=subprocess
.PIPE
)
246 l
= tarp
.stdout
.readline()
248 print(l
.decode(), end
="")
249 l
= tarp
.stderr
.readline()
251 print(l
.decode(), end
="")
253 for l
in tarp
.stdout
.readlines():
254 print(l
.decode(), end
="")
256 for l
in tarp
.stderr
.readlines():
257 print(l
.decode(), end
="")
261 print(tarpath
+ " returned with exit status " + str(rett
) + ":")
262 print( tarp
.stderr
.read().decode() )
266 """Make a new backup, if necessary"""
268 now
= datetime
.datetime
.now()
269 oldbackups
= self
.listOldBackups()
270 epoch
= self
.getDesiredEpoch(oldbackups
, now
)
273 print("No backup planned.")
278 mode
= self
.conf
.epochmodes
[epoch
]
279 print("Making a backup. Epoch: " + epoch
+ ", mode: " + mode
)
281 oldfullbackups
= [ b
for b
in oldbackups
if b
.mode
=="full" ]
283 # No old full backups existing
284 if mode
!= "full" and len(oldfullbackups
)==0:
285 print("No full backups existing. Making a full backup.")
287 # Checksum changed -> self.config file changed
288 if self
.conf
.checksum
!= self
.conf
.lastchecksum
:
289 print("Config file changed since last time.")
291 print("** Warning: full backup recommended!")
293 # Create new target directory
294 basedir
= self
.conf
.directory
295 dirname
= Backup
.getDirName(now
, epoch
, mode
)
296 tmpdirname
= dirname
+ ("-%x" % (random
.random()*2e16
) )
297 targetdir
= os
.path
.join(basedir
, tmpdirname
)
298 os
.mkdir( targetdir
)
300 # If we have a full backup, we backup everything
303 # Get latest full backup time
305 since
= sorted(oldfullbackups
, key
=lambda b
: b
.date
)[-1].date
306 # Get latest backup time
308 since
= sorted(oldbackups
, key
=lambda b
: b
.date
)[-1].date
310 # Backup all file sets
311 for s
in self
.conf
.sets
:
312 self
.backupFileSet(s
, targetdir
, since
)
314 # Rename backup directory to final name
315 os
.rename( targetdir
, os
.path
.join(basedir
, dirname
) )
317 # We made a full backup -- recall checksum of config
319 f
= open( os
.path
.join(basedir
, self
.conf
.checksumfn
), "w")
320 f
.write( self
.conf
.checksum
)
325 """Prune old backup files"""
327 # Collect all directories not matching backup name
328 dirs
= [ d
for d
in self
.listAllDirs() if not Backup
.isBackupDir(d
) ]
330 # Get all directories which are outdated
331 backups
= self
.listOldBackups()
332 byepoch
= { e
: list(sorted( [ b
for b
in backups
if b
.epoch
== e
], \
333 key
=lambda b
: b
.date
, reverse
=True)) for e
in Epoch
}
335 keep
= self
.conf
.epochkeeps
[e
]
336 old
= byepoch
[e
][keep
:]
337 dirs
+= [ Backup
.getDirName(b
.date
, b
.epoch
, b
.mode
) for b
in old
]
340 print("No stale/outdated entries to remove.")
343 print("List of stale/outdated entries:")
347 basedir
= self
.conf
.directory
348 yesno
= input("Remove listed entries? [y, N] ")
351 shutil
.rmtree(os
.path
.join(basedir
, d
))
354 if __name__
== "__main__":
356 conffn
= "shbackup.conf"
357 if len(sys
.argv
) > 1:
360 man
= BackupManager(conffn
)