cbd5396cc897636c93a272c4df36caae195c4818
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 ReadError(RuntimeError):
56 """An exception raised when reading configurations."""
57 def __init__(self
, value
):
62 """A fileset has a name and a list of directories."""
63 def __init__(self
, name
, dirs
):
68 return "[name: " + self
.name
+ ", dirs: " + str(self
.dirs
) + "]"
70 formats
= ["tar", "tar.gz", "tar.bz2", "tar.xz" ]
72 # Filename where checksum of config is saved
73 checksumfn
= "checksum"
76 self
.directory
= "/media/backup"
77 self
.format
= self
.formats
[0]
78 self
.epochkeeps
= { k
: 0 for k
in RealEpoch
.keys() }
79 self
.epochmodes
= { k
: "full" for k
in RealEpoch
.keys() }
80 self
.exclpatterns
= []
83 self
.lastchecksum
= None
86 return "[directory: " + self
.directory
+ \
87 ", format: " + self
.format
+ \
88 ", keeps: " + str(self
.epochkeeps
) + \
89 ", modes: " + str(self
.epochmodes
) + \
90 ", exclpatterns: " + str(self
.exclpatterns
) + \
91 ", sets: " + str([str(s
) for s
in self
.sets
]) + "]"
93 def read(self
, filename
):
94 """Read configuration from file"""
96 if not os
.path
.isfile(filename
):
97 raise Config
.ReadError("Cannot read config file '" + filename
+ "'.")
99 config
= configparser
.RawConfigParser()
100 config
.read(filename
)
102 for reqsec
in ["destination"]:
103 if not config
.has_section(reqsec
):
104 raise Config
.ReadError("Section '" + reqsec
+ "' is missing.")
106 self
.directory
= config
.get("destination", "directory")
107 if not os
.path
.isdir(self
.directory
):
108 raise Config
.ReadError("Directory '{0}' does not exist.".format(self
.directory
))
110 self
.format
= config
.get("destination", "format")
111 if not self
.format
in Config
.formats
:
112 raise Config
.ReadError("Invalid 'format' given.")
115 if config
.has_section("history"):
116 for opt
in config
.options("history"):
117 if opt
.startswith("keep"):
119 if not epoch
in RealEpoch
.keys():
120 raise Config
.ReadError("Invalid option 'keep" + epoch
+ "'.")
122 self
.epochkeeps
[epoch
] = int(config
.getint("history", opt
))
124 raise Config
.ReadError("Invalid integer given for '" + opt
+ "'.")
125 elif opt
.startswith("mode"):
127 if not epoch
in RealEpoch
.keys():
128 raise Config
.ReadError("Invalid option 'mode" + epoch
+ "'.")
129 self
.epochmodes
[epoch
] = config
.get("history", opt
)
130 if not self
.epochmodes
[epoch
] in Mode
:
131 raise Config
.ReadError("Invalid mode given.")
133 raise Config
.ReadError("Invalid option '" + opt
+ "'.")
135 if config
.has_section("input"):
136 for opt
in config
.options("input"):
137 if opt
.startswith("exclude"):
138 self
.exclpatterns
+= [ config
.get("input", opt
) ]
140 raise Config
.ReadError("Invalid option '" + opt
+ "'.")
142 for sec
in config
.sections():
143 if sec
in ["destination", "history", "input"]:
145 elif sec
.startswith("set "):
146 name
= sec
[4:].strip()
149 for opt
in config
.options(sec
):
150 if not opt
.startswith("dir"):
151 raise Config
.ReadError("Unknown option '" + opt
+ "'.")
153 dirs
+= [config
.get(sec
, opt
)]
154 self
.sets
+= [Config
.FileSet(name
, dirs
)]
156 raise Config
.ReadError("Unknown section '" + sec
+ "'.")
158 # Compute checksum of config file
160 f
= open(filename
, 'rb')
163 self
.checksum
= m
.hexdigest()
168 f
= open(os
.path
.join(self
.directory
, self
.checksumfn
), 'r')
169 self
.lastchecksum
= f
.read().strip()
172 self
.lastchecksum
= None
176 """List and create backups"""
178 def __init__(self
, conffn
):
180 self
.conf
.read(conffn
)
183 def listAllDirs(self
):
184 """List all dirs in destination directory"""
187 basedir
= self
.conf
.directory
188 dirs
= os
.listdir(basedir
)
190 return [ d
for d
in dirs
if os
.path
.isdir(os
.path
.join(basedir
, d
)) ]
193 def listOldBackups(self
):
194 """Returns a list of old backups."""
198 for entry
in [ b
for b
in self
.listAllDirs() if Backup
.isBackupDir(b
) ]:
199 [strdate
, strtime
, epoch
, mode
] = entry
.split("-")
201 if not epoch
in Epoch
.keys():
202 raise ValueError("Invalid epoch: " + epoch
)
205 raise ValueError("Invalid mode: " + mode
)
207 date
= datetime
.datetime(int(strdate
[0:4]),
208 int(strdate
[4:6]), int(strdate
[6:8]),\
209 int(strtime
[0:2]), int(strtime
[2:4]))
210 backups
+= [ Backup(date
, epoch
, mode
) ]
215 def getDesiredEpoch(self
, backups
, now
):
216 """Get desired epoch based on self.configuration and list of old backups"""
218 # Find the longest epoch for which we would like the make a backup
219 latest
= datetime
.datetime(1900, 1, 1)
220 for timespan
, e
in reversed(sorted( [ (Epoch
[e
], e
) for e
in RealEpoch
] )):
221 # We make backups of that epoch
222 if self
.conf
.epochkeeps
[e
] == 0:
225 # Get backups of that epoch
226 byepoch
= list(sorted( [ b
for b
in backups
if b
.epoch
==e
], \
227 key
=lambda b
: b
.date
))
229 # If there are any, determine the latest
231 latest
= max(latest
, byepoch
[-1].date
)
233 # the latest backup is too old
234 if now
-latest
> timespan
:
237 # No backup is to be made
242 def backupFileSet(self
, fileset
, targetdir
, since
=None):
243 """Create an archive for given fileset at given target directory."""
245 print("Running file set: " + fileset
.name
)
247 fsfn
= os
.path
.join(targetdir
, fileset
.name
) + "." + self
.conf
.format
252 taropts
+= ["-N", since
.strftime("%Y-%m-%d %H:%M:%S")]
254 for pat
in self
.conf
.exclpatterns
:
255 taropts
+= ["--exclude", pat
]
257 tarargs
= [tarpath
] + taropts
+ ["-f", fsfn
] + fileset
.dirs
258 #print("tarargs: ", tarargs)
259 tarp
= subprocess
.Popen( tarargs
)
263 print(tarpath
+ " returned with exit status " + str(rett
) + ":")
266 def backup(self
, epoch
=None, mode
=None):
267 """Make a new backup, if necessary. If epoch is None then determine
268 desired epoch automatically. Use given epoch otherwise. If mode is None
269 then use mode for given epoch. Use given mode otherwise."""
271 now
= datetime
.datetime
.now()
272 oldbackups
= self
.listOldBackups()
274 # Get epoch of backup
276 epoch
= self
.getDesiredEpoch(oldbackups
, now
)
278 print("No backup planned.")
283 mode
= self
.conf
.epochmodes
[epoch
]
284 print("Making a backup. Epoch: " + epoch
+ ", mode: " + mode
)
286 oldfullbackups
= [ b
for b
in oldbackups
if b
.mode
== "full" ]
288 # No old full backups existing
289 if mode
!= "full" and len(oldfullbackups
)==0:
290 print("No full backups existing. Making a full backup.")
292 # Checksum changed -> self.config file changed
293 if self
.conf
.checksum
!= self
.conf
.lastchecksum
:
294 print("Config file changed since last time.")
296 print("** Warning: full backup recommended!")
298 # Create new target directory
299 basedir
= self
.conf
.directory
300 dirname
= Backup
.getDirName(now
, epoch
, mode
)
301 tmpdirname
= dirname
+ ("-%x" % (random
.random()*2e16
) )
302 targetdir
= os
.path
.join(basedir
, tmpdirname
)
303 os
.mkdir( targetdir
)
305 # If we have a full backup, we backup everything
308 # Get latest full backup time
310 since
= sorted(oldfullbackups
, key
=lambda b
: b
.date
)[-1].date
311 # Get latest backup time
313 since
= sorted(oldbackups
, key
=lambda b
: b
.date
)[-1].date
315 # Backup all file sets
316 for s
in self
.conf
.sets
:
317 self
.backupFileSet(s
, targetdir
, since
)
319 # Rename backup directory to final name
320 os
.rename( targetdir
, os
.path
.join(basedir
, dirname
) )
322 # We made a full backup -- recall checksum of config
324 f
= open( os
.path
.join(basedir
, self
.conf
.checksumfn
), "w")
325 f
.write( self
.conf
.checksum
)
330 """Prune old backup files"""
332 # Collect all directories not matching backup name
333 dirs
= [ d
for d
in self
.listAllDirs() if not Backup
.isBackupDir(d
) ]
335 # Get all directories which are outdated
336 backups
= self
.listOldBackups()
337 byepoch
= { e
: list(sorted( [ b
for b
in backups
if b
.epoch
== e
], \
338 key
=lambda b
: b
.date
, reverse
=True)) for e
in RealEpoch
}
340 keep
= self
.conf
.epochkeeps
[e
]
341 old
= byepoch
[e
][keep
:]
342 dirs
+= [ Backup
.getDirName(b
.date
, b
.epoch
, b
.mode
) for b
in old
]
345 print("No stale/outdated entries to remove.")
348 print("List of stale/outdated entries:")
352 basedir
= self
.conf
.directory
353 yesno
= input("Remove listed entries? [y, N] ")
356 shutil
.rmtree(os
.path
.join(basedir
, d
))
360 """Print --help text"""
362 print("shbackup - a simple backup solution.")
365 print(" " + sys
.argv
[0] + " {options} [cmd]")
366 print(" " + sys
.argv
[0] + " --help")
369 print(" backup make a new backup, if necessary")
370 print(" list list all backups (default)")
371 print(" prune prune outdated/old backups")
374 print(" -h, --help print this usage text")
375 print(" -c, --conf <configfile> use given configuration file")
376 print(" default: /etc/shbackup.conf")
377 print(" -e, --epoch <epoch> force to create backup for given epoch:")
378 print(" year, month, week, day, hour, sporadic")
379 print(" -m, --mode <mode> override mode: full, diff, or incr")
382 if __name__
== "__main__":
384 conffn
= "/etc/shbackup.conf"
390 while i
< len(sys
.argv
)-1:
394 if opt
in ["-h", "--help"]:
398 elif opt
in ["-c", "--conf"]:
402 elif opt
in ["-m", "--mode"]:
406 print("Unknown mode '" + mode
+ "'.")
409 elif opt
in ["-e", "--epoch"]:
412 if not epoch
in Epoch
:
413 print("Unknown epoch '" + epoch
+ "'.")
417 elif opt
in ["backup", "list", "prune"]:
421 print("Unknown option: " + opt
)
425 man
= BackupManager(conffn
)
428 man
.backup(epoch
, mode
)
431 for b
in sorted(man
.listOldBackups(), key
=lambda b
: b
.date
):
432 print(b
.date
.strftime("%Y-%m-%d %H:%M") + \
433 "\t" + b
.epoch
+ "\t" + b
.mode
)
438 except (Config
.ReadError
, configparser
.DuplicateOptionError
) as e
:
439 print("Error reading config file: " + e
.message
)