aws_xregion_sync 0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/LICENSE.txt +20 -0
- data/README.md +138 -0
- data/lib/aws_xregion_sync.rb +62 -0
- data/lib/aws_xregion_sync/aws_sync.rb +119 -0
- data/lib/aws_xregion_sync/configure.rb +69 -0
- data/lib/aws_xregion_sync/ec2_ami_sync.rb +115 -0
- data/lib/aws_xregion_sync/errors.rb +7 -0
- data/lib/aws_xregion_sync/rds_automated_snapshot_sync.rb +202 -0
- data/lib/aws_xregion_sync/sync_result.rb +22 -0
- data/lib/aws_xregion_sync/version.rb +3 -0
- metadata +97 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 62a8ca4d29553c5aa20118465c339057c0f9545a
|
4
|
+
data.tar.gz: 5caabbc8f7f3b39c78f98e96bf8e9af18462e1c9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 6bae78d1fdc1ddb8be11e51f42fe3830d0403a21e313b9db797780794cee96933a9eed395dc95de8b1010f314138cf80d8a8f1249b67ae8a44ca4420a7da3c6c
|
7
|
+
data.tar.gz: 7a640afc58a6b5c43669979258380cf7c89271bc846f53289df6168d740d79cdc17b5e3170bc77b77f2464f017d2a632b6fbacee90bc97a7cb624eeffa845060
|
data/LICENSE.txt
ADDED
@@ -0,0 +1,20 @@
|
|
1
|
+
The MIT License (MIT)
|
2
|
+
|
3
|
+
Copyright (c) 2013 Vandegrift Forwarding Company Inc.
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of
|
6
|
+
this software and associated documentation files (the "Software"), to deal in
|
7
|
+
the Software without restriction, including without limitation the rights to
|
8
|
+
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
9
|
+
the Software, and to permit persons to whom the Software is furnished to do so,
|
10
|
+
subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
17
|
+
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
18
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
19
|
+
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
20
|
+
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,138 @@
|
|
1
|
+
aws_xregion_sync
|
2
|
+
================
|
3
|
+
|
4
|
+
AWS Cross-Region Sync is a simple tool to help provide for easy disaster recovery by directly syncing AWS resources across AWS regions.
|
5
|
+
|
6
|
+
At this time only EC2 AMI and RDS Automated Snapshot syncing is supported.
|
7
|
+
|
8
|
+
## How To
|
9
|
+
|
10
|
+
Configuring and running AWS X Region Sync is done via a simple YAML configuration file. The easiest way to show how to use the system is provide an example
|
11
|
+
config file and then walk through the options.
|
12
|
+
|
13
|
+
Assume the following config data is found in the file '/my/config.yaml':
|
14
|
+
|
15
|
+
```
|
16
|
+
{
|
17
|
+
|
18
|
+
sync_my_web_app: {
|
19
|
+
sync_type: "ec2_ami",
|
20
|
+
source_region: "us-east-1",
|
21
|
+
destination_region: "us-west-1",
|
22
|
+
ami_owner: "12345678910123",
|
23
|
+
sync_identifier: "Web Application",
|
24
|
+
# http://docs.aws.amazon.com/AWSEC2/latest/CommandLineReference/ApiReference-cmd-DescribeImages.html
|
25
|
+
filters: ["tag:Environment=Production", "tag-value=Sync"]
|
26
|
+
},
|
27
|
+
sync_my_database: {
|
28
|
+
sync_type: "rds_automated_snapshot",
|
29
|
+
source_region: "us-east-1",
|
30
|
+
destination_region: "us-west-1",
|
31
|
+
db_instance: "mydatabase",
|
32
|
+
max_snapshots_to_retain: 5,
|
33
|
+
aws_client_config: {
|
34
|
+
aws_access_key: "my_other_access_key",
|
35
|
+
aws_secret_key: "my_other_secret_key",
|
36
|
+
aws_account_id: "4321-4321-4321"
|
37
|
+
}
|
38
|
+
},
|
39
|
+
aws_client_config: {
|
40
|
+
aws_access_key: "my_access_key",
|
41
|
+
aws_secret_key: "my_secret_key",
|
42
|
+
}
|
43
|
+
}
|
44
|
+
```
|
45
|
+
|
46
|
+
Each YAML key that starts with 'sync_' defines a distinct job to sync 1 particular resource. Lets examine each of these sync types now.
|
47
|
+
|
48
|
+
### ec2_ami
|
49
|
+
The 'sync_my_web_app' job uses the 'ec2_ami' type which locates a single AMI instance associated with the given account credentials and will copy the image
|
50
|
+
and all resource tags associated with the image to the defined 'destination_region'. If the source image has already been copied to the destination region
|
51
|
+
this job will be a no-op. The process utilizes a single resource tag as means of tracking if the image has already been synced and will NOT repeatedly sync
|
52
|
+
the same image to the same region if it can determine the destination region already contains a copy of the source image.
|
53
|
+
|
54
|
+
Copying the image may take quite some time, depending on the size of the AMI. The sync job does not block while the image is being copied and will not report
|
55
|
+
the final status of the AWS copy task. It is assumed that the AMI copy will eventually complete.
|
56
|
+
|
57
|
+
#### ec2_ami Configuration
|
58
|
+
|
59
|
+
The following configuration options are available for 'ec2_ami' sync jobs (star'ed options are required):
|
60
|
+
|
61
|
+
- source_region * - The name of the AWS region the source EC2 AMI will be found in.
|
62
|
+
- destination_region * - The name of the AWS region the source EC2 AMI should be copied to.
|
63
|
+
- ami_owner - The AWS owner id of the AMI. If left blank, the account associated with the AWS client credentials is used.
|
64
|
+
- sync_identifier - If given, a resource tag named 'Sync-Identifier' with the provided value will expected to be associated with the source AMI. This is probably the simplest means of identifying which AMI to sync.
|
65
|
+
- filters - An array of String filter values that can be used to further filter AMI options down to a single source AMI. Multiple filters are ANDed togther. See the 'filter' parameter here for further explanation of available filter values: http://docs.aws.amazon.com/AWSEC2/latest/CommandLineReference/ApiReference-cmd-DescribeImages.html
|
66
|
+
|
67
|
+
In general, the combination of ami_owner, sync_identifier, and filter options MUST narrow down the list of AMI images to a SINGLE AMI. If they do not, the sync job will be aborted.
|
68
|
+
|
69
|
+
NOTE: Under the covers, the sync task is performed by the AWS <a href="http://docs.aws.amazon.com/AWSEC2/latest/CommandLineReference/ApiReference-cmd-CopyImage.html">ec2-copy-image</a> API method.
|
70
|
+
|
71
|
+
### rds_automated_snapshot
|
72
|
+
The 'sync_my_database' job uses the 'rds_automated_snapshot' type which locates the newest automated rds snapshot associated with the given 'db_instance' and utilizes
|
73
|
+
the AWS copy_snapshot functionality to copy the snapshot to the destination region. If the source snapshot is determined to already have been synced to the destination
|
74
|
+
snapshot the job will be a no-op. The process utilizes the snapshot's created at attribute combined with a resource tag on destination snapshots to determine if
|
75
|
+
the snapshot has already been synced.
|
76
|
+
|
77
|
+
Copying the snapshot may take quite some time, depending on the size of the snapshot and amount of time since the last snapshot has been synced.
|
78
|
+
The sync job does not block while the snapshot is being copied and will not report the final status of the AWS copy task.
|
79
|
+
It is assumed that the snapshot copy will eventually complete.
|
80
|
+
|
81
|
+
NOTE: Under the covers, the sync task is performed by the AWS <a href="http://docs.aws.amazon.com/AmazonRDS/latest/CommandLineReference/CLIReference-cmd-CopyDBSnapshot.html">rds-copy-db-snapshot</a> API method. Please see it for any further explanation of how the sync is performed. In particular note that once an initial snapshot
|
82
|
+
has been copied only incremental changes are copied between regions, saving both time and bandwidth costs. Each source automated snapshot that is
|
83
|
+
copied will result in a new destination snapshot. The sync job will retain however many snapshots in the destination region you would like to keep.
|
84
|
+
|
85
|
+
#### rds_automated_snapshot Configuration
|
86
|
+
|
87
|
+
The following configuration options are available for 'ec2_ami' sync jobs (star'ed options are required):
|
88
|
+
|
89
|
+
- source_region * - The name of the AWS region the source RDS Snapshot will be found in.
|
90
|
+
- destination_region * - The name of the AWS region the source RDS Snapshot should be copied to.
|
91
|
+
- db_instance * - The RDS DB Instance Identifier value for the source database in the source region.
|
92
|
+
- max_snapshots_to_retain - The number of snapshots to retain in the destination region. Defaults to retaining 2 existing ones - which ends up being 3 total including the one in the process of copying. You'll probably wan't to keep this to at least 1 so that you'll always have at least a single previous snapshot available while the current one copies over to the dest. region.
|
93
|
+
- aws_client_config/aws_account_id- The AWS API for RDS snapshot tags requires providing an account number, therefore, the effective aws_client_credentials utilized for an rds snapshot sync can contain an account number. This value can be found automatically in a rather hacky fashion by examining IAM user ARNs associated with the AWS keys, and this method will be utilized if no aws_account_id is provided. However, be prepared for this method to fail and to have to provide the account id directly.
|
94
|
+
|
95
|
+
### aws_client_config
|
96
|
+
|
97
|
+
The 'aws_client_config' values can be defined both at a global level and/or inside each individual sync job. The values defined at the global level are merged
|
98
|
+
together with any values defined at the job level (pretty much exactly as global_config.merge(job_config)). The resulting config hash comprised of one or both values
|
99
|
+
are then passed directly to the AWS SDK. Because of this, you can provide any acceptable configuration values to the AWS ruby SDK client.
|
100
|
+
|
101
|
+
Addtional aws client config properties are:
|
102
|
+
|
103
|
+
- aws_account_id - Some sync tasks require the use of a account id to construct ARN's. In some cases, the account id can be deduced from the access and secret key via an IAM user lookup. When this is not possible, an error will be raised associated with the sync job and you will need to provide the aws_account_id manually.
|
104
|
+
|
105
|
+
## Sample Code
|
106
|
+
|
107
|
+
In general, the only class/method your code needs to call is the AwsXRegionSync.run method. It will return you a collection of AwsXRegionSync::SyncResult objects,
|
108
|
+
one for each sync job contained in your config file.
|
109
|
+
|
110
|
+
Here's a really simple, direct example of running the config file above (apologies for verbosity of if statements - easiest most direct way of showing potential return values from the run method):
|
111
|
+
```ruby
|
112
|
+
require 'aws_xregion_sync'
|
113
|
+
results = AwsXRegionSync.run '/my/config.yaml'
|
114
|
+
results.each do |result|
|
115
|
+
if result.failed?
|
116
|
+
puts "#{result.name} encountered the following errors:\n#{result.errors.map(&:message).join('\n')}"
|
117
|
+
else
|
118
|
+
# If an image/snapshot was created by the job, created_resource will be populated.
|
119
|
+
# Resources may not be created in such cases where an EC2 image may already be in sync.
|
120
|
+
if result.created_resource
|
121
|
+
puts "#{result.name} successfully completed and created the AWS resource #{result.created_resource}."
|
122
|
+
else
|
123
|
+
puts "#{result.name} successfully completed without creating any new AWS resources."
|
124
|
+
end
|
125
|
+
end
|
126
|
+
end
|
127
|
+
|
128
|
+
```
|
129
|
+
|
130
|
+
## Contributing
|
131
|
+
|
132
|
+
See something you want added or have a bug that needs sqashing?
|
133
|
+
Feel free to open an issue and we'll get back to you OR better yet, fork the project, create a topic branch, code up your awesome change, push to your branch and open a pull request with us.
|
134
|
+
|
135
|
+
License
|
136
|
+
-------
|
137
|
+
|
138
|
+
aws_xregion_sync is available under the MIT License. See LICENSE.txt for more information
|
@@ -0,0 +1,62 @@
|
|
1
|
+
require 'aws-sdk'
|
2
|
+
require 'require_all'
|
3
|
+
require_rel 'aws_xregion_sync'
|
4
|
+
|
5
|
+
class AwsXRegionSync
|
6
|
+
|
7
|
+
def self.run config_file_path_or_hash
|
8
|
+
do_syncs config_file_path_or_hash, :sync
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.sync_required? config_file_path_or_hash
|
12
|
+
do_syncs config_file_path_or_hash, :sync_required?
|
13
|
+
end
|
14
|
+
|
15
|
+
def self.do_syncs config_file_path_or_hash, job_method
|
16
|
+
job_configs = configure config_file_path_or_hash
|
17
|
+
error_results = create_results_from_config_errors job_configs[:errors]
|
18
|
+
job_syncs = sync job_configs[:jobs], job_method
|
19
|
+
|
20
|
+
job_syncs + error_results
|
21
|
+
end
|
22
|
+
private_class_method :do_syncs
|
23
|
+
|
24
|
+
def self.configure config
|
25
|
+
# Use to_hash since this will allow a broader range of config options to anyone
|
26
|
+
# passing in a config object rather than a filename
|
27
|
+
if config.respond_to? :to_hash
|
28
|
+
Configure.generate_sync_jobs config.to_hash
|
29
|
+
else
|
30
|
+
Configure.configure_from_file config
|
31
|
+
end
|
32
|
+
end
|
33
|
+
private_class_method :configure
|
34
|
+
|
35
|
+
def self.sync jobs, job_method
|
36
|
+
results = []
|
37
|
+
jobs.each do |job|
|
38
|
+
completed = false
|
39
|
+
errors = nil
|
40
|
+
synced_object_id = nil
|
41
|
+
begin
|
42
|
+
synced_object_id = job.send job_method
|
43
|
+
completed = true
|
44
|
+
rescue Exception => e
|
45
|
+
# Yes, we're trapping everything here. I'd like all the sync jobs to at least attempt
|
46
|
+
# to run each time sync is called. If they all bail with something like a memory error
|
47
|
+
# or something else along those lines, then so be it, the error will get raised eventually
|
48
|
+
errors = [e]
|
49
|
+
end
|
50
|
+
|
51
|
+
results << SyncResult.new(job.sync_name, completed, synced_object_id, errors)
|
52
|
+
end
|
53
|
+
|
54
|
+
results
|
55
|
+
end
|
56
|
+
private_class_method :sync
|
57
|
+
|
58
|
+
def self.create_results_from_config_errors errors
|
59
|
+
errors ? errors.map {|job_key, config_errors| SyncResult.new(job_key, false, nil, config_errors)} : []
|
60
|
+
end
|
61
|
+
private_class_method :create_results_from_config_errors
|
62
|
+
end
|
@@ -0,0 +1,119 @@
|
|
1
|
+
class AwsXRegionSync
|
2
|
+
class AwsSync
|
3
|
+
|
4
|
+
attr_reader :config, :sync_name
|
5
|
+
|
6
|
+
def initialize sync_name, config
|
7
|
+
@sync_name = sync_name
|
8
|
+
@config = config
|
9
|
+
end
|
10
|
+
|
11
|
+
def validate_config
|
12
|
+
# We could validate if these are actual valid region names via the AWS client, but for now we'll assume we're actually getting something valid
|
13
|
+
# as long as the option is there - the sync will blow up and give the reason anyway.
|
14
|
+
raise AwsXRegionSyncConfigError, "The #{sync_name} configuration must have a valid 'source_region' value." unless config['source_region'] && config['source_region'].length > 0
|
15
|
+
raise AwsXRegionSyncConfigError, "The #{sync_name} configuration must have a valid 'destination_region' value." unless config['destination_region'] && config['destination_region'].length > 0
|
16
|
+
|
17
|
+
if config['filters']
|
18
|
+
raise AwsXRegionSyncConfigError, "The #{sync_name} configuration 'filters' value must be an Enumerable object." unless config['filters'] && config['filters'].is_a?(Enumerable)
|
19
|
+
parse_config_filters config['filters']
|
20
|
+
end
|
21
|
+
self
|
22
|
+
end
|
23
|
+
|
24
|
+
def parse_config_filters config_filters
|
25
|
+
# Splits all the given filters on '=' so they can be utilized in a standard describe filter clause
|
26
|
+
filters = []
|
27
|
+
config_filters.each do |cf|
|
28
|
+
split = cf.to_s.split("=")
|
29
|
+
raise AwsXRegionSyncConfigError, "The #{sync_name} configuration 'filters' value '#{cf}' must be of the form filter-field=filter-value." unless split.size == 2
|
30
|
+
|
31
|
+
filters << split
|
32
|
+
end
|
33
|
+
filters
|
34
|
+
end
|
35
|
+
|
36
|
+
def aws_config
|
37
|
+
global = config['aws_client_config']
|
38
|
+
# We want to make sure if we create a new config hash that we're
|
39
|
+
# storing it so any changes made by a caller to the returned hash
|
40
|
+
# are stored off
|
41
|
+
unless global
|
42
|
+
global = {}
|
43
|
+
config['aws_client_config'] = global
|
44
|
+
end
|
45
|
+
|
46
|
+
global
|
47
|
+
end
|
48
|
+
|
49
|
+
def create_sync_tag region, source_identifier, options = {}
|
50
|
+
options = {timestamp: Time.now}.merge options
|
51
|
+
|
52
|
+
timestamp = options[:timestamp].utc
|
53
|
+
key = "#{sync_tag_indicator}"
|
54
|
+
key += "-#{options[:sync_subtype]}" if options[:sync_subtype]
|
55
|
+
key += "-#{region}"
|
56
|
+
{key: key, value: build_sync_tag_value(source_identifier, timestamp)}
|
57
|
+
end
|
58
|
+
|
59
|
+
def parse_sync_tag_value value
|
60
|
+
# Sync Timestamp (YYYYMMDDHHmm) to second / resource identifier
|
61
|
+
# AWS only allows 10 tags per resource so we're trying to limit the # of tags we consume by combining the timestamp and resource identifier into a single tag
|
62
|
+
values = {}
|
63
|
+
if value && value.length > 0
|
64
|
+
split = value.split(" / ")
|
65
|
+
values[:timestamp] = Time.strptime(split[0], timestamp_format).utc
|
66
|
+
values[:resource_identifier] = split[1]
|
67
|
+
end
|
68
|
+
|
69
|
+
values
|
70
|
+
end
|
71
|
+
|
72
|
+
def sync_tag_indicator
|
73
|
+
"Sync"
|
74
|
+
end
|
75
|
+
|
76
|
+
def discover_aws_account_id raise_error_if_not_found = true
|
77
|
+
return @aws_account_id if defined?(@aws_account_id)
|
78
|
+
|
79
|
+
aws_account_id = aws_config['aws_account_id']
|
80
|
+
unless aws_account_id
|
81
|
+
aws_account_id = retrieve_aws_account_id
|
82
|
+
end
|
83
|
+
raise AwsXRegionSyncConfigError, "The #{self.sync_name} configuration must provide an 'aws_account_id' option to use for manipulating snapshot tags, unable to retrieve id automatically." if (aws_account_id.nil? || aws_account_id.length == 0) && raise_error_if_not_found
|
84
|
+
|
85
|
+
@aws_account_id = aws_account_id
|
86
|
+
end
|
87
|
+
|
88
|
+
private
|
89
|
+
|
90
|
+
def timestamp_format
|
91
|
+
"%Y%m%d%H%M%S%z"
|
92
|
+
end
|
93
|
+
|
94
|
+
def build_sync_tag_value source_identifier, sync_timestamp
|
95
|
+
"#{sync_timestamp.strftime(timestamp_format)} / #{source_identifier}"
|
96
|
+
end
|
97
|
+
|
98
|
+
def create_iam config
|
99
|
+
#split out for mocking purposes
|
100
|
+
AWS::IAM.new config
|
101
|
+
end
|
102
|
+
|
103
|
+
def retrieve_aws_account_id
|
104
|
+
# We can use the ARN associated with any user to find the account id associated with the access/secret key from the config
|
105
|
+
# This feels like a massive hack, but it's the only automated way I've found to retrieve this information that's needed to construct
|
106
|
+
# resource ARN's for other direct API calls.
|
107
|
+
iam = create_iam aws_config
|
108
|
+
arn = nil
|
109
|
+
iam.users.each(limit: 1) {|u| arn = u.arn}
|
110
|
+
|
111
|
+
account_id = nil
|
112
|
+
if arn && arn =~ /^arn:aws:iam::(\d+):/
|
113
|
+
account_id = $1
|
114
|
+
end
|
115
|
+
|
116
|
+
account_id
|
117
|
+
end
|
118
|
+
end
|
119
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
require 'yaml'
|
2
|
+
|
3
|
+
class AwsXRegionSync
|
4
|
+
class Configure
|
5
|
+
|
6
|
+
def self.configure_from_file file_path
|
7
|
+
# For now, just assume a yaml config file..we can certainly support json/xml or something more complicated here internally later.
|
8
|
+
generate_sync_jobs load_yaml_config_file file_path
|
9
|
+
end
|
10
|
+
|
11
|
+
def self.generate_sync_jobs config
|
12
|
+
# We need at least one sync key, each sync must have a type indicator, each sync must have a source and destination region
|
13
|
+
sync_keys = config.keys
|
14
|
+
|
15
|
+
global_aws_config = config['aws_client_config']
|
16
|
+
sync_jobs = []
|
17
|
+
configuration_errors = {}
|
18
|
+
sync_keys.each do |job_key|
|
19
|
+
if job_key.upcase.to_s.start_with? "SYNC_"
|
20
|
+
sync_config = config[job_key]
|
21
|
+
begin
|
22
|
+
sync_jobs << create_sync_job(global_aws_config, job_key, sync_config)
|
23
|
+
rescue => e
|
24
|
+
configuration_errors[job_key] = [e]
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
28
|
+
|
29
|
+
{jobs: sync_jobs, errors: configuration_errors}
|
30
|
+
end
|
31
|
+
|
32
|
+
def self.create_sync_job global_aws_config, job_key, job_config
|
33
|
+
# merge the global and local aws client config settings together, allowing for easy global and per sync config settings
|
34
|
+
if global_aws_config
|
35
|
+
if job_config['aws_client_config']
|
36
|
+
job_config['aws_client_config'] = global_aws_config.merge job_config['aws_client_config']
|
37
|
+
else
|
38
|
+
# Just want to make a new hash object here
|
39
|
+
job_config['aws_client_config'] = global_aws_config.merge({})
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
sync_job = nil
|
44
|
+
case job_config['sync_type']
|
45
|
+
when 'ec2_ami'
|
46
|
+
sync_job = Ec2AmiSync
|
47
|
+
when 'rds_automated_snapshot'
|
48
|
+
sync_job = RdsAutomatedSnapshotSync
|
49
|
+
else
|
50
|
+
raise AwsXRegionSyncConfigError, "The #{job_key} configuration 'sync_type' value '#{job_config['sync_type']}' is not a supported AWS Sync type."
|
51
|
+
end
|
52
|
+
|
53
|
+
job = sync_job.new job_key, job_config
|
54
|
+
job.validate_config
|
55
|
+
job
|
56
|
+
end
|
57
|
+
private_class_method :create_sync_job
|
58
|
+
|
59
|
+
def self.load_yaml_config_file config
|
60
|
+
if config.is_a? String
|
61
|
+
YAML.load_file config
|
62
|
+
else
|
63
|
+
YAML.load config
|
64
|
+
end
|
65
|
+
end
|
66
|
+
private_class_method :load_yaml_config_file
|
67
|
+
|
68
|
+
end
|
69
|
+
end
|
@@ -0,0 +1,115 @@
|
|
1
|
+
class AwsXRegionSync
|
2
|
+
class Ec2AmiSync < AwsSync
|
3
|
+
|
4
|
+
def sync
|
5
|
+
setup = pre_sync_setup
|
6
|
+
|
7
|
+
sync_ec2_image_to_region setup[:destination_region], setup[:source_region], setup[:image]
|
8
|
+
end
|
9
|
+
|
10
|
+
def sync_required?
|
11
|
+
setup = pre_sync_setup
|
12
|
+
needs_sync? setup[:destination_region], setup[:image]
|
13
|
+
end
|
14
|
+
|
15
|
+
def sync_ec2_image_to_region destination_region, source_region, image
|
16
|
+
destination_ami_id = nil
|
17
|
+
|
18
|
+
if needs_sync?(destination_region, image)
|
19
|
+
# At this point, we know the sync has not happened (or the destination image has been removed)
|
20
|
+
# Initiate the image copy command (we're not using client_token to ensure indempotency since we're already just using the Sync- identifier tag to ensure we're not
|
21
|
+
# copying the image multiple times to the destination region)
|
22
|
+
results = destination_region.client.copy_image source_region: source_region.name, source_image_id: image.id, name: image.name
|
23
|
+
|
24
|
+
destination_ami_id = results[:image_id]
|
25
|
+
|
26
|
+
tag = create_sync_tag destination_region.name, destination_ami_id
|
27
|
+
# We want to log the destination AMI-ID and the time we started the sync back to the source image
|
28
|
+
image.tags[tag[:key]] = tag[:value]
|
29
|
+
end
|
30
|
+
|
31
|
+
destination_ami_id
|
32
|
+
end
|
33
|
+
|
34
|
+
private
|
35
|
+
|
36
|
+
def pre_sync_setup
|
37
|
+
config = {'owner_id'=>'self'}.merge self.config
|
38
|
+
|
39
|
+
filters = parse_filters config['sync_identifier'], config['filters']
|
40
|
+
|
41
|
+
ec2 = ec2_client aws_config
|
42
|
+
|
43
|
+
source_region = validate_region ec2, config['source_region']
|
44
|
+
destination_region = validate_region ec2, config['destination_region']
|
45
|
+
|
46
|
+
# Assemble the filter criteria that should help us to find the specific image we're after
|
47
|
+
ami_query = source_region.images
|
48
|
+
ami_query = ami_query.with_owner(config['owner_id'])
|
49
|
+
|
50
|
+
filters.each {|f| ami_query = ami_query.filter(f[0], f[1])}
|
51
|
+
|
52
|
+
images = ami_query.to_a
|
53
|
+
if images.size > 1
|
54
|
+
raise AwsXRegionSyncConfigError, "More than one EC2 Image was found with the filter settings for identifier '#{self.sync_name}': #{images.collect{|i| i.id}.join(", ")}."
|
55
|
+
elsif images.size == 0
|
56
|
+
raise AwsXRegionSyncConfigError, "No EC2 Images were found using the filter settings for identifier '#{self.sync_name}'."
|
57
|
+
end
|
58
|
+
|
59
|
+
{destination_region: destination_region, source_region: source_region, image: images[0]}
|
60
|
+
end
|
61
|
+
|
62
|
+
def needs_sync? destination_region, image
|
63
|
+
source_tags = image.tags.to_a
|
64
|
+
|
65
|
+
# Look for a tag indicating the image has been synced to the destination region
|
66
|
+
dest_ami_id = find_destination_ami source_tags, destination_region.name
|
67
|
+
!image_exists(destination_region, dest_ami_id)
|
68
|
+
end
|
69
|
+
|
70
|
+
def ec2_client aws_client_config
|
71
|
+
AWS::EC2.new(aws_client_config)
|
72
|
+
end
|
73
|
+
|
74
|
+
def image_exists destination_region, ami_id
|
75
|
+
image = ami_id ? destination_region.images[ami_id] : nil
|
76
|
+
!image.nil? && image.exists?
|
77
|
+
rescue
|
78
|
+
false
|
79
|
+
end
|
80
|
+
|
81
|
+
def parse_filters sync_identifier, config_filters
|
82
|
+
filters = []
|
83
|
+
filters << ["tag:#{sync_tag_indicator}-Identifier", sync_identifier] if sync_identifier && sync_identifier.length > 0
|
84
|
+
if config_filters && config_filters.length > 0
|
85
|
+
filters = filters + parse_config_filters(config_filters)
|
86
|
+
end
|
87
|
+
filters
|
88
|
+
end
|
89
|
+
|
90
|
+
def validate_region ec2, region_id
|
91
|
+
region = ec2.regions[region_id]
|
92
|
+
|
93
|
+
# AWS does an actual HTTP query to establish if the region exists, and then doesn't catch any socket errors
|
94
|
+
# if it fails. Since the aws gem directly uses the value you pass for the region as part of the http host it
|
95
|
+
# calls the gem ends up raising a naming exception when you used a bad region name instead of just returning
|
96
|
+
# false that the region doesn't exist
|
97
|
+
exists = false
|
98
|
+
begin
|
99
|
+
exists = region.exists?
|
100
|
+
rescue
|
101
|
+
end
|
102
|
+
|
103
|
+
raise AwsXRegionSyncConfigError, "Invalid region code of '#{region_id}' found for identifier '#{self.sync_name}'. It either does not exist or the given credentials cannot access it." unless exists
|
104
|
+
region
|
105
|
+
end
|
106
|
+
|
107
|
+
def find_destination_ami tags, region
|
108
|
+
# We're expecting the tags to be like [["Key1", "Value1"], ["Key2", "Value2"]]
|
109
|
+
# which is how to_a on a TagCollection object retutns the values
|
110
|
+
key = create_sync_tag(region, nil)[:key]
|
111
|
+
tag_value = parse_sync_tag_value(tags.detect(->(){[]}) {|tag| tag[0] == key}[1])
|
112
|
+
tag_value[:resource_identifier]
|
113
|
+
end
|
114
|
+
end
|
115
|
+
end
|
@@ -0,0 +1,202 @@
|
|
1
|
+
class AwsXRegionSync
|
2
|
+
class RdsAutomatedSnapshotSync < AwsSync
|
3
|
+
|
4
|
+
def validate_config
|
5
|
+
raise AwsXRegionSyncConfigError, "The #{self.sync_name} configuration must provide a 'db_instance' option to use to locate automated snapshots." unless config['db_instance'] && config['db_instance'].length > 0
|
6
|
+
# This call will raise if there's an invalid value..so just call it right now
|
7
|
+
max_snapshots
|
8
|
+
super
|
9
|
+
end
|
10
|
+
|
11
|
+
def sync
|
12
|
+
setup = pre_sync_setup
|
13
|
+
sync_snapshot_to_region setup[:source_region], setup[:destination_region], setup[:snapshot]
|
14
|
+
end
|
15
|
+
|
16
|
+
def sync_required?
|
17
|
+
setup = pre_sync_setup
|
18
|
+
sync = needs_sync? setup[:destination_region], setup[:source_region], setup[:snapshot], discover_aws_account_id
|
19
|
+
sync[:sync_required]
|
20
|
+
end
|
21
|
+
|
22
|
+
def sync_snapshot_to_region source_region, destination_region, source_snapshot
|
23
|
+
# The way automated snapshot copying seems to work is as long as you're copying snapshot from the same db instance between regions
|
24
|
+
# every snapshot you copy from the source region after the first one is simply just an incremental copy. Because of this we
|
25
|
+
# should copy the latest snapshot
|
26
|
+
|
27
|
+
# This first thing we should do is look for a sync tag in the destination snapshot (if present) and see we've already synced this
|
28
|
+
# snapshot-id (or if the id is outdated)
|
29
|
+
aws_account_id = discover_aws_account_id
|
30
|
+
|
31
|
+
sync = needs_sync?(destination_region, source_region, source_snapshot, aws_account_id)
|
32
|
+
if sync[:sync_required]
|
33
|
+
sr = region(source_region)
|
34
|
+
result = destination_region.client.copy_db_snapshot source_db_snapshot_identifier: arn(sr, aws_account_id, source_snapshot.id), target_db_snapshot_identifier: sanitize_snapshot_id(source_snapshot.id)
|
35
|
+
|
36
|
+
if result[:db_snapshot_identifier]
|
37
|
+
destination_snapshot_id = result[:db_snapshot_identifier]
|
38
|
+
|
39
|
+
# Move the source snapshot's created timestamp and id to the destination snapshot's sync tag so that we know the last time
|
40
|
+
# the snapshot was synced and what region it was synced from. Also, log the sync against the source too so we have tangible evidence
|
41
|
+
# that it was synced just by looking at it as well.
|
42
|
+
dr = region(destination_region)
|
43
|
+
|
44
|
+
dest_sync_tag = create_sync_tag sr, source_snapshot.id, timestamp: source_snapshot.created_at, sync_subtype: "From"
|
45
|
+
source_sync_tag = create_sync_tag dr, destination_snapshot_id, timestamp: source_snapshot.created_at
|
46
|
+
|
47
|
+
destination_region.client.add_tags_to_resource resource_name: arn(dr, aws_account_id, result[:db_snapshot_identifier]), tags: [dest_sync_tag]
|
48
|
+
source_region.client.add_tags_to_resource resource_name: arn(sr, aws_account_id, source_snapshot.id), tags: [source_sync_tag]
|
49
|
+
|
50
|
+
# We'll now clean up older snapshots if necessary
|
51
|
+
cleanup_old_snapshots sync[:snapshots], max_snapshots, destination_region
|
52
|
+
end
|
53
|
+
end
|
54
|
+
|
55
|
+
destination_snapshot_id
|
56
|
+
end
|
57
|
+
|
58
|
+
private
|
59
|
+
|
60
|
+
def pre_sync_setup
|
61
|
+
# There doesn't appear to be a direct way of actually obtaining
|
62
|
+
# a region reference after initializing the rds client - like there is for the s3 client
|
63
|
+
# so we'll just create multiple clients.
|
64
|
+
source_rds = rds_client config['source_region'], config['db_instance']
|
65
|
+
destination_rds = rds_client config['destination_region'], config['db_instance']
|
66
|
+
|
67
|
+
instance = source_rds.db_instances[config['db_instance']]
|
68
|
+
raise AwsXRegionSyncConfigError, "No DB Instance with identifier '#{config['db_instance']}' is available for these credentials in region #{config['db_instance']}." unless instance
|
69
|
+
|
70
|
+
# Find all the snapshots
|
71
|
+
snapshots = instance.snapshots.with_type('automated').to_a
|
72
|
+
raise AwsXRegionSyncConfigError, "No automated snapshots for db '#{config['db_instance']}' are available for these credentials in region #{config['source_region']}." unless snapshots.size > 0
|
73
|
+
|
74
|
+
# Use the created_at attribute of the snapshot to find the newest one
|
75
|
+
newest_snapshot = extract_newest_snapshot snapshots
|
76
|
+
|
77
|
+
{source_region: source_rds, destination_region: destination_rds, snapshot: newest_snapshot}
|
78
|
+
end
|
79
|
+
|
80
|
+
def needs_sync? destination_region, source_region, source_snapshot, aws_account_id
|
81
|
+
destination_snapshots = retrieve_destination_snapshots_for_instance destination_region, region(source_region), source_snapshot.db_instance.id, aws_account_id
|
82
|
+
sync_needed = destination_snapshots.size == 0 || snapshot_needs_copying(destination_snapshots, source_snapshot)
|
83
|
+
|
84
|
+
{sync_required: sync_needed, snapshots: destination_snapshots}
|
85
|
+
end
|
86
|
+
|
87
|
+
def rds_client region, db_instance
|
88
|
+
# There doesn't appear to be a direct way of actually obtaining
|
89
|
+
# a region reference after initializing an rds client object (unlike w/ the EC2 API)
|
90
|
+
rds = make_rds aws_config.merge({region: region})
|
91
|
+
# Just force an API call to validate the region setting. Use the db instance call which we'll use later and, I believe, is cached
|
92
|
+
# so there should be no penalty for calling it here (since we use it pretty much directly after this call anyway)
|
93
|
+
rds.db_instances[db_instance]
|
94
|
+
rds
|
95
|
+
rescue
|
96
|
+
raise AwsXRegionSyncConfigError, "Region '#{region}' is invalid. It either does not exist or the given credentials cannot access it."
|
97
|
+
end
|
98
|
+
|
99
|
+
def make_rds config
|
100
|
+
# purely for mocking
|
101
|
+
AWS::RDS.new config
|
102
|
+
end
|
103
|
+
|
104
|
+
def extract_newest_snapshot snapshots
|
105
|
+
# Grab the snapshot created at time for snapshot as the hash key, then we can sort on the keys and extract the last one from that sorted array
|
106
|
+
snapshot_values = {}
|
107
|
+
snapshots.each {|s| snapshot_values[s.created_at] = s}
|
108
|
+
snapshot_values[snapshot_values.keys.sort[-1]]
|
109
|
+
end
|
110
|
+
|
111
|
+
def arn region, account_number, snapshot_id
|
112
|
+
# Strip all non-decimal characters from account numbers
|
113
|
+
account_number = account_number.gsub(/\D/, "")
|
114
|
+
"arn:aws:rds:#{region}:#{account_number}:snapshot:#{snapshot_id}"
|
115
|
+
end
|
116
|
+
|
117
|
+
def region client
|
118
|
+
client.config.region
|
119
|
+
end
|
120
|
+
|
121
|
+
def snapshot_needs_copying destination_snapshots, source_snapshot
|
122
|
+
# The snapshot array is already sorted, so the last value from the array is the newest one
|
123
|
+
newest_snapshot_hash = destination_snapshots.last
|
124
|
+
source_snapshot.created_at.to_i > newest_snapshot_hash[:sync_timestamp].to_i
|
125
|
+
end
|
126
|
+
|
127
|
+
def tags_for_snapshot rds, account_number, snapshot_id
|
128
|
+
region_client = rds.client
|
129
|
+
region_name = region(region_client)
|
130
|
+
|
131
|
+
aws_resource = arn(region_name, account_number, snapshot_id)
|
132
|
+
result = region_client.list_tags_for_resource resource_name: arn(region_name, account_number, snapshot_id)
|
133
|
+
result[:tag_list]
|
134
|
+
end
|
135
|
+
|
136
|
+
def retrieve_destination_snapshots_for_instance destination_region, source_region_name, db_instance, account_number
|
137
|
+
snapshots = destination_region.db_instances[db_instance].snapshots.with_type('manual').to_a
|
138
|
+
|
139
|
+
# order them newest to oldest based on their sync tags
|
140
|
+
snapshot_list = []
|
141
|
+
snapshots.each do |s|
|
142
|
+
tags = tags_for_snapshot(destination_region, account_number, s.id)
|
143
|
+
|
144
|
+
sync_tag = find_sync_tag tags, source_region_name, db_instance
|
145
|
+
# Ignore any snapshot that doesn't have a sync tag, these are likely actual user initiated manual copies and we don't want to mess with them
|
146
|
+
next unless sync_tag
|
147
|
+
|
148
|
+
split_sync_value = parse_sync_tag_value(sync_tag[:value]) if sync_tag
|
149
|
+
snapshot_list << {snapshot: s, tags: tags, :sync_timestamp => split_sync_value[:timestamp], :sync_tag=>sync_tag}
|
150
|
+
end
|
151
|
+
|
152
|
+
snapshot_list.sort_by {|snapshot| snapshot[:sync_timestamp]}
|
153
|
+
end
|
154
|
+
|
155
|
+
def make_to_snapshot_sync_tag_key source_region
|
156
|
+
create_sync_tag(source_region, nil, sync_subtype: "From")[:key]
|
157
|
+
end
|
158
|
+
|
159
|
+
def find_sync_tag tags, source_region_name, db_instance
|
160
|
+
sync_key = make_to_snapshot_sync_tag_key source_region_name
|
161
|
+
|
162
|
+
tags.each do |t|
|
163
|
+
return t if t[:key] == sync_key
|
164
|
+
end
|
165
|
+
|
166
|
+
nil
|
167
|
+
end
|
168
|
+
|
169
|
+
def sanitize_snapshot_id id
|
170
|
+
# The official "rules" for identifiers are:
|
171
|
+
# Identifiers must begin with a letter; must contain only ASCII letters, digits, and hyphens;"
|
172
|
+
# and must not end with a hyphen or contain two consecutive hyphens
|
173
|
+
# Ids for automated snapshots appear to look like "rds:db_instance-YYYY-mm-dd-HH-MM"..that hyphen is invalid
|
174
|
+
# For now, just translate any : to -
|
175
|
+
id.gsub(":", "-")
|
176
|
+
end
|
177
|
+
|
178
|
+
def cleanup_old_snapshots destination_snapshots, max_snapshots_to_retain, destination_region
|
179
|
+
if destination_snapshots.size > max_snapshots_to_retain
|
180
|
+
# Grab the first max_snapshots # of destination_snapshots (since they're sorted already in ascending order) and just delete them
|
181
|
+
snapshots_to_delete = destination_snapshots.take(destination_snapshots.size - max_snapshots_to_retain)
|
182
|
+
|
183
|
+
snapshots_to_delete.each do |snapshot|
|
184
|
+
destination_region.client.delete_db_snapshot db_snapshot_identifier: snapshot[:snapshot].id
|
185
|
+
end
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
def max_snapshots
|
190
|
+
retain = nil
|
191
|
+
if config['max_snapshots_to_retain']
|
192
|
+
retain = Integer(config['max_snapshots_to_retain'])
|
193
|
+
else
|
194
|
+
retain = 2
|
195
|
+
end
|
196
|
+
|
197
|
+
retain
|
198
|
+
rescue
|
199
|
+
raise AwsXRegionSyncConfigError, "The #{self.sync_name} configuration must provide a valid 'max_snapshots_to_retain' option. '#{config['max_snapshots_to_retain']}' is not valid."
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
class AwsXRegionSync
|
2
|
+
class SyncResult
|
3
|
+
|
4
|
+
attr_reader :name, :completed, :created_resource, :errors
|
5
|
+
|
6
|
+
def initialize name, completed, created_resource, errors = []
|
7
|
+
@name = name
|
8
|
+
@completed = completed
|
9
|
+
@created_resource = created_resource
|
10
|
+
@errors = errors ? errors : []
|
11
|
+
end
|
12
|
+
|
13
|
+
|
14
|
+
def failed?
|
15
|
+
!completed
|
16
|
+
end
|
17
|
+
|
18
|
+
def sync_required?
|
19
|
+
completed && created_resource == true
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
metadata
ADDED
@@ -0,0 +1,97 @@
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
2
|
+
name: aws_xregion_sync
|
3
|
+
version: !ruby/object:Gem::Version
|
4
|
+
version: '0.1'
|
5
|
+
platform: ruby
|
6
|
+
authors:
|
7
|
+
- Vandegrift Forwarding Company
|
8
|
+
autorequire:
|
9
|
+
bindir: bin
|
10
|
+
cert_chain: []
|
11
|
+
date: 2013-12-12 00:00:00.000000000 Z
|
12
|
+
dependencies:
|
13
|
+
- !ruby/object:Gem::Dependency
|
14
|
+
name: aws-sdk
|
15
|
+
requirement: !ruby/object:Gem::Requirement
|
16
|
+
requirements:
|
17
|
+
- - '>='
|
18
|
+
- !ruby/object:Gem::Version
|
19
|
+
version: '1.17'
|
20
|
+
type: :runtime
|
21
|
+
prerelease: false
|
22
|
+
version_requirements: !ruby/object:Gem::Requirement
|
23
|
+
requirements:
|
24
|
+
- - '>='
|
25
|
+
- !ruby/object:Gem::Version
|
26
|
+
version: '1.17'
|
27
|
+
- !ruby/object:Gem::Dependency
|
28
|
+
name: require_all
|
29
|
+
requirement: !ruby/object:Gem::Requirement
|
30
|
+
requirements:
|
31
|
+
- - '>='
|
32
|
+
- !ruby/object:Gem::Version
|
33
|
+
version: '0'
|
34
|
+
type: :runtime
|
35
|
+
prerelease: false
|
36
|
+
version_requirements: !ruby/object:Gem::Requirement
|
37
|
+
requirements:
|
38
|
+
- - '>='
|
39
|
+
- !ruby/object:Gem::Version
|
40
|
+
version: '0'
|
41
|
+
- !ruby/object:Gem::Dependency
|
42
|
+
name: rspec
|
43
|
+
requirement: !ruby/object:Gem::Requirement
|
44
|
+
requirements:
|
45
|
+
- - '>='
|
46
|
+
- !ruby/object:Gem::Version
|
47
|
+
version: '0'
|
48
|
+
type: :development
|
49
|
+
prerelease: false
|
50
|
+
version_requirements: !ruby/object:Gem::Requirement
|
51
|
+
requirements:
|
52
|
+
- - '>='
|
53
|
+
- !ruby/object:Gem::Version
|
54
|
+
version: '0'
|
55
|
+
description: Sync EC2 AMIs and RDS Snapshots across AWS regions as part of your Disaster
|
56
|
+
Recovery planning.
|
57
|
+
email:
|
58
|
+
- it-admin@vandegriftinc.com
|
59
|
+
executables: []
|
60
|
+
extensions: []
|
61
|
+
extra_rdoc_files: []
|
62
|
+
files:
|
63
|
+
- lib/aws_xregion_sync.rb
|
64
|
+
- lib/aws_xregion_sync/aws_sync.rb
|
65
|
+
- lib/aws_xregion_sync/ec2_ami_sync.rb
|
66
|
+
- lib/aws_xregion_sync/rds_automated_snapshot_sync.rb
|
67
|
+
- lib/aws_xregion_sync/configure.rb
|
68
|
+
- lib/aws_xregion_sync/errors.rb
|
69
|
+
- lib/aws_xregion_sync/sync_result.rb
|
70
|
+
- lib/aws_xregion_sync/version.rb
|
71
|
+
- LICENSE.txt
|
72
|
+
- README.md
|
73
|
+
homepage: http://github.com/Vandegrift/aws_xregion_sync
|
74
|
+
licenses:
|
75
|
+
- MIT
|
76
|
+
metadata: {}
|
77
|
+
post_install_message:
|
78
|
+
rdoc_options: []
|
79
|
+
require_paths:
|
80
|
+
- lib
|
81
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
82
|
+
requirements:
|
83
|
+
- - '>='
|
84
|
+
- !ruby/object:Gem::Version
|
85
|
+
version: '2'
|
86
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
87
|
+
requirements:
|
88
|
+
- - '>='
|
89
|
+
- !ruby/object:Gem::Version
|
90
|
+
version: '1.5'
|
91
|
+
requirements: []
|
92
|
+
rubyforge_project:
|
93
|
+
rubygems_version: 2.0.7
|
94
|
+
signing_key:
|
95
|
+
specification_version: 4
|
96
|
+
summary: Simple tool to help sync Amazon resources across regions.
|
97
|
+
test_files: []
|