diff --git a/changelogs/fragments/ec2_vpc_igw-delete_unattached-gateway.yml b/changelogs/fragments/ec2_vpc_igw-delete_unattached-gateway.yml new file mode 100644 index 00000000000..a94fb0224f6 --- /dev/null +++ b/changelogs/fragments/ec2_vpc_igw-delete_unattached-gateway.yml @@ -0,0 +1,6 @@ +--- +minor_changes: + - ec2_vpc_igw - Add ability to delete a vpc internet gateway using the id of the gateway (https://github.com/ansible-collections/amazon.aws/pull/1786). + - ec2_vpc_igw - Add ability to create an internet gateway without attaching a VPC (https://github.com/ansible-collections/amazon.aws/pull/1786). + - ec2_vpc_igw - Add ability to attach/detach VPC to/from internet gateway (https://github.com/ansible-collections/amazon.aws/pull/1786). + - ec2_vpc_igw - Add ability to change VPC attached to internet gateway (https://github.com/ansible-collections/amazon.aws/pull/1786). diff --git a/plugins/modules/ec2_vpc_igw.py b/plugins/modules/ec2_vpc_igw.py index 48db6917580..5e14716ea08 100644 --- a/plugins/modules/ec2_vpc_igw.py +++ b/plugins/modules/ec2_vpc_igw.py @@ -13,10 +13,17 @@ - Manage an AWS VPC Internet gateway author: Robert Estelle (@erydo) options: + internet_gateway_id: + version_added: 7.0.0 + description: + - The ID of Internet Gateway to manage. + required: false + type: str vpc_id: description: - - The VPC ID for the VPC in which to manage the Internet Gateway. - required: true + - The VPC ID for the VPC to attach (when state=present) + - VPC ID can also be provided to find the internet gateway to manage that the VPC is attached to + required: false type: str state: description: @@ -24,6 +31,21 @@ default: present choices: [ 'present', 'absent' ] type: str + force_attach: + version_added: 7.0.0 + description: + - Force attaching VPC to I(vpc_id). + - Setting this option to true will detach an existing VPC attachment and attach to the supplied I(vpc_id). + - Ignored when I(state=absent). + - I(vpc_id) must be specified when I(force_attach) is true + default: false + type: bool + detach_vpc: + version_added: 7.0.0 + description: + - Remove attached VPC from gateway + default: false + type: bool notes: - Support for I(purge_tags) was added in release 1.3.0. extends_documentation_fragment: @@ -53,11 +75,38 @@ Tag2: tag2 register: igw -- name: Delete Internet gateway +- name: Create a detached gateway + amazon.aws.ec2_vpc_igw: + state: present + register: igw + +- name: Change the VPC the gateway is attached to + amazon.aws.ec2_vpc_igw: + internet_gateway_id: igw-abcdefgh + vpc_id: vpc-stuvwxyz + force_attach: true + state: present + register: igw + +- name: Delete Internet gateway using the attached vpc id amazon.aws.ec2_vpc_igw: state: absent vpc_id: vpc-abcdefgh register: vpc_igw_delete + +- name: Delete Internet gateway with gateway id + amazon.aws.ec2_vpc_igw: + state: absent + internet_gateway_id: igw-abcdefgh + register: vpc_igw_delete + +- name: Delete Internet gateway ensuring attached VPC is correct + amazon.aws.ec2_vpc_igw: + state: absent + internet_gateway_id: igw-abcdefgh + vpc_id: vpc-abcdefgh + register: vpc_igw_delete + """ RETURN = r""" @@ -109,6 +158,11 @@ def describe_igws_with_backoff(connection, **params): return paginator.paginate(**params).build_full_result()["InternetGateways"] +def describe_vpcs_with_backoff(connection, **params): + paginator = connection.get_paginator("describe_vpcs") + return paginator.paginate(**params).build_full_result()["Vpcs"] + + class AnsibleEc2Igw: def __init__(self, module, results): self._module = module @@ -117,15 +171,18 @@ def __init__(self, module, results): self._check_mode = self._module.check_mode def process(self): + internet_gateway_id = self._module.params.get("internet_gateway_id") vpc_id = self._module.params.get("vpc_id") state = self._module.params.get("state", "present") tags = self._module.params.get("tags") purge_tags = self._module.params.get("purge_tags") + force_attach = self._module.params.get("force_attach") + detach_vpc = self._module.params.get("detach_vpc") if state == "present": - self.ensure_igw_present(vpc_id, tags, purge_tags) + self.ensure_igw_present(internet_gateway_id, vpc_id, tags, purge_tags, force_attach, detach_vpc) elif state == "absent": - self.ensure_igw_absent(vpc_id) + self.ensure_igw_absent(internet_gateway_id, vpc_id) def get_matching_igw(self, vpc_id, gateway_id=None): """ @@ -136,11 +193,11 @@ def get_matching_igw(self, vpc_id, gateway_id=None): Returns: igw (dict): dict of igw found, None if none found """ - filters = ansible_dict_to_boto3_filter_list({"attachment.vpc-id": vpc_id}) try: # If we know the gateway_id, use it to avoid bugs with using filters # See https://github.com/ansible-collections/amazon.aws/pull/766 if not gateway_id: + filters = ansible_dict_to_boto3_filter_list({"attachment.vpc-id": vpc_id}) igws = describe_igws_with_backoff(self._connection, Filters=filters) else: igws = describe_igws_with_backoff(self._connection, InternetGatewayIds=[gateway_id]) @@ -155,6 +212,30 @@ def get_matching_igw(self, vpc_id, gateway_id=None): return igw + def get_matching_vpc(self, vpc_id): + """ + Returns the virtual private cloud found. + Parameters: + vpc_id (str): VPC ID + Returns: + vpc (dict): dict of vpc found, None if none found + """ + try: + vpcs = describe_vpcs_with_backoff(self._connection, VpcIds=[vpc_id]) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + # self._module.fail_json(msg=f"{str(e)}") + if "InvalidVpcID.NotFound" in str(e): + self._module.fail_json(msg=f"VPC with Id {vpc_id} not found, aborting") + self._module.fail_json_aws(e) + + vpc = None + if len(vpcs) > 1: + self._module.fail_json(msg=f"EC2 returned more than one VPC for {vpc_id}, aborting") + elif vpcs: + vpc = camel_dict_to_snake_dict(vpcs[0]) + + return vpc + @staticmethod def get_igw_info(igw, vpc_id): return { @@ -163,28 +244,62 @@ def get_igw_info(igw, vpc_id): "vpc_id": vpc_id, } - def ensure_igw_absent(self, vpc_id): - igw = self.get_matching_igw(vpc_id) + def detach_vpc(self, igw_id, vpc_id): + try: + self._connection.detach_internet_gateway(aws_retry=True, InternetGatewayId=igw_id, VpcId=vpc_id) + + self._results["changed"] = True + except botocore.exceptions.WaiterError as e: + self._module.fail_json_aws(e, msg="Unable to detach VPC.") + + def attach_vpc(self, igw_id, vpc_id): + try: + self._connection.attach_internet_gateway(aws_retry=True, InternetGatewayId=igw_id, VpcId=vpc_id) + + # Ensure the gateway is attached before proceeding + waiter = get_waiter(self._connection, "internet_gateway_attached") + waiter.wait(InternetGatewayIds=[igw_id]) + + self._results["changed"] = True + except botocore.exceptions.WaiterError as e: + self._module.fail_json_aws(e, msg="Failed to attach VPC.") + + def ensure_igw_absent(self, igw_id, vpc_id): + igw = self.get_matching_igw(vpc_id, gateway_id=igw_id) if igw is None: return self._results + igw_vpc_id = "" + + if len(igw["attachments"]) > 0: + igw_vpc_id = igw["attachments"][0]["vpc_id"] + + if vpc_id and (igw_vpc_id != vpc_id): + self._module.fail_json(msg=f"Supplied VPC ({vpc_id}) does not match found VPC ({igw_vpc_id}), aborting") + if self._check_mode: self._results["changed"] = True return self._results try: self._results["changed"] = True - self._connection.detach_internet_gateway( - aws_retry=True, InternetGatewayId=igw["internet_gateway_id"], VpcId=vpc_id - ) + + if igw_vpc_id: + self.detach_vpc(igw["internet_gateway_id"], igw_vpc_id) + self._connection.delete_internet_gateway(aws_retry=True, InternetGatewayId=igw["internet_gateway_id"]) except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: self._module.fail_json_aws(e, msg="Unable to delete Internet Gateway") return self._results - def ensure_igw_present(self, vpc_id, tags, purge_tags): - igw = self.get_matching_igw(vpc_id) + def ensure_igw_present(self, igw_id, vpc_id, tags, purge_tags, force_attach, detach_vpc): + igw = None + + if igw_id: + igw = self.get_matching_igw(None, gateway_id=igw_id) + elif vpc_id: + igw = self.get_matching_igw(vpc_id) if igw is None: if self._check_mode: @@ -192,26 +307,61 @@ def ensure_igw_present(self, vpc_id, tags, purge_tags): self._results["gateway_id"] = None return self._results + if vpc_id: + self.get_matching_vpc(vpc_id) + try: response = self._connection.create_internet_gateway(aws_retry=True) # Ensure the gateway exists before trying to attach it or add tags waiter = get_waiter(self._connection, "internet_gateway_exists") waiter.wait(InternetGatewayIds=[response["InternetGateway"]["InternetGatewayId"]]) + self._results["changed"] = True igw = camel_dict_to_snake_dict(response["InternetGateway"]) - self._connection.attach_internet_gateway( - aws_retry=True, InternetGatewayId=igw["internet_gateway_id"], VpcId=vpc_id - ) - # Ensure the gateway is attached before proceeding - waiter = get_waiter(self._connection, "internet_gateway_attached") - waiter.wait(InternetGatewayIds=[igw["internet_gateway_id"]]) - self._results["changed"] = True + if vpc_id: + self.attach_vpc(igw["internet_gateway_id"], vpc_id) except botocore.exceptions.WaiterError as e: self._module.fail_json_aws(e, msg="No Internet Gateway exists.") except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: self._module.fail_json_aws(e, msg="Unable to create Internet Gateway") + else: + igw_vpc_id = None + + if len(igw["attachments"]) > 0: + igw_vpc_id = igw["attachments"][0]["vpc_id"] + + if detach_vpc: + if self._check_mode: + self._results["changed"] = True + self._results["gateway_id"] = igw["internet_gateway_id"] + return self._results + + self.detach_vpc(igw["internet_gateway_id"], igw_vpc_id) + + elif igw_vpc_id != vpc_id: + if self._check_mode: + self._results["changed"] = True + self._results["gateway_id"] = igw["internet_gateway_id"] + return self._results + + if force_attach: + self.get_matching_vpc(vpc_id) + + self.detach_vpc(igw["internet_gateway_id"], igw_vpc_id) + self.attach_vpc(igw["internet_gateway_id"], vpc_id) + else: + self._module.fail_json(msg="VPC already attached, but does not match requested VPC.") + + elif vpc_id: + if self._check_mode: + self._results["changed"] = True + self._results["gateway_id"] = igw["internet_gateway_id"] + return self._results + + self.get_matching_vpc(vpc_id) + self.attach_vpc(igw["internet_gateway_id"], vpc_id) # Modify tags self._results["changed"] |= ensure_ec2_tags( @@ -234,16 +384,30 @@ def ensure_igw_present(self, vpc_id, tags, purge_tags): def main(): argument_spec = dict( - vpc_id=dict(required=True), + internet_gateway_id=dict(), + vpc_id=dict(), state=dict(default="present", choices=["present", "absent"]), tags=dict(required=False, type="dict", aliases=["resource_tags"]), purge_tags=dict(default=True, type="bool"), + force_attach=dict(default=False, type="bool"), + detach_vpc=dict(default=False, type="bool"), ) + required_if = [ + ("force_attach", True, ("vpc_id",), False), + ("state", "absent", ("internet_gateway_id", "vpc_id"), True), + ("detach_vpc", True, ("internet_gateway_id", "vpc_id"), True), + ] + + mutually_exclusive = [("force_attach", "detach_vpc")] + module = AnsibleAWSModule( argument_spec=argument_spec, supports_check_mode=True, + required_if=required_if, + mutually_exclusive=mutually_exclusive, ) + results = dict(changed=False) igw_manager = AnsibleEc2Igw(module=module, results=results) igw_manager.process() diff --git a/tests/integration/targets/ec2_vpc_igw/defaults/main.yml b/tests/integration/targets/ec2_vpc_igw/defaults/main.yml index a4590b4c0cd..8f42fce069e 100644 --- a/tests/integration/targets/ec2_vpc_igw/defaults/main.yml +++ b/tests/integration/targets/ec2_vpc_igw/defaults/main.yml @@ -1,3 +1,6 @@ vpc_name: '{{ resource_prefix }}-vpc' vpc_seed: '{{ resource_prefix }}' vpc_cidr: 10.{{ 256 | random(seed=vpc_seed) }}.0.0/16 +vpc_name_2: '{{ tiny_prefix }}-vpc-2' +vpc_seed_2: '{{ tiny_prefix }}' +vpc_cidr_2: 10.{{ 256 | random(seed=vpc_seed_2) }}.0.0/16 diff --git a/tests/integration/targets/ec2_vpc_igw/tasks/main.yml b/tests/integration/targets/ec2_vpc_igw/tasks/main.yml index f0e4124b637..ed70119f88f 100644 --- a/tests/integration/targets/ec2_vpc_igw/tasks/main.yml +++ b/tests/integration/targets/ec2_vpc_igw/tasks/main.yml @@ -39,6 +39,16 @@ - vpc_result.vpc.tags["Name"] == "{{ resource_prefix }}-vpc" - vpc_result.vpc.tags["Description"] == "Created by ansible-test" + # ============================================================ + - name: Create a second VPC + ec2_vpc_net: + name: '{{ vpc_name_2 }}' + state: present + cidr_block: '{{ vpc_cidr_2 }}' + tags: + Description: Created by ansible-test + register: vpc_2_result + # ============================================================ - name: Search for internet gateway by VPC - no matches ec2_vpc_igw_info: @@ -95,6 +105,7 @@ set_fact: igw_id: '{{ vpc_igw_create.gateway_id }}' vpc_id: '{{ vpc_result.vpc.id }}' + vpc_2_id: '{{ vpc_2_result.vpc.id }}' - name: Search for internet gateway by VPC ec2_vpc_igw_info: @@ -534,6 +545,274 @@ that: - vpc_igw_delete is not changed + # ============================================================ + - name: Create new detached internet gateway - CHECK_MODE + ec2_vpc_igw: + state: present + register: detached_igw_result + check_mode: true + + - name: Assert creation would happen (expected changed=true) - CHECK_MODE + assert: + that: + - detached_igw_result is changed + + - name: Create new detached internet gateway (expected changed=true) + ec2_vpc_igw: + state: present + register: detached_igw_result + + - name: Assert creation happened (expected changed=true) + assert: + that: + - detached_igw_result is changed + - '"gateway_id" in detached_igw_result' + - detached_igw_result.gateway_id.startswith("igw-") + - not detached_igw_result.vpc_id + + # ============================================================ + - name: Test state=absent when supplying only a gateway id (expected chaged=true) - CHECK_MODE + ec2_vpc_igw: + state: absent + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + register: vpc_igw_delete + check_mode: true + + - name: Assert state=absent when supplying only a gateway id (expected chaged=true) - CHECK_MODE + assert: + that: + - vpc_igw_delete is changed + + - name: Search for IGW by ID + ec2_vpc_igw_info: + internet_gateway_ids: '{{ detached_igw_result.gateway_id }}' + register: igw_info + + - name: Check that IGW was not deleted in check mode + assert: + that: + - '"internet_gateways" in igw_info' + - igw_info.internet_gateways | length == 1 + + - name: Test state=absent when supplying only a gateway id (expected chaged=true) + ec2_vpc_igw: + state: absent + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + register: vpc_igw_delete + + - name: Assert state=absent when supplying only a gateway id (expected chaged=true) + assert: + that: + - vpc_igw_delete is changed + + - name: Fetch removed IGW by ID + ec2_vpc_igw_info: + internet_gateway_ids: '{{ detached_igw_result.gateway_id }}' + register: igw_info + ignore_errors: true + + - name: Check IGW does not exist + assert: + that: + # Deliberate choice not to change bevahiour when searching by ID + - igw_info is failed + + # ============================================================ + - name: Create new internet gateway for vpc tests + ec2_vpc_igw: + state: present + register: detached_igw_result + + # ============================================================ + - name: Test attaching VPC to gateway - CHECK_MODE + ec2_vpc_igw: + state: present + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + vpc_id: '{{ vpc_id }}' + register: attach_vpc_result + check_mode: true + + - name: Assert that VPC was attached - CHECK_MODE + assert: + that: + - attach_vpc_result is changed + + - name: Test attaching VPC to gateway + ec2_vpc_igw: + state: present + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + vpc_id: '{{ vpc_id }}' + register: attach_vpc_result + + - name: Assert that VPC was attached + assert: + that: + - attach_vpc_result is changed + - attach_vpc_result.vpc_id == vpc_id + - attach_vpc_result.gateway_id == detached_igw_result.gateway_id + + # ============================================================ + - name: Test detaching VPC from gateway - CHECK_MODE + ec2_vpc_igw: + state: present + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + detach_vpc: true + register: detach_vpc_result + check_mode: true + + - name: Assert that VPC was detached - CHECK_MODE + assert: + that: + - detach_vpc_result is changed + + - name: Test detaching VPC from gateway + ec2_vpc_igw: + state: present + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + detach_vpc: true + register: detach_vpc_result + + - name: Assert that VPC was detached + assert: + that: + - detach_vpc_result is changed + - not detach_vpc_result.vpc_id + - detach_vpc_result.gateway_id == detached_igw_result.gateway_id + + # ============================================================ + - name: Attach VPC to gateway for VPC change tests + ec2_vpc_igw: + state: present + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + vpc_id: '{{ vpc_id }}' + + # ============================================================ + - name: Attempt change attached VPC with force_attach=false (default) - CHECK_MODE + ec2_vpc_igw: + state: present + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + vpc_id: '{{ vpc_2_result.vpc.id }}' + register: igw_vpc_changed_result + check_mode: true + + - name: Assert VPC changed with force_attach=false (default) - CHECK_MODE + assert: + that: + - igw_vpc_changed_result is changed + - vpc_id not in igw_vpc_changed_result + + - name: Attempt change with force_attach=false (default) (expected failure) + ec2_vpc_igw: + state: present + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + vpc_id: '{{ vpc_2_result.vpc.id }}' + register: igw_vpc_changed_result + ignore_errors: true + + - name: Assert VPC changed with force_attach=false (default) + assert: + that: + - igw_vpc_changed_result is failed + - igw_vpc_changed_result.msg is search('VPC already attached, but does not match requested VPC.') + + # ============================================================ + - name: Attempt change attached VPC with force_attach=true - CHECK_MODE + ec2_vpc_igw: + state: present + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + vpc_id: '{{ vpc_2_result.vpc.id }}' + force_attach: true + register: igw_vpc_changed_result + check_mode: true + + - name: Assert VPC changed with force_attach=true - CHECK_MODE + assert: + that: + - igw_vpc_changed_result is changed + - vpc_id not in igw_vpc_changed_result + + - name: Attempt change with force_attach=true + ec2_vpc_igw: + state: present + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + vpc_id: '{{ vpc_2_result.vpc.id }}' + force_attach: true + register: igw_vpc_changed_result + + - name: Assert VPC changed with force_attach=true + assert: + that: + - igw_vpc_changed_result is changed + - igw_vpc_changed_result.vpc_id == vpc_2_id + + # ============================================================ + - name: Test state=absent when supplying a gateway id and wrong vpc id (expected failure) - CHECK_MODE + ec2_vpc_igw: + state: absent + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + vpc_id: 'vpc-xxxxxxxxx' + register: vpc_igw_delete + check_mode: true + ignore_errors: true + + - name: Assert state=absent when supplying a gateway id and wrong vpc id (expected failure) - CHECK_MODE + assert: + that: + - vpc_igw_delete is failed + - vpc_igw_delete.msg is search('Supplied VPC.*does not match found VPC.*') + + - name: Test state=absent when supplying a gateway id and wrong vpc id (expected failure) + ec2_vpc_igw: + state: absent + internet_gateway_id: '{{ detached_igw_result.gateway_id}}' + vpc_id: 'vpc-xxxxxxxxx' + register: vpc_igw_delete + ignore_errors: true + + - name: Assert state=absent when supplying a gateway id and wrong vpc id (expected failure) + assert: + that: + - vpc_igw_delete is failed + - vpc_igw_delete.msg is search('Supplied VPC.*does not match found VPC.*') + + # ============================================================ + - name: Test state=absent when supplying a gateway id and vpc id (expected chaged=true) - CHECK_MODE + ec2_vpc_igw: + state: absent + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + vpc_id: '{{ vpc_2_id }}' + register: vpc_igw_delete + check_mode: true + + - name: Assert state=absent when supplying a gateway id and vpc id (expected chaged=true) - CHECK_MODE + assert: + that: + - vpc_igw_delete is changed + + - name: Test state=absent when supplying a gateway id and vpc id (expected chaged=true) + ec2_vpc_igw: + state: absent + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + vpc_id: '{{ vpc_2_id }}' + register: vpc_igw_delete + + - name: Assert state=absent when supplying a gateway id and vpc id (expected chaged=true) + assert: + that: + - vpc_igw_delete is changed + + - name: Fetch removed IGW by ID + ec2_vpc_igw_info: + internet_gateway_ids: '{{ detached_igw_result.gateway_id }}' + register: igw_info + ignore_errors: true + + - name: Check IGW does not exist + assert: + that: + # Deliberate choice not to change bevahiour when searching by ID + - igw_info is failed + always: # ============================================================ - name: Tidy up IGW @@ -542,9 +821,28 @@ vpc_id: '{{ vpc_result.vpc.id }}' ignore_errors: true + - name: Tidy up IGW on second VPC + ec2_vpc_igw: + state: absent + vpc_id: '{{ vpc_2_result.vpc.id }}' + ignore_errors: true + + - name: Tidy up detached IGW + ec2_vpc_igw: + state: absent + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + ignore_errors: true + - name: Tidy up VPC ec2_vpc_net: name: '{{ vpc_name }}' state: absent cidr_block: '{{ vpc_cidr }}' ignore_errors: true + + - name: Tidy up second VPC + ec2_vpc_net: + name: '{{ vpc_name_2 }}' + state: absent + cidr_block: '{{ vpc_cidr_2 }}' + ignore_errors: true