From c8467181d6ad2b208d5bd0c109bdddae282e0dcc Mon Sep 17 00:00:00 2001 From: Joseph Torcasso <87090265+jatorcasso@users.noreply.github.com> Date: Wed, 25 May 2022 00:41:29 -0400 Subject: [PATCH] rds_instance_snapshot - add copy snapshot functionality (#1078) rds_instance_snapshot - add copy snapshot functionality Depends-On: ansible-collections/amazon.aws#776 Depends-On: #1116 SUMMARY Add support for copying a snapshot Fixes #210 Don't require db_instance_identifier on state = present (only required for creation) ISSUE TYPE Feature Pull Request COMPONENT NAME rds_instance_snapshot Reviewed-by: Markus Bergholz Reviewed-by: Joseph Torcasso Reviewed-by: Alina Buzachis This commit was initially merged in https://github.com/ansible-collections/community.aws See: https://github.com/ansible-collections/community.aws/commit/d04ab42766f82d928440db9c84dfb9bb23039326 --- plugins/modules/rds_instance_snapshot.py | 163 +++++++++++---- .../rds_instance_snapshot/defaults/main.yml | 6 +- .../rds_instance_snapshot/tasks/main.yml | 193 +++++++++++++++--- 3 files changed, 294 insertions(+), 68 deletions(-) diff --git a/plugins/modules/rds_instance_snapshot.py b/plugins/modules/rds_instance_snapshot.py index 2fa30f92d09..0d7a50a06e7 100644 --- a/plugins/modules/rds_instance_snapshot.py +++ b/plugins/modules/rds_instance_snapshot.py @@ -32,15 +32,37 @@ type: str db_instance_identifier: description: - - Database instance identifier. Required when state is present. + - Database instance identifier. Required when creating a snapshot. aliases: - instance_id type: str + source_db_snapshot_identifier: + description: + - The identifier of the source DB snapshot. + - Required when copying a snapshot. + - If the source snapshot is in the same AWS region as the copy, specify the snapshot's identifier. + - If the source snapshot is in a different AWS region as the copy, specify the snapshot's ARN. + aliases: + - source_id + - source_snapshot_id + type: str + version_added: 3.3.0 + source_region: + description: + - The region that contains the snapshot to be copied. + type: str + version_added: 3.3.0 + copy_tags: + description: + - Whether to copy all tags from I(source_db_snapshot_identifier) to I(db_instance_identifier). + type: bool + default: False + version_added: 3.3.0 wait: description: - Whether or not to wait for snapshot creation or deletion. type: bool - default: 'no' + default: False wait_timeout: description: - how long before wait gives up, in seconds. @@ -52,13 +74,14 @@ type: dict purge_tags: description: - - whether to remove tags not present in the C(tags) parameter. + - whether to remove tags not present in the I(tags) parameter. default: True type: bool author: - "Will Thames (@willthames)" - "Michael De La Rue (@mikedlr)" - "Alina Buzachis (@alinabuzachis)" + - "Joseph Torcasso (@jatorcasso)" extends_documentation_fragment: - amazon.aws.aws - amazon.aws.ec2 @@ -70,6 +93,15 @@ community.aws.rds_instance_snapshot: db_instance_identifier: new-database db_snapshot_identifier: new-database-snapshot + register: snapshot + +- name: Copy snapshot from a different region and copy its tags + community.aws.rds_instance_snapshot: + id: new-database-snapshot-copy + region: us-east-1 + source_id: "{{ snapshot.db_snapshot_arn }}" + source_region: us-east-2 + copy_tags: yes - name: Delete snapshot community.aws.rds_instance_snapshot: @@ -163,6 +195,12 @@ returned: always type: list sample: [] +source_db_snapshot_identifier: + description: The DB snapshot ARN that the DB snapshot was copied from. + returned: when snapshot is a copy + type: str + sample: arn:aws:rds:us-west-2:123456789012:snapshot:ansible-test-16638696-test-snapshot-source + version_added: 3.3.0 snapshot_create_time: description: Creation time of the snapshot. returned: always @@ -202,31 +240,41 @@ # import module snippets from ansible_collections.amazon.aws.plugins.module_utils.core import AnsibleAWSModule +from ansible_collections.amazon.aws.plugins.module_utils.core import get_boto3_client_method_parameters from ansible_collections.amazon.aws.plugins.module_utils.core import is_boto3_error_code +from ansible_collections.amazon.aws.plugins.module_utils.ec2 import ansible_dict_to_boto3_tag_list from ansible_collections.amazon.aws.plugins.module_utils.ec2 import AWSRetry from ansible_collections.amazon.aws.plugins.module_utils.ec2 import camel_dict_to_snake_dict -from ansible_collections.amazon.aws.plugins.module_utils.ec2 import ansible_dict_to_boto3_tag_list -from ansible_collections.amazon.aws.plugins.module_utils.rds import get_tags -from ansible_collections.amazon.aws.plugins.module_utils.rds import ensure_tags +from ansible_collections.amazon.aws.plugins.module_utils.rds import arg_spec_to_rds_params from ansible_collections.amazon.aws.plugins.module_utils.rds import call_method +from ansible_collections.amazon.aws.plugins.module_utils.rds import ensure_tags +from ansible_collections.amazon.aws.plugins.module_utils.rds import get_rds_method_attribute +from ansible_collections.amazon.aws.plugins.module_utils.rds import get_tags def get_snapshot(snapshot_id): try: - response = client.describe_db_snapshots(DBSnapshotIdentifier=snapshot_id) - except is_boto3_error_code("DBSnapshotNotFoundFault"): - return None - except is_boto3_error_code("DBSnapshotNotFound"): # pylint: disable=duplicate-except - return None + snapshot = client.describe_db_snapshots(DBSnapshotIdentifier=snapshot_id)['DBSnapshots'][0] + snapshot['Tags'] = get_tags(client, module, snapshot['DBSnapshotArn']) + except is_boto3_error_code("DBSnapshotNotFound"): + return {} except (botocore.exceptions.BotoCoreError, botocore.exceptions.ClientError) as e: # pylint: disable=duplicate-except module.fail_json_aws(e, msg="Couldn't get snapshot {0}".format(snapshot_id)) - return response['DBSnapshots'][0] + return snapshot -def fetch_tags(snapshot): - snapshot["Tags"] = get_tags(client, module, snapshot["DBSnapshotArn"]) +def get_parameters(parameters, method_name): + if method_name == 'copy_db_snapshot': + parameters['TargetDBSnapshotIdentifier'] = module.params['db_snapshot_identifier'] - return camel_dict_to_snake_dict(snapshot, ignore_list=["Tags"]) + required_options = get_boto3_client_method_parameters(client, method_name, required=True) + if any(parameters.get(k) is None for k in required_options): + module.fail_json(msg='To {0} requires the parameters: {1}'.format( + get_rds_method_attribute(method_name, module).operation_description, required_options)) + options = get_boto3_client_method_parameters(client, method_name) + parameters = dict((k, v) for k, v in parameters.items() if k in options and v is not None) + + return parameters def ensure_snapshot_absent(): @@ -236,40 +284,68 @@ def ensure_snapshot_absent(): snapshot = get_snapshot(snapshot_name) if not snapshot: - return dict(changed=changed) + module.exit_json(changed=changed) elif snapshot and snapshot["Status"] != "deleting": snapshot, changed = call_method(client, module, "delete_db_snapshot", params) - return dict(changed=changed) + module.exit_json(changed=changed) -def ensure_snapshot_present(): - db_instance_identifier = module.params.get('db_instance_identifier') +def ensure_snapshot_present(params): + source_id = module.params.get('source_db_snapshot_identifier') snapshot_name = module.params.get('db_snapshot_identifier') changed = False snapshot = get_snapshot(snapshot_name) + + # Copy snapshot + if source_id: + changed |= copy_snapshot(params) + + # Create snapshot + elif not snapshot: + changed |= create_snapshot(params) + + # Snapshot exists and we're not creating a copy - modify exising snapshot + else: + changed |= modify_snapshot() + + snapshot = get_snapshot(snapshot_name) + module.exit_json(changed=changed, **camel_dict_to_snake_dict(snapshot, ignore_list=['Tags'])) + + +def create_snapshot(params): + method_params = get_parameters(params, 'create_db_snapshot') + if method_params.get('Tags'): + method_params['Tags'] = ansible_dict_to_boto3_tag_list(method_params['Tags']) + snapshot, changed = call_method(client, module, 'create_db_snapshot', method_params) + + return changed + + +def copy_snapshot(params): + changed = False + snapshot_id = module.params.get('db_snapshot_identifier') + snapshot = get_snapshot(snapshot_id) + if not snapshot: - params = { - "DBSnapshotIdentifier": snapshot_name, - "DBInstanceIdentifier": db_instance_identifier - } - if module.params.get("tags"): - params['Tags'] = ansible_dict_to_boto3_tag_list(module.params.get("tags")) - _result, changed = call_method(client, module, "create_db_snapshot", params) + method_params = get_parameters(params, 'copy_db_snapshot') + if method_params.get('Tags'): + method_params['Tags'] = ansible_dict_to_boto3_tag_list(method_params['Tags']) + result, changed = call_method(client, module, 'copy_db_snapshot', method_params) - if module.check_mode: - return dict(changed=changed) + return changed - return dict(changed=changed, **fetch_tags(get_snapshot(snapshot_name))) - existing_tags = get_tags(client, module, snapshot["DBSnapshotArn"]) - changed |= ensure_tags(client, module, snapshot["DBSnapshotArn"], existing_tags, - module.params["tags"], module.params["purge_tags"]) +def modify_snapshot(): + # TODO - add other modifications aside from purely tags + changed = False + snapshot_id = module.params.get('db_snapshot_identifier') + snapshot = get_snapshot(snapshot_id) - if module.check_mode: - return dict(changed=changed) + if module.params.get('tags'): + changed |= ensure_tags(client, module, snapshot['DBSnapshotArn'], snapshot['Tags'], module.params['tags'], module.params['purge_tags']) - return dict(changed=changed, **fetch_tags(get_snapshot(snapshot_name))) + return changed def main(): @@ -280,16 +356,18 @@ def main(): state=dict(choices=['present', 'absent'], default='present'), db_snapshot_identifier=dict(aliases=['id', 'snapshot_id'], required=True), db_instance_identifier=dict(aliases=['instance_id']), + source_db_snapshot_identifier=dict(aliases=['source_id', 'source_snapshot_id']), wait=dict(type='bool', default=False), wait_timeout=dict(type='int', default=300), tags=dict(type='dict'), purge_tags=dict(type='bool', default=True), + copy_tags=dict(type='bool', default=False), + source_region=dict(type='str'), ) module = AnsibleAWSModule( argument_spec=argument_spec, - required_if=[['state', 'present', ['db_instance_identifier']]], - supports_check_mode=True, + supports_check_mode=True ) retry_decorator = AWSRetry.jittered_backoff(retries=10) @@ -298,12 +376,13 @@ def main(): except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: module.fail_json_aws(e, msg="Failed to connect to AWS.") - if module.params['state'] == 'absent': - ret_dict = ensure_snapshot_absent() - else: - ret_dict = ensure_snapshot_present() + state = module.params.get("state") + if state == 'absent': + ensure_snapshot_absent() - module.exit_json(**ret_dict) + elif state == 'present': + params = arg_spec_to_rds_params(dict((k, module.params[k]) for k in module.params if k in argument_spec)) + ensure_snapshot_present(params) if __name__ == '__main__': diff --git a/tests/integration/targets/rds_instance_snapshot/defaults/main.yml b/tests/integration/targets/rds_instance_snapshot/defaults/main.yml index 68676559650..235bc5ba2a0 100644 --- a/tests/integration/targets/rds_instance_snapshot/defaults/main.yml +++ b/tests/integration/targets/rds_instance_snapshot/defaults/main.yml @@ -2,13 +2,13 @@ # defaults file for rds_instance_snapshot # Create RDS instance -instance_id: 'ansible-test-instance-{{ resource_prefix }}' +instance_id: '{{ resource_prefix }}-instance' username: 'testrdsusername' -password: 'test-rds_password' +password: "{{ lookup('password', '/dev/null') }}" db_instance_class: db.t3.micro allocated_storage: 10 engine: 'mariadb' mariadb_engine_version: 10.3.31 # Create snapshot -snapshot_id: 'ansible-test-instance-snapshot-{{ resource_prefix }}' +snapshot_id: '{{ instance_id }}-snapshot' diff --git a/tests/integration/targets/rds_instance_snapshot/tasks/main.yml b/tests/integration/targets/rds_instance_snapshot/tasks/main.yml index f26cdae9df5..c639291a54d 100644 --- a/tests/integration/targets/rds_instance_snapshot/tasks/main.yml +++ b/tests/integration/targets/rds_instance_snapshot/tasks/main.yml @@ -27,12 +27,12 @@ that: - _result_create_instance.changed - _result_create_instance.db_instance_identifier == "{{ instance_id }}" - + - name: Get all RDS snapshots for the existing instance rds_snapshot_info: db_instance_identifier: "{{ instance_id }}" register: _result_instance_snapshot_info - + - assert: that: - _result_instance_snapshot_info is successful @@ -49,7 +49,7 @@ - assert: that: - _result_instance_snapshot.changed - + - name: Take a snapshot of the existing RDS instance rds_instance_snapshot: state: present @@ -89,18 +89,70 @@ - _result_instance_snapshot.storage_type == "gp2" - "'tags' in _result_instance_snapshot" - "'vpc_id' in _result_instance_snapshot" - + + - name: Take a snapshot of the existing RDS instance (CHECK_MODE - IDEMPOTENCE) + rds_instance_snapshot: + state: present + db_instance_identifier: "{{ instance_id }}" + db_snapshot_identifier: "{{ snapshot_id }}" + check_mode: yes + register: _result_instance_snapshot + + - assert: + that: + - not _result_instance_snapshot.changed + + - name: Take a snapshot of the existing RDS instance (IDEMPOTENCE) + rds_instance_snapshot: + state: present + db_instance_identifier: "{{ instance_id }}" + db_snapshot_identifier: "{{ snapshot_id }}" + wait: true + register: _result_instance_snapshot + + - assert: + that: + - not _result_instance_snapshot.changed + - "'availability_zone' in _result_instance_snapshot" + - "'instance_create_time' in _result_instance_snapshot" + - "'db_instance_identifier' in _result_instance_snapshot" + - _result_instance_snapshot.db_instance_identifier == "{{ instance_id }}" + - "'db_snapshot_identifier' in _result_instance_snapshot" + - _result_instance_snapshot.db_snapshot_identifier == "{{ snapshot_id }}" + - "'db_snapshot_arn' in _result_instance_snapshot" + - "'dbi_resource_id' in _result_instance_snapshot" + - "'encrypted' in _result_instance_snapshot" + - "'engine' in _result_instance_snapshot" + - _result_instance_snapshot.engine == "{{ engine }}" + - "'engine_version' in _result_instance_snapshot" + - _result_instance_snapshot.engine_version == "{{ mariadb_engine_version }}" + - "'iam_database_authentication_enabled' in _result_instance_snapshot" + - "'license_model' in _result_instance_snapshot" + - "'master_username' in _result_instance_snapshot" + - _result_instance_snapshot.master_username == "{{ username }}" + - "'snapshot_create_time' in _result_instance_snapshot" + - "'snapshot_type' in _result_instance_snapshot" + - "'status' in _result_instance_snapshot" + - _result_instance_snapshot.status == "available" + - "'snapshot_type' in _result_instance_snapshot" + - _result_instance_snapshot.snapshot_type == "manual" + - "'status' in _result_instance_snapshot" + - "'storage_type' in _result_instance_snapshot" + - _result_instance_snapshot.storage_type == "gp2" + - "'tags' in _result_instance_snapshot" + - "'vpc_id' in _result_instance_snapshot" + - name: Get information about the existing DB snapshot rds_snapshot_info: db_snapshot_identifier: "{{ snapshot_id }}" register: _result_instance_snapshot_info - + - assert: that: - _result_instance_snapshot_info is successful - _result_instance_snapshot_info.snapshots[0].db_instance_identifier == "{{ instance_id }}" - _result_instance_snapshot_info.snapshots[0].db_snapshot_identifier == "{{ snapshot_id }}" - + - name: Take another snapshot of the existing RDS instance rds_instance_snapshot: state: present @@ -140,38 +192,59 @@ - _result_instance_snapshot.storage_type == "gp2" - "'tags' in _result_instance_snapshot" - "'vpc_id' in _result_instance_snapshot" - + - name: Get all snapshots for the existing RDS instance rds_snapshot_info: db_instance_identifier: "{{ instance_id }}" register: _result_instance_snapshot_info - + - assert: that: - _result_instance_snapshot_info is successful #- _result_instance_snapshot_info.cluster_snapshots | length == 3 - + - name: Delete existing DB instance snapshot (CHECK_MODE) rds_instance_snapshot: state: absent db_snapshot_identifier: "{{ snapshot_id }}-b" register: _result_delete_snapshot check_mode: yes - + - assert: that: - _result_delete_snapshot.changed - + - name: Delete the existing DB instance snapshot rds_instance_snapshot: state: absent db_snapshot_identifier: "{{ snapshot_id }}-b" register: _result_delete_snapshot - + - assert: that: - _result_delete_snapshot.changed - + + - name: Delete existing DB instance snapshot (CHECK_MODE - IDEMPOTENCE) + rds_instance_snapshot: + state: absent + db_snapshot_identifier: "{{ snapshot_id }}-b" + register: _result_delete_snapshot + check_mode: yes + + - assert: + that: + - not _result_delete_snapshot.changed + + - name: Delete the existing DB instance snapshot (IDEMPOTENCE) + rds_instance_snapshot: + state: absent + db_snapshot_identifier: "{{ snapshot_id }}-b" + register: _result_delete_snapshot + + - assert: + that: + - not _result_delete_snapshot.changed + - name: Take another snapshot of the existing RDS instance and assign tags rds_instance_snapshot: state: present @@ -217,7 +290,7 @@ - _result_instance_snapshot.tags["tag_one"] == "{{ snapshot_id }}-b One" - _result_instance_snapshot.tags["Tag Two"] == "two {{ snapshot_id }}-b" - "'vpc_id' in _result_instance_snapshot" - + - name: Attempt to take another snapshot of the existing RDS instance and assign tags (idempotence) rds_instance_snapshot: state: present @@ -232,7 +305,7 @@ - assert: that: - not _result_instance_snapshot.changed - + - name: Take another snapshot of the existing RDS instance and update tags rds_instance_snapshot: state: present @@ -277,7 +350,7 @@ - _result_instance_snapshot.tags["tag_three"] == "{{ snapshot_id }}-b Three" - _result_instance_snapshot.tags["Tag Two"] == "two {{ snapshot_id }}-b" - "'vpc_id' in _result_instance_snapshot" - + - name: Take another snapshot of the existing RDS instance and update tags without purge rds_instance_snapshot: state: present @@ -323,7 +396,7 @@ - _result_instance_snapshot.tags["Tag Two"] == "two {{ snapshot_id }}-b" - _result_instance_snapshot.tags["tag_three"] == "{{ snapshot_id }}-b Three" - "'vpc_id' in _result_instance_snapshot" - + - name: Take another snapshot of the existing RDS instance and do not specify any tag to ensure previous tags are not removed rds_instance_snapshot: state: present @@ -334,25 +407,99 @@ - assert: that: - not _result_instance_snapshot.changed - - always: + + # ------------------------------------------------------------------------------------------ + # Test copying a snapshot + ### Note - copying a snapshot from a different region is supported, but not in CI runs, + ### because the aws-terminator only terminates resources in one region. + + - set_fact: + _snapshot_arn: "{{ _result_instance_snapshot.db_snapshot_arn }}" + + - name: Copy a snapshot (check mode) + rds_instance_snapshot: + id: "{{ snapshot_id }}-copy" + source_id: "{{ snapshot_id }}-b" + copy_tags: yes + wait: true + register: _result_instance_snapshot + check_mode: yes + + - assert: + that: + - _result_instance_snapshot.changed + + - name: Copy a snapshot + rds_instance_snapshot: + id: "{{ snapshot_id }}-copy" + source_id: "{{ snapshot_id }}-b" + copy_tags: yes + wait: true + register: _result_instance_snapshot + + - assert: + that: + - _result_instance_snapshot.changed + - _result_instance_snapshot.db_instance_identifier == "{{ instance_id }}" + - _result_instance_snapshot.source_db_snapshot_identifier == "{{ _snapshot_arn }}" + - _result_instance_snapshot.db_snapshot_identifier == "{{ snapshot_id }}-copy" + - "'tags' in _result_instance_snapshot" + - _result_instance_snapshot.tags | length == 3 + - _result_instance_snapshot.tags["tag_one"] == "{{ snapshot_id }}-b One" + - _result_instance_snapshot.tags["Tag Two"] == "two {{ snapshot_id }}-b" + - _result_instance_snapshot.tags["tag_three"] == "{{ snapshot_id }}-b Three" + + - name: Copy a snapshot (idempotence - check mode) + rds_instance_snapshot: + id: "{{ snapshot_id }}-copy" + source_id: "{{ snapshot_id }}-b" + copy_tags: yes + wait: true + register: _result_instance_snapshot + check_mode: yes + + - assert: + that: + - not _result_instance_snapshot.changed + + - name: Copy a snapshot (idempotence) + rds_instance_snapshot: + id: "{{ snapshot_id }}-copy" + source_id: "{{ snapshot_id }}-b" + copy_tags: yes + wait: true + register: _result_instance_snapshot + + - assert: + that: + - not _result_instance_snapshot.changed + - _result_instance_snapshot.db_instance_identifier == "{{ instance_id }}" + - _result_instance_snapshot.source_db_snapshot_identifier == "{{ _snapshot_arn }}" + - _result_instance_snapshot.db_snapshot_identifier == "{{ snapshot_id }}-copy" + - "'tags' in _result_instance_snapshot" + - _result_instance_snapshot.tags | length == 3 + - _result_instance_snapshot.tags["tag_one"] == "{{ snapshot_id }}-b One" + - _result_instance_snapshot.tags["Tag Two"] == "two {{ snapshot_id }}-b" + - _result_instance_snapshot.tags["tag_three"] == "{{ snapshot_id }}-b Three" + + always: - name: Delete the existing DB instance snapshots rds_instance_snapshot: state: absent db_snapshot_identifier: "{{ item }}" + wait: false register: _result_delete_snapshot ignore_errors: true loop: - "{{ snapshot_id }}" - "{{ snapshot_id }}-b" + - "{{ snapshot_id }}-copy" - name: Delete the existing RDS instance without creating a final snapshot rds_instance: state: absent - instance_id: "{{ item }}" + instance_id: "{{ instance_id }}" skip_final_snapshot: True + wait: false register: _result_delete_instance ignore_errors: true - loop: - - "{{ instance_id }}" - - "{{ instance_id }}-b"