show num of files to delete in summary
[infrastructure.git] / scripts / clean-backupdb.py
1 #!/usr/bin/python
2
3 ### a utility to filter backups in the /db-backup area
4 #
5 # lists all files that match <prefix>-<date>-<time>.<suffix>
6 # and preserves only the following
7 # (*) more than 2 months old : one file per month
8 # (*) more than 2 weeks old : one file per week
9 # (*) recent stuff: unchanged
10
11
12 import sys
13 import os, os.path
14 from datetime import datetime, timedelta
15 from glob import glob
16 import re
17 import traceback
18
19 from optparse import OptionParser
20
21 now=datetime.now()
22 counter=0
23
24 class FileIgnored (Exception): pass
25
26 # in days
27 LEAVE_FILES_YOUNGER_THAN = 20
28 # keep that amount of (plain) months organized in weeks
29 # should not exceed 11
30 KEEP_FILES_IN_MONTH_AFTER = 2
31
32 # monday=0 sunday=6
33 # we prefer saturday as this is the end of the week
34 PREFERRED_WEEKDAY=5
35
36 #
37 ## utility to parse a filename
38 datetime_pattern="(?P<datetime>[0-9]{8}\-[0-9]{4})"
39 # returns a tuple
40 # (bool, prefix, suffix, datetime)
41
42 prefix_pattern="(?P<prefix>[\w\-]+)"
43 datetime_pattern="(?P<datetime>[0-9]{8}\-[0-9]{4})"
44 suffix_pattern="(?P<suffix>[\w\.]+)"
45 filename_matcher=re.compile("\A%s[-_]%s\.%s\Z"%(prefix_pattern,datetime_pattern,suffix_pattern))
46
47 parsing_failed = (False, None, None, None)
48
49 def parse_filename (filename):
50     match=filename_matcher.match(filename)
51     if not match:
52         return parsing_failed
53     else:
54         d = match.groupdict()
55         try:
56             dt = datetime.strptime(d['datetime'],"%Y%m%d-%H%M")
57             return (True, d['prefix'], d['suffix'], dt)
58         except:
59             print "failed to parse timestamp %s from %s"%(d['datetime'],filename)
60             traceback.print_exc()
61             return parsing_failed
62
63 # one entry like this per file, managed in the Kind class
64 class File:
65
66     def __init__ (self, dir, filename, datetime, options):
67         self.dir=dir
68         self.filename=filename
69         self.datetime=datetime
70         self.options=options
71         self.age=now-datetime
72         self.weekday=self.datetime.weekday()
73         if self.age.days<0:
74             if self.options.verbose: print 'Filename %s is from the future - skipped'%filename
75             raise FileIgnored,"Filename from the future %s"%filename
76         self.group = self._group_string()
77
78     def __repr__ (self):
79         return "%s (%s) -- weekday %s"%(self.path(),self.datetime,self.datetime.weekday())
80
81     def path (self):
82         return os.path.normpath(os.path.join(self.dir,self.filename))
83
84     def age_days (self):
85         return self.age.days
86
87     # oldest first
88     @staticmethod
89     def sort_age (file1, file2):
90         # for 2.7, seems safer
91         try:
92             return int((file1.datetime-file2.datetime).total_seconds())
93         # otherwise resort to days, we should be clear as our backups are daily
94         except:
95             return int((file1.datetime-file2.datetime).days)
96         
97     @staticmethod
98     def sort_relevance (file1, file2):
99         w1=file1.weekday
100         w2=file2.weekday
101         if w1==PREFERRED_WEEKDAY and w2==PREFERRED_WEEKDAY:
102             return File.sort_age (file1,file2)
103         elif w1!=PREFERRED_WEEKDAY and w2!=PREFERRED_WEEKDAY:
104             return File.sort_age (file1,file2)
105         elif w1==PREFERRED_WEEKDAY and w2!=PREFERRED_WEEKDAY:
106             return -1
107         elif w1!=PREFERRED_WEEKDAY and w2==PREFERRED_WEEKDAY:
108             return 1
109     
110     month_barrier=None
111     week_barrier=None
112     @staticmethod
113     def _compute_barriers ():
114         if File.month_barrier:
115             return
116         # find the exact datetime for a month change 
117         # KEEP_FILES_IN_MONTH_AFTER months ago +
118         year=now.year
119         month=now.month
120         day=1
121         if now.month>=KEEP_FILES_IN_MONTH_AFTER+1:
122             month -= KEEP_FILES_IN_MONTH_AFTER
123         else:
124             year -= 1
125             month += (12-KEEP_FILES_IN_MONTH_AFTER)
126         File.month_barrier = datetime (year=year, month=month, day=day)
127         # find the next monday morning
128         remaining_days=(7-File.month_barrier.weekday())%7
129         File.week_barrier=File.month_barrier+timedelta(days=remaining_days)
130
131     @staticmethod
132     def compute_month_barrier(): File._compute_barriers(); return File.month_barrier
133     @staticmethod
134     def compute_week_barrier(): File._compute_barriers(); return File.week_barrier
135
136     # returns a key for grouping files, the cleanup then
137     # preserving one entry in the set of files with same group
138     def _group_string (self):
139         if self.age.days<=LEAVE_FILES_YOUNGER_THAN:
140             if self.options.verbose: print 'Filename %s is recent (%d d) - skipped'%\
141                     (self.filename,LEAVE_FILES_YOUNGER_THAN)
142             raise FileIgnored,"Filename %s is recent"%self.filename
143         # in the month range
144         if self.datetime <= File.compute_month_barrier():
145             return self.datetime.strftime("%Y%m")
146         else:
147             weeks=(self.datetime-File.compute_week_barrier()).days/7
148             weeks += 1
149             return "week%02d"%weeks
150
151     def cleanup (self, preserved):
152         global counter
153         counter+=1
154         src = os.path.abspath(os.path.basename(self.path()));
155         if self.options.summary:
156             pass
157         elif self.options.destination:
158             dst = os.path.abspath(self.options.destination) + '/' + os.path.basename(self.path())
159             if self.options.verbose:
160                 print "moving %s\n\tto %s"%(self.path(), dst)
161             if not self.options.dry_run:
162                 os.rename (src, dst)
163         else:
164             if self.options.verbose:
165                print "Would cleanup %s"%(src)
166                print "    (keeping %s)"%preserved.path()
167             if not self.options.dry_run:
168                 if self.options.verbose: print "unlink",src
169                 os.unlink (src)
170
171 # all files in a given timeslot (either month or week)
172 class Group:
173     def __init__ (self, groupname):
174         self.groupname=groupname
175         self.files=[]
176         self.count = 0
177     def insert (self, file):
178         self.files.append(file)
179     def epilogue (self):
180         self.files.sort (File.sort_relevance)
181     def keep_one (self):
182         for file in self.files[1:]:
183             file.cleanup(self.files[0])
184             self.count += 1
185
186 # all files with the same (prefix, suffix)
187 class Kind:
188
189     def __init__ (self, prefix, suffix, options):
190         self.prefix=prefix
191         self.suffix=suffix
192         self.options=options
193         self.todelete = 0
194         # will contain tuples (filename, datetime)
195         self.list = []
196
197     # allow for basic checking to be done in File
198     def add_file (self, dir, filename, datetime):
199         try:
200             self.list.append ( File (dir, filename, datetime, self.options) )
201         except FileIgnored: pass
202         except:
203             print 'could not append %s'%filename
204             traceback.print_exc()
205             pass
206
207     def epilogue (self):
208         # sort self.list according to file age, oldest first
209         self.list.sort(File.sort_age)
210         # prepare groups according to age
211         self.groups = {}
212         for file in self.list:
213             groupname=file.group
214             if groupname not in self.groups:
215                 self.groups[groupname]=Group(groupname)
216             self.groups[groupname].insert(file)
217         for group in self.groups.values():
218             group.epilogue()
219
220     def show (self):
221         if not self.options.verbose: return
222         print 30*'-',"%s-<date>.%s"%(self.prefix,self.suffix)
223         entries=len(self.list)
224         print " %d entries" % entries,
225         if entries >=1:
226             f=self.list[0]
227             print " << %s - %s d old"%(f.filename, f.age_days()),
228         if entries >=2:
229             f=self.list[-1]
230             print "|| %s - %s d old >>"%(f.filename, f.age_days())
231         groupnames=self.groups.keys()
232         groupnames.sort()
233         groupnames.reverse()
234         if self.options.extra_verbose:
235             print " Found %d groups"%len(groupnames)
236             for g in groupnames: 
237                 print "  %s"%g
238                 files=self.groups[g].files
239                 for file in files:
240                     print "    %s"%file
241         elif self.options.verbose:
242             print " Found %d groups"%len(groupnames),
243             for g in groupnames: print "%s->%d"%(g,len(self.groups[g].files)),
244             print ''
245
246     # sort on number of entries
247     @staticmethod
248     def sort_size (k1, k2):
249         return len(k1.list)-len(k2.list)
250
251     def cleanup (self):
252         groupnames=self.groups.keys()
253         groupnames.sort()
254         for groupname in groupnames:
255             if self.options.extra_verbose: print 'GROUP',groupname
256             self.groups[groupname].keep_one()
257             self.todelete += self.groups[groupname].count
258
259 # keeps an index of all files found, index by (prefix, suffix), then sorted by time
260 class Index:
261     def __init__ (self,options):
262         self.options=options
263         self.index = {}
264
265     def insert (self, dir, filename, prefix, suffix, datetime):
266         key= (prefix, suffix)
267         if key not in self.index:
268             self.index[key] = Kind(prefix,suffix, self.options)
269         self.index[key].add_file (dir, filename, datetime)
270
271     # we're done inserting, do housecleaning
272     def epilogue (self):
273         for (key, kind) in self.index.items():
274             kind.epilogue()
275
276     def show (self):
277         # sort on number of entries
278         kinds = self.index.values()
279         kinds.sort (Kind.sort_size)
280         for kind in kinds:
281             kind.show()
282
283     def insert_many (self, dir, filenames):
284         for filename in filenames:
285             (b,p,s,d) = parse_filename (filename)
286             if not b:
287                 print "Filename %s does not match - skipped"%filename
288                 continue
289             self.insert (dir, filename, p, s, d)
290
291     def cleanup (self):
292         for kind in self.index.values():
293             kind.cleanup()
294             
295     def summary (self):
296         print "%-30s%-10s%s"%("Prefix","Suffix","Num (Del)")
297         for kind in self.index.values():
298             print "%-30s%-10s%3s (%s)"%(kind.prefix, kind.suffix, len(kind.list), kind.todelete)
299
300 def handle_dir_pattern (index, dir, pattern):
301     try:
302         os.chdir(dir)
303     except:
304         print "Cannot chdir into %s - skipped"%dir
305         return
306     filenames=glob(pattern)
307     index.insert_many (dir, filenames)
308
309 def main ():
310     usage="Usage: %prog [options] dir_or_files"
311     parser=OptionParser(usage=usage)
312     parser.add_option ("-v","--verbose",dest='verbose',action='store_true',default=False,
313                        help="run in verbose mode")
314     parser.add_option ("-x","--extra-verbose",dest='extra_verbose',action='store_true',default=False,
315                        help="run in extra verbose mode")
316     parser.add_option ("-n","--dry-run",dest='dry_run',action='store_true',default=False,
317                        help="dry run")
318     parser.add_option ("-m","--move-to",dest='destination',action='store',type='string',default=False,
319                        help="move to <destination> instead of removing the file")
320     parser.add_option ("-o","--offset",dest='offset',action='store',type='int',default=0,
321                        help="pretend we run <offset> days in the future")
322     parser.add_option ("-s","--summary",dest='summary',action='store_true',default=False,
323                        help="print a summary then exit")
324     (options, args) = parser.parse_args()
325     if options.extra_verbose: options.verbose=True
326     try:
327         if options.offset !=0:
328             global now
329             now += timedelta(days=options.offset)
330     except:
331         traceback.print_exc()
332         print "Offset not understood %s - expect an int. number of days"%options.offset
333         sys.exit(1)
334     
335     if options.destination and not os.path.isdir(options.destination):
336         print "Destination should be a directory"
337         sys.exit(1)
338
339     if len(args) ==0:
340         parser.print_help()
341         sys.exit(1)
342
343     # args can be directories, or patterns, like 
344     # main /db-backup /db-backup-f8/*bz2
345     # in any case we handle each arg completely separately
346     dir_patterns=[]
347     for arg in args:
348         if os.path.isdir (arg):
349             if arg=='.': arg=os.getcwd()
350             dir_patterns.append ( (arg, '*',) )
351         else:
352             (dir,pattern)=os.path.split(arg)
353             if not dir: dir=os.getcwd()
354             dir_patterns.append ( (dir, pattern,) )
355
356     index = Index (options)
357     for (dir, pattern) in dir_patterns: handle_dir_pattern (index, dir, pattern)
358     index.epilogue()
359     index.show()
360     index.cleanup()
361     if (options.summary) :
362         index.summary()
363     
364     print 'Found %d entries to unlink'%counter
365             
366 if __name__ == '__main__':
367     main()