rds_backup_service 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,12 @@
1
+ *.gem
2
+ .bundle
3
+ config/accounts.yml
4
+ pkg/*
5
+ *~
6
+ .DS_Store
7
+ *.tmproj
8
+ log/*.log
9
+ tmp/**
10
+ doc/**
11
+ .yardoc
12
+ 1
data/.yardopts ADDED
@@ -0,0 +1 @@
1
+ --no-private --protected lib/**/*.rb - API.md
data/API.md ADDED
@@ -0,0 +1,29 @@
1
+ RDS Backup Service REST API
2
+ ================
3
+ The REST API exposes only one API call:
4
+
5
+ POST /api/v1/backups
6
+
7
+ POST to here with parameter `rds_instance` set to an RDS instance name.
8
+
9
+ ----------------
10
+ Backups
11
+ ----------------
12
+ A Backup represents a long-running backup process for an RDS.
13
+
14
+ **RESOURCE LOCATION**: `/api/v1/backups`
15
+
16
+ **REQUEST PARAMETERS**:
17
+
18
+ * rds_instance: the name of the RDS instance to dump to S3 - **REQUIRED**
19
+ * email: an optional email address to send a message to on completion
20
+
21
+
22
+ **RESULT FIELDS**:
23
+
24
+ * rds_instance: the name of the RDS instance being dumped to S3
25
+ * account_name: the name of account from accounts.yml, if determined
26
+ * backup_status: an HTTP-like status code for the process
27
+ * status_url: a signed S3 URL to this job's JSON status as updated
28
+ * status_message: a descriptive progress message
29
+ * files: an Array of output files for this job, including S3 URLs
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source "http://rubygems.org"
2
+ gemspec
data/LICENSE.TXT ADDED
@@ -0,0 +1,10 @@
1
+ ----------------
2
+ *MIT License*
3
+
4
+ Copyright (c) 2012 Benton Roberts
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
7
+
8
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
9
+
10
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,125 @@
1
+ RDS Backup Service
2
+ ================
3
+ Fire-and-forget SQL backups of Amazon Web Services' RDS databases into S3.
4
+
5
+ ----------------
6
+ What is it?
7
+ ----------------
8
+ A REST-style web service and middleware library for safely dumping the contents
9
+ of a live AWS Relational Database Service instance into a compressed SQL file.
10
+
11
+ The service has only one API call (a POST to `/api/v1/backups`), which spawns
12
+ a long-running worker process. The worker performs the following steps:
13
+
14
+ 1. Snapshots the original RDS
15
+ 2. Creates a new RDS instance based on the snapshot
16
+ 3. Configures the new RDS as needed, including rebooting for Parameter Group
17
+ 4. Connects to the RDS and dumps the database contents, compressing on the fly
18
+ 5. Uploads the compressed SQL file to S3, and optionally emails its URL
19
+ 6. Deletes up the snapshot, temporary instance, and local SQL dump
20
+
21
+ ----------------
22
+ Why is it?
23
+ ----------------
24
+ Safely and consistently grabbing the contents of a loaded, live RDS instance
25
+ is a pain (if it has no existing slave). Though the steps are simple, they're
26
+ brittle, slow, and involve lots of waiting for indeterminate time periods.
27
+
28
+ ----------------
29
+ Installation
30
+ ----------------
31
+ First install the dependencies:
32
+
33
+ * Ruby 1.9, rake, and bundler
34
+ * [Redis][] (for [Resque][] workers), or [DelayedJob][] (library only for now)
35
+
36
+ The RDS Backup Service can be installed as a standalone application or as a
37
+ Rack middleware library.
38
+
39
+ ### To install as an application ###
40
+
41
+ Install project dependencies, fetch the code, and bundle up.
42
+
43
+ gem install rake bundler
44
+ git clone https://github.com/benton/rds_backup_service.git
45
+ cd rds_backup_service
46
+ bundle
47
+
48
+ ### To install as a library ###
49
+
50
+ 1) Install the gem, or add it as a Bundler dependency and `bundle`.
51
+
52
+ gem install rds_backup_service
53
+
54
+ 2) Require the middleware from your Rack application, then insert it
55
+ in the stack:
56
+
57
+ require 'rds_backup_service'
58
+ ...
59
+ config.middleware.use RDSBackup::Service # (Rails application.rb)
60
+ # or
61
+ use RDSBackup::Service # (Sinatra)
62
+
63
+ 3) If desired, require the SecurityGroup setup task in your `Rakefile`:
64
+
65
+ require 'rds_backup_service/tasks'
66
+
67
+ ----------------
68
+ Configuration and Setup
69
+ ----------------
70
+ Two configuration files are required _(see included examples)_:
71
+
72
+ * `./config/accounts.yml` or `ENV['RDS_ACCOUNTS_FILE']`
73
+
74
+ This file defines three different types of AWS accounts: the various RDS
75
+ accounts to grab SQL from; the S3 account where the SQL output
76
+ will be written; and an optional EC2 account, which is used by the
77
+ `setup:rds_backup_groups` rake task to perform post-configuration setup.
78
+
79
+ * `./config/settings.yml` or `ENV['RDS_SETTINGS_FILE']`
80
+
81
+ This file defines the S3 bucket name for the output, plus some other options.
82
+
83
+ Once these files have been edited, run `rake setup:rds_backup_groups`, which:
84
+
85
+ * makes sure the configured Security Groups exist in all the RDS and EC2 accounts
86
+ * opens the RDS Security Group in each RDS account to the EC2 Security Group
87
+ * checks to see that the current host is in the EC2 Security Group (when in EC2)
88
+
89
+ ----------------
90
+ Usage
91
+ ----------------
92
+ The service is run in the standard Rack manner:
93
+
94
+ bundle exec rackup
95
+
96
+ The entry point for the REST API is `/api/v1/backups`
97
+ (See the {file:API.md API documentation})
98
+
99
+ The Resque workers are run with:
100
+
101
+ QUEUE=backups rake resque:work
102
+
103
+
104
+ ----------------
105
+ DelayedJob
106
+ ----------------
107
+ The library (though not the service) can be used with DelayedJob.
108
+ Place some code like this in your Controller or Model:
109
+
110
+ require 'rds_backup_service'
111
+ ...
112
+ job = RDSBackup::Job.new(params[:rds_id])
113
+ job.write_to_s3
114
+ Delayed::Job.enqueue RDSBackup::DelayedJob.new(job.rds_id, {
115
+ 'backup_id' => job.backup_id,
116
+ 'requested' => job.requested.to_s,
117
+ 'email' => params[:email],
118
+ })
119
+
120
+
121
+
122
+ [Redis]: http://redis.io/
123
+ [Resque]: https://github.com/defunkt/resque
124
+ [DelayedJob]: https://github.com/collectiveidea/delayed_job
125
+
data/Rakefile ADDED
@@ -0,0 +1,18 @@
1
+ # Set up bundler
2
+ %w{rubygems bundler bundler/gem_tasks}.each {|dep| require dep}
3
+ bundles = [:default]
4
+ Bundler.setup(:default)
5
+ case ENV['RACK_ENV']
6
+ when 'development' then Bundler.setup(:default, :development)
7
+ when 'test' then Bundler.setup(:default, :development, :test)
8
+ end
9
+
10
+ require 'rds_backup_service'
11
+ require 'resque/tasks'
12
+ ENV['TERM_CHILD'] = '1'
13
+
14
+ # Load all tasks from 'lib/tasks'
15
+ Dir["#{File.dirname(__FILE__)}/lib/tasks/*.rake"].sort.each {|ext| load ext}
16
+
17
+ desc 'Default: runs all tests'
18
+ task :default => :spec
@@ -0,0 +1,46 @@
1
+ # The S3 account is used to store all job output.
2
+ s3account-name:
3
+ credentials:
4
+ aws_access_key_id: XXXXXXXXXXXXXXXXXXXX
5
+ aws_secret_access_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
6
+ provider: AWS
7
+ service: Storage
8
+
9
+ # This service should run in an EC2 account.
10
+ # The creds here are used only for automated SecurityGroup setup.
11
+ # If you don't run 'rake setup:rds_backup_groups',
12
+ # then these won't be used -- but one-time EC2 setup must be done manually.
13
+ ec2-account-for-service:
14
+ credentials:
15
+ aws_access_key_id: XXXXXXXXXXXXXXXXXXXX
16
+ aws_secret_access_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
17
+ provider: AWS
18
+ service: Compute
19
+
20
+ # The RDS instances in all the RDS accounts are avaliable for backup.
21
+
22
+ rds-development:
23
+ credentials:
24
+ aws_access_key_id: XXXXXXXXXXXXXXXXXXXX
25
+ aws_secret_access_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
26
+ delay: 120
27
+ provider: AWS
28
+ service: RDS
29
+ exclude_resources:
30
+ - parameters
31
+ - security_groups
32
+ - parameter_groups
33
+ - snapshots
34
+
35
+ rds-production:
36
+ credentials:
37
+ aws_access_key_id: XXXXXXXXXXXXXXXXXXXX
38
+ aws_secret_access_key: XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
39
+ delay: 120
40
+ provider: AWS
41
+ service: RDS
42
+ exclude_resources:
43
+ - parameters
44
+ - security_groups
45
+ - parameter_groups
46
+ - snapshots
@@ -0,0 +1,26 @@
1
+ # REQUIRED - The S3 bucket name where all backups will be saved
2
+ backup_bucket: rdsbackups
3
+
4
+ # Backups can be prefixed with backup_prefix if desired.
5
+ # (Do not include a leading or trailing /)
6
+ # default value = nil
7
+
8
+ #backup_prefix: testing
9
+
10
+ # The rds_security_group must be defined in each
11
+ # RDS account, and be open to this server, so that the
12
+ # mysqldump utlility can connect.
13
+ # default value = rds-backup-service
14
+
15
+ #rds_security_group: rds-backup-service
16
+
17
+ # The ec2_security_group must be defined in the EC2 account
18
+ # under which this service runs.
19
+ # default value = rds-backup-service
20
+
21
+ #ec2_security_group: rds-backup-service
22
+
23
+ # Where to store DB data while compressing it, before upload
24
+ # defaults to Ruby's Dir.tmpdir
25
+
26
+ #tmp_dir: "/tmp"
data/config.ru ADDED
@@ -0,0 +1,2 @@
1
+ require './lib/rds_backup_service/service'
2
+ run RDSBackup::Service
@@ -0,0 +1,68 @@
1
+ module RDSBackup
2
+ # models logic for post-configuration setup
3
+ module Config
4
+
5
+ # Attempts to set up the EC2 and RDS security groups as specified in the
6
+ # configuration. Raises an Exception on errors. Best if run from EC2.
7
+ def self.setup_security_groups(logger = nil)
8
+ log = logger || RDSBackup.default_logger(STDOUT)
9
+ # Configuration
10
+ log.info "Scanning system..."
11
+ (system = Ohai::System.new).all_plugins
12
+ log.info "Reading config files..."
13
+ settings = RDSBackup.settings
14
+ ec2_group_name = settings['ec2_security_group']
15
+ rds_group_name = settings['rds_security_group']
16
+ ec2 = RDSBackup.ec2
17
+
18
+ # EC2 Security Group creation
19
+ log.info "Checking EC2 for Security Group #{ec2_group_name}"
20
+ unless ec2_group = ec2.security_groups.get(ec2_group_name)
21
+ log.info "Creating EC2 Security group #{ec2_group_name}"
22
+ ec2_group = ec2.security_groups.create(:name => ec2_group_name,
23
+ :description => 'Created by rds_backup_service')
24
+ end
25
+
26
+ # RDS Security Group creation and authorization
27
+ RDSBackup.rds_accounts.each do |account_name, account_data|
28
+ log.info "Checking account #{account_name} for "+
29
+ "RDS Security group #{rds_group_name}"
30
+ rds = ::Fog::AWS::RDS.new(account_data[:credentials])
31
+ rds_group = rds.security_groups.get rds_group_name
32
+ unless rds_group
33
+ log.info "Creating security group #{rds_group_name} in #{account_name}"
34
+ rds_group = rds.security_groups.create(:id => rds_group_name,
35
+ :description => 'Created by rds_backup_service')
36
+ end
37
+ # Apply EC2 authorization to RDS Security Groups
38
+ owner = ec2.security_groups.first.owner_id
39
+ authorized = false
40
+ rds_group.ec2_security_groups.each do |authorization|
41
+ if (authorization['EC2SecurityGroupName'] == ec2_group_name) &&
42
+ (authorization['EC2SecurityGroupOwnerId'] == owner)
43
+ authorized = true
44
+ end
45
+ end
46
+ unless authorized
47
+ log.info "Authorizing EC2 Group for #{account_name}/#{rds_group_name}"
48
+ rds_group.authorize_ec2_security_group(ec2_group_name, owner)
49
+ end
50
+ end
51
+
52
+ # EC2 Security Group check for this host
53
+ unless system[:ec2]
54
+ log.warn "Not running in EC2 - open RDS groups to this host!"
55
+ else
56
+ unless this_host = ec2.servers.get(system[:ec2][:instance_id])
57
+ log.warn "Not running in EC2 account #{s3_acc_name}!"
58
+ else
59
+ log.info "Running in EC2. Current Security Groups = #{this_host.groups}"
60
+ unless this_host.groups.include? ec2_group_name
61
+ log.warn "This host is not in Security Group #{ec2_group_name}!"
62
+ end
63
+ end
64
+ end
65
+
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,252 @@
1
+ require 'fog_tracker'
2
+ require 'fileutils'
3
+ require 'json'
4
+ module RDSBackup
5
+ # Backs up the contents of a single RDS database to S3
6
+ class Job
7
+
8
+ @queue = :backups
9
+
10
+ attr_reader :backup_id, :rds_id, :account_name, :options
11
+ attr_reader :status, :status_url, :message, :files, :requested
12
+
13
+ # Constructor.
14
+ # @param [String] rds_instance_id the ID of the RDS instance to backup
15
+ # @param [Hash] options optional additional parameters:
16
+ # - backup_id - a unique ID for this job, if necessary
17
+ # - requested - a Time when this job was requested
18
+ # - email - an email address to be notified on completion
19
+ # - logger - a Logger object, for printing this job's ongoing status
20
+ def initialize(rds_instance_id, options = {})
21
+ @rds_id, @options = rds_instance_id, options
22
+ @backup_id = options['backup_id'] || "%016x" % (rand * 0xffffffffffffffff)
23
+ @requested = options['requested'] ? Time.parse(options['requested']) : Time.now
24
+ @status = 200
25
+ @message = "queued"
26
+ @files = []
27
+ @config = RDSBackup.settings
28
+ @bucket = @config['backup_bucket']
29
+ @s3_path = (@config['backup_prefix'] ? "#{@config['backup_prefix']}/" : "")+
30
+ "#{requested.strftime("%Y/%m/%d")}/#{rds_id}/#{backup_id}"
31
+ @snapshot_id = "rds-backup-service-#{rds_id}-#{backup_id}"
32
+ @new_rds_id = "rds-backup-service-#{backup_id}"
33
+ @new_password = "#{backup_id}"
34
+ @account_name = options['account_name']
35
+ end
36
+
37
+ # returns a JSON-format String representation of this backup job
38
+ def to_json
39
+ JSON.pretty_generate({
40
+ rds_instance: rds_id,
41
+ account_name: account_name,
42
+ backup_status: status,
43
+ status_message: message,
44
+ status_url: status_url,
45
+ files: files,
46
+ })
47
+ end
48
+
49
+ # Writes this job's JSON representation to S3
50
+ def write_to_s3
51
+ status_path = "#{@s3_path}/status.json"
52
+ s3.put_object(@bucket, status_path, "#{to_json}\n")
53
+ unless @status_url
54
+ expire_date = Time.now + (3600 * 24) # one day from now
55
+ @status_url = s3.get_object_http_url(@bucket, status_path, expire_date)
56
+ s3.put_object(@bucket, status_path, "#{to_json}\n")
57
+ end
58
+ end
59
+
60
+ # Entry point for the Resque framework.
61
+ # Parameters are the same as for #initialize()
62
+ def self.perform(rds_instance_id, options = {})
63
+ job = Job.new(rds_instance_id, options)
64
+ begin
65
+ job.perform_backup
66
+ rescue Resque::TermException => e
67
+ ::Resque.enqueue_to(:backups, Job, rds_instance_id,
68
+ options.merge(backup_id: job.backup_id, requested: job.requested.to_s))
69
+ job.update_status "Terminated on interrupt signal - requeued"
70
+ end
71
+ end
72
+
73
+ # Top-level, long-running method for performing the backup.
74
+ def perform_backup
75
+ begin
76
+ prepare_backup
77
+ update_status "Backing up #{rds_id} from account #{account_name}"
78
+ create_disconnected_rds
79
+ download_data_from_tmp_rds # populates @sql_file
80
+ delete_disconnected_rds
81
+ upload_output_to_s3
82
+ update_status "Backup of #{rds_id} complete"
83
+ send_mail
84
+ rescue Exception => e
85
+ update_status "ERROR: #{e.message.split("\n").first}", 500
86
+ raise e
87
+ end
88
+ end
89
+
90
+ # Step 1 of the overall process - create a disconnected copy of the RDS
91
+ def create_disconnected_rds(new_rds_name = nil)
92
+ @new_rds_id = new_rds_name if new_rds_name
93
+ prepare_backup unless @original_server # in case run as a convenience method
94
+ snapshot_original_rds
95
+ create_tmp_rds_from_snapshot
96
+ configure_tmp_rds
97
+ wait_for_new_security_group
98
+ wait_for_new_parameter_group # (reboots as needed)
99
+ destroy_snapshot
100
+ end
101
+
102
+ # Queries RDS for any pre-existing entities associated with this job.
103
+ # Also waits for the original RDS to become ready.
104
+ def prepare_backup
105
+ unless @original_server = RDSBackup.get_rds(rds_id)
106
+ names = RDSBackup.rds_accounts.map {|name, account| name }
107
+ raise "Unable to find RDS #{rds_id} in accounts #{names.join ", "}"
108
+ end
109
+ @account_name = @original_server.tracker_account[:name]
110
+ @rds = ::Fog::AWS::RDS.new(
111
+ RDSBackup.rds_accounts[@account_name][:credentials])
112
+ @snapshot = @rds.snapshots.get @snapshot_id
113
+ @new_instance = @rds.servers.get @new_rds_id
114
+ end
115
+
116
+ # Snapshots the original RDS
117
+ def snapshot_original_rds
118
+ unless @new_instance || @snapshot
119
+ update_status "Waiting for RDS instance #{@original_server.id}"
120
+ @original_server.wait_for { ready? }
121
+ update_status "Creating snapshot #{@snapshot_id} from RDS #{rds_id}"
122
+ @snapshot = @rds.snapshots.create(id: @snapshot_id, instance_id: rds_id)
123
+ end
124
+ end
125
+
126
+ # Creates a new RDS from the snapshot
127
+ def create_tmp_rds_from_snapshot
128
+ unless @new_instance
129
+ update_status "Waiting for snapshot #{@snapshot_id}"
130
+ @snapshot.wait_for { ready? }
131
+ update_status "Booting new RDS #{@new_rds_id} from snapshot #{@snapshot.id}"
132
+ @rds.restore_db_instance_from_db_snapshot(@snapshot.id,
133
+ @new_rds_id, 'DBInstanceClass' => @original_server.flavor_id)
134
+ @new_instance = @rds.servers.get @new_rds_id
135
+ end
136
+ end
137
+
138
+ # Destroys the snapshot if it exists
139
+ def destroy_snapshot
140
+ if (@snapshot = @rds.snapshots.get @snapshot_id)
141
+ update_status "Deleting snapshot #{@snapshot.id}"
142
+ @snapshot.destroy
143
+ end
144
+ end
145
+
146
+ # Updates the Master Password and applies the configured RDS Security Group
147
+ def configure_tmp_rds
148
+ update_status "Waiting for instance #{@new_instance.id}..."
149
+ @new_instance.wait_for { ready? }
150
+ update_status "Modifying RDS attributes for new RDS #{@new_instance.id}"
151
+ @rds.modify_db_instance(@new_instance.id, true, {
152
+ 'DBParameterGroupName' => @original_server.db_parameter_groups.
153
+ first['DBParameterGroupName'],
154
+ 'DBSecurityGroups' => [ @config['rds_security_group'] ],
155
+ 'MasterUserPassword' => @new_password,
156
+ })
157
+ end
158
+
159
+ # Wait for the new RDS Security Group to become 'active'
160
+ def wait_for_new_security_group
161
+ old_group_name = @config['rds_security_group']
162
+ update_status "Applying security group #{old_group_name}"+
163
+ " to #{@new_instance.id}"
164
+ @new_instance.wait_for {
165
+ new_group = (db_security_groups.select do |group|
166
+ group['DBSecurityGroupName'] == old_group_name
167
+ end).first
168
+ (new_group ? new_group['Status'] : 'Unknown') == 'active'
169
+ }
170
+ end
171
+
172
+ # Wait for the new RDS Parameter Group to become 'in-sync'
173
+ def wait_for_new_parameter_group
174
+ old_name = @original_server.db_parameter_groups.first['DBParameterGroupName']
175
+ update_status "Applying parameter group #{old_name} to #{@new_instance.id}"
176
+ job = self # save local var for closure in wait_for, below
177
+ @new_instance.wait_for {
178
+ new_group = (db_parameter_groups.select do |group|
179
+ group['DBParameterGroupName'] == old_name
180
+ end).first
181
+ status = (new_group ? new_group['ParameterApplyStatus'] : 'Unknown')
182
+ if (status == "pending-reboot")
183
+ job.update_status "Rebooting RDS #{id} to apply ParameterGroup #{old_name}"
184
+ reboot and wait_for { ready? }
185
+ end
186
+ status == 'in-sync' && ready?
187
+ }
188
+ end
189
+
190
+ # Connects to the RDS server, and dumps the database to a temp dir
191
+ def download_data_from_tmp_rds
192
+ @new_instance.wait_for { ready? }
193
+ db_name = @original_server.db_name
194
+ db_user = @original_server.master_username
195
+ update_status "Dumping database #{db_name} from #{@new_instance.id}"
196
+ dump_time = @snapshot ? Time.parse(@snapshot.created_at.to_s) : Time.now
197
+ date_stamp = dump_time.strftime("%Y-%m-%d-%H%M%S")
198
+ @sql_file = "#{@config['tmp_dir']}/#{@s3_path}/#{db_name}.#{date_stamp}.sql.gz"
199
+ hostname = @new_instance.endpoint['Address']
200
+ dump_cmd = "mysqldump -u #{db_user} -h #{hostname} "+
201
+ "-p#{@new_password} #{db_name} | gzip >#{@sql_file}"
202
+ FileUtils.mkpath(File.dirname @sql_file)
203
+ @log.debug "Executing command: #{dump_cmd}"
204
+ `#{dump_cmd}`
205
+ end
206
+
207
+ # Destroys the temporary RDS instance
208
+ def delete_disconnected_rds
209
+ update_status "Deleting RDS instance #{@new_instance.id}"
210
+ @new_instance.destroy
211
+ end
212
+
213
+ # Uploads the compressed SQL file to S3
214
+ def upload_output_to_s3
215
+ update_status "Uploading output file #{::File.basename @sql_file}"
216
+ dump_path = "#{@s3_path}/#{::File.basename @sql_file}"
217
+ s3.put_object(@bucket, dump_path, File.read(@sql_file))
218
+ upload = s3.directories.get(@bucket).files.get dump_path
219
+ @files = [ {
220
+ name: ::File.basename(@sql_file),
221
+ size: upload.content_length,
222
+ url: upload.url(Time.now + (3600 * 24 * 30)) # 30 days from now
223
+ } ]
224
+ @log.info "Deleting tmp directory #{File.dirname @sql_file}"
225
+ FileUtils.rm_rf(File.dirname @sql_file)
226
+ end
227
+
228
+ # Writes a new status message to the log, and writes the job info to S3
229
+ def update_status(message, new_status = nil)
230
+ @log = @options['logger'] || RDSBackup.default_logger(STDOUT)
231
+ @message = message
232
+ @status = new_status if new_status
233
+ @status == 200 ? (@log.info message) : (@log.error message)
234
+ write_to_s3
235
+ end
236
+
237
+ # Sends a status email
238
+ def send_mail
239
+ return unless @options['email']
240
+ @log.info "Emailing #{@options['email']}..."
241
+ begin
242
+ RDSBackup::Email.new(self).send!
243
+ rescue Exception => e
244
+ @log.warn "Error sending email: #{e.message.split("\n").first}"
245
+ end
246
+ end
247
+
248
+ # lazily initializes and returns S3 connection
249
+ def s3 ; @s3 ||= RDSBackup.s3 end
250
+
251
+ end
252
+ end
@@ -0,0 +1,10 @@
1
+ module RDSBackup
2
+ # convenience wrapper class for DelayedJob.
3
+ # Parameters are the same as for RDSBackup::Job.initialize()
4
+ class DelayedJob < Struct.new(:rds_id, :options)
5
+ # Entry point for the DelayedJob framework.
6
+ def perform
7
+ RDSBackup::Job.new(rds_id, options).perform_backup
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,42 @@
1
+ require 'mail'
2
+ module RDSBackup
3
+ # an email representation of a Job, that can send itself to recipients.
4
+ class Email
5
+
6
+ attr_reader :job
7
+
8
+ # constructor - requires an RDSBackup::Job
9
+ def initialize(backup_job)
10
+ @job = backup_job
11
+ end
12
+
13
+ # Attempts to send email through local ESMTP port 25.
14
+ # Raises an Exception on failure.
15
+ def send!
16
+ raise "job #{job.backup_id} has no email option" unless job.options['email']
17
+ main_text = body_text # define local variables for closure over Mail.new
18
+ recipients, header = job.options['email'],
19
+ header = "Backup of RDS #{job.rds_id} (job ID #{job.backup_id})"
20
+ mail = Mail.new do
21
+ from 'rdsbackupservice@mdsol.com'
22
+ to recipients
23
+ subject header
24
+ body "#{main_text}\n"
25
+ end
26
+ mail.deliver!
27
+ end
28
+
29
+ # defines the body of a Job's status email
30
+ def body_text
31
+ msg = "Hello.\n\n"
32
+ if job.status == 200
33
+ msg += "Your backup of database #{job.rds_id} is complete.\n"+
34
+ (job.files.empty? ? "" : "Output is at #{job.files.first[:url]}\n")
35
+ else
36
+ msg += "Your backup is incomplete. (job ID #{job.backup_id})\n"
37
+ end
38
+ msg += "Job status: #{job.message}"
39
+ end
40
+
41
+ end
42
+ end
@@ -0,0 +1,72 @@
1
+ # The top-level project module. Contains some static helper methods.
2
+ module RDSBackup
3
+ require 'fog'
4
+ require 'tmpdir'
5
+ require 'ohai'
6
+ PROJECT_DIR = File.expand_path(File.join(File.dirname(__FILE__), '..', '..'))
7
+ require "#{PROJECT_DIR}/lib/rds_backup_service/version"
8
+
9
+ # Loads account information defined in config/accounts.yml, or
10
+ # ENV['RDS_ACCOUNTS_FILE'].
11
+ # @param account_file the path to a YAML file (see accounts.yml.example).
12
+ # @return [Hash] a Hash representing the account info.
13
+ # The keys in each account Hash are converted to Symbols.
14
+ def self.read_accounts(account_file = ENV['RDS_ACCOUNTS_FILE'])
15
+ YAML::load(File.read(account_file || "./config/accounts.yml")).
16
+ inject({}) {|a,b| a[b[0]] = b[1].symbolize_keys ; a }
17
+ end
18
+
19
+ # Loads account information, and returns only those
20
+ # entries that repesent RDS accounts.
21
+ def self.rds_accounts
22
+ RDSBackup.read_accounts.select{|id,acc| acc[:service] == 'RDS'}
23
+ end
24
+
25
+ # Returns a new connection to the AWS EC2 service (Fog::Compute::AWS)
26
+ def self.ec2
27
+ accts = RDSBackup.read_accounts.select{|id,acc| acc[:service] == 'Compute'}
28
+ raise "At least one S3 account must be defined" if accts.empty?
29
+ Fog::Compute::AWS.new(accts.first[1][:credentials])
30
+ end
31
+
32
+ # Returns a new connection to the AWS S3 service (Fog::Storage::AWS)
33
+ def self.s3
34
+ accts = RDSBackup.read_accounts.select{|id,acc| acc[:service] == 'Storage'}
35
+ raise "At least one S3 account must be defined" if accts.empty?
36
+ Fog::Storage::AWS.new(accts.first[1][:credentials])
37
+ end
38
+
39
+ # Returns the configuration Hash read from config/s3_account.yml
40
+ # or ENV['RDSDUMP_SETTINGS_FILE'].
41
+ def self.settings(settings_file = ENV['RDS_SETTINGS_FILE'])
42
+ { # here are some defaults
43
+ 'rds_security_group' => 'rds-backup-service',
44
+ 'ec2_security_group' => 'rds-backup-service',
45
+ 'tmp_dir' => Dir.tmpdir,
46
+ }.merge(YAML::load(
47
+ File.read(settings_file || "./config/settings.yml")))
48
+ end
49
+
50
+ # Defines the root URI path of the web service.
51
+ # @return [String] the root URI path of the web service
52
+ def self.root
53
+ "/api/v#{RDSBackup::API_VERSION}"
54
+ end
55
+
56
+ def self.default_logger(output = nil)
57
+ logger = ::Logger.new(output)
58
+ logger.sev_threshold = Logger::INFO
59
+ logger.formatter = proc {|lvl, time, prog, msg| "#{lvl}: #{msg}\n"}
60
+ logger
61
+ end
62
+
63
+ # Returns a Fog RDS entity for a given an RDS ID. Polls all accounts.
64
+ # The account name is attached to the result as 'tracker_account[:name]'.
65
+ # @param [String] the name of the desired RDS entity
66
+ # @return [Fog::AWS::RDS::Server] the RDS instance, or nil if not found
67
+ def self.get_rds(rds_id)
68
+ ::FogTracker::Tracker.new(RDSBackup.rds_accounts).update.
69
+ select{|rds| rds.identity == rds_id}.first
70
+ end
71
+
72
+ end
@@ -0,0 +1,59 @@
1
+ #!/usr/bin/env ruby
2
+ require File.join(File.dirname(__FILE__), '..', 'rds_backup_service')
3
+ require 'sinatra/base'
4
+ require 'fog_tracker'
5
+ require 'resque'
6
+
7
+ module RDSBackup
8
+ # A RESTful web service for backing up RDS databases to S3.
9
+ # See the README for running this Rack-compliant service.
10
+ class Service < Sinatra::Base
11
+
12
+ # configure logging when not in test mode
13
+ configure :production, :development do
14
+ @log = RDSBackup.default_logger(STDOUT)
15
+ enable :logging
16
+ end
17
+
18
+ # on startup, load account information and start tracking RDS instances
19
+ configure do
20
+ @log.info "Loading account information..."
21
+ tracker = FogTracker::Tracker.new(RDSBackup.rds_accounts, :logger => @log)
22
+ @log.info "Starting tracker..."
23
+ tracker.update
24
+ tracker.start
25
+ set :tracker, tracker
26
+ end
27
+
28
+ before do ; content_type 'application/json' end # serve JSON
29
+
30
+ ######## POST /api/vXXX/backups ########
31
+ # Queues a Job for a given :rds_instance
32
+ post "#{RDSBackup.root}/backups" do
33
+ rds_id = params[:rds_instance]
34
+ servers = settings.tracker['*::AWS::RDS::servers']
35
+ rds = (servers.select {|rds| rds.identity == rds_id}).first
36
+
37
+ # check for errors
38
+ if ! rds_id
39
+ return [ 400, { errors: ["Parameter 'rds_instance' required"]}.to_json ]
40
+ elsif ! (servers.map {|r| r.identity}).include?(rds_id)
41
+ return [ 404, { errors: ["RDS instance #{rds_id} not found"]}.to_json ]
42
+ end
43
+
44
+ # request is OK - queue up a BackupJob
45
+ job = Job.new(rds_id, params.merge(account_name: rds.tracker_account[:name]))
46
+ logger.info "Queuing backup of RDS #{rds_id} in account #{job.account_name}"
47
+ job.write_to_s3
48
+ ::Resque.enqueue_to(:backups, Job, job.rds_id, job.options.
49
+ merge({'backup_id' => job.backup_id, 'requested' => job.requested.to_s}))
50
+
51
+ [ 201, # return HTTP_CREATED, and
52
+ { 'Location' => job.status_url }, # point to the S3 document
53
+ "#{job.to_json}\n" # and return the job as JSON
54
+ ]
55
+ end
56
+
57
+ run! if app_file == $0 # start the server if ruby file executed directly
58
+ end
59
+ end
@@ -0,0 +1,2 @@
1
+ require 'rake'
2
+ Dir["#{File.dirname(__FILE__)}/../tasks/*.rake"].sort.each {|ext| load ext}
@@ -0,0 +1,7 @@
1
+ module RDSBackup
2
+ # The version of this Gem / Library.
3
+ VERSION = "0.0.1"
4
+
5
+ # This library's version of the REST API.
6
+ API_VERSION = "1"
7
+ end
@@ -0,0 +1,8 @@
1
+ %w{rubygems bundler}.each {|lib| require lib}
2
+ Bundler.setup
3
+
4
+ # Load all the other files in this library, except the service
5
+ %w{ version rds_backup_service config
6
+ model/backup_job model/delayed_job model/email }.each do |file|
7
+ require File.join(File.dirname(__FILE__), "rds_backup_service/#{file}")
8
+ end
@@ -0,0 +1,6 @@
1
+ desc "Attempts to set up the rds_backup_service security groups"
2
+ namespace :setup do
3
+ task :rds_backup_groups do
4
+ RDSBackup::Config.setup_security_groups
5
+ end
6
+ end
@@ -0,0 +1,41 @@
1
+ # -*- encoding: utf-8 -*-
2
+ $:.push File.expand_path("../lib", __FILE__)
3
+ require "rds_backup_service/version"
4
+
5
+ Gem::Specification.new do |s|
6
+ s.name = "rds_backup_service"
7
+ s.version = RDSBackup::VERSION
8
+ s.authors = ["Benton Roberts"]
9
+ s.email = ["benton@bentonroberts.com"]
10
+ s.homepage = "http://github.com/benton/rds_backup_service"
11
+ s.summary = %q{Provides a REST API for backing up live RDS instances }+
12
+ %q{to S3 as a compressed SQL file.}
13
+ s.description = %q{Provides a REST API for backing up live RDS instances }+
14
+ %q{to S3 as a compressed SQL file.}
15
+ s.rubyforge_project = "rds_backup_service"
16
+
17
+ # This project is both a Gem and an Application,
18
+ # so the Gemfile.lock is included in the repo for application users,
19
+ # but excluded from the packaged Gem, for middleware use.
20
+ git_files = `git ls-files`.split("\n") # Read all files in the repo,
21
+ git_files.delete "Gemfile.lock" # remove Gemfile.lock, and
22
+ s.files = git_files # use the result in the Gem.
23
+
24
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
25
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
26
+ s.require_paths = ["lib"]
27
+
28
+ # Runtime dependencies
29
+ s.add_dependency "sinatra"
30
+ s.add_dependency "fog_tracker", ">=0.4.0"
31
+ s.add_dependency "resque"
32
+ s.add_dependency "mail"
33
+ s.add_dependency "ohai"
34
+
35
+ # Development / Test dependencies
36
+ s.add_development_dependency "rake"
37
+ s.add_development_dependency "rspec"
38
+ s.add_development_dependency "guard"
39
+ s.add_development_dependency "guard-rspec"
40
+ s.add_development_dependency "ruby_gntp"
41
+ end
metadata ADDED
@@ -0,0 +1,234 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: rds_backup_service
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Benton Roberts
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2012-09-04 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: sinatra
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: fog_tracker
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: 0.4.0
38
+ type: :runtime
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: 0.4.0
46
+ - !ruby/object:Gem::Dependency
47
+ name: resque
48
+ requirement: !ruby/object:Gem::Requirement
49
+ none: false
50
+ requirements:
51
+ - - ! '>='
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
54
+ type: :runtime
55
+ prerelease: false
56
+ version_requirements: !ruby/object:Gem::Requirement
57
+ none: false
58
+ requirements:
59
+ - - ! '>='
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ - !ruby/object:Gem::Dependency
63
+ name: mail
64
+ requirement: !ruby/object:Gem::Requirement
65
+ none: false
66
+ requirements:
67
+ - - ! '>='
68
+ - !ruby/object:Gem::Version
69
+ version: '0'
70
+ type: :runtime
71
+ prerelease: false
72
+ version_requirements: !ruby/object:Gem::Requirement
73
+ none: false
74
+ requirements:
75
+ - - ! '>='
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ - !ruby/object:Gem::Dependency
79
+ name: ohai
80
+ requirement: !ruby/object:Gem::Requirement
81
+ none: false
82
+ requirements:
83
+ - - ! '>='
84
+ - !ruby/object:Gem::Version
85
+ version: '0'
86
+ type: :runtime
87
+ prerelease: false
88
+ version_requirements: !ruby/object:Gem::Requirement
89
+ none: false
90
+ requirements:
91
+ - - ! '>='
92
+ - !ruby/object:Gem::Version
93
+ version: '0'
94
+ - !ruby/object:Gem::Dependency
95
+ name: rake
96
+ requirement: !ruby/object:Gem::Requirement
97
+ none: false
98
+ requirements:
99
+ - - ! '>='
100
+ - !ruby/object:Gem::Version
101
+ version: '0'
102
+ type: :development
103
+ prerelease: false
104
+ version_requirements: !ruby/object:Gem::Requirement
105
+ none: false
106
+ requirements:
107
+ - - ! '>='
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: rspec
112
+ requirement: !ruby/object:Gem::Requirement
113
+ none: false
114
+ requirements:
115
+ - - ! '>='
116
+ - !ruby/object:Gem::Version
117
+ version: '0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ none: false
122
+ requirements:
123
+ - - ! '>='
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ - !ruby/object:Gem::Dependency
127
+ name: guard
128
+ requirement: !ruby/object:Gem::Requirement
129
+ none: false
130
+ requirements:
131
+ - - ! '>='
132
+ - !ruby/object:Gem::Version
133
+ version: '0'
134
+ type: :development
135
+ prerelease: false
136
+ version_requirements: !ruby/object:Gem::Requirement
137
+ none: false
138
+ requirements:
139
+ - - ! '>='
140
+ - !ruby/object:Gem::Version
141
+ version: '0'
142
+ - !ruby/object:Gem::Dependency
143
+ name: guard-rspec
144
+ requirement: !ruby/object:Gem::Requirement
145
+ none: false
146
+ requirements:
147
+ - - ! '>='
148
+ - !ruby/object:Gem::Version
149
+ version: '0'
150
+ type: :development
151
+ prerelease: false
152
+ version_requirements: !ruby/object:Gem::Requirement
153
+ none: false
154
+ requirements:
155
+ - - ! '>='
156
+ - !ruby/object:Gem::Version
157
+ version: '0'
158
+ - !ruby/object:Gem::Dependency
159
+ name: ruby_gntp
160
+ requirement: !ruby/object:Gem::Requirement
161
+ none: false
162
+ requirements:
163
+ - - ! '>='
164
+ - !ruby/object:Gem::Version
165
+ version: '0'
166
+ type: :development
167
+ prerelease: false
168
+ version_requirements: !ruby/object:Gem::Requirement
169
+ none: false
170
+ requirements:
171
+ - - ! '>='
172
+ - !ruby/object:Gem::Version
173
+ version: '0'
174
+ description: Provides a REST API for backing up live RDS instances to S3 as a compressed
175
+ SQL file.
176
+ email:
177
+ - benton@bentonroberts.com
178
+ executables: []
179
+ extensions: []
180
+ extra_rdoc_files: []
181
+ files:
182
+ - .gitignore
183
+ - .yardopts
184
+ - API.md
185
+ - Gemfile
186
+ - LICENSE.TXT
187
+ - README.md
188
+ - Rakefile
189
+ - config.ru
190
+ - config/accounts.example.yml
191
+ - config/settings.yml
192
+ - lib/rds_backup_service.rb
193
+ - lib/rds_backup_service/config.rb
194
+ - lib/rds_backup_service/model/backup_job.rb
195
+ - lib/rds_backup_service/model/delayed_job.rb
196
+ - lib/rds_backup_service/model/email.rb
197
+ - lib/rds_backup_service/rds_backup_service.rb
198
+ - lib/rds_backup_service/service.rb
199
+ - lib/rds_backup_service/tasks.rb
200
+ - lib/rds_backup_service/version.rb
201
+ - lib/tasks/setup_groups.rake
202
+ - rds_backup_service.gemspec
203
+ homepage: http://github.com/benton/rds_backup_service
204
+ licenses: []
205
+ post_install_message:
206
+ rdoc_options: []
207
+ require_paths:
208
+ - lib
209
+ required_ruby_version: !ruby/object:Gem::Requirement
210
+ none: false
211
+ requirements:
212
+ - - ! '>='
213
+ - !ruby/object:Gem::Version
214
+ version: '0'
215
+ segments:
216
+ - 0
217
+ hash: 1151512606421308315
218
+ required_rubygems_version: !ruby/object:Gem::Requirement
219
+ none: false
220
+ requirements:
221
+ - - ! '>='
222
+ - !ruby/object:Gem::Version
223
+ version: '0'
224
+ segments:
225
+ - 0
226
+ hash: 1151512606421308315
227
+ requirements: []
228
+ rubyforge_project: rds_backup_service
229
+ rubygems_version: 1.8.24
230
+ signing_key:
231
+ specification_version: 3
232
+ summary: Provides a REST API for backing up live RDS instances to S3 as a compressed
233
+ SQL file.
234
+ test_files: []