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