From b6e25202c89dc6f434319590ae972191fd600a1b Mon Sep 17 00:00:00 2001 From: Antonin Rykalsky Date: Thu, 30 Nov 2023 13:46:12 +0100 Subject: [PATCH] Add collection fucntionality for extra args to API calls, add metal_gateway and metal_gateway_info --- README.md | 2 + docs/modules/metal_gateway.md | 112 ++++++ docs/modules/metal_gateway_info.md | 68 ++++ plugins/module_utils/equinix.py | 22 +- plugins/module_utils/metal/api_routes.py | 21 ++ plugins/module_utils/metal/metal_api.py | 39 ++- plugins/module_utils/metal/spec_types.py | 4 + plugins/modules/metal_gateway.py | 328 ++++++++++++++++++ plugins/modules/metal_gateway_info.py | 128 +++++++ .../targets/metal_gateway/tasks/main.yml | 97 ++++++ .../tasks/main.yml | 106 ++++++ 11 files changed, 920 insertions(+), 7 deletions(-) create mode 100644 docs/modules/metal_gateway.md create mode 100644 docs/modules/metal_gateway_info.md create mode 100644 plugins/modules/metal_gateway.py create mode 100644 plugins/modules/metal_gateway_info.py create mode 100644 tests/integration/targets/metal_gateway/tasks/main.yml create mode 100644 tests/integration/targets/metal_gateway_ip_reservation/tasks/main.yml diff --git a/README.md b/README.md index fd96545..61f9719 100755 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Name | Description | --- | ------------ | [equinix.cloud.metal_connection](./docs/modules/metal_connection.md)|Manage an Interconnection in Equinix Metal| [equinix.cloud.metal_device](./docs/modules/metal_device.md)|Create, update, or delete Equinix Metal devices| +[equinix.cloud.metal_gateway](./docs/modules/metal_gateway.md)|Manage Metal Gateway in Equinix Metal| [equinix.cloud.metal_hardware_reservation](./docs/modules/metal_hardware_reservation.md)|Lookup a single hardware_reservation by ID in Equinix Metal| [equinix.cloud.metal_ip_assignment](./docs/modules/metal_ip_assignment.md)|Manage Equinix Metal IP assignments| [equinix.cloud.metal_organization](./docs/modules/metal_organization.md)|Lookup a single organization by ID in Equinix Metal| @@ -45,6 +46,7 @@ Name | Description | [equinix.cloud.metal_available_ips_info](./docs/modules/metal_available_ips_info.md)|Get list of avialable IP addresses from a reserved IP block| [equinix.cloud.metal_connection_info](./docs/modules/metal_connection_info.md)|Gather information about Interconnections| [equinix.cloud.metal_device_info](./docs/modules/metal_device_info.md)|Select list of Equinix Metal devices| +[equinix.cloud.metal_gateway_info](./docs/modules/metal_gateway_info.md)|Gather information about Metal Gateways| [equinix.cloud.metal_hardware_reservation_info](./docs/modules/metal_hardware_reservation_info.md)|Gather information about Equinix Metal hardware_reservations| [equinix.cloud.metal_ip_assignment_info](./docs/modules/metal_ip_assignment_info.md)|Gather IP address assignments for a device| [equinix.cloud.metal_metro_info](./docs/modules/metal_metro_info.md)|Gather information about Equinix Metal metros| diff --git a/docs/modules/metal_gateway.md b/docs/modules/metal_gateway.md new file mode 100644 index 0000000..5836d22 --- /dev/null +++ b/docs/modules/metal_gateway.md @@ -0,0 +1,112 @@ +# metal_gateway + +Manage Metal Gateway in Equinix Metal. You can use *id* or *ip_reservation_id* to lookup a Gateway. If you want to create new resource, you must provide *project_id*, *virtual_network_id* and either *ip_reservation_id* or *private_ipv4_subnet_size*. + + +- [Examples](#examples) +- [Parameters](#parameters) +- [Return Values](#return-values) + +## Examples + +```yaml +- name: Create new gateway with existing IP reservation + hosts: localhost + tasks: + - equinix.cloud.metal_gateway: + project_id: "a4cc87f9-e00f-48c2-9460-74aa60beb6b0" + ip_reservation_id: "83b5503c-7b7f-4883-9509-b6b728b41491" + virtual_network_id: "eef49903-7a09-4ca1-af67-4087c29ab5b6" + +``` + +```yaml +- name: Create new gateway with new private /29 subnet + hosts: localhost + tasks: + - equinix.cloud.metal_gateway: + project_id: "{{ project.id }}" + virtual_network_id: "{{ vlan.id }}" + private_ipv4_subnet_size: 8 + +``` + +```yaml +- name: Lookup a gateway by ID + hosts: localhost + tasks: + - equinix.cloud.metal_gateway: + id: "eef49903-7a09-4ca1-af67-4087c29ab5b6" + register: gateway + +``` + +```yaml +- name: Lookup a gateway by IP reservation ID + hosts: localhost + tasks: + - equinix.cloud.metal_gateway: + ip_reservation_id: "a4cc87f9-e00f-48c2-9460-74aa60beb6b0" + register: gateway + +``` + + + + + + + + + + +## Parameters + +| Field | Type | Required | Description | +|-----------|------|----------|------------------------------------------------------------------------------| +| `id` |
`str`
|
Optional
| UUID of the gateway. | +| `project_id` |
`str`
|
Optional
| UUID of the project where the gateway is scoped to. | +| `ip_reservation_id` |
`str`
|
Optional
| UUID of Public Reservation to associate with the gateway, the reservation must be in the same metro as the VLAN, conflicts with private_ipv4_subnet_size. | +| `private_ipv4_subnet_size` |
`int`
|
Optional
| Size of the private IPv4 subnet to create for this metal gateway, must be one of 8, 16, 32, 64, 128. Conflicts with ip_reservation_id. | +| `virtual_network_id` |
`str`
|
Optional
| UUID of the VLAN where the gateway is scoped to. | +| `timeout` |
`int`
|
Optional
| Timeout in seconds for gateway to get to "ready" state, and for gateway to be removed **(Default: `10`)** | + + + + + + +## Return Values + +- `metal_gateway` - The module object + + - Sample Response: + ```json + + { + + "changed": true, + "id": "1f4d30da-4041-406d-8d94-6ce929340d98", + "ip_reservation_id": "fa017281-b10e-4b22-b449-35a93fb88d85", + "metal_state": "ready", + "private_ipv4_subnet_size": null, + "project_id": "2e85a66a-ea6a-4e33-8029-cc5ab9a0bc91", + "virtual_network_id": "4a06c542-e47c-4e3c-ab85-bfc3cba4004d" + } + + ``` + ```json + + { + "changed": true, + "id": "be809e36-42a0-4a3b-982c-8f4487b9b9fc", + "ip_reservation_id": "e5c4be29-e238-431a-8c5f-f44f30fd5098", + "metal_state": "ready", + "private_ipv4_subnet_size": 8, + "project_id": "0491c16b-376d-4842-89d2-da3efead4991", + "virtual_network_id": "f46ab2c8-1332-4f87-91e9-f3a6a81d9769" + } + + ``` + + diff --git a/docs/modules/metal_gateway_info.md b/docs/modules/metal_gateway_info.md new file mode 100644 index 0000000..598fe34 --- /dev/null +++ b/docs/modules/metal_gateway_info.md @@ -0,0 +1,68 @@ +# metal_gateway_info + +Gather information about Metal Gateways + + +- [Examples](#examples) +- [Parameters](#parameters) +- [Return Values](#return-values) + +## Examples + +```yaml +- name: Gather information about all gateways in a project + hosts: localhost + tasks: + - equinix.cloud.metal_gateway_info: + project_id: 2a5122b9-c323-4d5c-b53c-9ad3f54273e7 + +``` + + + + + + + + + + +## Parameters + +| Field | Type | Required | Description | +|-----------|------|----------|------------------------------------------------------------------------------| +| `project_id` |
`str`
|
Optional
| UUID of parent project the gateway is scoped to. | + + + + + + +## Return Values + +- `resources` - Found Metal Gateways + + - Sample Response: + ```json + + [ + { + "id": "771c9418-7c60-4a45-8fa6-3a002132331d", + "ip_reservation_id": "d45c9629-3aab-4a7b-af5d-4ca50041e311", + "metal_state": "ready", + "private_ipv4_subnet_size": 8, + "project_id": "f7a35065-2e41-4747-b3d1-400af0a3e0e8", + "virtual_network_id": "898972b3-7eb9-4ca2-b803-7b5d339bbea7" + }, + { + "id": "b66eb02d-c4bb-4ae8-a22e-0f7934da971e", + "ip_reservation_id": "6282982a-e6de-4f4d-b230-2ae27e90778c", + "metal_state": "ready", + "private_ipv4_subnet_size": 8, + "project_id": "f7a35065-2e41-4747-b3d1-400af0a3e0e8", + "virtual_network_id": "898972b3-7eb9-4ca2-b803-7b5d339bbea7" + } + ] + ``` + + diff --git a/plugins/module_utils/equinix.py b/plugins/module_utils/equinix.py index 9f7f370..709113d 100644 --- a/plugins/module_utils/equinix.py +++ b/plugins/module_utils/equinix.py @@ -171,11 +171,16 @@ def update_by_id(self, update_dict: dict, resource_type: str): update_dict['id'] = specified_id return self._metal_api_call(resource_type, action.UPDATE, update_dict) - def wait_for_resource_condition(self, resource_type: str, - attribute: str, target_value: str, timeout: int): + def _get_id_safe(self): specified_id = self.params.get('id') if specified_id is None: - raise Exception('no id in module when waiting for condition, this is a module bug') + raise Exception('no id in module when about to poll for a condition, this is a module bug') + return specified_id + + + def wait_for_resource_condition(self, resource_type: str, + attribute: str, target_value: str, timeout: int): + specified_id = self._get_id_safe() stop_time = time.time() + timeout while time.time() < stop_time: result = self._metal_api_call(resource_type, action.GET, self.params.copy()) @@ -184,6 +189,17 @@ def wait_for_resource_condition(self, resource_type: str, time.sleep(5) raise Exception(f'wait for {resource_type} {specified_id} {attribute} {target_value} timed out') + def wait_for_resource_removal(self, resource_type: str, timeout: int): + specified_id = self._get_id_safe() + stop_time = time.time() + timeout + while time.time() < stop_time: + try: + self._metal_api_call(resource_type, action.GET, self.params.copy()) + except metal_client.NotFoundException: + return + time.sleep(5) + raise Exception(f'wait for {resource_type} {specified_id} removal timed out') + def get_hardware_reservation(self): params = {'id': self.params['hardware_reservation_id']} return self._metal_api_call('metal_hardware_reservation', action.GET, params) diff --git a/plugins/module_utils/metal/api_routes.py b/plugins/module_utils/metal/api_routes.py index 61276d9..d39e96c 100644 --- a/plugins/module_utils/metal/api_routes.py +++ b/plugins/module_utils/metal/api_routes.py @@ -65,6 +65,10 @@ def get_routes(mpc): ('metal_vrf', action.GET): spec_types.Specs( equinix_metal.VRFsApi(mpc).find_vrf_by_id, ), + ("metal_gateway", action.GET): spec_types.Specs( + equinix_metal.MetalGatewaysApi(mpc).find_metal_gateway_by_id, + extra_kwargs={"include": ["ip_reservation"]}, + ), # LISTERS ('metal_project_device', action.LIST): spec_types.Specs( @@ -131,6 +135,11 @@ def get_routes(mpc): equinix_metal.VRFsApi(mpc).find_vrfs, {'id': 'project_id'}, ), + ('metal_gateway', action.LIST): spec_types.Specs( + equinix_metal.MetalGatewaysApi(mpc).find_metal_gateways_by_project, + {'project_id': 'project_id'}, + extra_kwargs={"include": ["ip_reservation"]}, + ), # DELETERS ('metal_device', action.DELETE): spec_types.Specs( @@ -157,6 +166,10 @@ def get_routes(mpc): ('metal_vrf', action.DELETE): spec_types.Specs( equinix_metal.VRFsApi(mpc).delete_vrf, ), + ('metal_gateway', action.DELETE): spec_types.Specs( + equinix_metal.MetalGatewaysApi(mpc).delete_metal_gateway, + extra_kwargs={"include": ["ip_reservation"]}, + ), # CREATORS ('metal_device', action.CREATE): spec_types.Specs( @@ -228,6 +241,14 @@ def get_routes(mpc): {'id': 'project_id'}, equinix_metal.VrfCreateInput, ), + ('metal_gateway', action.CREATE): spec_types.Specs( + equinix_metal.MetalGatewaysApi(mpc).create_metal_gateway, + {'project_id': 'project_id'}, + equinix_metal.MetalGatewayCreateInput, + equinix_metal.CreateMetalGatewayRequest, + {"include": ["ip_reservation"]}, + ), + # UPDATERS ('metal_device', action.UPDATE): spec_types.Specs( diff --git a/plugins/module_utils/metal/metal_api.py b/plugins/module_utils/metal/metal_api.py index e27cb26..08bc059 100644 --- a/plugins/module_utils/metal/metal_api.py +++ b/plugins/module_utils/metal/metal_api.py @@ -15,6 +15,8 @@ api_routes, ) +def ip_count_from_mask(mask: int): + return 2 ** (32 - mask) def optional(key: str): return lambda resource: utils.dict_get(resource, key) @@ -39,9 +41,8 @@ def optional_bool(key: str): def optional_float(key: str): return lambda resource: resource.get(key, 0.0) - def cidr_to_quantity(key: str): - return lambda resource: 2 ** (32 - resource.get(key)) + return lambda resource: ip_count_from_mask(resource.get(key)) def ip_address_getter(resource: dict): @@ -149,6 +150,7 @@ def extract_ids_from_projects_hrefs(resource: dict): 'virtual_networks', 'interconnections', 'vrfs', + 'metal_gateways', ] @@ -226,6 +228,34 @@ def get_assignment_address(resource: dict): 'status': 'status', } +def private_ipv4_subnet_size(resource: dict): + """ + Computes the private_ipv4_subnet_size from the ip_reservation.cidr field in the API response. + Returns None if the ip_reservation is public. + """ + ip_reservation = resource.get('ip_reservation') + if ip_reservation is None: + raise Exception("ip_reservation is not present in API response") + is_public = ip_reservation.get('public') + if is_public is None: + raise Exception("'public' is not present in ip_reservation field in API response (maybe we need to explicitly include ip_reservation in request kwargs?)") + if is_public: + return None + cidr = ip_reservation.get('cidr') + if cidr is None: + raise Exception("'cidr' is not present in ip_reservation field in API response (maybe we need to explicitly include ip_reservation through request kwargs?)") + return ip_count_from_mask(cidr) + + +METAL_GATEWAY_RESPONSE_ATTRIBUTE_MAP = { + 'id': 'id', + 'ip_reservation_id': 'ip_reservation.id', + 'private_ipv4_subnet_size': private_ipv4_subnet_size, + 'virtual_network_id': 'virtual_network.id', + 'project_id': 'project.id', + 'metal_state': optional_str('state'), +} + def get_attribute_mapper(resource_type): """ @@ -242,6 +272,7 @@ def get_attribute_mapper(resource_type): 'metal_connection_project_dedicated', 'metal_connection_organization_dedicated', 'metal_connection_project_vlanfabric', 'metal_connection_project_vrf']) vrf_resources = set(['metal_vrf']) + gateway_resources = set(["metal_gateway", "metal_gateway_vrf"]) if resource_type in device_resources: return METAL_DEVICE_RESPONSE_ATTRIBUTE_MAP elif resource_type in project_resources: @@ -266,6 +297,8 @@ def get_attribute_mapper(resource_type): return VLAN_RESPONSE_ATTRIBUTE_MAP elif resource_type in vrf_resources: return METAL_VRF_RESPONSE_ATTRIBUTE_MAP + elif resource_type in gateway_resources: + return METAL_GATEWAY_RESPONSE_ATTRIBUTE_MAP else: raise NotImplementedError("No mapper for resource type %s" % resource_type) @@ -281,8 +314,6 @@ def call(resource_type, action, equinix_metal_client, params={}): call = api_routes.build_api_call(conf, params) response = call.do() - # uncomment to check response in /tmp/q - # import q; q(response) if action == action.DELETE: return None attribute_mapper = get_attribute_mapper(resource_type) diff --git a/plugins/module_utils/metal/spec_types.py b/plugins/module_utils/metal/spec_types.py index acdca5e..806fe2f 100644 --- a/plugins/module_utils/metal/spec_types.py +++ b/plugins/module_utils/metal/spec_types.py @@ -24,11 +24,13 @@ def __init__(self, named_args_mapping: Optional[Dict[str, str]] = None, request_model_class: Optional[Callable] = None, request_superclass: Optional[Callable] = None, + extra_kwargs: Optional[Dict] = None, ): self.func = func self.named_args_mapping = named_args_mapping self.request_model_class = request_model_class self.request_superclass = request_superclass + self.extra_kwargs = extra_kwargs if self.request_model_class is not None: if not inspect.isclass(request_model_class): raise ValueError('request_model_class must be a class, is {-1}'.format(type(request_model_class))) @@ -58,6 +60,8 @@ def __init__(self, param_names = set(inspect.signature(conf.func).parameters.keys()) self.sdk_kwargs = {} + if conf.extra_kwargs is not None: + self.sdk_kwargs.update(conf.extra_kwargs) arg_mapping = self.conf.named_args_mapping or {} for param_name in param_names: lookup_name = param_name diff --git a/plugins/modules/metal_gateway.py b/plugins/modules/metal_gateway.py new file mode 100644 index 0000000..45723e1 --- /dev/null +++ b/plugins/modules/metal_gateway.py @@ -0,0 +1,328 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# DOCUMENTATION, EXAMPLES, and RETURN are generated by +# ansible_specdoc. Do not edit them directly. + +DOCUMENTATION = ''' +author: Equinix DevRel Team (@equinix) +description: Manage Metal Gateway in Equinix Metal. You can use *id* or *ip_reservation_id* + to lookup a Gateway. If you want to create new resource, you must provide *project_id*, + *virtual_network_id* and either *ip_reservation_id* or *private_ipv4_subnet_size*. +module: metal_gateway +notes: [] +options: + id: + description: + - UUID of the gateway. + required: false + type: str + ip_reservation_id: + description: + - UUID of Public Reservation to associate with the gateway, the reservation must + be in the same metro as the VLAN, conflicts with private_ipv4_subnet_size. + required: false + type: str + private_ipv4_subnet_size: + description: + - Size of the private IPv4 subnet to create for this metal gateway, must be one + of 8, 16, 32, 64, 128. Conflicts with ip_reservation_id. + required: false + type: int + project_id: + description: + - UUID of the project where the gateway is scoped to. + required: false + type: str + timeout: + default: 10 + description: + - Timeout in seconds for gateway to get to "ready" state, and for gateway to be + removed + required: false + type: int + virtual_network_id: + description: + - UUID of the VLAN where the gateway is scoped to. + required: false + type: str +requirements: null +short_description: Manage Metal Gateway in Equinix Metal +''' +EXAMPLES = ''' +- name: Create new gateway with existing IP reservation + hosts: localhost + tasks: + - equinix.cloud.metal_gateway: + project_id: a4cc87f9-e00f-48c2-9460-74aa60beb6b0 + ip_reservation_id: 83b5503c-7b7f-4883-9509-b6b728b41491 + virtual_network_id: eef49903-7a09-4ca1-af67-4087c29ab5b6 +- name: Create new gateway with new private /29 subnet + hosts: localhost + tasks: + - equinix.cloud.metal_gateway: + project_id: '{{ project.id }}' + virtual_network_id: '{{ vlan.id }}' + private_ipv4_subnet_size: 8 +- name: Lookup a gateway by ID + hosts: localhost + tasks: + - equinix.cloud.metal_gateway: + id: eef49903-7a09-4ca1-af67-4087c29ab5b6 + register: gateway +- name: Lookup a gateway by IP reservation ID + hosts: localhost + tasks: + - equinix.cloud.metal_gateway: + ip_reservation_id: a4cc87f9-e00f-48c2-9460-74aa60beb6b0 + register: gateway +''' +RETURN = ''' +metal_gateway: + description: The module object + returned: always + sample: + - "\n{\n \n \"changed\": true,\n \"id\": \"1f4d30da-4041-406d-8d94-6ce929340d98\"\ + ,\n \"ip_reservation_id\": \"fa017281-b10e-4b22-b449-35a93fb88d85\",\n \"metal_state\"\ + : \"ready\",\n \"private_ipv4_subnet_size\": null,\n \"project_id\": \"2e85a66a-ea6a-4e33-8029-cc5ab9a0bc91\"\ + ,\n \"virtual_network_id\": \"4a06c542-e47c-4e3c-ab85-bfc3cba4004d\"\n}\n" + - "\n{\n \"changed\": true,\n \"id\": \"be809e36-42a0-4a3b-982c-8f4487b9b9fc\"\ + ,\n \"ip_reservation_id\": \"e5c4be29-e238-431a-8c5f-f44f30fd5098\",\n \"metal_state\"\ + : \"ready\",\n \"private_ipv4_subnet_size\": 8,\n \"project_id\": \"0491c16b-376d-4842-89d2-da3efead4991\"\ + ,\n \"virtual_network_id\": \"f46ab2c8-1332-4f87-91e9-f3a6a81d9769\"\n}\n" + type: dict +''' + +# End of generated documentation + +# This is a template for a new module. It is not meant to be used as is. +# It is meant to be copied and modified to create a new module. +# Replace all occurrences of "metal_resource" with the name of the new +# module, for example "metal_vlan". + + +from ansible.module_utils._text import to_native +from ansible_specdoc.objects import ( + SpecField, + FieldType, + SpecReturnValue, +) +import traceback + +from ansible_collections.equinix.cloud.plugins.module_utils.equinix import ( + EquinixModule, + get_diff, + getSpecDocMeta, +) + +MODULE_NAME = "metal_gateway" + +module_spec = dict( + id=SpecField( + type=FieldType.string, + description=['UUID of the gateway.'], + ), + project_id=SpecField( + type=FieldType.string, + description=['UUID of the project where the gateway is scoped to.'], + ), + # in order to support VRF, we should do antoher parameter + # vrf_ip_reservation_id which will use VRF + ip_reservation_id=SpecField( + type=FieldType.string, + conflicts_with=["private_ipv4_subnet_size"], + description=['UUID of Public Reservation to associate with the gateway, the reservation must be in the same metro as the VLAN, conflicts with private_ipv4_subnet_size.'], + ), + private_ipv4_subnet_size=SpecField( + type=FieldType.integer, + conflicts_with=["ip_reservation_id"], + description=['Size of the private IPv4 subnet to create for this metal gateway, must be one of 8, 16, 32, 64, 128. Conflicts with ip_reservation_id.'], + ), + virtual_network_id=SpecField( + type=FieldType.string, + description=['UUID of the VLAN where the gateway is scoped to.'], + ), + timeout=SpecField( + type=FieldType.integer, + description=['Timeout in seconds for gateway to get to "ready" state, and for gateway to be removed'], + default=10, + ), +) + + +specdoc_examples = [ +''' +- name: Create new gateway with existing IP reservation + hosts: localhost + tasks: + - equinix.cloud.metal_gateway: + project_id: "a4cc87f9-e00f-48c2-9460-74aa60beb6b0" + ip_reservation_id: "83b5503c-7b7f-4883-9509-b6b728b41491" + virtual_network_id: "eef49903-7a09-4ca1-af67-4087c29ab5b6" +''', +''' +- name: Create new gateway with new private /29 subnet + hosts: localhost + tasks: + - equinix.cloud.metal_gateway: + project_id: "{{ project.id }}" + virtual_network_id: "{{ vlan.id }}" + private_ipv4_subnet_size: 8 +''', +''' +- name: Lookup a gateway by ID + hosts: localhost + tasks: + - equinix.cloud.metal_gateway: + id: "eef49903-7a09-4ca1-af67-4087c29ab5b6" + register: gateway +''', +''' +- name: Lookup a gateway by IP reservation ID + hosts: localhost + tasks: + - equinix.cloud.metal_gateway: + ip_reservation_id: "a4cc87f9-e00f-48c2-9460-74aa60beb6b0" + register: gateway +''', +] + +result_sample = [''' +{ + + "changed": true, + "id": "1f4d30da-4041-406d-8d94-6ce929340d98", + "ip_reservation_id": "fa017281-b10e-4b22-b449-35a93fb88d85", + "metal_state": "ready", + "private_ipv4_subnet_size": null, + "project_id": "2e85a66a-ea6a-4e33-8029-cc5ab9a0bc91", + "virtual_network_id": "4a06c542-e47c-4e3c-ab85-bfc3cba4004d" +} +''', +''' +{ + "changed": true, + "id": "be809e36-42a0-4a3b-982c-8f4487b9b9fc", + "ip_reservation_id": "e5c4be29-e238-431a-8c5f-f44f30fd5098", + "metal_state": "ready", + "private_ipv4_subnet_size": 8, + "project_id": "0491c16b-376d-4842-89d2-da3efead4991", + "virtual_network_id": "f46ab2c8-1332-4f87-91e9-f3a6a81d9769" +} +''', +] + +MUTABLE_ATTRIBUTES = [ + k for k, v in module_spec.items() if v.editable +] + +SPECDOC_META = getSpecDocMeta( + short_description='Manage Metal Gateway in Equinix Metal', + description=( + 'Manage Metal Gateway in Equinix Metal. ' + 'You can use *id* or *ip_reservation_id* to lookup a Gateway. ' + 'If you want to create new resource, you must provide *project_id*, *virtual_network_id* and either *ip_reservation_id* or *private_ipv4_subnet_size*.' + ), + examples=specdoc_examples, + options=module_spec, + return_values={ + "metal_gateway": SpecReturnValue( + description='The module object', + type=FieldType.dict, + sample=result_sample, + ), + }, +) + + +def main(): + # In case you ever want to try to understand the dependencies between module options: + # https://docs.ansible.com/ansible/latest/dev_guide/developing_program_flow_modules.html#argument-spec-dependencies + module = EquinixModule( + argument_spec=SPECDOC_META.ansible_spec, + # we either create new gateway with ip_reservation_id or with private_ipv4_subnet_size + mutually_exclusive=[("private_ipv4_subnet_size", "ip_reservation_id")], + required_one_of=[ + # we either create new gateway or lookup existing one by id or + # by ip_reservation_id + ("id", "project_id", "ip_reservation_id"), + ], + required_by=dict( + # if we create new gateway in a project we need to provide virtual_network_id + project_id=["virtual_network_id"], + # if we lookup existing gateway, we need to provide ip_reservation_id + ip_reservation_id=["virtual_network_id","project_id"], + ), + # I think we still need to check that: + # - if we create new gateway in a project, either of ip_reservation_id or private_ipv4_subnet_size must be provided + ) + + + state = module.params.get("state") + changed = False + fetched = False + + try: + module.params_syntax_check() + if module.params.get("id"): + tolerate_not_found = state == "absent" + fetched = module.get_by_id(MODULE_NAME, tolerate_not_found) + else: + fetched = None + + # If user supplied ip_reservation_id, we need to check if + # there's already a gateway with the same reservation ID + if module.params.get("ip_reservation_id") is not None: + fetched = module.get_one_from_list( + MODULE_NAME, + ['ip_reservation_id'], + ) + + if fetched: + module.params['id'] = fetched['id'] + if state == "present": + diff = get_diff(module.params, fetched, MUTABLE_ATTRIBUTES) + if diff: + module.fail_json(msg="Metal_gateway isn't mutable.") + + else: + module.delete_by_id(MODULE_NAME) + # We wait for removal because Terraform resource for Gateway + # also waits for removal. + module.wait_for_resource_removal( + "metal_gateway", + timeout=module.params.get("timeout"), + ) + changed = True + else: + if state == "present": + # if not any((module.params.get("private_ipv4_subnet_size"), module.params.get("ip_reservation_id"))): + # module.fail_json(msg="You must set either ip_reservation_id or private_ipv4_subnet_size!") + # todo remove + # module.params.pop("ip_reservation_id") + fetched = module.create(MODULE_NAME) + if 'id' not in fetched: + module.fail_json(msg="UUID not found in gateway creation response") + module.params['id'] = fetched['id'] + seconds = module.params.get("timeout") + fetched = module.wait_for_resource_condition( + "metal_gateway", + "metal_state", + "ready", + timeout=seconds) + changed = True + else: + fetched = {} + except Exception as e: + tb = traceback.format_exc() + module.fail_json(msg=f"Error in metal_gateway: {to_native(e)}", + exception=tb) + + fetched.update({'changed': changed}) + module.exit_json(**fetched) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/metal_gateway_info.py b/plugins/modules/metal_gateway_info.py new file mode 100644 index 0000000..2ee22e1 --- /dev/null +++ b/plugins/modules/metal_gateway_info.py @@ -0,0 +1,128 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +# DOCUMENTATION, EXAMPLES, and RETURN are generated by +# ansible_specdoc. Do not edit them directly. + +DOCUMENTATION = ''' +author: Equinix DevRel Team (@equinix) +description: Gather information about Metal Gateways +module: metal_gateway_info +notes: [] +options: + project_id: + description: + - UUID of parent project the gateway is scoped to. + required: false + type: str +requirements: null +short_description: Gather information about Metal Gateways +''' +EXAMPLES = ''' +- name: Gather information about all gateways in a project + hosts: localhost + tasks: + - equinix.cloud.metal_gateway_info: + project_id: 2a5122b9-c323-4d5c-b53c-9ad3f54273e7 +''' +RETURN = ''' +resources: + description: Found Metal Gateways + returned: always + sample: + - "\n[\n {\n \"id\": \"771c9418-7c60-4a45-8fa6-3a002132331d\",\n \ + \ \"ip_reservation_id\": \"d45c9629-3aab-4a7b-af5d-4ca50041e311\",\n \ + \ \"metal_state\": \"ready\",\n \"private_ipv4_subnet_size\": 8,\n \ + \ \"project_id\": \"f7a35065-2e41-4747-b3d1-400af0a3e0e8\",\n \"virtual_network_id\"\ + : \"898972b3-7eb9-4ca2-b803-7b5d339bbea7\"\n },\n {\n \"id\": \"\ + b66eb02d-c4bb-4ae8-a22e-0f7934da971e\",\n \"ip_reservation_id\": \"6282982a-e6de-4f4d-b230-2ae27e90778c\"\ + ,\n \"metal_state\": \"ready\",\n \"private_ipv4_subnet_size\":\ + \ 8,\n \"project_id\": \"f7a35065-2e41-4747-b3d1-400af0a3e0e8\",\n \ + \ \"virtual_network_id\": \"898972b3-7eb9-4ca2-b803-7b5d339bbea7\"\n }\n\ + ]" + type: dict +''' + +# End + +from ansible.module_utils._text import to_native +from ansible_specdoc.objects import SpecField, FieldType, SpecReturnValue +import traceback + +from ansible_collections.equinix.cloud.plugins.module_utils.equinix import ( + EquinixModule, + getSpecDocMeta, +) + +module_spec = dict( + project_id=SpecField( + type=FieldType.string, + description=['UUID of parent project the gateway is scoped to.'], + ), +) + +specdoc_examples = [''' +- name: Gather information about all gateways in a project + hosts: localhost + tasks: + - equinix.cloud.metal_gateway_info: + project_id: 2a5122b9-c323-4d5c-b53c-9ad3f54273e7 +''', +] + +result_sample = [''' +[ + { + "id": "771c9418-7c60-4a45-8fa6-3a002132331d", + "ip_reservation_id": "d45c9629-3aab-4a7b-af5d-4ca50041e311", + "metal_state": "ready", + "private_ipv4_subnet_size": 8, + "project_id": "f7a35065-2e41-4747-b3d1-400af0a3e0e8", + "virtual_network_id": "898972b3-7eb9-4ca2-b803-7b5d339bbea7" + }, + { + "id": "b66eb02d-c4bb-4ae8-a22e-0f7934da971e", + "ip_reservation_id": "6282982a-e6de-4f4d-b230-2ae27e90778c", + "metal_state": "ready", + "private_ipv4_subnet_size": 8, + "project_id": "f7a35065-2e41-4747-b3d1-400af0a3e0e8", + "virtual_network_id": "898972b3-7eb9-4ca2-b803-7b5d339bbea7" + } +]''', +] + +SPECDOC_META = getSpecDocMeta( + short_description="Gather information about Metal Gateways", + description=( + 'Gather information about Metal Gateways' + ), + examples=specdoc_examples, + options=module_spec, + return_values={ + "resources": SpecReturnValue( + description='Found Metal Gateways', + type=FieldType.dict, + sample=result_sample, + ), + }, +) + + +def main(): + module = EquinixModule( + argument_spec=SPECDOC_META.ansible_spec, + is_info=True, + ) + try: + module.params_syntax_check() + return_value = {'resources': module.get_list("metal_gateway")} + except Exception as e: + tr = traceback.format_exc() + module.fail_json(msg=to_native(e), exception=tr) + module.exit_json(**return_value) + + +if __name__ == '__main__': + main() diff --git a/tests/integration/targets/metal_gateway/tasks/main.yml b/tests/integration/targets/metal_gateway/tasks/main.yml new file mode 100644 index 0000000..c76af82 --- /dev/null +++ b/tests/integration/targets/metal_gateway/tasks/main.yml @@ -0,0 +1,97 @@ +- name: metal_gateway + module_defaults: + equinix.cloud.metal_gateway: + metal_api_token: '{{ metal_api_token }}' + equinix.cloud.metal_gateway_info: + metal_api_token: '{{ metal_api_token }}' + equinix.cloud.metal_project: + metal_api_token: '{{ metal_api_token }}' + equinix.cloud.metal_project_info: + metal_api_token: '{{ metal_api_token }}' + equinix.cloud.metal_vlan: + metal_api_token: '{{ metal_api_token }}' + block: + - set_fact: + test_resource_name_prefix: 'ansible-integration-test-gateway' + - set_fact: + unique_id: "{{ lookup('community.general.random_string', upper=false, numbers=false, special=false) }}" + - set_fact: + test_prefix: "{{ test_resource_name_prefix }}-{{ unique_id }}" + - set_fact: + test_vxlan: 123 + + - name: create project for test + equinix.cloud.metal_project: + name: "{{ test_prefix }}-project" + register: project + + - name: create vlan for test + equinix.cloud.metal_vlan: + project_id: "{{ project.id }}" + vxlan: "{{ test_vxlan }}" + metro: "{{ metal_test_metro }}" + register: vlan + + - name: create gateway for test + equinix.cloud.metal_gateway: + project_id: "{{ project.id }}" + virtual_network_id: "{{ vlan.id }}" + private_ipv4_subnet_size: 8 + register: gateway + + # We can't check idempotent calls for metal_gateway from private IP range. + # There can be many of those. + + - name: create another gateway for test + equinix.cloud.metal_gateway: + project_id: "{{ project.id }}" + virtual_network_id: "{{ vlan.id }}" + private_ipv4_subnet_size: 8 + register: gateway_2 + + - name: fetch gateway + equinix.cloud.metal_gateway: + id: "{{ gateway.id }}" + register: fetched_gateway + + - assert: + that: + - fetched_gateway.id == "{{ gateway.id }}" + + - name: list gateways + equinix.cloud.metal_gateway_info: + project_id: "{{ project.id }}" + register: listed_gateways + + + - assert: + that: + - "listed_gateways.resources | length == 2" + + - name: delete gateways + equinix.cloud.metal_gateway: + id: "{{ item.id }}" + state: absent + loop: "{{ listed_gateways.resources }}" + + - name: delete test vlan + equinix.cloud.metal_vlan: + id: "{{ vlan.id }}" + state: absent + + always: + - name: Announce teardown start + debug: + msg: "***** TESTING COMPLETE. COMMENCE TEARDOWN *****" + + - name: list test projects + equinix.cloud.metal_project_info: + name: "{{ test_prefix }}" + register: test_projects_listed + + - name: delete test projects + equinix.cloud.metal_project: + id: "{{ item.id }}" + state: absent + loop: "{{ test_projects_listed.resources }}" + ignore_errors: yes \ No newline at end of file diff --git a/tests/integration/targets/metal_gateway_ip_reservation/tasks/main.yml b/tests/integration/targets/metal_gateway_ip_reservation/tasks/main.yml new file mode 100644 index 0000000..953e52d --- /dev/null +++ b/tests/integration/targets/metal_gateway_ip_reservation/tasks/main.yml @@ -0,0 +1,106 @@ +- name: metal_gateway_ip_reservation + module_defaults: + equinix.cloud.metal_gateway: + metal_api_token: '{{ metal_api_token }}' + equinix.cloud.metal_gateway_info: + metal_api_token: '{{ metal_api_token }}' + equinix.cloud.metal_project: + metal_api_token: '{{ metal_api_token }}' + equinix.cloud.metal_project_info: + metal_api_token: '{{ metal_api_token }}' + equinix.cloud.metal_vlan: + metal_api_token: '{{ metal_api_token }}' + equinix.cloud.metal_reserved_ip_block: + metal_api_token: '{{ metal_api_token }}' + block: + - set_fact: + test_resource_name_prefix: 'ansible-integration-test-gateway-ip-res' + - set_fact: + unique_id: "{{ lookup('community.general.random_string', upper=false, numbers=false, special=false) }}" + - set_fact: + test_prefix: "{{ test_resource_name_prefix }}-{{ unique_id }}" + - set_fact: + test_vxlan: 123 + + - name: create project for test + equinix.cloud.metal_project: + name: "{{ test_prefix }}-project" + register: project + + - name: create vlan for test + equinix.cloud.metal_vlan: + project_id: "{{ project.id }}" + vxlan: "{{ test_vxlan }}" + metro: "{{ metal_test_metro }}" + register: vlan + + # 8 is the minimum amount of IPs we can use for Metal Gateway + - name: request 8 public IPs for test + equinix.cloud.metal_reserved_ip_block: + type: "public_ipv4" + metro: "{{ metal_test_metro }}" + quantity: 8 + project_id: "{{ project.id }}" + register: ip_reservation + + - name: create gateway for test + equinix.cloud.metal_gateway: + project_id: "{{ project.id }}" + virtual_network_id: "{{ vlan.id }}" + ip_reservation_id: "{{ ip_reservation.id }}" + register: gateway + + - name: check idempotency of metal_gateway module when using existing ip_reservation + equinix.cloud.metal_gateway: + project_id: "{{ project.id }}" + virtual_network_id: "{{ vlan.id }}" + ip_reservation_id: "{{ ip_reservation.id }}" + register: gateway_again + + - assert: + that: + - gateway_again.id == "{{ gateway.id }}" + - gateway_again.changed == false + + - name: fetch gateway by id + equinix.cloud.metal_gateway: + id: "{{ gateway.id }}" + register: fetched_gateway + + - assert: + that: + - fetched_gateway.id == "{{ gateway.id }}" + - fetched_gateway.ip_reservation_id == "{{ ip_reservation.id }}" + - fetched_gateway.virtual_network_id == "{{ vlan.id }}" + + - name: delete gateway + equinix.cloud.metal_gateway: + id: "{{ gateway.id }}" + state: absent + + - name: delete ip reservation + equinix.cloud.metal_reserved_ip_block: + id: "{{ ip_reservation.id }}" + state: absent + + - name: delete test vlan + equinix.cloud.metal_vlan: + id: "{{ vlan.id }}" + state: absent + + always: + - name: Announce teardown start + debug: + msg: "***** TESTING COMPLETE. COMMENCE TEARDOWN *****" + + - name: list test projects + equinix.cloud.metal_project_info: + name: "{{ test_prefix }}" + register: test_projects_listed + + - name: delete test projects + equinix.cloud.metal_project: + id: "{{ item.id }}" + state: absent + loop: "{{ test_projects_listed.resources }}" + ignore_errors: yes \ No newline at end of file