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", "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
)
256 print(tarpath
+ " returned with exit status " + str(rett
) + ":")
259 def backup(self
, epoch
=None, mode
=None):
260 """Make a new backup, if necessary. If epoch is None then determine
261 desired epoch automatically. Use given epoch otherwise. If mode is None
262 then use mode for given epoch. Use given mode otherwise."""
264 now
= datetime
.datetime
.now()
265 oldbackups
= self
.listOldBackups()
267 # Get epoch of backup
269 epoch
= self
.getDesiredEpoch(oldbackups
, now
)
271 print("No backup planned.")
276 mode
= self
.conf
.epochmodes
[epoch
]
277 print("Making a backup. Epoch: " + epoch
+ ", mode: " + mode
)
279 oldfullbackups
= [ b
for b
in oldbackups
if b
.mode
== "full" ]
281 # No old full backups existing
282 if mode
!= "full" and len(oldfullbackups
)==0:
283 print("No full backups existing. Making a full backup.")
285 # Checksum changed -> self.config file changed
286 if self
.conf
.checksum
!= self
.conf
.lastchecksum
:
287 print("Config file changed since last time.")
289 print("** Warning: full backup recommended!")
291 # Create new target directory
292 basedir
= self
.conf
.directory
293 dirname
= Backup
.getDirName(now
, epoch
, mode
)
294 tmpdirname
= dirname
+ ("-%x" % (random
.random()*2e16
) )
295 targetdir
= os
.path
.join(basedir
, tmpdirname
)
296 os
.mkdir( targetdir
)
298 # If we have a full backup, we backup everything
301 # Get latest full backup time
303 since
= sorted(oldfullbackups
, key
=lambda b
: b
.date
)[-1].date
304 # Get latest backup time
306 since
= sorted(oldbackups
, key
=lambda b
: b
.date
)[-1].date
308 # Backup all file sets
309 for s
in self
.conf
.sets
:
310 self
.backupFileSet(s
, targetdir
, since
)
312 # Rename backup directory to final name
313 os
.rename( targetdir
, os
.path
.join(basedir
, dirname
) )
315 # We made a full backup -- recall checksum of config
317 f
= open( os
.path
.join(basedir
, self
.conf
.checksumfn
), "w")
318 f
.write( self
.conf
.checksum
)
323 """Prune old backup files"""
325 # Collect all directories not matching backup name
326 dirs
= [ d
for d
in self
.listAllDirs() if not Backup
.isBackupDir(d
) ]
328 # Get all directories which are outdated
329 backups
= self
.listOldBackups()
330 byepoch
= { e
: list(sorted( [ b
for b
in backups
if b
.epoch
== e
], \
331 key
=lambda b
: b
.date
, reverse
=True)) for e
in RealEpoch
}
333 keep
= self
.conf
.epochkeeps
[e
]
334 old
= byepoch
[e
][keep
:]
335 dirs
+= [ Backup
.getDirName(b
.date
, b
.epoch
, b
.mode
) for b
in old
]
338 print("No stale/outdated entries to remove.")
341 print("List of stale/outdated entries:")
345 basedir
= self
.conf
.directory
346 yesno
= input("Remove listed entries? [y, N] ")
349 shutil
.rmtree(os
.path
.join(basedir
, d
))
353 """Print --help text"""
355 print("shbackup - a simple backup solution.")
358 print(" " + sys
.argv
[0] + " {options} [cmd]")
359 print(" " + sys
.argv
[0] + " --help")
362 print(" backup make a new backup, if necessary")
363 print(" list list all backups (default)")
364 print(" prune prune outdated/old backups")
367 print(" -h, --help print this usage text")
368 print(" -c, --conf <configfile> use given configuration file")
369 print(" default: /etc/shbackup.conf")
370 print(" -e, --epoch <epoch> force to create backup for given epoch:")
371 print(" year, month, week, day, hour, sporadic")
372 print(" -m, --mode <mode> override mode: full, diff, or incr")
375 if __name__
== "__main__":
377 conffn
= "/etc/shbackup.conf"
383 while i
< len(sys
.argv
)-1:
387 if opt
in ["-h", "--help"]:
391 elif opt
in ["-c", "--conf"]:
395 elif opt
in ["-m", "--mode"]:
399 print("Unknown mode '" + mode
+ "'.")
402 elif opt
in ["-e", "--epoch"]:
405 if not epoch
in Epoch
:
406 print("Unknown epoch '" + epoch
+ "'.")
410 elif opt
in ["backup", "list", "prune"]:
414 print("Unknown option: " + opt
)
418 man
= BackupManager(conffn
)
421 man
.backup(epoch
, mode
)
424 for b
in sorted(man
.listOldBackups(), key
=lambda b
: b
.date
):
425 print(b
.date
.strftime("%Y-%m-%d %H:%M") + \
426 "\t" + b
.epoch
+ "\t" + b
.mode
)
431 except Config
.ReadException
as e
:
432 print("Error reading config file: ", end
="")