e1f038d559708119baa96f8b2522ee4397ea68b0
2 """Stefan Huber's simplistic backup solution."""
12 Mode
= ["full", "incr", "diff"]
14 Epoch
= { "hour" : datetime
.timedelta(0, 3600), \
15 "day" : datetime
.timedelta(1), \
16 "week" : datetime
.timedelta(7), \
17 "month" : datetime
.timedelta(30), \
18 "year" : datetime
.timedelta(365) }
21 """A single backup has a date, an epoch and a mode."""
23 def __init__(self
, date
, epoch
, mode
):
29 return "[date: " + self
.date
.ctime() + \
30 ", epoch: " + self
.epoch
+ \
31 ", mode: " + self
.mode
+ "]"
34 def getDirName(date
, epoch
, mode
):
35 """Get directory name of backup by given properties."""
36 return date
.strftime("%Y%m%d-%H%M") + "-" + epoch
+ "-" + mode
39 def isBackupDir(dirname
):
40 """Is directory a backup directory?"""
41 p
= re
.compile(r
'^\d\d\d\d\d\d\d\d-\d\d\d\d-\w+-\w+$')
42 return p
.match(dirname
)
48 """Encapsules the configuration for the backup program."""
50 class ReadException(Exception):
51 """An exception raised when reading configurations."""
55 """A fileset has a name and a list of directories."""
56 def __init__(self
, name
, dirs
):
61 return "[name: " + self
.name
+ ", dirs: " + str(self
.dirs
) + "]"
63 formats
= ["tar.gz", "tar.bz2", "tar.xz" ]
65 # Filename where checksum of config is saved
66 checksumfn
= "checksum"
69 self
.directory
= "/media/backup"
70 self
.format
= self
.formats
[0]
71 self
.epochkeeps
= { k
: 0 for k
in Epoch
.keys() }
72 self
.epochmodes
= { k
: "full" for k
in Epoch
.keys() }
73 self
.exclpatterns
= []
76 self
.lastchecksum
= None
79 return "[directory: " + self
.directory
+ \
80 ", format: " + self
.format
+ \
81 ", keeps: " + str(self
.epochkeeps
) + \
82 ", modes: " + str(self
.epochmodes
) + \
83 ", exclpatterns: " + str(self
.exclpatterns
) + \
84 ", sets: " + str([str(s
) for s
in self
.sets
]) + "]"
86 def read(self
, filename
):
87 """Read configuration from file"""
89 if not os
.path
.isfile(filename
):
90 raise Config
.ReadException("No file '" + filename
+ "'.")
92 config
= configparser
.RawConfigParser()
95 for reqsec
in ["destination"]:
96 if not config
.has_section(reqsec
):
97 raise Config
.ReadException("Section '" + reqsec
+ "' is missing.")
99 self
.directory
= config
.get("destination", "directory")
101 self
.format
= config
.get("destination", "format")
102 if not self
.format
in Config
.formats
:
103 raise Config
.ReadException("Invalid 'format' given.")
106 if config
.has_section("history"):
107 for opt
in config
.options("history"):
108 if opt
.startswith("keep"):
110 if not epoch
in Epoch
.keys():
111 raise Config
.ReadException("Invalid option 'keep" + epoch
+ "'.")
112 self
.epochkeeps
[epoch
] = int(config
.getint("history", opt
))
113 elif opt
.startswith("mode"):
115 if not epoch
in Epoch
.keys():
116 raise Config
.ReadException("Invalid option 'mode" + epoch
+ "'.")
117 self
.epochmodes
[epoch
] = config
.get("history", opt
)
118 if not self
.epochmodes
[epoch
] in Mode
:
119 raise Config
.ReadException("Invalid mode given.")
121 raise Config
.ReadException("Invalid option '" + opt
+ "'.")
123 if config
.has_section("input"):
124 for opt
in config
.options("input"):
125 if opt
.startswith("exclude"):
126 self
.exclpatterns
+= [ config
.get("input", opt
) ]
128 raise Config
.ReadException("Invalid option '" + opt
+ "'.")
130 for sec
in config
.sections():
131 if sec
in ["destination", "history", "input"]:
133 elif sec
.startswith("set "):
134 name
= sec
[4:].strip()
137 for opt
in config
.options(sec
):
138 if not opt
.startswith("dir"):
139 raise Config
.ReadException("Unknown option '" + opt
+ "'.")
141 dirs
+= [config
.get(sec
, opt
)]
142 self
.sets
+= [Config
.FileSet(name
, dirs
)]
144 raise Config
.ReadException("Unknown section '" + sec
+ "'.")
146 # Compute checksum of config file
148 f
= open(filename
, 'rb')
151 self
.checksum
= m
.hexdigest()
156 f
= open(os
.path
.join(self
.directory
, self
.checksumfn
), 'r')
157 self
.lastchecksum
= f
.read().strip()
160 self
.lastchecksum
= None
164 """List and create backups"""
166 def __init__(self
, conffn
):
168 self
.conf
.read(conffn
)
171 def listAllDirs(self
):
172 """List all dirs in destination directory"""
175 basedir
= self
.conf
.directory
176 dirs
= os
.listdir(basedir
)
178 return [ d
for d
in dirs
if os
.path
.isdir(os
.path
.join(basedir
, d
)) ]
180 def listOldBackups(self
):
181 """Returns a list of old backups."""
185 for entry
in [ b
for b
in self
.listAllDirs() if Backup
.isBackupDir(b
) ]:
186 [strdate
, strtime
, epoch
, mode
] = entry
.split("-")
188 if not epoch
in Epoch
.keys():
189 raise ValueError("Invalid epoch: " + epoch
)
192 raise ValueError("Invalid mode: " + mode
)
194 date
= datetime
.datetime(int(strdate
[0:4]),
195 int(strdate
[4:6]), int(strdate
[6:8]),\
196 int(strtime
[0:2]), int(strtime
[2:4]))
197 backups
+= [ Backup(date
, epoch
, mode
) ]
202 def getDesiredEpoch(self
, backups
, now
):
203 """Get desired epoch based on self.configuration and list of old backups"""
205 # Find the longest epoch for which we would like the make a backup
206 latest
= datetime
.datetime(1900, 1, 1)
207 for timespan
, e
in reversed(sorted( [ (Epoch
[e
], e
) for e
in Epoch
] )):
208 # We make backups of that epoch
209 if self
.conf
.epochkeeps
[e
] == 0:
212 # Get backups of that epoch
213 byepoch
= list(sorted( [ b
for b
in backups
if b
.epoch
==e
], \
214 key
=lambda b
: b
.date
))
216 # If there are any, determine the latest
218 latest
= max(latest
, byepoch
[-1].date
)
220 # the latest backup is too old
221 if now
-latest
> timespan
:
224 # No backup is to be made
229 def backupFileSet(self
, fileset
, targetdir
, since
=None):
230 """Create an archive for given fileset at given target directory."""
232 print("Running file set: " + fileset
.name
)
234 fsfn
= os
.path
.join(targetdir
, fileset
.name
) + "." + self
.conf
.format
239 taropts
+= ["-N", since
.strftime("%Y-%m-%d %H:%M:%S")]
241 for pat
in self
.conf
.exclpatterns
:
242 taropts
+= ["--exclude", pat
]
244 tarargs
= [tarpath
] + taropts
+ ["-f", fsfn
] + fileset
.dirs
245 print("tarargs: ", tarargs
)
246 tarp
= subprocess
.Popen( tarargs
, \
247 stdout
=subprocess
.PIPE
, stderr
=subprocess
.PIPE
)
250 l
= tarp
.stdout
.readline()
252 print(l
.decode(), end
="")
253 l
= tarp
.stderr
.readline()
255 print(l
.decode(), end
="")
257 for l
in tarp
.stdout
.readlines():
258 print(l
.decode(), end
="")
260 for l
in tarp
.stderr
.readlines():
261 print(l
.decode(), end
="")
265 print(tarpath
+ " returned with exit status " + str(rett
) + ":")
266 print( tarp
.stderr
.read().decode() )
270 """Make a new backup, if necessary"""
272 now
= datetime
.datetime
.now()
273 oldbackups
= self
.listOldBackups()
274 epoch
= self
.getDesiredEpoch(oldbackups
, now
)
277 print("No backup planned.")
282 mode
= self
.conf
.epochmodes
[epoch
]
283 print("Making a backup. Epoch: " + epoch
+ ", mode: " + mode
)
285 oldfullbackups
= [ b
for b
in oldbackups
if b
.mode
== "full" ]
287 # No old full backups existing
288 if mode
!= "full" and len(oldfullbackups
)==0:
289 print("No full backups existing. Making a full backup.")
291 # Checksum changed -> self.config file changed
292 if self
.conf
.checksum
!= self
.conf
.lastchecksum
:
293 print("Config file changed since last time.")
295 print("** Warning: full backup recommended!")
297 # Create new target directory
298 basedir
= self
.conf
.directory
299 dirname
= Backup
.getDirName(now
, epoch
, mode
)
300 tmpdirname
= dirname
+ ("-%x" % (random
.random()*2e16
) )
301 targetdir
= os
.path
.join(basedir
, tmpdirname
)
302 os
.mkdir( targetdir
)
304 # If we have a full backup, we backup everything
307 # Get latest full backup time
309 since
= sorted(oldfullbackups
, key
=lambda b
: b
.date
)[-1].date
310 # Get latest backup time
312 since
= sorted(oldbackups
, key
=lambda b
: b
.date
)[-1].date
314 # Backup all file sets
315 for s
in self
.conf
.sets
:
316 self
.backupFileSet(s
, targetdir
, since
)
318 # Rename backup directory to final name
319 os
.rename( targetdir
, os
.path
.join(basedir
, dirname
) )
321 # We made a full backup -- recall checksum of config
323 f
= open( os
.path
.join(basedir
, self
.conf
.checksumfn
), "w")
324 f
.write( self
.conf
.checksum
)
329 """Prune old backup files"""
331 # Collect all directories not matching backup name
332 dirs
= [ d
for d
in self
.listAllDirs() if not Backup
.isBackupDir(d
) ]
334 # Get all directories which are outdated
335 backups
= self
.listOldBackups()
336 byepoch
= { e
: list(sorted( [ b
for b
in backups
if b
.epoch
== e
], \
337 key
=lambda b
: b
.date
, reverse
=True)) for e
in Epoch
}
339 keep
= self
.conf
.epochkeeps
[e
]
340 old
= byepoch
[e
][keep
:]
341 dirs
+= [ Backup
.getDirName(b
.date
, b
.epoch
, b
.mode
) for b
in old
]
344 print("No stale/outdated entries to remove.")
347 print("List of stale/outdated entries:")
351 basedir
= self
.conf
.directory
352 yesno
= input("Remove listed entries? [y, N] ")
355 shutil
.rmtree(os
.path
.join(basedir
, d
))
359 """Print --help text"""
361 print("shbackup - a simple backup solution.")
364 print(" " + sys
.argv
[0] + " [-C <configfile>")
365 print(" " + sys
.argv
[0] + " --help")
368 print(" -C <configfile> default: /etc/shbackup.conf")
371 if __name__
== "__main__":
373 conffn
= "/etc/shbackup.conf"
376 while i
< len(sys
.argv
)-1:
380 if opt
in ["-h", "--help"]:
384 elif opt
in ["-C", "--config"]:
390 print("Unknown option: " + opt
)
394 man
= BackupManager(conffn
)
398 except Config
.ReadException
as e
:
399 print("Error reading config file: ", end
="")