diff --git a/lib/ansible/module_utils/hcloud.py b/lib/ansible/module_utils/hcloud.py new file mode 100644 index 0000000000..932b0c5294 --- /dev/null +++ b/lib/ansible/module_utils/hcloud.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Hetzner Cloud GmbH + +# Simplified BSD License (see licenses/simplified_bsd.txt or https://opensource.org/licenses/BSD-2-Clause) + +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +from ansible.module_utils.ansible_release import __version__ +from ansible.module_utils.basic import env_fallback, missing_required_lib + +try: + import hcloud + + HAS_HCLOUD = True +except ImportError: + HAS_HCLOUD = False + + +class Hcloud(object): + def __init__(self, module, represent): + self.module = module + self.represent = represent + self.result = {"changed": False, self.represent: None} + if not HAS_HCLOUD: + module.fail_json(msg=missing_required_lib("hcloud-python")) + self._build_client() + + def _build_client(self): + self.client = hcloud.Client( + token=self.module.params["api_token"], + api_endpoint=self.module.params["endpoint"], + application_name="ansible-module", + application_version=__version__, + ) + + def _mark_as_changed(self): + self.result["changed"] = True + + @staticmethod + def base_module_arguments(): + return { + "api_token": { + "type": "str", + "required": True, + "fallback": (env_fallback, ["HCLOUD_TOKEN"]), + "no_log": True, + }, + "endpoint": {"type": "str", "default": "https://api.hetzner.cloud/v1"}, + } + + def _prepare_result(self): + """Prepare the result for every module + + :return: dict + """ + return {} + + def get_result(self): + if getattr(self, self.represent) is not None: + self.result[self.represent] = self._prepare_result() + return self.result diff --git a/lib/ansible/modules/cloud/hcloud/__init__.py b/lib/ansible/modules/cloud/hcloud/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lib/ansible/modules/cloud/hcloud/hcloud_server.py b/lib/ansible/modules/cloud/hcloud/hcloud_server.py new file mode 100644 index 0000000000..bd3a0d743e --- /dev/null +++ b/lib/ansible/modules/cloud/hcloud/hcloud_server.py @@ -0,0 +1,380 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: (c) 2019, Hetzner Cloud GmbH +# 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: hcloud_server + +short_description: Create and manage cloud servers on the Hetzner Cloud. + +version_added: "2.8" + +description: + - Create, update and manage cloud servers on the Hetzner Cloud. + +author: + - Lukas Kaemmerling (@lkaemmerling) + +options: + id: + description: + - The ID of the Hetzner Cloud server to manage. + - Only required if no server I(name) is given + type: int + name: + description: + - The Name of the Hetzner Cloud server to manage. + - Only required if no server I(id) is given or a server does not exists. + type: str + server_type: + description: + - The Server Type of the Hetzner Cloud server to manage. + - Required if server does not exists. + type: str + ssh_keys: + description: + - List of SSH Keys Names + type: list + volumes: + description: + - List of Volumes IDs that should be attached to the server on server creation. + type: list + image: + description: + - Image the server should be created from. + - Required if server does not exists. + type: str + location: + description: + - Location of Server. + - Required if no I(datacenter) is given and server does not exists. + type: str + datacenter: + description: + - Datacenter of Server. + - Required of no I(location) is given and server does not exists. + type: str + backups: + description: + - Enable or disable Backups for the given Server. + type: bool + default: no + upgrade_disk: + description: + - Resize the disk size, when resizing a server. + - If you want to downgrade the server later, this value should be False. + type: bool + default: no + force_upgrade: + description: + - Force the upgrade of the server. + - Power off the server if it is running on upgrade. + type: bool + default: no + user_data: + description: + - User Data to be passed to the server on creation. + - Only used if server does not exists. + type: str + state: + description: + - State of the server. + default: present + choices: [ absent, present, restarted, started, stopped ] + type: str +extends_documentation_fragment: hcloud +""" + +EXAMPLES = """ +- name: Create a basic server + hcloud_server: + name: my-server + server_type: cx11 + image: ubuntu-18.04 + state: present + +- name: Create a basic server with ssh key + hcloud_server: + name: my-server + server_type: cx11 + image: ubuntu-18.04 + location: fsn1 + ssh-key: my-ssh-key + state: present + +- name: Resize an existing server + hcloud_server: + name: my-server + server_type: cx21 + keep_disk: yes + state: present + +- name: Ensure the server is absent (remove if needed) + hcloud_server: + name: my-server + state: absent + +- name: Ensure the server is started + hcloud_server: + name: my-server + state: started + +- name: Ensure the server is stopped + hcloud_server: + name: my-server + state: stopped + +- name: Ensure the server is restarted + hcloud_server: + name: my-server + state: restarted +""" + +RETURN = """ +hcloud_server: + description: The server instance + returned: Always + type: dict + sample: { + "backup_window": null, + "datacenter": "nbg1-dc3", + "id": 1937415, + "image": "ubuntu-18.04", + "ipv4_address": "116.203.104.109", + "ipv6": "2a01:4f8:1c1c:c140::/64", + "labels": {}, + "location": "nbg1", + "name": "mein-server-2", + "rescue_enabled": false, + "server_type": "cx11", + "status": "running" + } +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils._text import to_native +from ansible.module_utils.hcloud import Hcloud + +try: + from hcloud.volumes.domain import Volume + from hcloud.ssh_keys.domain import SSHKey + from hcloud.servers.domain import Server + from hcloud import APIException +except ImportError: + pass + + +class AnsibleHcloudServer(Hcloud): + def __init__(self, module): + Hcloud.__init__(self, module, "hcloud_server") + self.hcloud_server = None + + def _prepare_result(self): + return { + "id": to_native(self.hcloud_server.id), + "name": to_native(self.hcloud_server.name), + "ipv4_address": to_native(self.hcloud_server.public_net.ipv4.ip), + "ipv6": to_native(self.hcloud_server.public_net.ipv6.ip), + "image": to_native(self.hcloud_server.image.name), + "server_type": to_native(self.hcloud_server.server_type.name), + "datacenter": to_native(self.hcloud_server.datacenter.name), + "location": to_native(self.hcloud_server.datacenter.location.name), + "rescue_enabled": self.hcloud_server.rescue_enabled, + "backup_window": to_native(self.hcloud_server.backup_window), + "labels": self.hcloud_server.labels, + "status": to_native(self.hcloud_server.status), + } + + def _get_server(self): + try: + if self.module.params.get("id") is not None: + self.hcloud_server = self.client.servers.get_by_id( + self.module.params.get("id") + ) + else: + self.hcloud_server = self.client.servers.get_by_name( + self.module.params.get("name") + ) + except APIException as e: + self.module.fail_json(msg=e.message) + + def _create_server(self): + + self.module.fail_on_missing_params( + required_params=["name", "server_type", "image"] + ) + params = { + "name": self.module.params.get("name"), + "server_type": self.client.server_types.get_by_name( + self.module.params.get("server_type") + ), + "image": self.client.images.get_by_name(self.module.params.get("image")), + "user_data": self.module.params.get("user_data"), + } + + if self.module.params.get("ssh_keys") is not None: + params["ssh_keys"] = [ + SSHKey(name=ssh_key_name) + for ssh_key_name in self.module.params.get("ssh_keys") + ] + + if self.module.params.get("volumes") is not None: + params["volumes"] = [ + Volume(id=volume_id) for volume_id in self.module.params.get("volumes") + ] + + if self.module.params.get("location") is None and self.module.params.get("datacenter") is None: + # When not given, the API will choose the location. + params["location"] = None + params["datacenter"] = None + elif self.module.params.get("location") is not None and self.module.params.get("datacenter") is None: + params["location"] = self.client.locations.get_by_name( + self.module.params.get("location") + ) + elif self.module.params.get("location") is None and self.module.params.get("datacenter") is not None: + params["datacenter"] = self.client.datacenters.get_by_name( + self.module.params.get("datacenter") + ) + + if not self.module.check_mode: + resp = self.client.servers.create(**params) + self.result["root_password"] = resp.root_password + resp.action.wait_until_finished() + [action.wait_until_finished() for action in resp.next_actions] + self._mark_as_changed() + self._get_server() + + def _update_server(self): + if self.module.params.get("backups") and self.hcloud_server.backup_window is None: + if not self.module.check_mode: + self.hcloud_server.enable_backup().wait_until_finished() + self._mark_as_changed() + elif not self.module.params.get("backups") and self.hcloud_server.backup_window is not None: + if not self.module.check_mode: + self.hcloud_server.disable_backup().wait_until_finished() + self._mark_as_changed() + + server_type = self.module.params.get("server_type") + if server_type is not None and self.hcloud_server.server_type.name != server_type: + previous_server_status = self.hcloud_server.status + state = self.module.params.get("state") + if previous_server_status == Server.STATUS_RUNNING: + if not self.module.check_mode: + if self.module.params.get("force_upgrade") or state == "stopped": + self.stop_server() # Only stopped server can be upgraded + else: + self.module.warn( + "You can not upgrade a running instance %s. You need to stop the instance or use force_upgrade=yes." + % self.hcloud_server.name + ) + timeout = 100 + if self.module.params.get("upgrade_disk"): + timeout = ( + 500 + ) # When we upgrade the disk too the resize progress takes some more time. + if not self.module.check_mode: + self.hcloud_server.change_type( + server_type=self.client.server_types.get_by_name(server_type), + upgrade_disk=self.module.params.get("upgrade_disk"), + ).wait_until_finished(timeout) + if state == "present" and previous_server_status == Server.STATUS_RUNNING or state == "started": + self.start_server() + + self._mark_as_changed() + self._get_server() + + def start_server(self): + if self.hcloud_server.status != Server.STATUS_RUNNING: + if not self.module.check_mode: + self.client.servers.power_on(self.hcloud_server).wait_until_finished() + self._mark_as_changed() + self._get_server() + + def stop_server(self): + if self.hcloud_server.status != Server.STATUS_OFF: + if not self.module.check_mode: + self.client.servers.power_off(self.hcloud_server).wait_until_finished() + self._mark_as_changed() + self._get_server() + + def present_server(self): + self._get_server() + if self.hcloud_server is None: + self._create_server() + else: + self._update_server() + + def delete_server(self): + self._get_server() + if self.hcloud_server is not None: + if not self.module.check_mode: + self.client.servers.delete(self.hcloud_server).wait_until_finished() + self._mark_as_changed() + self.hcloud_server = None + + @staticmethod + def define_module(): + return AnsibleModule( + argument_spec=dict( + id={"type": "int"}, + name={"type": "str"}, + image={"type": "str"}, + server_type={"type": "str"}, + location={"type": "str"}, + datacenter={"type": "str"}, + user_data={"type": "str"}, + ssh_keys={"type": "list"}, + volumes={"type": "list"}, + backups={"type": "bool", "default": False}, + upgrade_disk={"type": "bool", "default": False}, + force_upgrade={"type": "bool", "default": False}, + state={ + "choices": ["absent", "present", "restarted", "started", "stopped"], + "default": "present", + }, + **Hcloud.base_module_arguments() + ), + required_one_of=[['id', 'name']], + mutually_exclusive=[["location", "datacenter"]], + supports_check_mode=True, + ) + + +def main(): + module = AnsibleHcloudServer.define_module() + + hcloud = AnsibleHcloudServer(module) + state = module.params.get("state") + if state == "absent": + hcloud.delete_server() + elif state == "present": + hcloud.present_server() + elif state == "started": + hcloud.present_server() + hcloud.start_server() + elif state == "stopped": + hcloud.present_server() + hcloud.stop_server() + elif state == "restarted": + hcloud.present_server() + hcloud.stop_server() + hcloud.start_server() + + module.exit_json(**hcloud.get_result()) + + +if __name__ == "__main__": + main() diff --git a/lib/ansible/plugins/doc_fragments/hcloud.py b/lib/ansible/plugins/doc_fragments/hcloud.py new file mode 100644 index 0000000000..a92b30268e --- /dev/null +++ b/lib/ansible/plugins/doc_fragments/hcloud.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright: (c) 2019, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +class ModuleDocFragment(object): + + DOCUMENTATION = ''' +options: + api_token: + description: + - This is the API Token for the Hetzner Cloud. + required: True + type: str + endpoint: + description: + - This is the API Endpoint for the Hetzner Cloud. + default: https://api.hetzner.cloud/v1 + type: str +requirements: + - hcloud-python >= 1.0.0 +seealso: +- name: Documentation for Hetzner Cloud API + description: Complete reference for the Hetzner Cloud API. + link: https://docs.hetzner.cloud/ +''' diff --git a/test/integration/cloud-config-hcloud.ini.template b/test/integration/cloud-config-hcloud.ini.template new file mode 100644 index 0000000000..ad93e3e123 --- /dev/null +++ b/test/integration/cloud-config-hcloud.ini.template @@ -0,0 +1,12 @@ +# This is the configuration template for ansible-test Hetzner Cloud integration tests. +# +# You do not need this template if you are: +# +# 1) Running integration tests without using ansible-test. +# +# If you want to test against the Hetzner Cloud public API, +# fill in the values below and save this file without the .template extension. +# This will cause ansible-test to use the given configuration. + +[default] +hcloud_api_token= @TOKEN diff --git a/test/integration/targets/hcloud_server/aliases b/test/integration/targets/hcloud_server/aliases new file mode 100644 index 0000000000..51742ee23f --- /dev/null +++ b/test/integration/targets/hcloud_server/aliases @@ -0,0 +1,2 @@ +cloud/hcloud +unsupported diff --git a/test/integration/targets/hcloud_server/defaults/main.yml b/test/integration/targets/hcloud_server/defaults/main.yml new file mode 100644 index 0000000000..b9a9a8df7b --- /dev/null +++ b/test/integration/targets/hcloud_server/defaults/main.yml @@ -0,0 +1,5 @@ +# Copyright: (c) 2019, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +hcloud_prefix: "tests" +hcloud_server_name: "{{hcloud_prefix}}-integration" diff --git a/test/integration/targets/hcloud_server/tasks/main.yml b/test/integration/targets/hcloud_server/tasks/main.yml new file mode 100644 index 0000000000..c53c0035d3 --- /dev/null +++ b/test/integration/targets/hcloud_server/tasks/main.yml @@ -0,0 +1,275 @@ +# Copyright: (c) 2019, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: setup + hcloud_server: + name: "{{ hcloud_server_name }}" + state: absent + register: result +- name: verify setup + assert: + that: + - result is success +- name: test missing required parameters on create server + hcloud_server: + name: "{{ hcloud_server_name }}" + register: result + ignore_errors: yes +- name: verify fail test missing required parameters on create server + assert: + that: + - result is failed + - 'result.msg == "missing required arguments: server_type, image"' + +- name: test create server with check mode + hcloud_server: + name: "{{ hcloud_server_name }}" + server_type: cx11 + image: ubuntu-18.04 + state: present + register: result + check_mode: yes +- name: test create server server + assert: + that: + - result is changed + +- name: test create server + hcloud_server: + name: "{{ hcloud_server_name}}" + server_type: cx11 + image: ubuntu-18.04 + state: started + register: main_server +- name: verify create server + assert: + that: + - main_server is changed + - main_server.hcloud_server.name == "{{ hcloud_server_name }}" + - main_server.hcloud_server.server_type == "cx11" + - main_server.hcloud_server.status == "running" + - main_server.root_password != "" + +- name: test create server idempotence + hcloud_server: + name: "{{ hcloud_server_name }}" + state: started + register: result +- name: verify create server idempotence + assert: + that: + - result is not changed + +- name: test stop server with check mode + hcloud_server: + name: "{{ hcloud_server_name }}" + state: stopped + register: result + check_mode: yes +- name: verify stop server with check mode + assert: + that: + - result is changed + - result.hcloud_server.status == "running" + +- name: test stop server + hcloud_server: + name: "{{ hcloud_server_name }}" + state: stopped + register: result +- name: verify stop server + assert: + that: + - result is changed + - result.hcloud_server.status == "off" + +- name: test start server with check mode + hcloud_server: + name: "{{ hcloud_server_name }}" + state: started + register: result + check_mode: true +- name: verify start server with check mode + assert: + that: + - result is changed + +- name: test start server + hcloud_server: + name: "{{ hcloud_server_name }}" + state: started + register: result +- name: verify start server + assert: + that: + - result is changed + - result.hcloud_server.status == "running" + +- name: test start server idempotence + hcloud_server: + name: "{{ hcloud_server_name }}" + state: started + register: result +- name: verify start server idempotence + assert: + that: + - result is not changed + - result.hcloud_server.status == "running" + +- name: test stop server by its id + hcloud_server: + id: "{{ main_server.hcloud_server.id }}" + state: stopped + register: result +- name: verify stop server by its id + assert: + that: + - result is changed + - result.hcloud_server.status == "off" + +- name: test resize server running without force + hcloud_server: + name: "{{ hcloud_server_name }}" + server_type: "cx21" + state: present + register: result + check_mode: true +- name: verify test resize server running without force + assert: + that: + - result is changed + - result.hcloud_server.server_type == "cx11" + +- name: test resize server with check mode + hcloud_server: + name: "{{ hcloud_server_name }}" + server_type: "cx21" + state: stopped + register: result + check_mode: true +- name: verify resize server with check mode + assert: + that: + - result is changed + +- name: test resize server without disk + hcloud_server: + name: "{{ hcloud_server_name }}" + server_type: "cx21" + state: stopped + register: result +- name: verify resize server without disk + assert: + that: + - result is changed + - result.hcloud_server.server_type == "cx21" + +- name: test resize server idempotence + hcloud_server: + name: "{{ hcloud_server_name }}" + server_type: "cx21" + state: stopped + register: result +- name: verify resize server idempotence + assert: + that: + - result is not changed + +- name: test resize server to smaller plan + hcloud_server: + name: "{{ hcloud_server_name }}" + server_type: "cx11" + state: stopped + register: result +- name: verify resize server to smaller plan + assert: + that: + - result is changed + - result.hcloud_server.server_type == "cx11" + +- name: test resize server with disk + hcloud_server: + name: "{{ hcloud_server_name }}" + server_type: "cx21" + upgrade_disk: true + state: stopped + register: result +- name: verify resize server with disk + assert: + that: + - result is changed + - result.hcloud_server.server_type == "cx21" + +- name: test enable backups with check mode + hcloud_server: + name: "{{ hcloud_server_name }}" + backups: true + state: stopped + register: result + check_mode: true +- name: verify enable backups with check mode + assert: + that: + - result is changed + +- name: test enable backups + hcloud_server: + name: "{{ hcloud_server_name }}" + backups: true + state: stopped + register: result +- name: verify enable backups + assert: + that: + - result is changed + - result.hcloud_server.backup_window != "" + +- name: test enable backups idempotence + hcloud_server: + name: "{{ hcloud_server_name }}" + backups: true + state: stopped + register: result +- name: verify enable backups idempotence + assert: + that: + - result is not changed + - result.hcloud_server.backup_window != "" + +- name: absent server + hcloud_server: + name: "{{ hcloud_server_name }}" + state: absent + register: result +- name: verify absent server + assert: + that: + - result is success + +- name: test create server with ssh key + hcloud_server: + name: "{{ hcloud_server_name}}" + server_type: cx11 + image: "ubuntu-18.04" + ssh_keys: + - ci@ansible.hetzner.cloud + state: started + register: main_server +- name: verify create server + assert: + that: + - main_server is changed + - main_server.hcloud_server.name == "{{ hcloud_server_name }}" + - main_server.hcloud_server.server_type == "cx11" + - main_server.hcloud_server.status == "running" + - main_server.root_password != "" + +- name: cleanup + hcloud_server: + name: "{{ hcloud_server_name }}" + state: absent + register: result +- name: verify cleanup + assert: + that: + - result is success diff --git a/test/runner/lib/cloud/hcloud.py b/test/runner/lib/cloud/hcloud.py new file mode 100644 index 0000000000..7c985d4b9a --- /dev/null +++ b/test/runner/lib/cloud/hcloud.py @@ -0,0 +1,69 @@ +"""Hetzner Cloud plugin for integration tests.""" +from __future__ import absolute_import, print_function + +from os.path import isfile + +from lib.cloud import ( + CloudProvider, + CloudEnvironment, + CloudEnvironmentConfig, +) + +from lib.util import ConfigParser, display + + +class HcloudCloudProvider(CloudProvider): + """Hetzner Cloud provider plugin. Sets up cloud resources before + delegation. + """ + + def __init__(self, args): + """ + :type args: TestConfig + """ + super(HcloudCloudProvider, self).__init__(args) + + def filter(self, targets, exclude): + """Filter out the cloud tests when the necessary config and resources are not available. + :type targets: tuple[TestTarget] + :type exclude: list[str] + """ + if isfile(self.config_static_path): + return + + super(HcloudCloudProvider, self).filter(targets, exclude) + + def setup(self): + """Setup the cloud resource before delegation and register a cleanup callback.""" + super(HcloudCloudProvider, self).setup() + + if isfile(self.config_static_path): + self.config_path = self.config_static_path + return True + + return False + + +class HcloudCloudEnvironment(CloudEnvironment): + """Hetzner Cloud cloud environment plugin. Updates integration test environment + after delegation. + """ + + def get_environment_config(self): + parser = ConfigParser() + parser.read(self.config_path) + + env_vars = dict( + HCLOUD_TOKEN=parser.get('default', 'hcloud_api_token'), + ) + + ansible_vars = dict( + hcloud_prefix=self.resource_prefix, + ) + + ansible_vars.update(dict((key.lower(), value) for key, value in env_vars.items())) + + return CloudEnvironmentConfig( + env_vars=env_vars, + ansible_vars=ansible_vars, + )