From 672acbea683b74bfb5951dca0e4dee0fc5dd207f Mon Sep 17 00:00:00 2001 From: John Kerkstra Date: Mon, 3 Dec 2018 02:34:53 -0600 Subject: [PATCH] Adds `redshift_cross_region_snapshots` module (#35527) * add redshift_cross_region_snapshots module, unit tests. * fix errors * use ec2_argument_spec as the basis for the argument spec. fixed metadata_version * follow best practices by naming example tasks. * code review changes * fix linting errors * Update version added --- .../amazon/redshift_cross_region_snapshots.py | 201 ++++++++++++++++++ .../test_redshift_cross_region_snapshots.py | 48 +++++ 2 files changed, 249 insertions(+) create mode 100644 lib/ansible/modules/cloud/amazon/redshift_cross_region_snapshots.py create mode 100644 test/units/modules/cloud/amazon/test_redshift_cross_region_snapshots.py diff --git a/lib/ansible/modules/cloud/amazon/redshift_cross_region_snapshots.py b/lib/ansible/modules/cloud/amazon/redshift_cross_region_snapshots.py new file mode 100644 index 0000000000..7e107d0ddf --- /dev/null +++ b/lib/ansible/modules/cloud/amazon/redshift_cross_region_snapshots.py @@ -0,0 +1,201 @@ +#!/usr/bin/python + +# Copyright: (c) 2018, JR Kerkstra +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +ANSIBLE_METADATA = {'status': ['preview'], + 'supported_by': 'community', + 'metadata_version': '1.1'} + +DOCUMENTATION = ''' +--- +module: redshift_cross_region_snapshots +short_description: Manage Redshift Cross Region Snapshots +description: + - Manage Redshift Cross Region Snapshots. Supports KMS-Encrypted Snapshots. + - For more information, see https://docs.aws.amazon.com/redshift/latest/mgmt/working-with-snapshots.html#cross-region-snapshot-copy +version_added: "2.8" +author: JR Kerkstra (@captainkerk) +options: + cluster_name: + description: + - The name of the cluster to configure cross-region snapshots for. + required: true + aliases: [ "cluster" ] + state: + description: + - Create or remove the cross-region snapshot configuration. + required: true + choices: [ "present", "absent" ] + default: present + region: + description: + - The clusters region + required: true + aliases: [ "source" ] + destination_region: + description: + - The region to copy snapshots to + required: true + aliases: [ "destination" ] + snapshot_copy_grant: + description: + - A grant for Amazon Redshift to use a master key in the destination region. + - See http://boto3.readthedocs.io/en/latest/reference/services/redshift.html#Redshift.Client.create_snapshot_copy_grant + required: false + aliases: [ "copy_grant" ] + snapshot_retention_period: + description: + - Keep cross-region snapshots for N number of days + required: true + aliases: [ "retention_period" ] +requirements: [ "botocore", "boto3" ] +extends_documentation_fragment: + - ec2 + - aws +''' + +EXAMPLES = ''' +- name: configure cross-region snapshot on cluster `johniscool` + redshift_cross_region_snapshots: + cluster_name: johniscool + state: present + region: us-east-1 + destination_region: us-west-2 + retention_period: 1 + +- name: configure cross-region snapshot on kms-encrypted cluster + redshift_cross_region_snapshots: + cluster_name: whatever + state: present + source: us-east-1 + destination: us-west-2 + copy_grant: 'my-grant-in-destination' + retention_period: 10 + +- name: disable cross-region snapshots, necessary before most cluster modifications (rename, resize) + redshift_cross_region_snapshots: + cluster_name: whatever + state: absent + region: us-east-1 + destination_region: us-west-2 +''' + +RETURN = ''' # ''' + +from ansible.module_utils.aws.core import AnsibleAWSModule +from ansible.module_utils.ec2 import ec2_argument_spec + + +class SnapshotController(object): + + def __init__(self, client, cluster_name): + self.client = client + self.cluster_name = cluster_name + + def get_cluster_snapshot_copy_status(self): + response = self.client.describe_clusters( + ClusterIdentifier=self.cluster_name + ) + return response['Clusters'][0].get('ClusterSnapshotCopyStatus') + + def enable_snapshot_copy(self, destination_region, grant_name, retention_period): + if grant_name: + self.client.enable_snapshot_copy( + ClusterIdentifier=self.cluster_name, + DestinationRegion=destination_region, + RetentionPeriod=retention_period, + SnapshotCopyGrantName=grant_name, + ) + else: + self.client.enable_snapshot_copy( + ClusterIdentifier=self.cluster_name, + DestinationRegion=destination_region, + RetentionPeriod=retention_period, + ) + + def disable_snapshot_copy(self): + self.client.disable_snapshot_copy( + ClusterIdentifier=self.cluster_name + ) + + def modify_snapshot_copy_retention_period(self, retention_period): + self.client.modify_snapshot_copy_retention_period( + ClusterIdentifier=self.cluster_name, + RetentionPeriod=retention_period + ) + + +def requesting_unsupported_modifications(actual, requested): + if (actual['SnapshotCopyGrantName'] != requested['snapshot_copy_grant'] or + actual['DestinationRegion'] != requested['destination_region']): + return True + return False + + +def needs_update(actual, requested): + if actual['RetentionPeriod'] != requested['snapshot_retention_period']: + return True + return False + + +def run_module(): + argument_spec = ec2_argument_spec() + argument_spec.update( + dict( + cluster_name=dict(type='str', required=True, aliases=['cluster']), + state=dict(type='str', choices=['present', 'absent'], default='present'), + region=dict(type='str', required=True, aliases=['source']), + destination_region=dict(type='str', required=True, aliases=['destination']), + snapshot_copy_grant=dict(type='str', aliases=['copy_grant']), + snapshot_retention_period=dict(type='int', required=True, aliases=['retention_period']), + ) + ) + + module = AnsibleAWSModule( + argument_spec=argument_spec, + supports_check_mode=True + ) + + result = dict( + changed=False, + message='' + ) + connection = module.client('redshift') + + snapshot_controller = SnapshotController(client=connection, + cluster_name=module.params.get('cluster_name')) + + current_config = snapshot_controller.get_cluster_snapshot_copy_status() + if current_config is not None: + if module.params.get('state') == 'present': + if requesting_unsupported_modifications(current_config, module.params): + message = 'Cannot modify destination_region or grant_name. ' \ + 'Please disable cross-region snapshots, and re-run.' + module.fail_json(msg=message, **result) + if needs_update(current_config, module.params): + result['changed'] = True + if not module.check_mode: + snapshot_controller.modify_snapshot_copy_retention_period( + module.params.get('snapshot_retention_period') + ) + else: + result['changed'] = True + if not module.check_mode: + snapshot_controller.disable_snapshot_copy() + else: + if module.params.get('state') == 'present': + result['changed'] = True + if not module.check_mode: + snapshot_controller.enable_snapshot_copy(module.params.get('destination_region'), + module.params.get('snapshot_copy_grant'), + module.params.get('snapshot_retention_period')) + module.exit_json(**result) + + +def main(): + run_module() + + +if __name__ == '__main__': + main() diff --git a/test/units/modules/cloud/amazon/test_redshift_cross_region_snapshots.py b/test/units/modules/cloud/amazon/test_redshift_cross_region_snapshots.py new file mode 100644 index 0000000000..4a00a20691 --- /dev/null +++ b/test/units/modules/cloud/amazon/test_redshift_cross_region_snapshots.py @@ -0,0 +1,48 @@ +from ansible.modules.cloud.amazon import redshift_cross_region_snapshots as rcrs + +mock_status_enabled = { + 'SnapshotCopyGrantName': 'snapshot-us-east-1-to-us-west-2', + 'DestinationRegion': 'us-west-2', + 'RetentionPeriod': 1, +} + +mock_status_disabled = {} + +mock_request_illegal = { + 'snapshot_copy_grant': 'changed', + 'destination_region': 'us-west-2', + 'snapshot_retention_period': 1 +} + +mock_request_update = { + 'snapshot_copy_grant': 'snapshot-us-east-1-to-us-west-2', + 'destination_region': 'us-west-2', + 'snapshot_retention_period': 3 +} + +mock_request_no_update = { + 'snapshot_copy_grant': 'snapshot-us-east-1-to-us-west-2', + 'destination_region': 'us-west-2', + 'snapshot_retention_period': 1 +} + + +def test_fail_at_unsupported_operations(): + response = rcrs.requesting_unsupported_modifications( + mock_status_enabled, mock_request_illegal + ) + assert response is True + + +def test_needs_update_true(): + response = rcrs.needs_update(mock_status_enabled, mock_request_update) + assert response is True + + +def test_no_change(): + response = rcrs.requesting_unsupported_modifications( + mock_status_enabled, mock_request_no_update + ) + needs_update_response = rcrs.needs_update(mock_status_enabled, mock_request_no_update) + assert response is False + assert needs_update_response is False