logrotate 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+