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