From 7875b4dcbfd95e7fc582c7c94b01dd3dc91706a2 Mon Sep 17 00:00:00 2001 From: Brant Evans Date: Mon, 16 Oct 2023 16:38:55 -0700 Subject: [PATCH] Add ability to create detached internet gateway Also adds the ability to attach/detach/change VPC of internet gateway Fixes: #1787 --- .../ec2_vpc_igw-delete_unattached-gateway.yml | 3 + plugins/modules/ec2_vpc_igw.py | 174 +++++++++++-- .../targets/ec2_vpc_igw/defaults/main.yml | 3 + .../targets/ec2_vpc_igw/tasks/main.yml | 246 +++++++++++++++--- 4 files changed, 371 insertions(+), 55 deletions(-) diff --git a/changelogs/fragments/ec2_vpc_igw-delete_unattached-gateway.yml b/changelogs/fragments/ec2_vpc_igw-delete_unattached-gateway.yml index 77b1e718b08..a94fb0224f6 100644 --- a/changelogs/fragments/ec2_vpc_igw-delete_unattached-gateway.yml +++ b/changelogs/fragments/ec2_vpc_igw-delete_unattached-gateway.yml @@ -1,3 +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 dbb10aac558..5e14716ea08 100644 --- a/plugins/modules/ec2_vpc_igw.py +++ b/plugins/modules/ec2_vpc_igw.py @@ -14,13 +14,15 @@ 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 to attach (when state=present) or that is attached the Internet Gateway to manage. + - 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: @@ -29,8 +31,22 @@ 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: -- I(internet_gateway_id) or I(vpc_id) must be supplied. Both can be supplied if managing an existing gateway. - Support for I(purge_tags) was added in release 1.3.0. extends_documentation_fragment: - amazon.aws.common.modules @@ -59,7 +75,20 @@ 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 @@ -129,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 @@ -142,11 +176,13 @@ def process(self): 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, internet_gateway_id) + self.ensure_igw_absent(internet_gateway_id, vpc_id) def get_matching_igw(self, vpc_id, gateway_id=None): """ @@ -176,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 { @@ -184,7 +244,27 @@ def get_igw_info(igw, vpc_id): "vpc_id": vpc_id, } - def ensure_igw_absent(self, vpc_id, igw_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 @@ -203,18 +283,23 @@ def ensure_igw_absent(self, vpc_id, igw_id): try: self._results["changed"] = True + if igw_vpc_id: - self._connection.detach_internet_gateway( - aws_retry=True, InternetGatewayId=igw["internet_gateway_id"], VpcId=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: @@ -222,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( @@ -269,11 +389,25 @@ def main(): 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_one_of = [("internet_gateway_id", "vpc_id")] + 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, + ) - module = AnsibleAWSModule(argument_spec=argument_spec, supports_check_mode=True, required_one_of=required_one_of) 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 3ab9fb8534c..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: @@ -535,16 +546,35 @@ - vpc_igw_delete is not changed # ============================================================ - - name: Create new internet gateway for removal by igw id tests + - name: Create new detached internet gateway - CHECK_MODE ec2_vpc_igw: state: present - vpc_id: '{{ vpc_result.vpc.id }}' - register: new_internet_gateway_result + 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: '{{ new_internet_gateway_result.gateway_id}}' + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' register: vpc_igw_delete check_mode: true @@ -553,10 +583,21 @@ 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: '{{ new_internet_gateway_result.gateway_id}}' + 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) @@ -566,7 +607,7 @@ - name: Fetch removed IGW by ID ec2_vpc_igw_info: - internet_gateway_ids: '{{ igw_id }}' + internet_gateway_ids: '{{ detached_igw_result.gateway_id }}' register: igw_info ignore_errors: true @@ -577,60 +618,138 @@ - igw_info is failed # ============================================================ - - name: Create new internet gateway for removal by igw id and vpc id tests + - name: Create new internet gateway for vpc tests ec2_vpc_igw: state: present - vpc_id: '{{ vpc_result.vpc.id }}' - register: new_internet_gateway_result + register: detached_igw_result - - name: Test state=absent when supplying a gateway id and vpc id (expected chaged=true) - CHECK_MODE + # ============================================================ + - name: Test attaching VPC to gateway - CHECK_MODE ec2_vpc_igw: - state: absent - internet_gateway_id: '{{ new_internet_gateway_result.gateway_id}}' - vpc_id: '{{ vpc_result.vpc.id }}' - register: vpc_igw_delete + state: present + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + vpc_id: '{{ vpc_id }}' + register: attach_vpc_result check_mode: true - - name: Assert state=absent when supplying a gateway id and vpc id (expected chaged=true) - CHECK_MODE + - name: Assert that VPC was attached - CHECK_MODE assert: that: - - vpc_igw_delete is changed + - attach_vpc_result is changed - - name: Test state=absent when supplying a gateway id and vpc id (expected chaged=true) + - name: Test attaching VPC to gateway ec2_vpc_igw: - state: absent - internet_gateway_id: '{{ new_internet_gateway_result.gateway_id}}' - vpc_id: '{{ vpc_result.vpc.id }}' - register: vpc_igw_delete + state: present + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' + vpc_id: '{{ vpc_id }}' + register: attach_vpc_result - - name: Assert state=absent when supplying a gateway id and vpc id (expected chaged=true) + - name: Assert that VPC was attached assert: that: - - vpc_igw_delete is changed + - attach_vpc_result is changed + - attach_vpc_result.vpc_id == vpc_id + - attach_vpc_result.gateway_id == detached_igw_result.gateway_id - - name: Fetch removed IGW by ID - ec2_vpc_igw_info: - internet_gateway_ids: '{{ igw_id }}' - register: igw_info + # ============================================================ + - 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: Check IGW does not exist + - name: Assert VPC changed with force_attach=false (default) assert: that: - # Deliberate choice not to change bevahiour when searching by ID - - igw_info is failed + - igw_vpc_changed_result is failed + - igw_vpc_changed_result.msg is search('VPC already attached, but does not match requested VPC.') # ============================================================ - - name: Create new internet gateway for removal by igw id and wrong vpc id tests + - name: Attempt change attached VPC with force_attach=true - CHECK_MODE ec2_vpc_igw: state: present - vpc_id: '{{ vpc_result.vpc.id }}' - register: new_internet_gateway_result + 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: '{{ new_internet_gateway_result.gateway_id}}' + internet_gateway_id: '{{ detached_igw_result.gateway_id }}' vpc_id: 'vpc-xxxxxxxxx' register: vpc_igw_delete check_mode: true @@ -645,7 +764,7 @@ - name: Test state=absent when supplying a gateway id and wrong vpc id (expected failure) ec2_vpc_igw: state: absent - internet_gateway_id: '{{ new_internet_gateway_result.gateway_id}}' + internet_gateway_id: '{{ detached_igw_result.gateway_id}}' vpc_id: 'vpc-xxxxxxxxx' register: vpc_igw_delete ignore_errors: true @@ -656,6 +775,44 @@ - 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 @@ -664,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