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