logrotate 1.0.0 → 1.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,416 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'zlib'
4
+ require 'date'
5
+ require 'logrotate/rotateinfo'
6
+
7
+ module LogRotate
8
+
9
+ #
10
+ # This module contains implementation methods for the LogRotate class.
11
+ # In most cases, the client of this library will only use the
12
+ # LogRotate class directly to rotate files. However, I have exposed a
13
+ # number of methods in this class, as they may be useful.
14
+ #
15
+ module Impl
16
+
17
+ DEFAULT_COUNT = 5
18
+ DEFAULT_GZIP = false
19
+ DEFAULT_DATE_TIME_EXTENSION = false
20
+ DEFAULT_DATE_TIME_FORMAT = '%F'
21
+ DEFAULT_DATE_TIME_EXT = false
22
+
23
+ # This method validates the rotated file name with date extension.
24
+ # Specifically, it checks that the file name specified has a date
25
+ # that is formatted according to the date/time format specified.
26
+ #
27
+ # ====Parameters:
28
+ # [file_name]
29
+ # The rotated file name to be validated.
30
+ # [rotation_base_name]
31
+ # The base name of the rotated file (without the date/time extension).
32
+ # [date_time_format]
33
+ # The format of the date/time extension of the rotated file.
34
+ #
35
+ def self.is_valid_date_time_file_name(file_name, rotation_base_name, date_time_format)
36
+ base_name = File.basename(file_name)
37
+
38
+ if (match_data = base_name.match("^#{rotation_base_name}\.([^.]+)(\.gz)?$"))
39
+ # Since the caller is allowed to specify the date/time format,
40
+ # here we do not have a regular expression to match the
41
+ # date/time portion of the rotated file's extension. As a
42
+ # result, some foreign files can sneak into our list of rotated
43
+ # files (eg. base_file.GARBAGE.gz). We will attempt to weed
44
+ # them out below.
45
+
46
+ # Validation #1: convert the date/time portion of the
47
+ # extension to a DateTime object and skip the file if an
48
+ # exception is thrown.
49
+ begin
50
+ date_time = DateTime.strptime(match_data[1], date_time_format)
51
+ gz = match_data[2] ? match_data[2] : ""
52
+
53
+ # Validation #2: convert the date/time portion of the
54
+ # extension to a DateTime object and back to a string again.
55
+ # Then reconstruct the rotated file name with this string,
56
+ # and ensure the reconstructed rotated file matches the
57
+ # original. This will take into account cases where
58
+ # strptime succeeded but had additional garbage characters
59
+ # present.
60
+ # eg: date_time_format = '%F', file = 'mysql_backup.2008-08-04.BACK.gz'
61
+ reconstructed_file = "#{rotation_base_name}.#{date_time.strftime(date_time_format)}#{gz}"
62
+ if (base_name == reconstructed_file)
63
+ return true
64
+ end
65
+ rescue => e
66
+ end
67
+ end
68
+
69
+ return false
70
+ end
71
+
72
+ #
73
+ # ====Description:
74
+ # This method is passed a list of rotated file names. This method
75
+ # validates that list, and returns a list of rotated file names and
76
+ # corresponding dates/times. Specifically, an array is returned,
77
+ # where each element is a hash containing the following fields:
78
+ # +file+:: A rotated file name.
79
+ # +date_time+:: A DateTime object extracted from the rotated file name's extension.
80
+ #
81
+ # Validating the list refers to ensuring that each file name has a
82
+ # properly formatted date/time. See is_valid_date_time_file_name
83
+ # for further information.
84
+ #
85
+ # ====Parameters:
86
+ # [file_names]
87
+ # The rotated file names.
88
+ # [rotation_base_name]
89
+ # The base name of the rotated file (without the date/time extension).
90
+ # [date_time_format]
91
+ # The format of the date/time extension of the rotated file.
92
+ #
93
+ # ===Returns:
94
+ # A list of rotated file names and corresponding date/times.
95
+ #
96
+ def self.organize_rotated_files_date_extension(file_names, rotation_base_name, date_time_format)
97
+ rotated_files_and_dates = file_names.map do |file|
98
+ if (is_valid_date_time_file_name(file, rotation_base_name, date_time_format))
99
+ next get_rotated_file_and_date(file, date_time_format)
100
+ end
101
+
102
+ nil
103
+ end
104
+
105
+ # Remove the null entries.
106
+ rotated_files_and_dates = rotated_files_and_dates.select { |entry| entry }
107
+
108
+ # Sort files by date (newer to older).
109
+ rotated_files_and_dates.sort! { |left, right| right[:date_time] <=> left[:date_time] }
110
+
111
+ return rotated_files_and_dates
112
+ end
113
+
114
+ #
115
+ # ====Description:
116
+ # This method returns a list of rotated files that are due to be deleted.
117
+ #
118
+ # The new_rotated_file field should only be set if the caller is
119
+ # currently in the process of rotating a file (eg. inside
120
+ # LogRotate::rotate_file), and the caller wants to retain 1 less
121
+ # file than count. This would be because the caller will shortly
122
+ # create a new rotated file, and would rather remove the expired
123
+ # rotated file BEFORE creating the new rotated file to minimize
124
+ # storage space usage. Thus, there would only be count rotated
125
+ # files at any time (as opposed to count + 1 if the caller first
126
+ # generated the new rotated file, and then deleted the expired
127
+ # file).
128
+ # ====Parameters:
129
+ # [rotated_files_and_dates]
130
+ # The rotated file names and dates.
131
+ # [count]
132
+ # The number of rotated files to keep.
133
+ # [new_rotated_file]
134
+ # If the caller is in the midst of rotating a file, this value
135
+ # is set to the new rotated file (yet to be created, not listed
136
+ # in rotated_files_and_dates).
137
+ # ===Returns:
138
+ # A list of rotated file names and corresponding date/times to delete.
139
+ #
140
+ def self.get_expired_rotated_files_date_extension(rotated_files_and_dates,
141
+ count,
142
+ new_rotated_file = nil)
143
+ if (new_rotated_file)
144
+ # Add 1 to account for the additional rotated file that will be
145
+ # created after the original file passed in is rotated.
146
+ num_old_files = rotated_files_and_dates.length() - count + 1
147
+
148
+ # There is a chance that the newly rotated file will have the same
149
+ # name as one of the already existing rotated files (eg. if the
150
+ # extension is simply the date, and the job is ran a second time
151
+ # in the same day). In this case, there will be 1 less rotated
152
+ # file (and thus 1 less to delete).
153
+ if (rotated_files_and_dates.find {|entry| entry[:file] == new_rotated_file})
154
+ num_old_files -= 1
155
+ end
156
+ else
157
+ num_old_files = rotated_files_and_dates.length() - count
158
+ end
159
+
160
+ files_to_delete = []
161
+ if (num_old_files > 0)
162
+ files_to_delete = rotated_files_and_dates.slice!(-num_old_files, num_old_files)
163
+ end
164
+
165
+ return files_to_delete
166
+ end
167
+
168
+ #
169
+ # ====Description:
170
+ # This method returns a list of rotated files that are due to be deleted.
171
+ #
172
+ # The new_rotated_file field should only be set if the caller is
173
+ # currently in the process of rotating a file (eg. inside
174
+ # LogRotate::rotate_file), and the caller wants to retain 1 less
175
+ # file than count. This would be because the caller will shortly
176
+ # create a new rotated file, and would rather remove the expired
177
+ # rotated file BEFORE creating the new rotated file to minimize
178
+ # storage space usage. Thus, there would only be count rotated
179
+ # files at any time (as opposed to count + 1 if the caller first
180
+ # generated the new rotated file, and then deleted the expired
181
+ # file).
182
+ # ====Parameters:
183
+ # [rotated_files_and_counts]
184
+ # The rotated file names and associated indexes.
185
+ # [count]
186
+ # The number of rotated files to keep.
187
+ # [new_rotated_file]
188
+ # If the caller is in the midst of rotating a file, this value
189
+ # is set to the new rotated file (yet to be created, not listed
190
+ # in rotated_files_and_counts).
191
+ # ===Returns:
192
+ # A list of rotated file names and corresponding indexes to delete.
193
+ #
194
+ def self.get_expired_rotated_files_integer_extension(rotated_files_and_counts,
195
+ count,
196
+ new_rotated_file = nil)
197
+ if (new_rotated_file)
198
+ count -= 1
199
+ end
200
+ deleted_files_and_counts = rotated_files_and_counts.select { |entry| entry[:index] > count }
201
+
202
+ return deleted_files_and_counts
203
+ end
204
+
205
+ #
206
+ # ====Description:
207
+ # This method is passed a list of rotated file names. This method
208
+ # validates that list, and returns a list of rotated file names and
209
+ # corresponding indexes (derived from the file names' integer
210
+ # extensions). Specifically, an array is returned, where each element
211
+ # is a hash containing the following fields:
212
+ # +file+:: A rotated file name.
213
+ # +index+:: The index extracted from the rotated file name's extension.
214
+ #
215
+ # Validating the list of file names specified consists of ensuring
216
+ # that each file name has a properly formatted integer extension.
217
+ #
218
+ # ====Parameters:
219
+ # [file_names]
220
+ # The rotated file names.
221
+ # [rotation_base_name]
222
+ # The base name of the rotated file (without the date/time extension).
223
+ #
224
+ # ===Returns:
225
+ # A list of rotated file names and corresponding indexes.
226
+ #
227
+ def self.organize_rotated_files_integer_extension(file_names, rotation_base_name)
228
+ rotated_files_and_counts = file_names.map do |file_name|
229
+
230
+ if (file_name.match("#{rotation_base_name}\.[0-9]+(\.gz)?$"))
231
+ index = file_name.match("\.([0-9]+)(\.gz)?$")[1].to_i()
232
+ next { :index => index, :file => file_name }
233
+ end
234
+
235
+ nil
236
+ end
237
+
238
+ rotated_files_and_counts = rotated_files_and_counts.select { |entry| entry }
239
+
240
+ # Sort files by count (newer to older).
241
+ rotated_files_and_counts.sort! { |left, right| left[:index] <=> right[:index] }
242
+
243
+ return rotated_files_and_counts
244
+ end
245
+
246
+ def self.rotate_single_file(file, options = {})
247
+ gzip = options[:gzip] ? options[:gzip] : DEFAULT_GZIP
248
+
249
+ # error checking
250
+ if (!File.exist?(file)) then raise "File does not exist: #{file}." end
251
+ if (!File.readable?(file)) then raise "File is not readable: #{file}." end
252
+
253
+ if (options[:pre_rotate])
254
+ options[:pre_rotate].call()
255
+ end
256
+
257
+ is_date_time_ext = options[:date_time_ext] ? options[:date_time_ext] : DEFAULT_DATE_TIME_EXT
258
+ if (is_date_time_ext)
259
+ result = rotate_file_date_extension(file, options)
260
+ else
261
+ result = rotate_file_integer_extension(file, options)
262
+ end
263
+
264
+ if (options[:post_rotate])
265
+ options[:post_rotate].call()
266
+ end
267
+
268
+ if (gzip)
269
+ result.new_rotated_file = gzip_file(result.new_rotated_file)
270
+ result.rotated_files[0] = result.new_rotated_file
271
+
272
+ if (options[:date_time_ext])
273
+ result.rotated_files_and_dates[0][:file] = result.new_rotated_file
274
+ else
275
+ result.rotated_files_and_counts[0][:file] = result.new_rotated_file
276
+ end
277
+ end
278
+
279
+ return result
280
+ end
281
+ private_class_method(:rotate_single_file)
282
+
283
+ def self.rotate_file_date_extension(file, options)
284
+ count = options[:count] ? options[:count] : DEFAULT_COUNT
285
+
286
+ now = options[:date_time] ? options[:date_time] : DateTime.now()
287
+
288
+ date_time_format = options[:date_time_format] ? options[:date_time_format] : DEFAULT_DATE_TIME_FORMAT
289
+
290
+ # Get a list of the rotated files.
291
+ (source_directory, rotation_base_name) = File.split(file)
292
+ directory = options[:directory] ? options[:directory] : source_directory
293
+
294
+ rotated_files = Dir.glob("#{directory}/*").select do |entry|
295
+ File.basename(entry).match("^#{rotation_base_name}\..+(\.gz)?$")
296
+ end
297
+ rotated_files_and_dates = organize_rotated_files_date_extension(rotated_files,
298
+ rotation_base_name,
299
+ date_time_format)
300
+
301
+ #
302
+ # Remove the oldest files so that there are count rotated files remaining.
303
+ #
304
+ new_rotated_file = File.join(directory, rotation_base_name + "." + now.strftime(date_time_format))
305
+ deleted_files_and_dates = get_expired_rotated_files_date_extension(rotated_files_and_dates,
306
+ count,
307
+ new_rotated_file)
308
+
309
+ deleted_files = deleted_files_and_dates.map { |entry| entry[:file] }
310
+ if (deleted_files_and_dates.length() > 0)
311
+ File.unlink(*deleted_files)
312
+ end
313
+
314
+ # Rename the original file.
315
+ File.rename(file, new_rotated_file)
316
+
317
+ # Push the newly rotated file name and associated date/time onto
318
+ # the front of the list of rotated files and date/times to be
319
+ # returned to the caller. Note: Do not specify the newly rotated
320
+ # file's date/time as the original date/time used when formatting
321
+ # the extension. Instead, specify the date/time with the
322
+ # granularity allowed by the date_time_format field.
323
+ rotated_files_and_dates.unshift(get_rotated_file_and_date(new_rotated_file, date_time_format))
324
+
325
+ return RotateInfoDateExtension.new(rotated_files_and_dates,
326
+ rotated_files_and_dates.map { |entry| entry[:file] },
327
+ deleted_files,
328
+ new_rotated_file)
329
+ end
330
+ private_class_method(:rotate_file_date_extension)
331
+
332
+ def self.rotate_file_integer_extension(file, options)
333
+ count = options[:count] ? options[:count] : DEFAULT_COUNT
334
+
335
+ # Get a list of the backed up files.
336
+ (source_directory, rotation_base_name) = File.split(file)
337
+ directory = options[:directory] ? options[:directory] : source_directory
338
+
339
+ rotated_files = Dir.glob("#{directory}/*").select do |entry|
340
+ File.basename(entry).match("^#{rotation_base_name}\..+(\.gz)?$")
341
+ end
342
+ rotated_files_and_counts = organize_rotated_files_integer_extension(rotated_files, rotation_base_name)
343
+
344
+ # Delete old files.
345
+ new_rotated_file = File.join(directory, rotation_base_name + ".1")
346
+ deleted_files_and_counts = get_expired_rotated_files_integer_extension(rotated_files_and_counts,
347
+ count,
348
+ new_rotated_file)
349
+ deleted_files = deleted_files_and_counts.map {|entry| entry[:file] }
350
+ File.unlink(*deleted_files)
351
+
352
+ rotated_files_and_counts = rotated_files_and_counts - deleted_files_and_counts
353
+
354
+ # Sort files such that the index is descending (eg. [hello.txt.5, hello.txt.4, ..]).
355
+ rotated_files_and_counts.sort! { |left, right| right[:index] <=> left[:index] }
356
+
357
+
358
+ # Rotate the files- Increment the index on each file.
359
+ rotated_files_and_counts = rotated_files_and_counts.map do |rotated_file|
360
+
361
+ new_index = rotated_file[:index] + 1
362
+ new_file = rotated_file[:file].sub(/(^.*\.)(#{rotated_file[:index]})(\.gz)?$/,
363
+ "\\1#{new_index}\\3")
364
+ File.rename(rotated_file[:file], new_file)
365
+
366
+ { :index => new_index, :file => new_file }
367
+ end
368
+
369
+ # Rename the original file.
370
+ File.rename(file, new_rotated_file)
371
+
372
+ # Reverse the list of files so that the index is ascending (newer
373
+ # to older), and add the newly backed up file.
374
+ rotated_files_and_counts.reverse!()
375
+ rotated_files_and_counts.unshift({ :index => 1, :file => new_rotated_file })
376
+
377
+ return RotateInfoIntegerExtension.new(rotated_files_and_counts,
378
+ rotated_files_and_counts.map { |entry| entry[:file] },
379
+ deleted_files,
380
+ new_rotated_file)
381
+ end
382
+ private_class_method(:rotate_file_integer_extension)
383
+
384
+ def self.gzip_file(file)
385
+ # Zip the original file if necessary.
386
+ gzip_file = file + ".gz"
387
+
388
+ begin
389
+ File.open(file) do |file_stream|
390
+ Zlib::GzipWriter.open(gzip_file) do |gz|
391
+ while (buffer = file_stream.read(1024))
392
+ gz.write(buffer)
393
+ end
394
+ end
395
+ end
396
+ rescue => error
397
+ raise "Unable to zip file: #{file}. Error: #{error}\n"
398
+ end
399
+ File.unlink(file)
400
+
401
+ return gzip_file
402
+ end
403
+ private_class_method(:gzip_file)
404
+
405
+ def self.get_rotated_file_and_date(file_name, date_time_format)
406
+ base_name = File.basename(file_name)
407
+
408
+ match_data = base_name.match("\.([^.]+)(\.gz)?$")
409
+ date_time = DateTime.strptime(match_data[1], date_time_format)
410
+
411
+ return { :date_time => date_time, :file => file_name }
412
+ end
413
+ private_class_method(:get_rotated_file_and_date)
414
+
415
+ end
416
+ end
@@ -0,0 +1,86 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'logrotate/impl'
4
+ require 'logrotate/rotateinfo'
5
+
6
+ #
7
+ # This module provides a few methods for log rotation.
8
+ #
9
+
10
+ module LogRotate
11
+
12
+ #
13
+ # ====Description: This method rotates the given files.
14
+ #
15
+ # ====Parameters:
16
+ # [file(s)]
17
+ # The files to be rotated.
18
+ # [options = {}]
19
+ # A list of optional arguments.
20
+ #
21
+ # See rotate_file for details.
22
+ #
23
+ # ====Returns:
24
+ # A list of RotateInfoDateExtension or
25
+ # RotateInfoIntegerExtension instances depending whether the
26
+ # option +date_time_ext+ was specified.
27
+ #
28
+ def self.rotate_files(files, options)
29
+ return files.map do |file|
30
+ Impl.send(:rotate_single_file, file, options)
31
+ end
32
+ end
33
+
34
+ #
35
+ # ====Description:
36
+ # This method rotates the given file(s).
37
+ #
38
+ # There are 2 types of extensions that can be used: the default
39
+ # extension which is an integer value (".1", ".2", ..), and a
40
+ # date/time extension (".2008-08-01", ..). If a date/time extension
41
+ # is chosen, the caller can specify a date/time format for this
42
+ # extension.
43
+ #
44
+ # If this method fails, an exception will be thrown. If this method
45
+ # succeeds, the method will return normally, and an instance will be
46
+ # returned with information about the rotated files.
47
+ #
48
+ # ====Parameters:
49
+ # [file]
50
+ # The file to be rotated.
51
+ # [options = {}]
52
+ # A list of optional arguments.
53
+ #
54
+ # The optional arguments are listed below:
55
+ # +count+:: The number of rotated files to be kept.
56
+ # +directory+:: The directory to store the newly rotated file. If this
57
+ # option is unspecified, the original file's directory
58
+ # will be used.
59
+ # +date_time_ext+:: Whether the extension on the rotated files should be
60
+ # a date. Possible values are: +true+, +false+.
61
+ # +date_time_format+:: For use with +date_time_ext+. This specifies the
62
+ # format of the date / time extension.
63
+ # +date_time+:: For use with +date_time_ext+. The +DateTime+ that will be
64
+ # used to construct the extension of the newly rotated file.
65
+ # If this option is not specified, the current date and time
66
+ # will be used.
67
+ # +pre_rotate+:: A +Proc+ to be executed before the rotation is started.
68
+ # +post_rotate+:: A +Proc+ to be executed after the rotation is finished,
69
+ # and before the newly rotated file is zipped.
70
+ # +gzip+:: Whether the newly rotated file should be gzipped. Possible
71
+ # values are: +true+, +false+.
72
+ #
73
+ # To see the default values of these options, see the DEFAULT_
74
+ # constants in LogRotate::Impl.
75
+ #
76
+ # ====Returns:
77
+ # A RotateInfoDateExtension or RotateInfoIntegerExtension instance
78
+ # depending whether the option +date_time_ext+ was specified. This instance
79
+ # contains information about the rotated files.
80
+ #
81
+ def self.rotate_file(file, options)
82
+ return Impl.send(:rotate_single_file, file, options)
83
+ end
84
+
85
+ end
86
+