diff --git a/lib/ansible/module_utils/linode.py b/lib/ansible/module_utils/linode.py new file mode 100644 index 0000000000..9d22b8f693 --- /dev/null +++ b/lib/ansible/module_utils/linode.py @@ -0,0 +1,37 @@ +# This code is part of Ansible, but is an independent component. +# This particular file snippet, and this file snippet only, is BSD licensed. +# Modules you write using this snippet, which is embedded dynamically by Ansible +# still belong to the author of the module, and may assign their own license +# to the complete work. +# +# Copyright (c), Luke Murphy @lwm +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. +# IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, +# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, +# PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT +# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE +# USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# + + +def get_user_agent(module): + """Retrieve a user-agent to send with LinodeClient requests.""" + try: + from ansible.module_utils.ansible_release import __version__ as ansible_version + except ImportError: + ansible_version = 'unknown' + return 'Ansible-%s/%s' % (module, ansible_version) diff --git a/lib/ansible/modules/cloud/linode/linode_v4.py b/lib/ansible/modules/cloud/linode/linode_v4.py new file mode 100644 index 0000000000..7eecd2a86e --- /dev/null +++ b/lib/ansible/modules/cloud/linode/linode_v4.py @@ -0,0 +1,297 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2017 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 + +ANSIBLE_METADATA = { + 'metadata_version': '1.1', + 'status': ['preview'], + 'supported_by': 'community', +} + +DOCUMENTATION = """ +--- +module: linode_v4 +short_description: Manage instances on the Linode cloud. +description: Manage instances on the Linode cloud. +version_added: "2.8" +requirements: + - python >= 2.7 + - linode_api4 >= 2.0.0 +author: + - Luke Murphy (@lwm) +notes: + - No Linode resizing is currently implemented. This module will, in time, + replace the current Linode module which uses deprecated API bindings on the + Linode side. +options: + region: + description: + - The region of the instance. This is a required parameter only when + creating Linode instances. See + U(https://developers.linode.com/api/v4#tag/Regions). + required: false + type: str + image: + description: + - The image of the instance. This is a required parameter only when + creating Linode instances. See + U(https://developers.linode.com/api/v4#tag/Images). + type: str + required: false + type: + description: + - The type of the instance. This is a required parameter only when + creating Linode instances. See + U(https://developers.linode.com/api/v4#tag/Linode-Types). + type: str + required: false + label: + description: + - The instance label. This label is used as the main determiner for + idempotence for the module and is therefore mandatory. + type: str + required: true + group: + description: + - The group that the instance should be marked under. Please note, that + group labelling is deprecated but still supported. The encouraged + method for marking instances is to use tags. + type: str + required: false + tags: + description: + - The tags that the instance should be marked under. See + U(https://developers.linode.com/api/v4#tag/Tags). + required: false + type: list + root_pass: + description: + - The password for the root user. If not specified, one will be + generated. This generated password will be available in the task + success JSON. + required: false + type: str + authorized_keys: + description: + - A list of SSH public key parts to deploy for the root user. + required: false + type: list + state: + description: + - The desired instance state. + type: str + choices: + - present + - absent + required: true + access_token: + description: + - The Linode API v4 access token. It may also be specified by exposing + the C(LINODE_ACCESS_TOKEN) environment variable. See + U(https://developers.linode.com/api/v4#section/Access-and-Authentication). + required: true +""" + +EXAMPLES = """ +- name: Create a new Linode. + linode_v4: + label: new-linode + type: g6-nanode-1 + region: eu-west + image: linode/debian9 + root_pass: passw0rd + authorized_keys: + - "ssh-rsa ..." + state: present + +- name: Delete that new Linode. + linode_v4: + label: new-linode + state: absent +""" + +RETURN = """ +instance: + description: The instance description in JSON serialized form. + returned: Always. + type: dict + sample: { + "root_pass": "foobar", # if auto-generated + "alerts": { + "cpu": 90, + "io": 10000, + "network_in": 10, + "network_out": 10, + "transfer_quota": 80 + }, + "backups": { + "enabled": false, + "schedule": { + "day": null, + "window": null + } + }, + "created": "2018-09-26T08:12:33", + "group": "Foobar Group", + "hypervisor": "kvm", + "id": 10480444, + "image": "linode/centos7", + "ipv4": [ + "130.132.285.233" + ], + "ipv6": "2a82:7e00::h03c:46ff:fe04:5cd2/64", + "label": "lin-foo", + "region": "eu-west", + "specs": { + "disk": 25600, + "memory": 1024, + "transfer": 1000, + "vcpus": 1 + }, + "status": "running", + "tags": [], + "type": "g6-nanode-1", + "updated": "2018-09-26T10:10:14", + "watchdog_enabled": true + } +""" + + +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible.module_utils.linode import get_user_agent + +try: + from linode_api4 import Instance, LinodeClient + HAS_LINODE_DEPENDENCY = True +except ImportError: + HAS_LINODE_DEPENDENCY = False + + +def create_linode(module, client, **kwargs): + """Creates a Linode instance and handles return format.""" + if kwargs['root_pass'] is None: + kwargs.pop('root_pass') + + try: + response = client.linode.instance_create(**kwargs) + except Exception as exception: + raise module.fail_json(msg=( + 'Unable to query the Linode API. Saw: %s' + ) % exception) + + try: + if isinstance(response, tuple): + instance, root_pass = response + instance_json = instance._raw_json + instance_json.update({'root_pass': root_pass}) + return instance_json + else: + return response._raw_json + except TypeError: + raise module.fail_json(msg=( + 'Unable to parse Linode instance creation ' + 'response. Please raise a bug against this ' + 'module on https://github.com/ansible/ansible/issues' + )) + + +def maybe_instance_from_label(module, client): + """Try to retrieve an instance based on a label.""" + try: + label = module.params['label'] + result = client.linode.instances(Instance.label == label) + return result[0] + except IndexError: + return None + except Exception as exception: + raise module.fail_json(msg=( + 'Unable to query the Linode API. Saw: %s' + ) % exception) + + +def initialise_module(): + """Initialise the module parameter specification.""" + return AnsibleModule( + argument_spec=dict( + label=dict(type='str', required=True), + state=dict( + type='str', + required=True, + choices=['present', 'absent'] + ), + access_token=dict( + type='str', + required=True, + no_log=True, + fallback=(env_fallback, ['LINODE_ACCESS_TOKEN']), + ), + authorized_keys=dict(type='list', required=False), + group=dict(type='str', required=False), + image=dict(type='str', required=False), + region=dict(type='str', required=False), + root_pass=dict(type='str', required=False, no_log=True), + tags=dict(type='list', required=False), + type=dict(type='str', required=False), + ), + supports_check_mode=False, + required_one_of=( + ['state', 'label'], + ), + required_together=( + ['region', 'image', 'type'], + ) + ) + + +def build_client(module): + """Build a LinodeClient.""" + return LinodeClient( + module.params['access_token'], + user_agent=get_user_agent('linode_v4_module') + ) + + +def main(): + """Module entrypoint.""" + module = initialise_module() + + if not HAS_LINODE_DEPENDENCY: + msg = 'The linode_v4 module requires the linode_api4 package' + raise module.fail_json(msg=msg) + + client = build_client(module) + instance = maybe_instance_from_label(module, client) + + if module.params['state'] == 'present' and instance is not None: + module.exit_json(changed=False, instance=instance._raw_json) + + elif module.params['state'] == 'present' and instance is None: + instance_json = create_linode( + module, client, + authorized_keys=module.params['authorized_keys'], + group=module.params['group'], + image=module.params['image'], + label=module.params['label'], + region=module.params['region'], + root_pass=module.params['root_pass'], + tags=module.params['tags'], + ltype=module.params['type'], + ) + module.exit_json(changed=True, instance=instance_json) + + elif module.params['state'] == 'absent' and instance is not None: + instance.delete() + module.exit_json(changed=True, instance=instance._raw_json) + + elif module.params['state'] == 'absent' and instance is None: + module.exit_json(changed=False, instance={}) + + +if __name__ == "__main__": + main() diff --git a/test/units/modules/cloud/linode_v4/__init__.py b/test/units/modules/cloud/linode_v4/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/test/units/modules/cloud/linode_v4/conftest.py b/test/units/modules/cloud/linode_v4/conftest.py new file mode 100644 index 0000000000..5dadafc5f7 --- /dev/null +++ b/test/units/modules/cloud/linode_v4/conftest.py @@ -0,0 +1,68 @@ +import pytest + + +@pytest.fixture +def access_token(monkeypatch): + monkeypatch.setenv('LINODE_ACCESS_TOKEN', 'barfoo') + + +@pytest.fixture +def no_access_token_in_env(monkeypatch): + try: + monkeypatch.delenv('LINODE_ACCESS_TOKEN') + except KeyError: + pass + + +@pytest.fixture +def default_args(): + return {'state': 'present', 'label': 'foo'} + + +@pytest.fixture +def mock_linode(): + class Linode(): + def delete(self, *args, **kwargs): + pass + + @property + def _raw_json(self): + return { + "alerts": { + "cpu": 90, + "io": 10000, + "network_in": 10, + "network_out": 10, + "transfer_quota": 80 + }, + "backups": { + "enabled": False, + "schedule": { + "day": None, + "window": None, + } + }, + "created": "2018-09-26T08:12:33", + "group": "Foobar Group", + "hypervisor": "kvm", + "id": 10480444, + "image": "linode/centos7", + "ipv4": [ + "130.132.285.233" + ], + "ipv6": "2a82:7e00::h03c:46ff:fe04:5cd2/64", + "label": "lin-foo", + "region": "eu-west", + "specs": { + "disk": 25600, + "memory": 1024, + "transfer": 1000, + "vcpus": 1 + }, + "status": "running", + "tags": [], + "type": "g6-nanode-1", + "updated": "2018-09-26T10:10:14", + "watchdog_enabled": True + } + return Linode() diff --git a/test/units/modules/cloud/linode_v4/test_linode_v4.py b/test/units/modules/cloud/linode_v4/test_linode_v4.py new file mode 100644 index 0000000000..a85ff85169 --- /dev/null +++ b/test/units/modules/cloud/linode_v4/test_linode_v4.py @@ -0,0 +1,323 @@ +from __future__ import (absolute_import, division, print_function) + +import json +import os +import sys + +import pytest + +linode_apiv4 = pytest.importorskip('linode_api4') +mandatory_py_version = pytest.mark.skipif( + sys.version_info < (2, 7), + reason='The linode_api4 dependency requires python2.7 or higher' +) + +from linode_api4.errors import ApiError as LinodeApiError +from linode_api4 import LinodeClient + +from ansible.modules.cloud.linode import linode_v4 +from ansible.module_utils.linode import get_user_agent +from units.modules.utils import set_module_args +from units.compat import mock + + +def test_mandatory_state_is_validated(capfd): + with pytest.raises(SystemExit): + set_module_args({'label': 'foo'}) + linode_v4.initialise_module() + + out, err = capfd.readouterr() + results = json.loads(out) + + assert all(txt in results['msg'] for txt in ('state', 'required')) + assert results['failed'] is True + + +def test_mandatory_label_is_validated(capfd): + with pytest.raises(SystemExit): + set_module_args({'state': 'present'}) + linode_v4.initialise_module() + + out, err = capfd.readouterr() + results = json.loads(out) + + assert all(txt in results['msg'] for txt in ('label', 'required')) + assert results['failed'] is True + + +def test_mandatory_access_token_is_validated(default_args, + no_access_token_in_env, + capfd): + with pytest.raises(SystemExit): + set_module_args(default_args) + linode_v4.initialise_module() + + out, err = capfd.readouterr() + results = json.loads(out) + + assert results['failed'] is True + assert all(txt in results['msg'] for txt in ( + 'missing', + 'required', + 'access_token', + )) + + +def test_mandatory_access_token_passed_in_env(default_args, + access_token): + set_module_args(default_args) + + try: + module = linode_v4.initialise_module() + except SystemExit: + pytest.fail("'access_token' is passed in environment") + + now_set_token = module.params['access_token'] + assert now_set_token == os.environ['LINODE_ACCESS_TOKEN'] + + +def test_mandatory_access_token_passed_in_as_parameter(default_args, + no_access_token_in_env): + default_args.update({'access_token': 'foo'}) + set_module_args(default_args) + + try: + module = linode_v4.initialise_module() + except SystemExit: + pytest.fail("'access_token' is passed in as parameter") + + assert module.params['access_token'] == 'foo' + + +def test_instance_by_label_cannot_authenticate(capfd, access_token, + default_args): + set_module_args(default_args) + module = linode_v4.initialise_module() + client = LinodeClient(module.params['access_token']) + + target = 'linode_api4.linode_client.LinodeGroup.instances' + with mock.patch(target, side_effect=LinodeApiError('foo')): + with pytest.raises(SystemExit): + linode_v4.maybe_instance_from_label(module, client) + + out, err = capfd.readouterr() + results = json.loads(out) + + assert results['failed'] is True + assert 'Unable to query the Linode API' in results['msg'] + + +def test_no_instances_found_with_label_gives_none(default_args, + access_token): + set_module_args(default_args) + module = linode_v4.initialise_module() + client = LinodeClient(module.params['access_token']) + + target = 'linode_api4.linode_client.LinodeGroup.instances' + with mock.patch(target, return_value=[]): + result = linode_v4.maybe_instance_from_label(module, client) + + assert result is None + + +def test_optional_region_is_validated(default_args, capfd, access_token): + default_args.update({'type': 'foo', 'image': 'bar'}) + set_module_args(default_args) + + with pytest.raises(SystemExit): + linode_v4.initialise_module() + + out, err = capfd.readouterr() + results = json.loads(out) + + assert results['failed'] is True + assert all(txt in results['msg'] for txt in ( + 'required', + 'together', + 'region' + )) + + +def test_optional_type_is_validated(default_args, capfd, access_token): + default_args.update({'region': 'foo', 'image': 'bar'}) + set_module_args(default_args) + + with pytest.raises(SystemExit): + linode_v4.initialise_module() + + out, err = capfd.readouterr() + results = json.loads(out) + + assert results['failed'] is True + assert all(txt in results['msg'] for txt in ( + 'required', + 'together', + 'type' + )) + + +def test_optional_image_is_validated(default_args, capfd, access_token): + default_args.update({'type': 'foo', 'region': 'bar'}) + set_module_args(default_args) + + with pytest.raises(SystemExit): + linode_v4.initialise_module() + + out, err = capfd.readouterr() + results = json.loads(out) + + assert results['failed'] is True + assert all(txt in results['msg'] for txt in ( + 'required', + 'together', + 'image' + )) + + +def test_instance_already_created(default_args, + mock_linode, + capfd, + access_token): + default_args.update({ + 'type': 'foo', + 'region': 'bar', + 'image': 'baz' + }) + set_module_args(default_args) + + target = 'linode_api4.linode_client.LinodeGroup.instances' + with mock.patch(target, return_value=[mock_linode]): + with pytest.raises(SystemExit) as sys_exit_exc: + linode_v4.main() + + assert sys_exit_exc.value.code == 0 + + out, err = capfd.readouterr() + results = json.loads(out) + + assert results['changed'] is False + assert 'root_password' not in results['instance'] + assert ( + results['instance']['label'] == + mock_linode._raw_json['label'] + ) + + +def test_instance_to_be_created_without_root_pass(default_args, + mock_linode, + capfd, + access_token): + default_args.update({ + 'type': 'foo', + 'region': 'bar', + 'image': 'baz' + }) + set_module_args(default_args) + + target = 'linode_api4.linode_client.LinodeGroup.instances' + with mock.patch(target, return_value=[]): + with pytest.raises(SystemExit) as sys_exit_exc: + target = 'linode_api4.linode_client.LinodeGroup.instance_create' + with mock.patch(target, return_value=(mock_linode, 'passw0rd')): + linode_v4.main() + + assert sys_exit_exc.value.code == 0 + + out, err = capfd.readouterr() + results = json.loads(out) + + assert results['changed'] is True + assert ( + results['instance']['label'] == + mock_linode._raw_json['label'] + ) + assert results['instance']['root_pass'] == 'passw0rd' + + +def test_instance_to_be_created_with_root_pass(default_args, + mock_linode, + capfd, + access_token): + default_args.update({ + 'type': 'foo', + 'region': 'bar', + 'image': 'baz', + 'root_pass': 'passw0rd', + }) + set_module_args(default_args) + + target = 'linode_api4.linode_client.LinodeGroup.instances' + with mock.patch(target, return_value=[]): + with pytest.raises(SystemExit) as sys_exit_exc: + target = 'linode_api4.linode_client.LinodeGroup.instance_create' + with mock.patch(target, return_value=mock_linode): + linode_v4.main() + + assert sys_exit_exc.value.code == 0 + + out, err = capfd.readouterr() + results = json.loads(out) + + assert results['changed'] is True + assert ( + results['instance']['label'] == + mock_linode._raw_json['label'] + ) + assert 'root_pass' not in results['instance'] + + +def test_instance_to_be_deleted(default_args, + mock_linode, + capfd, + access_token): + default_args.update({'state': 'absent'}) + set_module_args(default_args) + + target = 'linode_api4.linode_client.LinodeGroup.instances' + with mock.patch(target, return_value=[mock_linode]): + with pytest.raises(SystemExit) as sys_exit_exc: + linode_v4.main() + + assert sys_exit_exc.value.code == 0 + + out, err = capfd.readouterr() + results = json.loads(out) + + assert results['changed'] is True + assert ( + results['instance']['label'] == + mock_linode._raw_json['label'] + ) + + +def test_instance_already_deleted_no_change(default_args, + mock_linode, + capfd, + access_token): + default_args.update({'state': 'absent'}) + set_module_args(default_args) + + target = 'linode_api4.linode_client.LinodeGroup.instances' + with mock.patch(target, return_value=[]): + with pytest.raises(SystemExit) as sys_exit_exc: + linode_v4.main() + + assert sys_exit_exc.value.code == 0 + + out, err = capfd.readouterr() + results = json.loads(out) + + assert results['changed'] is False + assert results['instance'] == {} + + +def test_user_agent_created_properly(): + try: + from ansible.module_utils.ansible_release import ( + __version__ as ansible_version + ) + except ImportError: + ansible_version = 'unknown' + + expected_user_agent = 'Ansible-linode_v4_module/%s' % ansible_version + assert expected_user_agent == get_user_agent('linode_v4_module')