aws_xregion_sync 0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -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
@@ -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.
@@ -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,7 @@
1
+ class AwsXRegionSync
2
+ class AwsXRegionSyncError < StandardError
3
+ end
4
+
5
+ class AwsXRegionSyncConfigError < AwsXRegionSyncError
6
+ end
7
+ 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
@@ -0,0 +1,3 @@
1
+ class AwsXRegionSync
2
+ VERSION ||= "0.1"
3
+ 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: []