s3_rotate 1.0.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,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