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