diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml index c46f431429..d17c2bf155 100644 --- a/.github/BOTMETA.yml +++ b/.github/BOTMETA.yml @@ -418,6 +418,8 @@ files: maintainers: Spredzy $modules/cloud/scaleway/scaleway_organization_info.py: maintainers: Spredzy + $modules/cloud/scaleway/scaleway_private_network.py: + maintainers: pastral $modules/cloud/scaleway/scaleway_security_group.py: maintainers: DenBeke $modules/cloud/scaleway/scaleway_security_group_info.py: diff --git a/plugins/module_utils/scaleway.py b/plugins/module_utils/scaleway.py index bcada5fcb9..e6fb8109cc 100644 --- a/plugins/module_utils/scaleway.py +++ b/plugins/module_utils/scaleway.py @@ -167,17 +167,61 @@ class Scaleway(object): SCALEWAY_LOCATION = { - 'par1': {'name': 'Paris 1', 'country': 'FR', "api_endpoint": 'https://api.scaleway.com/instance/v1/zones/fr-par-1'}, - 'EMEA-FR-PAR1': {'name': 'Paris 1', 'country': 'FR', "api_endpoint": 'https://api.scaleway.com/instance/v1/zones/fr-par-1'}, + 'par1': { + 'name': 'Paris 1', + 'country': 'FR', + 'api_endpoint': 'https://api.scaleway.com/instance/v1/zones/fr-par-1', + 'api_endpoint_vpc': 'https://api.scaleway.com/vpc/v1/zones/fr-par-1' + }, - 'par2': {'name': 'Paris 2', 'country': 'FR', "api_endpoint": 'https://api.scaleway.com/instance/v1/zones/fr-par-2'}, - 'EMEA-FR-PAR2': {'name': 'Paris 2', 'country': 'FR', "api_endpoint": 'https://api.scaleway.com/instance/v1/zones/fr-par-2'}, + 'EMEA-FR-PAR1': { + 'name': 'Paris 1', + 'country': 'FR', + 'api_endpoint': 'https://api.scaleway.com/instance/v1/zones/fr-par-1', + 'api_endpoint_vpc': 'https://api.scaleway.com/vpc/v1/zones/fr-par-1' + }, - 'ams1': {'name': 'Amsterdam 1', 'country': 'NL', "api_endpoint": 'https://api.scaleway.com/instance/v1/zones/nl-ams-1'}, - 'EMEA-NL-EVS': {'name': 'Amsterdam 1', 'country': 'NL', "api_endpoint": 'https://api.scaleway.com/instance/v1/zones/nl-ams-1'}, + 'par2': { + 'name': 'Paris 2', + 'country': 'FR', + 'api_endpoint': 'https://api.scaleway.com/instance/v1/zones/fr-par-2', + 'api_endpoint_vpc': 'https://api.scaleway.com/vpc/v1/zones/fr-par-2' + }, - 'waw1': {'name': 'Warsaw 1', 'country': 'PL', "api_endpoint": 'https://api.scaleway.com/instance/v1/zones/pl-waw-1'}, - 'EMEA-PL-WAW1': {'name': 'Warsaw 1', 'country': 'PL', "api_endpoint": 'https://api.scaleway.com/instance/v1/zones/pl-waw-1'}, + 'EMEA-FR-PAR2': { + 'name': 'Paris 2', + 'country': 'FR', + 'api_endpoint': 'https://api.scaleway.com/instance/v1/zones/fr-par-2', + 'api_endpoint_vpc': 'https://api.scaleway.com/vpc/v1/zones/fr-par-2' + }, + + 'ams1': { + 'name': 'Amsterdam 1', + 'country': 'NL', + 'api_endpoint': 'https://api.scaleway.com/instance/v1/zones/nl-ams-1', + 'api_endpoint_vpc': 'https://api.scaleway.com/vpc/v1/zones/nl-ams-10' + }, + + 'EMEA-NL-EVS': { + 'name': 'Amsterdam 1', + 'country': 'NL', + 'api_endpoint': 'https://api.scaleway.com/instance/v1/zones/nl-ams-1', + 'api_endpoint_vpc': 'https://api.scaleway.com/vpc/v1/zones/nl-ams-1' + }, + + 'waw1': { + 'name': 'Warsaw 1', + 'country': 'PL', + 'api_endpoint': 'https://api.scaleway.com/instance/v1/zones/pl-waw-1', + 'api_endpoint_vpc': 'https://api.scaleway.com/vpc/v1/zones/pl-waw-1' + }, + + 'EMEA-PL-WAW1': { + 'name': 'Warsaw 1', + 'country': 'PL', + 'api_endpoint': 'https://api.scaleway.com/instance/v1/zones/pl-waw-1', + 'api_endpoint_vpc': 'https://api.scaleway.com/vpc/v1/zones/pl-waw-1' + }, } SCALEWAY_ENDPOINT = "https://api.scaleway.com" diff --git a/plugins/modules/cloud/scaleway/scaleway_private_network.py b/plugins/modules/cloud/scaleway/scaleway_private_network.py new file mode 100644 index 0000000000..996a3cce27 --- /dev/null +++ b/plugins/modules/cloud/scaleway/scaleway_private_network.py @@ -0,0 +1,234 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Scaleway VPC management module +# +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +DOCUMENTATION = ''' +--- +module: scaleway_private_network +short_description: Scaleway private network management +version_added: 4.5.0 +author: Pascal MANGIN (@pastral) +description: + - This module manages private network on Scaleway account + (U(https://developer.scaleway.com)). +extends_documentation_fragment: +- community.general.scaleway + + +options: + state: + type: str + description: + - Indicate desired state of the VPC. + default: present + choices: + - present + - absent + + project: + type: str + description: + - Project identifier. + required: true + + region: + type: str + description: + - Scaleway region to use (for example C(par1)). + required: true + choices: + - ams1 + - EMEA-NL-EVS + - par1 + - EMEA-FR-PAR1 + - par2 + - EMEA-FR-PAR2 + - waw1 + - EMEA-PL-WAW1 + + name: + type: str + description: + - Name of the VPC. + + tags: + type: list + elements: str + description: + - List of tags to apply to the instance. + default: [] + +''' + +EXAMPLES = ''' +- name: Create an private network + community.general.scaleway_vpc: + project: '{{ scw_project }}' + name: 'vpc_one' + state: present + region: par1 + register: vpc_creation_task + +- name: Make sure private network with name 'foo' is deleted in region par1 + community.general.scaleway_vpc: + name: 'foo' + state: absent + region: par1 +''' + +RETURN = ''' +scaleway_private_network: + description: Information on the VPC. + returned: success when C(state=present) + type: dict + sample: + { + "created_at": "2022-01-15T11:11:12.676445Z", + "id": "12345678-f1e6-40ec-83e5-12345d67ed89", + "name": "network", + "organization_id": "a123b4cd-ef5g-678h-90i1-jk2345678l90", + "project_id": "a123b4cd-ef5g-678h-90i1-jk2345678l90", + "tags": [ + "tag1", + "tag2", + "tag3", + "tag4", + "tag5" + ], + "updated_at": "2022-01-15T11:12:04.624837Z", + "zone": "fr-par-2" + } +''' + +from ansible_collections.community.general.plugins.module_utils.scaleway import SCALEWAY_LOCATION, scaleway_argument_spec, Scaleway +from ansible.module_utils.basic import AnsibleModule + + +def get_private_network(api, name, page=1): + page_size = 10 + response = api.get('private-networks', params={'name': name, 'order_by': 'name_asc', 'page': page, 'page_size': page_size}) + if not response.ok: + msg = "Error during get private network creation: %s: '%s' (%s)" % (response.info['msg'], response.json['message'], response.json) + api.module.fail_json(msg=msg) + + if response.json['total_count'] == 0: + return None + + i = 0 + while i < len(response.json['private_networks']): + if response.json['private_networks'][i]['name'] == name: + return response.json['private_networks'][i] + i += 1 + + # search on next page if needed + if (page * page_size) < response.json['total_count']: + return get_private_network(api, name, page + 1) + + return None + + +def present_strategy(api, wished_private_network): + + changed = False + private_network = get_private_network(api, wished_private_network['name']) + if private_network is not None: + if set(wished_private_network['tags']) == set(private_network['tags']): + return changed, private_network + else: + # private network need to be updated + data = {'name': wished_private_network['name'], + 'tags': wished_private_network['tags'] + } + changed = True + if api.module.check_mode: + return changed, {"status": "private network would be updated"} + + response = api.patch(path='private-networks/' + private_network['id'], data=data) + if not response.ok: + api.module.fail_json(msg='Error updating private network [{0}: {1}]'.format(response.status_code, response.json)) + + return changed, response.json + + # private network need to be create + changed = True + if api.module.check_mode: + return changed, {"status": "private network would be created"} + + data = {'name': wished_private_network['name'], + 'project_id': wished_private_network['project'], + 'tags': wished_private_network['tags'] + } + + response = api.post(path='private-networks/', data=data) + + if not response.ok: + api.module.fail_json(msg='Error creating private network [{0}: {1}]'.format(response.status_code, response.json)) + + return changed, response.json + + +def absent_strategy(api, wished_private_network): + + changed = False + private_network = get_private_network(api, wished_private_network['name']) + if private_network is None: + return changed, {} + + changed = True + if api.module.check_mode: + return changed, {"status": "private network would be destroyed"} + + response = api.delete('private-networks/' + private_network['id']) + + if not response.ok: + api.module.fail_json(msg='Error deleting private network [{0}: {1}]'.format( + response.status_code, response.json)) + + return changed, response.json + + +def core(module): + + wished_private_network = { + "project": module.params['project'], + "tags": module.params['tags'], + "name": module.params['name'] + } + + region = module.params["region"] + module.params['api_url'] = SCALEWAY_LOCATION[region]["api_endpoint_vpc"] + + api = Scaleway(module=module) + if module.params["state"] == "absent": + changed, summary = absent_strategy(api=api, wished_private_network=wished_private_network) + else: + changed, summary = present_strategy(api=api, wished_private_network=wished_private_network) + module.exit_json(changed=changed, scaleway_private_network=summary) + + +def main(): + argument_spec = scaleway_argument_spec() + argument_spec.update(dict( + state=dict(default='present', choices=['absent', 'present']), + project=dict(required=True), + region=dict(required=True, choices=list(SCALEWAY_LOCATION.keys())), + tags=dict(type="list", elements="str", default=[]), + name=dict() + )) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + core(module) + + +if __name__ == '__main__': + main() diff --git a/plugins/modules/scaleway_private_network.py b/plugins/modules/scaleway_private_network.py new file mode 120000 index 0000000000..b35eef41c1 --- /dev/null +++ b/plugins/modules/scaleway_private_network.py @@ -0,0 +1 @@ +cloud/scaleway/scaleway_private_network.py \ No newline at end of file diff --git a/tests/unit/plugins/modules/cloud/scaleway/test_scaleway_private_network.py b/tests/unit/plugins/modules/cloud/scaleway/test_scaleway_private_network.py new file mode 100644 index 0000000000..be7d6fd798 --- /dev/null +++ b/tests/unit/plugins/modules/cloud/scaleway/test_scaleway_private_network.py @@ -0,0 +1,197 @@ + +# Copyright: (c) 2019, Ansible Project +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os +import json +import pytest + + +from ansible_collections.community.general.plugins.modules.cloud.scaleway import scaleway_private_network +from ansible_collections.community.general.plugins.module_utils.scaleway import Scaleway, Response +from ansible_collections.community.general.tests.unit.plugins.modules.utils import set_module_args +from ansible_collections.community.general.tests.unit.compat.mock import patch + + +def response_with_zero_network(): + info = {"status": 200, + "body": '{ "private_networks": [], "total_count": 0}' + } + return Response(None, info) + + +def response_with_new_network(): + info = {"status": 200, + "body": ('{ "private_networks": [{' + '"id": "c123b4cd-ef5g-678h-90i1-jk2345678l90",' + '"name": "new_network_name",' + '"tags": ["tag1"]' + '}], "total_count": 1}' + ) + } + return Response(None, info) + + +def response_create_new(): + info = {"status": 200, + "body": ('{"id": "c123b4cd-ef5g-678h-90i1-jk2345678l90",' + '"name": "anoter_network",' + '"organization_id": "a123b4cd-ef5g-678h-90i1-jk2345678l90",' + '"project_id": "a123b4cd-ef5g-678h-90i1-jk2345678l90",' + '"zone": "fr-par-2",' + '"tags": ["tag1"],' + '"created_at": "2019-04-18T15:27:24.177854Z",' + '"updated_at": "2019-04-18T15:27:24.177854Z"}' + ) + } + return Response(None, info) + + +def response_create_new_newtag(): + info = {"status": 200, + "body": ('{"id": "c123b4cd-ef5g-678h-90i1-jk2345678l90",' + '"name": "anoter_network",' + '"organization_id": "a123b4cd-ef5g-678h-90i1-jk2345678l90",' + '"project_id": "a123b4cd-ef5g-678h-90i1-jk2345678l90",' + '"zone": "fr-par-2",' + '"tags": ["newtag"],' + '"created_at": "2019-04-18T15:27:24.177854Z",' + '"updated_at": "2020-01-18T15:27:24.177854Z"}' + ) + } + return Response(None, info) + + +def response_delete(): + info = {"status": 204} + return Response(None, info) + + +def test_scaleway_private_network_without_arguments(capfd): + set_module_args({}) + with pytest.raises(SystemExit) as results: + scaleway_private_network.main() + out, err = capfd.readouterr() + + assert not err + assert json.loads(out)['failed'] + + +def test_scaleway_create_pn(capfd): + set_module_args({"state": "present", + "project": "a123b4cd-ef5g-678h-90i1-jk2345678l90", + "region": "par2", + "name": "new_network_name", + "tags": ["tag1"] + }) + + os.environ['SCW_API_TOKEN'] = 'notrealtoken' + with patch.object(Scaleway, 'get') as mock_scw_get: + mock_scw_get.return_value = response_with_zero_network() + with patch.object(Scaleway, 'post') as mock_scw_post: + mock_scw_post.return_value = response_create_new() + with pytest.raises(SystemExit) as results: + scaleway_private_network.main() + mock_scw_post.assert_any_call(path='private-networks/', data={'name': 'new_network_name', + 'project_id': 'a123b4cd-ef5g-678h-90i1-jk2345678l90', + 'tags': ['tag1']}) + mock_scw_get.assert_any_call('private-networks', params={'name': 'new_network_name', 'order_by': 'name_asc', 'page': 1, 'page_size': 10}) + + out, err = capfd.readouterr() + del os.environ['SCW_API_TOKEN'] + + +def test_scaleway_existing_pn(capfd): + set_module_args({"state": "present", + "project": "a123b4cd-ef5g-678h-90i1-jk2345678l90", + "region": "par2", + "name": "new_network_name", + "tags": ["tag1"] + }) + + os.environ['SCW_API_TOKEN'] = 'notrealtoken' + with patch.object(Scaleway, 'get') as mock_scw_get: + mock_scw_get.return_value = response_with_new_network() + with pytest.raises(SystemExit) as results: + scaleway_private_network.main() + mock_scw_get.assert_any_call('private-networks', params={'name': 'new_network_name', 'order_by': 'name_asc', 'page': 1, 'page_size': 10}) + + out, err = capfd.readouterr() + del os.environ['SCW_API_TOKEN'] + + assert not err + assert not json.loads(out)['changed'] + + +def test_scaleway_add_tag_pn(capfd): + set_module_args({"state": "present", + "project": "a123b4cd-ef5g-678h-90i1-jk2345678l90", + "region": "par2", + "name": "new_network_name", + "tags": ["newtag"] + }) + + os.environ['SCW_API_TOKEN'] = 'notrealtoken' + with patch.object(Scaleway, 'get') as mock_scw_get: + mock_scw_get.return_value = response_with_new_network() + with patch.object(Scaleway, 'patch') as mock_scw_patch: + mock_scw_patch.return_value = response_create_new_newtag() + with pytest.raises(SystemExit) as results: + scaleway_private_network.main() + mock_scw_patch.assert_any_call(path='private-networks/c123b4cd-ef5g-678h-90i1-jk2345678l90', data={'name': 'new_network_name', 'tags': ['newtag']}) + mock_scw_get.assert_any_call('private-networks', params={'name': 'new_network_name', 'order_by': 'name_asc', 'page': 1, 'page_size': 10}) + + out, err = capfd.readouterr() + del os.environ['SCW_API_TOKEN'] + + assert not err + assert json.loads(out)['changed'] + + +def test_scaleway_remove_pn(capfd): + set_module_args({"state": "absent", + "project": "a123b4cd-ef5g-678h-90i1-jk2345678l90", + "region": "par2", + "name": "new_network_name", + "tags": ["newtag"] + }) + + os.environ['SCW_API_TOKEN'] = 'notrealtoken' + with patch.object(Scaleway, 'get') as mock_scw_get: + mock_scw_get.return_value = response_with_new_network() + with patch.object(Scaleway, 'delete') as mock_scw_delete: + mock_scw_delete.return_value = response_delete() + with pytest.raises(SystemExit) as results: + scaleway_private_network.main() + mock_scw_delete.assert_any_call('private-networks/c123b4cd-ef5g-678h-90i1-jk2345678l90') + mock_scw_get.assert_any_call('private-networks', params={'name': 'new_network_name', 'order_by': 'name_asc', 'page': 1, 'page_size': 10}) + + out, err = capfd.readouterr() + del os.environ['SCW_API_TOKEN'] + + assert not err + assert json.loads(out)['changed'] + + +def test_scaleway_absent_pn_not_exists(capfd): + set_module_args({"state": "absent", + "project": "a123b4cd-ef5g-678h-90i1-jk2345678l90", + "region": "par2", + "name": "new_network_name", + "tags": ["newtag"] + }) + + os.environ['SCW_API_TOKEN'] = 'notrealtoken' + with patch.object(Scaleway, 'get') as mock_scw_get: + mock_scw_get.return_value = response_with_zero_network() + with pytest.raises(SystemExit) as results: + scaleway_private_network.main() + mock_scw_get.assert_any_call('private-networks', params={'name': 'new_network_name', 'order_by': 'name_asc', 'page': 1, 'page_size': 10}) + + out, err = capfd.readouterr() + del os.environ['SCW_API_TOKEN'] + + assert not err + assert not json.loads(out)['changed']