ab15ddf5e54a765740ddfa8d6c8fe5f41a7c5071
[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.destination:
156             dst = os.path.abspath(self.options.destination) + '/' + os.path.basename(self.path())
157             if self.options.verbose:
158                 print "moving %s\n\tto %s"%(self.path(), dst)
159             if not self.options.dry_run:
160                 os.rename (src, dst)
161         else:
162             if self.options.verbose:
163                print "Would cleanup %s"%(src)
164                print "    (keeping %s)"%preserved.path()
165             if not self.options.dry_run:
166                 if self.options.verbose: print "unlink",src
167                 os.unlink (src)
168
169 # all files in a given timeslot (either month or week)
170 class Group:
171     def __init__ (self, groupname):
172         self.groupname=groupname
173         self.files=[]
174     def insert (self, file):
175         self.files.append(file)
176     def epilogue (self):
177         self.files.sort (File.sort_relevance)
178     def keep_one (self):
179         for file in self.files[1:]:
180             file.cleanup(self.files[0])
181
182 # all files with the same (prefix, suffix)
183 class Kind:
184
185     def __init__ (self, prefix, suffix, options):
186         self.prefix=prefix
187         self.suffix=suffix
188         self.options=options
189         # will contain tuples (filename, datetime)
190         self.list = []
191
192     # allow for basic checking to be done in File
193     def add_file (self, dir, filename, datetime):
194         try:
195             self.list.append ( File (dir, filename, datetime, self.options) )
196         except FileIgnored: pass
197         except:
198             print 'could not append %s'%filename
199             traceback.print_exc()
200             pass
201
202     def epilogue (self):
203         # sort self.list according to file age, oldest first
204         self.list.sort(File.sort_age)
205         # prepare groups according to age
206         self.groups = {}
207         for file in self.list:
208             groupname=file.group
209             if groupname not in self.groups:
210                 self.groups[groupname]=Group(groupname)
211             self.groups[groupname].insert(file)
212         for group in self.groups.values():
213             group.epilogue()
214
215     def show (self):
216         if not self.options.verbose: return
217         print 30*'-',"%s-<date>.%s"%(self.prefix,self.suffix)
218         entries=len(self.list)
219         print " %d entries" % entries,
220         if entries >=1:
221             f=self.list[0]
222             print " << %s - %s d old"%(f.filename, f.age_days()),
223         if entries >=2:
224             f=self.list[-1]
225             print "|| %s - %s d old >>"%(f.filename, f.age_days())
226         groupnames=self.groups.keys()
227         groupnames.sort()
228         groupnames.reverse()
229         if self.options.extra_verbose:
230             print " Found %d groups"%len(groupnames)
231             for g in groupnames: 
232                 print "  %s"%g
233                 files=self.groups[g].files
234                 for file in files:
235                     print "    %s"%file
236         elif self.options.verbose:
237             print " Found %d groups"%len(groupnames),
238             for g in groupnames: print "%s->%d"%(g,len(self.groups[g].files)),
239             print ''
240
241     # sort on number of entries
242     @staticmethod
243     def sort_size (k1, k2):
244         return len(k1.list)-len(k2.list)
245
246     def cleanup (self):
247         groupnames=self.groups.keys()
248         groupnames.sort()
249         for groupname in groupnames:
250             if self.options.extra_verbose: print 'GROUP',groupname
251             self.groups[groupname].keep_one()
252
253 # keeps an index of all files found, index by (prefix, suffix), then sorted by time
254 class Index:
255     def __init__ (self,options):
256         self.options=options
257         self.index = {}
258
259     def insert (self, dir, filename, prefix, suffix, datetime):
260         key= (prefix, suffix)
261         if key not in self.index:
262             self.index[key] = Kind(prefix,suffix, self.options)
263         self.index[key].add_file (dir, filename, datetime)
264
265     # we're done inserting, do housecleaning
266     def epilogue (self):
267         for (key, kind) in self.index.items():
268             kind.epilogue()
269
270     def show (self):
271         # sort on number of entries
272         kinds = self.index.values()
273         kinds.sort (Kind.sort_size)
274         for kind in kinds:
275             kind.show()
276
277     def insert_many (self, dir, filenames):
278         for filename in filenames:
279             (b,p,s,d) = parse_filename (filename)
280             if not b:
281                 print "Filename %s does not match - skipped"%filename
282                 continue
283             self.insert (dir, filename, p, s, d)
284
285     def cleanup (self):
286         for kind in self.index.values():
287             kind.cleanup()
288             
289     def summary (self):
290         print "%-30s%-10s%s"%("Prefix","Suffix","Num")
291         for kind in self.index.values():
292             print "%-30s%-10s%3s"%(kind.prefix, kind.suffix, len(kind.list))
293
294 def handle_dir_pattern (index, dir, pattern):
295     try:
296         os.chdir(dir)
297     except:
298         print "Cannot chdir into %s - skipped"%dir
299         return
300     filenames=glob(pattern)
301     index.insert_many (dir, filenames)
302
303 def main ():
304     usage="Usage: %prog [options] dir_or_files"
305     parser=OptionParser(usage=usage)
306     parser.add_option ("-v","--verbose",dest='verbose',action='store_true',default=False,
307                        help="run in verbose mode")
308     parser.add_option ("-x","--extra-verbose",dest='extra_verbose',action='store_true',default=False,
309                        help="run in extra verbose mode")
310     parser.add_option ("-n","--dry-run",dest='dry_run',action='store_true',default=False,
311                        help="dry run")
312     parser.add_option ("-m","--move-to",dest='destination',action='store',type='string',default=False,
313                        help="move to <destination> instead of removing the file")
314     parser.add_option ("-o","--offset",dest='offset',action='store',type='int',default=0,
315                        help="pretend we run <offset> days in the future")
316     parser.add_option ("-s","--summary",dest='summary',action='store_true',default=False,
317                        help="print a summary then exit")
318     (options, args) = parser.parse_args()
319     if options.extra_verbose: options.verbose=True
320     try:
321         if options.offset !=0:
322             global now
323             now += timedelta(days=options.offset)
324     except:
325         traceback.print_exc()
326         print "Offset not understood %s - expect an int. number of days"%options.offset
327         sys.exit(1)
328     
329     if options.destination and not os.path.isdir(options.destination):
330         print "Destination should be a directory"
331         sys.exit(1)
332
333     if len(args) ==0:
334         parser.print_help()
335         sys.exit(1)
336
337     # args can be directories, or patterns, like 
338     # main /db-backup /db-backup-f8/*bz2
339     # in any case we handle each arg completely separately
340     dir_patterns=[]
341     for arg in args:
342         if os.path.isdir (arg):
343             if arg=='.': arg=os.getcwd()
344             dir_patterns.append ( (arg, '*',) )
345         else:
346             (dir,pattern)=os.path.split(arg)
347             if not dir: dir=os.getcwd()
348             dir_patterns.append ( (dir, pattern,) )
349
350     index = Index (options)
351     for (dir, pattern) in dir_patterns: handle_dir_pattern (index, dir, pattern)
352     index.epilogue()
353     index.show()
354     if (options.summary) :
355         index.summary()
356     else:
357         index.cleanup()
358     print 'Found %d entries to unlink'%counter
359             
360 if __name__ == '__main__':
361     main()