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