s3_rotate 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,62 @@
1
+ require 's3_rotate/core/backup_uploader'
2
+ require 's3_rotate/core/backup_rotator'
3
+
4
+ module S3Rotate
5
+
6
+ class BackupManager
7
+
8
+ # attributes
9
+ attr_accessor :s3_client
10
+ attr_accessor :uploader
11
+ attr_accessor :rotator
12
+
13
+ #
14
+ # Initialize a new BackupManager instance.
15
+ #
16
+ # @param key String representing the AWS ACCESS KEY ID.
17
+ # @param secret String representing the AWS ACCESS KEY SECRET.
18
+ # @param bucket String representing the name of the bucket ot use.
19
+ # @param region String representing the region to conect to.
20
+ #
21
+ # @return the newly instanciated object.
22
+ #
23
+ def initialize(key, secret, bucket, region)
24
+ @s3_client = S3Client.new(key, secret, bucket, region)
25
+ @uploader = BackupUploader.new(@s3_client)
26
+ @rotator = BackupRotator.new(@s3_client)
27
+ end
28
+
29
+ #
30
+ # Upload local backup files to AWS S3
31
+ # Only uploads new backups
32
+ # Only uploads backups as daily backups: use `rotate` to generate the weekly & monthly files
33
+ #
34
+ # @param backup_name String containing the name of the backup to upload
35
+ # @param local_backups_path String containing the path to the directory containing the backups
36
+ # @param date_regex Regex returning the date contained in the filename of each backup
37
+ #
38
+ # @return nothing
39
+ #
40
+ def upload(backup_name, local_backups_path, date_regex=/\d{4}-\d{2}-\d{2}/)
41
+ @uploader.upload(backup_name, local_backups_path, date_regex)
42
+ end
43
+
44
+ #
45
+ # Rotate files (local, daily, weekly, monthly) and apply maximum limits for each type
46
+ #
47
+ # @param backup_name String containing the name of the backup to rotate
48
+ # @param local_backups_path String containing the path to the directory containing the backups
49
+ # @param max_local Integer specifying the maximum number of local backups to keep
50
+ # @param max_daily Integer specifying the maximum number of daily backups to keep
51
+ # @param max_weekly Integer specifying the maximum number of weekly backups to keep
52
+ # @param max_monthly Integer specifying the maximum number of monthly backups to keep
53
+ #
54
+ # @return nothing
55
+ #
56
+ def rotate(backup_name, local_backups_dir, max_local=3, max_daily=7, max_weekly=4, max_monthly=3)
57
+ @rotator.rotate(backup_name, local_backups_dir, max_local, max_daily, max_weekly, max_monthly)
58
+ end
59
+
60
+ end
61
+
62
+ end
@@ -0,0 +1,239 @@
1
+ # s3_rotate
2
+ require 's3_rotate/utils/file_utils'
3
+
4
+ module S3Rotate
5
+
6
+ #
7
+ # BackupRotator Class
8
+ # Handles backup rotation locally and on S3
9
+ #
10
+ class BackupRotator
11
+
12
+ # attributes
13
+ attr_accessor :s3_client
14
+
15
+ #
16
+ # Initialize a new BackupRotator instance.
17
+ #
18
+ # @param s3_client S3Client instance
19
+ #
20
+ # @return the newly instanciated object.
21
+ #
22
+ def initialize(s3_client)
23
+ @s3_client = s3_client
24
+ end
25
+
26
+ #
27
+ # Rotate files (local, daily, weekly, monthly) and apply maximum limits for each type
28
+ #
29
+ # @param backup_name String containing the name of the backup to rotate
30
+ # @param local_backups_path String containing the path to the directory containing the backups
31
+ # @param max_local Integer specifying the maximum number of local backups to keep
32
+ # @param max_daily Integer specifying the maximum number of daily backups to keep
33
+ # @param max_weekly Integer specifying the maximum number of weekly backups to keep
34
+ # @param max_monthly Integer specifying the maximum number of monthly backups to keep
35
+ #
36
+ # @return nothing
37
+ #
38
+ def rotate(backup_name, local_backups_dir, max_local=3, max_daily=7, max_weekly=4, max_monthly=3)
39
+ rotate_local(local_backups_dir, max_local)
40
+ rotate_daily(backup_name, max_daily)
41
+ rotate_weekly(backup_name, max_weekly)
42
+ rotate_monthly(backup_name, max_monthly)
43
+ end
44
+
45
+ #
46
+ # Rotate daily files
47
+ #
48
+ # @param backup_name String containing the name of the backup being rotated
49
+ # @param max_daily Integer specifying the maximum number of daily backups to keep
50
+ # - If there are less than `max_daily` daily files: do nothing
51
+ # - If there are more than `max_daily` daily files: delete the oldest files to leave `max_daily` files
52
+ #
53
+ # The rotation works as follows:
54
+ # - Less than 7 days datediff between the oldest daily file and the most recent weekly file: do nothing
55
+ # - More than 7 days datediff between the oldest daily file and the most recent weekly file: promote the oldest daily file to weekly file
56
+ # - In both cases, apply the `max_daily`
57
+ #
58
+ # @return nothing
59
+ #
60
+ def rotate_daily(backup_name, max_daily)
61
+ # get backup files
62
+ daily_backups = @s3_client.remote_backups(backup_name, "daily").files
63
+ weekly_backups = @s3_client.remote_backups(backup_name, "weekly").files
64
+
65
+ # get most recent weekly file
66
+ recent_weekly_file = weekly_backups.last
67
+
68
+ # look through daily backups to find which oness should be promoted
69
+ daily_backups.each do |backup|
70
+ # promote to weekly if applicable
71
+ if should_promote_daily_to_weekly?(backup.key, recent_weekly_file&.key)
72
+ recent_weekly_file = promote(backup_name, backup.key, backup.body, "weekly")
73
+ end
74
+ end
75
+
76
+ # cleanup old files
77
+ if daily_backups.length > max_daily
78
+ daily_backups.each_with_index { |backup, i| backup.destroy if i < daily_backups.length - max_daily }
79
+ end
80
+ end
81
+
82
+ #
83
+ # Rotate weekly files
84
+ #
85
+ # @param backup_name String containing the name of the backup being rotated
86
+ # @param max_weekly Integer specifying the maximum number of weekly backups to keep
87
+ # - If there are less than `max_weekly` weekly files: do nothing
88
+ # - If there are more than `max_weekly` weekly files: delete the oldest files to leave `max_weekly` files
89
+ #
90
+ # The rotation works as follows:
91
+ # - Less than 1 month datediff between the oldest weekly file and the most recent monthly file: do nothing
92
+ # - More than 1 month datediff between the oldest weekly file and the most recent monthly file: promote the oldest daily file to weekly file
93
+ # - In both cases, apply the `max_weekly`
94
+ #
95
+ # @return nothing
96
+ #
97
+ def rotate_weekly(backup_name, max_weekly)
98
+ # get backup files
99
+ weekly_backups = @s3_client.remote_backups(backup_name, "weekly").files
100
+ monthly_backups = @s3_client.remote_backups(backup_name, "monthly").files
101
+
102
+ # get most recent monthly file
103
+ recent_monthly_file = monthly_backups.last
104
+
105
+ # look through weekly backups to find which oness should be promoted
106
+ weekly_backups.each do |backup|
107
+ # promote to monthly if applicable
108
+ if should_promote_weekly_to_monthly?(backup.key, recent_monthly_file&.key)
109
+ recent_monthly_file = promote(backup_name, backup.key, backup.body, "monthly")
110
+ end
111
+ end
112
+
113
+ # cleanup old files
114
+ if weekly_backups.length > max_weekly
115
+ weekly_backups.each_with_index { |backup, i| backup.destroy if i < weekly_backups.length - max_weekly }
116
+ end
117
+ end
118
+
119
+ #
120
+ # Rotate monthly files
121
+ #
122
+ # @param backup_name String containing the name of the backup being rotated
123
+ # @param max_monthly Integer specifying the maximum number of month backups to keep
124
+ # - If there are less than `max_monthly` monthly files: do nothing
125
+ # - If there are more than `max_monthly` monthly files: delete the oldest files to leave `max_monthly` files
126
+ #
127
+ # @return nothing
128
+ #
129
+ def rotate_monthly(backup_name, max_monthly)
130
+ # get backup files
131
+ monthly_backups = @s3_client.remote_backups(backup_name, "monthly").files
132
+
133
+ # cleanup old files
134
+ if monthly_backups.length > max_monthly
135
+ monthly_backups.each_with_index { |backup, i| backup.destroy if i < monthly_backups.length - max_monthly }
136
+ end
137
+ end
138
+
139
+ #
140
+ # Rotate local files
141
+ #
142
+ # @param local_backups_path String containing the path to the directory containing the backups
143
+ # @param max_local Integer specifying the maximum number of local backups to keep
144
+ # - If there are less than `max_local` local files: do nothing
145
+ # - If there are more than `max_local` local files: delete the oldest files to leave `max_local` files
146
+ #
147
+ # @return nothing
148
+ #
149
+ def rotate_local(local_backups_path, max_local)
150
+ # get backup files
151
+ local_backups = FileUtils::files_in_directory(local_backups_path)
152
+
153
+ # cleanup old files
154
+ if local_backups.length > max_local
155
+ local_backups[0..(local_backups.length - max_local - 1)].each { |backup| File.delete("#{local_backups_path}/#{backup}") }
156
+ end
157
+ end
158
+
159
+ #
160
+ # Check whether `daily_file` should be promoted into a weekly file
161
+ # Only promote a daily file if the most recent weekly backup is one week old
162
+ #
163
+ # @param daily_file String, filename of the daily backup to be checked for promotion
164
+ # @param weekly_file String, filename of the most recent weekly backup
165
+ #
166
+ # @return Boolean, True or False, whether the file should be promoted
167
+ #
168
+ def should_promote_daily_to_weekly?(daily_file, weekly_file)
169
+ # never promote if no daily file
170
+ return false if not daily_file
171
+
172
+ # always promote if no weekly file
173
+ return true if not weekly_file
174
+
175
+ # retrieve the date of each file
176
+ begin
177
+ date_daily_file = FileUtils::date_from_filename(daily_file)
178
+ date_weekly_file = FileUtils::date_from_filename(weekly_file)
179
+ rescue
180
+ print "Wrong date (Date.parse in should_promote_daily_to_weekly)."
181
+ return false
182
+ end
183
+
184
+ # perform date comparison
185
+ return (date_daily_file - date_weekly_file).abs >= 7
186
+ end
187
+
188
+ #
189
+ # Check whether `weekly_file` should be promoted into a monthly file
190
+ # Only promote a weekly file if the most recent monthly backup is one month old
191
+ #
192
+ # @param weekly_file String, filename of the weekly backup to be checked for promotion
193
+ # @param monthly_file String, filename of the most recent monthly backup
194
+ #
195
+ # @return Boolean, True or False, whether the file should be promoted
196
+ #
197
+ def should_promote_weekly_to_monthly?(weekly_file, monthly_file)
198
+ # never promote if no weekly file
199
+ return false if not weekly_file
200
+
201
+ # always promote if no monthly file
202
+ return true if not monthly_file
203
+
204
+ # retrieve the date of each file
205
+ begin
206
+ date_weekly_file = FileUtils::date_from_filename(weekly_file)
207
+ date_monthly_file = FileUtils::date_from_filename(monthly_file)
208
+ rescue
209
+ print "Wrong date (Date.parse in should_promote_weekly_to_monthly)."
210
+ return false
211
+ end
212
+
213
+ # perform date comparison
214
+ return date_weekly_file.prev_month >= date_monthly_file
215
+ end
216
+
217
+ #
218
+ # Promote a daily backup into a weekly backup
219
+ # This operation keeps the original daily file, and creates a new weekly backup
220
+ #
221
+ # @param backup_name String containing the name of the backup being updated
222
+ # @param filename String, filename of the backup you want to promote
223
+ # @param body String, body of the file you want to promote
224
+ # @param type String representing the type of backup being uploaded, one of "daily", "weekly" or "monthly"
225
+ #
226
+ # @return created S3 Bucket File
227
+ #
228
+ def promote(backup_name, filename, body, type)
229
+ # parse the date & extension
230
+ backup_date = FileUtils::date_from_filename(filename)
231
+ backup_extension = FileUtils::extension_from_filename(filename)
232
+
233
+ # upload
234
+ @s3_client.upload(backup_name, backup_date, type, backup_extension, body)
235
+ end
236
+
237
+ end
238
+
239
+ end
@@ -0,0 +1,60 @@
1
+ # s3_rotate
2
+ require 's3_rotate/utils/file_utils'
3
+
4
+ module S3Rotate
5
+
6
+ #
7
+ # BackupUploader Class
8
+ # Handles backup uploads with the right format
9
+ #
10
+ class BackupUploader
11
+
12
+ # attributes
13
+ attr_accessor :s3_client
14
+
15
+ #
16
+ # Initialize a new BackupUploader instance.
17
+ #
18
+ # @param s3_client S3Client instance
19
+ #
20
+ # @return the newly instanciated object.
21
+ #
22
+ def initialize(s3_client)
23
+ @s3_client = s3_client
24
+ end
25
+
26
+ #
27
+ # Upload local backup files to AWS S3
28
+ # Only uploads new backups
29
+ # Only uploads backups as daily backups: use `rotate` to generate the weekly & monthly files
30
+ #
31
+ # @param backup_name String containing the name of the backup to upload
32
+ # @param local_backups_path String containing the path to the directory containing the backups
33
+ # @param date_regex Regex returning the date contained in the filename of each backup
34
+ #
35
+ # @return nothing
36
+ #
37
+ def upload(backup_name, local_backups_path, date_regex=/\d{4}-\d{2}-\d{2}/)
38
+ # get backup files
39
+ local_backups = FileUtils::files_in_directory(local_backups_path).reverse
40
+
41
+ # upload local backups until we find one backup already uploaded
42
+ local_backups.each do |local_backup|
43
+ # parse the date & extension
44
+ backup_date = FileUtils::date_from_filename(local_backup, date_regex)
45
+ backup_extension = FileUtils::extension_from_filename(local_backup)
46
+
47
+ # skip invalid files
48
+ next if not backup_date
49
+
50
+ # stop uploading once we reach a file already uploaded
51
+ break if @s3_client.exists?(backup_name, backup_date, "daily", extension=backup_extension)
52
+
53
+ # upload file
54
+ @s3_client.upload_local_backup_to_s3(backup_name, backup_date, "daily", backup_extension, File.open(local_backup))
55
+ end
56
+ end
57
+
58
+ end
59
+
60
+ end
@@ -0,0 +1,65 @@
1
+ require 'date'
2
+
3
+ module S3Rotate
4
+
5
+ module FileUtils
6
+
7
+ #
8
+ # Parse the date in a filename
9
+ # Date can be any format recognize by Date.parse, or be a timestamp
10
+ #
11
+ # @param filename String containing the filename to be parsed.
12
+ # @param date_regex Regex returning the date contained in the filename
13
+ #
14
+ # @return Date instance, representing the parsed date
15
+ #
16
+ def FileUtils.date_from_filename(filename, date_regex=/\d{4}-\d{2}-\d{2}/)
17
+ # match the date in the filename
18
+ match = filename.match(date_regex)
19
+ date_str = match&.captures&.first || match&.to_s
20
+
21
+ # if nothing could be match, immediately fail
22
+ raise "Invalid date_regex or filename format" if not date_str
23
+
24
+ # regular date
25
+ begin
26
+ if date_str.include?("-")
27
+ Date.parse(date_str)
28
+ # timestamp
29
+ else
30
+ DateTime.strptime(date_str, "%s").to_date
31
+ end
32
+ rescue
33
+ raise "Date format not supported"
34
+ end
35
+ end
36
+
37
+ #
38
+ # Parse the extension in a filename
39
+ #
40
+ # @param filename String containing the filename to be parsed
41
+ #
42
+ # @return String containing the extension of the filename if relevant, None otherwise
43
+ #
44
+ def FileUtils.extension_from_filename(filename)
45
+ if filename.include?('.')
46
+ '.' + filename.split('/').last.split('.')[1..-1].join('.')
47
+ end
48
+ end
49
+
50
+ #
51
+ # Get the list of files in the specified directory
52
+ #
53
+ # @param directory String containing the path to the directory
54
+ #
55
+ # @return array of filenames, in ascending date order
56
+ #
57
+ def FileUtils.files_in_directory(directory)
58
+ Dir.entries(directory).select { |f| !File.directory? f }.sort
59
+ rescue
60
+ raise "Invalid directory #{directory}"
61
+ end
62
+
63
+ end
64
+
65
+ end
@@ -0,0 +1,15 @@
1
+ require 'date'
2
+
3
+ Gem::Specification.new do |s|
4
+ s.name = 's3_rotate'
5
+ s.version = '1.0.0'
6
+ s.homepage = 'https://github.com/Whova/s3_rotate'
7
+ s.date = Date.today.to_s
8
+ s.summary = "AWS S3 upload with rotation mechanism"
9
+ s.description = s.summary
10
+ s.authors = ["Simon Ninon"]
11
+ s.email = 'simon.ninon@gmail.com'
12
+ s.files = `git ls-files`.split("\n")
13
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
14
+ s.license = 'MIT'
15
+ end
@@ -0,0 +1,128 @@
1
+ # standard
2
+ require 'date'
3
+
4
+ # 3rd part
5
+ require 'fog-aws'
6
+
7
+ # s3_rotate
8
+ require File.expand_path("../../../../lib/s3_rotate/aws/s3_client", __FILE__)
9
+
10
+ describe S3Rotate::S3Client do
11
+
12
+ before :each do
13
+ @client = S3Rotate::S3Client.new('key', 'secret', 'bucket', 'region')
14
+ end
15
+
16
+ describe '#initialize' do
17
+
18
+ it 'sets the access_key' do
19
+ expect(@client.access_key).to eq 'key'
20
+ end
21
+
22
+ it 'sets the access_secret' do
23
+ expect(@client.access_secret).to eq 'secret'
24
+ end
25
+
26
+ it 'sets the bucket_name' do
27
+ expect(@client.bucket_name).to eq 'bucket'
28
+ end
29
+
30
+ it 'sets the region' do
31
+ expect(@client.region).to eq 'region'
32
+ end
33
+
34
+ end
35
+
36
+ describe '#connection' do
37
+
38
+ it 'sets the connection when unset' do
39
+ # mock
40
+ @client.connection = nil
41
+
42
+ # perform test
43
+ expect(@client.connection).not_to eq nil
44
+ end
45
+
46
+ it 'does not set the connection when set' do
47
+ # mock
48
+ @client.connection = "some connection"
49
+
50
+ # perform test
51
+ expect(@client.connection).to eq "some connection"
52
+ end
53
+
54
+ end
55
+
56
+ describe '#bucket' do
57
+
58
+ it 'gets the bucket' do
59
+ expect(@client.bucket).not_to eq nil
60
+ expect(@client.bucket.key).to eq 'bucket'
61
+ end
62
+
63
+ end
64
+
65
+ describe '#remote_backups' do
66
+
67
+ before do
68
+ @client.connection.directories.get('bucket').files.create(key: '/backup_name/daily/2020-01-01.tgz', body: 'some data')
69
+ @client.connection.directories.get('bucket').files.create(key: '/backup_name/daily/2020-01-02.tgz', body: 'some data')
70
+ @client.connection.directories.get('bucket').files.create(key: '/backup_name/daily/2020-02-03.tgz', body: 'some data')
71
+ @client.connection.directories.get('bucket').files.create(key: '/backup_name/daily/2021-02-04.tgz', body: 'some data')
72
+ @client.connection.directories.get('bucket').files.create(key: '/backup_name/weekly/2020-01-03.tgz', body: 'some data')
73
+ @client.connection.directories.get('bucket').files.create(key: '/backup_name/monthly/2020-01-04.tgz', body: 'some data')
74
+ @client.connection.directories.get('bucket').files.create(key: '/other_backup_name/daily/2020-01-05.tgz', body: 'some data')
75
+ end
76
+
77
+ it 'gets the remote backups' do
78
+ expect(@client.remote_backups('backup_name', 'daily')).not_to eq nil
79
+ expect(@client.remote_backups('backup_name', 'daily').files).not_to eq nil
80
+ expect(@client.remote_backups('backup_name', 'daily').files.length).to eq 4
81
+ expect(@client.remote_backups('backup_name', 'daily').files[0].key).to eq "/backup_name/daily/2020-01-01.tgz"
82
+ expect(@client.remote_backups('backup_name', 'daily').files[1].key).to eq "/backup_name/daily/2020-01-02.tgz"
83
+ expect(@client.remote_backups('backup_name', 'daily').files[2].key).to eq "/backup_name/daily/2020-02-03.tgz"
84
+ expect(@client.remote_backups('backup_name', 'daily').files[3].key).to eq "/backup_name/daily/2021-02-04.tgz"
85
+ end
86
+
87
+ end
88
+
89
+ describe '#exists?' do
90
+
91
+ before do
92
+ @client.connection.directories.get('bucket').files.create(key: '/backup_name/daily/2020-01-01.tgz', body: 'some data')
93
+ end
94
+
95
+ it 'returns true for existing backups' do
96
+ expect(@client.exists?('backup_name', Date.new(2020, 1, 1), 'daily', '.tgz')).to eq true
97
+ end
98
+
99
+ it 'returns false for wrong extension' do
100
+ expect(@client.exists?('backup_name', Date.new(2020, 1, 1), 'daily', '.tar.gz')).to eq false
101
+ end
102
+
103
+ it 'returns false for wrong type' do
104
+ expect(@client.exists?('backup_name', Date.new(2020, 1, 1), 'weekly', '.tgz')).to eq false
105
+ end
106
+
107
+ it 'returns false for wrong date' do
108
+ expect(@client.exists?('backup_name', Date.new(2020, 1, 2), 'daily', '.tgz')).to eq false
109
+ end
110
+
111
+ it 'returns false for backup name' do
112
+ expect(@client.exists?('other_backup_name', Date.new(2020, 1, 1), 'daily', '.tgz')).to eq false
113
+ end
114
+
115
+ end
116
+
117
+ describe '#upload' do
118
+
119
+ it 'uploads files' do
120
+ @client.upload('backup_name', Date.new(2020, 1, 1), 'daily', '.tgz', 'hello world')
121
+ expect(@client.connection.directories.get('bucket', prefix: '/backup_name/daily/2020-01-01.tgz').files.length).to eq 1
122
+ expect(@client.connection.directories.get('bucket', prefix: '/backup_name/daily/2020-01-01.tgz').files.first.key).to eq '/backup_name/daily/2020-01-01.tgz'
123
+ expect(@client.connection.directories.get('bucket', prefix: '/backup_name/daily/2020-01-01.tgz').files.first.body).to eq 'hello world'
124
+ end
125
+
126
+ end
127
+
128
+ end