From 5c9653168174b50dac91a57cbcd8e254ec2517d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Moser?= Date: Thu, 8 Sep 2022 22:42:38 +0200 Subject: [PATCH] implement instance module (#21) --- plugins/module_utils/vultr_v2.py | 113 ++-- plugins/modules/instance.py | 584 ++++++++++++++++++ plugins/modules/startup_script.py | 16 +- tests/integration/targets/instance/aliases | 3 + .../targets/instance/defaults/main.yml | 45 ++ .../targets/instance/meta/main.yml | 3 + .../targets/instance/tasks/absent.yml | 38 ++ .../targets/instance/tasks/failures.yml | 39 ++ .../targets/instance/tasks/main.yml | 13 + .../targets/instance/tasks/present.yml | 148 +++++ .../targets/instance/tasks/stop-start.yml | 78 +++ .../targets/instance/tasks/tests.yml | 47 ++ 12 files changed, 1070 insertions(+), 57 deletions(-) create mode 100644 plugins/modules/instance.py create mode 100644 tests/integration/targets/instance/aliases create mode 100644 tests/integration/targets/instance/defaults/main.yml create mode 100644 tests/integration/targets/instance/meta/main.yml create mode 100644 tests/integration/targets/instance/tasks/absent.yml create mode 100644 tests/integration/targets/instance/tasks/failures.yml create mode 100644 tests/integration/targets/instance/tasks/main.yml create mode 100644 tests/integration/targets/instance/tasks/present.yml create mode 100644 tests/integration/targets/instance/tasks/stop-start.yml create mode 100644 tests/integration/targets/instance/tasks/tests.yml diff --git a/plugins/module_utils/vultr_v2.py b/plugins/module_utils/vultr_v2.py index f2abffe..0d1fcba 100644 --- a/plugins/module_utils/vultr_v2.py +++ b/plugins/module_utils/vultr_v2.py @@ -8,6 +8,7 @@ import random import time +import urllib from ansible.module_utils._text import to_native, to_text from ansible.module_utils.basic import env_fallback @@ -34,9 +35,7 @@ def vultr_argument_spec(): fallback=(env_fallback, ["VULTR_API_TIMEOUT"]), default=60, ), - api_retries=dict( - type="int", fallback=(env_fallback, ["VULTR_API_RETRIES"]), default=5 - ), + api_retries=dict(type="int", fallback=(env_fallback, ["VULTR_API_RETRIES"]), default=5), api_retry_max_delay=dict( type="int", fallback=(env_fallback, ["VULTR_API_RETRY_MAX_DELAY"]), @@ -49,6 +48,14 @@ def vultr_argument_spec(): ) +def backoff(retry, retry_max_delay=12): + randomness = random.randint(0, 1000) / 1000.0 + delay = 2**retry + randomness + if delay > retry_max_delay: + delay = retry_max_delay + randomness + time.sleep(delay) + + class AnsibleVultr: def __init__( self, @@ -72,9 +79,7 @@ def __init__( self.ressource_result_key_singular = ressource_result_key_singular # The API result data key e.g ssh_keys - self.ressource_result_key_plural = ( - ressource_result_key_plural or "%ss" % ressource_result_key_singular - ) + self.ressource_result_key_plural = ressource_result_key_plural or "%ss" % ressource_result_key_singular # The API resource path e.g /ssh-keys self.resource_path = resource_path @@ -122,8 +127,17 @@ def configure(self): pass def api_query(self, path, method="GET", data=None): + + if method == "GET" and data: + data_encoded = data.copy() + try: + data = urllib.urlencode(data_encoded) + except AttributeError: + data = urllib.parse.urlencode(data_encoded) + else: + data = self.module.jsonify(data) + retry_max_delay = self.module.params["api_retry_max_delay"] - randomness = random.randint(0, 1000) / 1000.0 info = dict() resp_body = None @@ -132,37 +146,31 @@ def api_query(self, path, method="GET", data=None): self.module, self.module.params["api_endpoint"] + path, method=method, - data=self.module.jsonify(data), + data=data, headers=self.headers, timeout=self.module.params["api_timeout"], ) resp_body = resp.read() if resp is not None else "" - # 429 Too Many Requests + # Check for 429 Too Many Requests if info["status"] != 429: break # Vultr has a rate limiting requests per second, try to be polite # Use exponential backoff plus a little bit of randomness - delay = 2**retry + randomness - if delay > retry_max_delay: - delay = retry_max_delay + randomness - time.sleep(delay) + backoff(retry=retry, retry_max_delay=retry_max_delay) # Success with content if info["status"] in (200, 201, 202): - return self.module.from_json( - to_text(resp_body, errors="surrogate_or_strict") - ) + return self.module.from_json(to_text(resp_body, errors="surrogate_or_strict")) # Success without content if info["status"] in (404, 204): return dict() self.module.fail_json( - msg='Failure while calling the Vultr API v2 with %s for "%s".' - % (method, path), + msg='Failure while calling the Vultr API v2 with %s for "%s".' % (method, path), fetch_url_info=info, ) @@ -173,19 +181,17 @@ def query_filter_list_by_name( result_key, param_key=None, key_id=None, + query_params=None, get_details=False, fail_not_found=False, ): param_value = self.module.params.get(param_key or key_name) found = dict() - for resource in self.query_list(path=path, result_key=result_key): + for resource in self.query_list(path=path, result_key=result_key, query_params=query_params): if resource.get(key_name) == param_value: if found: - self.module.fail_json( - msg="More than one record with name=%s found. " - "Use multiple=yes if module supports it." % param_value - ) + self.module.fail_json(msg="More than one record with name=%s found. " "Use multiple=yes if module supports it." % param_value) found = resource if found: if get_details: @@ -194,9 +200,7 @@ def query_filter_list_by_name( return found elif fail_not_found: - self.module.fail_json( - msg="No Resource %s with %s found: %s" % (path, key_name, param_value) - ) + self.module.fail_json(msg="No Resource %s with %s found: %s" % (path, key_name, param_value)) return dict() @@ -215,9 +219,7 @@ def query_by_id(self, resource_id=None, path=None, result_key=None): path = path or self.resource_path result_key = result_key or self.ressource_result_key_singular - resource = self.api_query( - path="%s%s" % (path, "/" + resource_id if resource_id else resource_id) - ) + resource = self.api_query(path="%s%s" % (path, "/" + resource_id if resource_id else resource_id)) if resource: return resource[result_key] @@ -227,14 +229,29 @@ def query(self): # Returns a single dict representing the resource return self.query_filter_list() - def query_list(self, path=None, result_key=None): + def query_list(self, path=None, result_key=None, query_params=None): # Defaults path = path or self.resource_path result_key = result_key or self.ressource_result_key_plural - resources = self.api_query(path=path) + resources = self.api_query(path=path, data=query_params) return resources[result_key] if resources else [] + def wait_for_state(self, resource, key, state, cmp="="): + for retry in range(0, 30): + resource = self.query_by_id(resource_id=resource[self.resource_key_id]) + if cmp == "=": + if key not in resource or resource[key] == state or not resource[key]: + break + else: + if key not in resource or resource[key] != state or not resource[key]: + break + backoff(retry=retry) + else: + self.module.fail_json(msg="Wait for %s to become %s timed out" % (key, state)) + + return resource + def create_or_update(self): resource = self.query() if not resource: @@ -265,26 +282,32 @@ def create(self): ) return resource.get(self.ressource_result_key_singular) if resource else dict() - def is_diff(self, data, resource): - for key, value in data.items(): - if value is None: - continue - elif isinstance(value, list): - for v in value: - if v not in resource[key]: - return True - elif resource[key] != value: - return True + def is_diff(self, param, resource): + value = self.module.params.get(param) + if value is None: + return False + + if param not in resource: + self.module.fail_json(msg="Can not diff, key %s not found in resource" % param) + + if isinstance(value, list): + for v in value: + if v not in resource[param]: + return True + elif resource[param] != value: + return True + return False def update(self, resource): data = dict() - for param in self.resource_update_param_keys: - data[param] = self.module.params.get(param) - if self.is_diff(data, resource): - self.result["changed"] = True + for param in self.resource_update_param_keys: + if self.is_diff(param, resource): + self.result["changed"] = True + data[param] = self.module.params.get(param) + if self.result["changed"]: self.result["diff"]["before"] = dict(**resource) self.result["diff"]["after"] = dict(**resource) self.result["diff"]["after"].update(data) diff --git a/plugins/modules/instance.py b/plugins/modules/instance.py new file mode 100644 index 0000000..df783b1 --- /dev/null +++ b/plugins/modules/instance.py @@ -0,0 +1,584 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2022, René Moser +# 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: instance +short_description: Manages server instances on Vultr. +description: + - Manage server instances on Vultr. +version_added: "1.1.0" +author: + - "René Moser (@resmo)" +options: + label: + description: + - Name of the instance. + required: true + aliases: [ name ] + type: str + hostname: + description: + - The hostname to assign to this instance. + type: str + os: + description: + - The operating system name. + - Mutually exclusive with I(image) and I(app). + type: str + app: + description: + - The app deploy name of Vultr OneClick apps. + - Mutually exclusive with I(image) and I(os). + type: str + image: + description: + - The image deploy name of Vultr Marketplace apps. + - Mutually exclusive with I(os) and I(app). + type: str + firewall_group: + description: + - The firewall group description to assign this instance to. + type: str + plan: + description: + - The plan name to use for the instance. + - Required if the instance does not yet exist. + type: str + activation_email: + description: + - Whether to send an activation email when the instance is ready or not. + - Only considered on creation. + type: bool + default: false + backups: + description: + - Whether to enable automatic backups or not. + type: bool + ddos_protection: + description: + - Whether to enable ddos_protection or not. + type: bool + enable_ipv6: + description: + - Whether to enable IPv6 or not. + type: bool + tags: + description: + - Tags for the instance. + type: list + elements: str + user_data: + description: + - User data to be passed to the instance. + type: str + startup_script: + description: + - Name or ID of the startup script to execute on boot. + - Only considered while creating the instance. + type: str + ssh_keys: + description: + - List of SSH key names passed to the instance on creation. + type: list + elements: str + reserved_ipv4: + description: + - IP address of the floating IP to use as the main IP of this instance. + - Only considered on creation. + type: str + region: + description: + - Region the instance is deployed into. + type: str + required: true + state: + description: + - State of the instance. + default: present + choices: [ present, absent, started, stopped, restarted ] + type: str +extends_documentation_fragment: + - vultr.cloud.vultr_v2 +""" + +EXAMPLES = """ +--- +- name: Create an instance using OS + vultr.cloud.instance: + label: my web server + hostname: my-hostname + user_data: | + #cloud-config + packages: + - nginx + firewall_group: my firewall group + plan: vc2-1c-2gb + ddos_protection: true + backups: true + enable_ipv6: true + ssh_keys: + - my ssh key + tags: + - web + - project-genesis + region: ams + os: Debian 11 x64 (bullseye) + +- name: Deploy an instance of a marketplace app + vultr.cloud.instance: + label: git-server + hostname: git + firewall_group: my firewall group + plan: vc2-1c-2gb + ddos_protection: true + backups: true + enable_ipv6: true + region: ams + image: Gitea on Ubuntu 20.04 + +- name: Stop an existing instance + vultr.cloud.instance: + label: my web server + region: ams + state: stopped + +- name: Start an existing instance + vultr.cloud.instance: + label: my web server + region: ams + state: started + +- name: Delete an instance + vultr.cloud.instance: + label: my web server + region: ams + state: absent +""" + +RETURN = """ +--- +vultr_api: + description: Response from Vultr API with a few additions/modification. + returned: success + type: dict + contains: + api_timeout: + description: Timeout used for the API requests. + returned: success + type: int + sample: 60 + api_retries: + description: Amount of max retries for the API requests. + returned: success + type: int + sample: 5 + api_retry_max_delay: + description: Exponential backoff delay in seconds between retries up to this max delay value. + returned: success + type: int + sample: 12 + api_endpoint: + description: Endpoint used for the API requests. + returned: success + type: str + sample: "https://api.vultr.com/v2" +vultr_instance: + description: Response from Vultr API. + returned: success + type: dict + contains: + id: + description: ID of the instance. + returned: success + type: str + sample: cb676a46-66fd-4dfb-b839-443f2e6c0b60 + v6_main_ip: + description: IPv6 of the instance. + returned: success + type: str + sample: "" + v6_network: + description: IPv6 network of the instance. + returned: success + type: str + sample: "" + v6_network_size: + description: IPv6 network size of the instance. + returned: success + type: int + sample: 0 + main_ip: + description: IPv4 of the instance. + returned: success + type: str + sample: 95.179.189.95 + netmask_v4: + description: Netmask IPv4 of the instance. + returned: success + type: str + sample: 255.255.254.0 + hostname: + description: Hostname of the instance. + returned: success + type: str + sample: vultr.guest + internal_ip: + description: Internal IP of the instance. + returned: success + type: str + sample: "" + gateway_v4: + description: Gateway IPv4. + returned: success + type: str + sample: 95.179.188.1 + kvm: + description: KVM of the instance. + returned: success + type: str + sample: "https://my.vultr.com/subs/vps/novnc/api.php?data=..." + disk: + description: Disk size of the instance. + returned: success + type: int + sample: 25 + allowed_bandwidth: + description: Allowed bandwidth of the instance. + returned: success + type: int + sample: 1000 + vcpu_count: + description: vCPUs of the instance. + returned: success + type: int + sample: 1 + firewall_group_id: + description: Firewall group ID of the instance. + returned: success + type: str + sample: "" + plan: + description: Plan of the instance. + returned: success + type: str + sample: vc2-1c-1gb + image_id: + description: Image ID of the instance. + returned: success + type: str + sample: "" + os_id: + description: OS ID of the instance. + returned: success + type: int + sample: 186 + app_id: + description: App ID of the instance. + returned: success + type: int + sample: 37 + date_created: + description: Date when the instance was created. + returned: success + type: str + sample: "2020-10-10T01:56:20+00:00" + label: + description: Label of the instance. + returned: success + type: str + sample: my instance + region: + description: Region the instance was deployed into. + returned: success + type: str + sample: ews + status: + description: Status about the deployment of the instance. + returned: success + type: str + sample: active + server_status: + description: Server status of the instance. + returned: success + type: str + sample: installingbooting + power_status: + description: Power status of the instance. + returned: success + type: str + sample: running + ram: + description: RAM in MB of the instance. + returned: success + type: int + sample: 1024 + os: + description: OS of the instance. + returned: success + type: str + sample: Application + tags: + description: Tags of the instance. + returned: success + type: list + sample: [ my-tag ] + features: + description: Features of the instance. + returned: success + type: list + sample: [ ddos_protection, ipv6, auto_backups ] + user_data: + description: Base64 encoded user data (cloud init) of the instance. + returned: success + type: str + sample: I2Nsb3VkLWNvbmZpZwpwYWNrYWdlczoKICAtIGh0b3AK +""" + +import base64 + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.vultr_v2 import AnsibleVultr, vultr_argument_spec + + +class AnsibleVultrInstance(AnsibleVultr): + def get_ssh_key_ids(self): + ssh_key_names = list(self.module.params["ssh_keys"]) + ssh_keys = self.query_list(path="/ssh-keys", result_key="ssh_keys") + + ssh_key_ids = [] + for ssh_key in ssh_keys: + if ssh_key["name"] in ssh_key_names: + ssh_key_ids.append(ssh_key["id"]) + ssh_key_names.remove(ssh_key["name"]) + + if ssh_key_names: + self.module.fail_json(msg="SSH key names not found: %s" % ", ".join(ssh_key_names)) + + return ssh_key_ids + + def get_firewall_group(self): + return self.query_filter_list_by_name( + key_name="description", + param_key="firewall_group", + path="/firewalls", + result_key="firewall_groups", + fail_not_found=True, + ) + + def get_startup_script(self): + return self.query_filter_list_by_name( + key_name="name", + param_key="startup_script", + path="/startup-scripts", + result_key="startup_scripts", + fail_not_found=True, + ) + + def get_os(self): + return self.query_filter_list_by_name( + key_name="name", + param_key="os", + path="/os", + result_key="os", + fail_not_found=True, + ) + + def get_app(self): + return self.query_filter_list_by_name( + key_name="deploy_name", + param_key="app", + path="/applications", + result_key="applications", + fail_not_found=True, + query_params={"type": "one-click"}, + ) + + def get_image(self): + return self.query_filter_list_by_name( + key_name="deploy_name", + param_key="image", + path="/applications", + result_key="applications", + fail_not_found=True, + query_params={"type": "marketplace"}, + ) + + def get_user_data(self, resource): + res = self.api_query( + path="%s/%s/%s" % (self.resource_path, resource[self.resource_key_id], "user-data"), + ) + if res: + return str(res.get("user_data", dict()).get("data")) + return "" + + def configure(self): + if self.module.params["state"] != "absent": + if self.module.params["startup_script"] is not None: + self.module.params["startup_script_id"] = self.get_startup_script()["id"] + + if self.module.params["firewall_group"] is not None: + self.module.params["firewall_group_id"] = self.get_firewall_group()["id"] + + if self.module.params["os"] is not None: + self.module.params["os_id"] = self.get_os()["id"] + + if self.module.params["app"] is not None: + self.module.params["app_id"] = self.get_app()["id"] + + if self.module.params["image"] is not None: + self.module.params["image_id"] = self.get_image()["image_id"] + + if self.module.params["user_data"] is not None: + self.module.params["user_data"] = base64.b64encode(self.module.params["user_data"].encode()) + + if self.module.params["ssh_keys"] is not None: + # sshkey_id ist a list of ids + self.module.params["sshkey_id"] = self.get_ssh_key_ids() + + def handle_power_status(self, resource, state, action, power_status, force=False): + if state == self.module.params["state"] and (resource["power_status"] != power_status or force): + self.result["changed"] = True + if not self.module.check_mode: + self.api_query( + path="%s/%s/%s" % (self.resource_path, resource[self.resource_key_id], action), + method="POST", + ) + resource = self.wait_for_state(resource=resource, key="power_status", state=power_status) + return resource + + def update_feature(self, param_key, resource, feature=None): + features = resource.get("features", list()) + + if feature is not None: + feature = param_key + + feature_enabled = self.module.params[param_key] + if feature_enabled is not None: + if feature_enabled != feature in features: + self.resource_update_param_keys.append(feature) + + def create(self): + param_keys = ("os", "image", "app") + if not any(self.module.params.get(x) is not None for x in param_keys): + self.module.fail_json(msg="missing required arguements, one of the following required: %s" % ", ".join(param_keys)) + return super(AnsibleVultrInstance, self).create() + + def update(self, resource): + self.update_feature(param_key="backups", resource=resource, feature="auto_backup") + self.update_feature(param_key="ddos_protection", resource=resource) + self.update_feature(param_key="enable_ipv6", resource=resource, feature="ipv6") + user_data = self.get_user_data(resource=resource) + resource["user_data"] = user_data.encode() + return super(AnsibleVultrInstance, self).update(resource=resource) + + def create_or_update(self): + resource = super(AnsibleVultrInstance, self).create_or_update() + if resource: + resource = self.wait_for_state(resource=resource, key="status", state="active") + resource = self.wait_for_state(resource=resource, key="server_status", state="locked", cmp="!=") + # Hanlde power status + resource = self.handle_power_status(resource=resource, state="stopped", action="halt", power_status="stopped") + resource = self.handle_power_status(resource=resource, state="started", action="start", power_status="running") + resource = self.handle_power_status(resource=resource, state="restarted", action="reboot", power_status="running", force=True) + return resource + + def transform_result(self, resource): + if resource: + resource["user_data"] = self.get_user_data(resource=resource) + return resource + + +def main(): + argument_spec = vultr_argument_spec() + argument_spec.update( + dict( + label=dict(type="str", required=True, aliases=["name"]), + hostname=dict(type="str"), + app=dict(type="str"), + image=dict(type="str"), + os=dict(type="str"), + plan=dict(type="str"), + activation_email=dict(type="bool", default=False), + ddos_protection=dict(type="bool"), + backups=dict(type="bool"), + enable_ipv6=dict(type="bool"), + tags=dict(type="list", elements="str"), + reserved_ipv4=dict(type="str"), + firewall_group=dict(type="str"), + startup_script=dict(type="str"), + user_data=dict(type="str"), + ssh_keys=dict(type="list", elements="str", no_log=False), + region=dict(type="str", required=True), + state=dict( + choices=[ + "present", + "absent", + "started", + "stopped", + "restarted", + ], + default="present", + ), + ) # type: ignore + ) + + module = AnsibleModule( + argument_spec=argument_spec, + required_if=(("state", "present", ("plan",)),), + mutually_exclusive=(("os", "app", "image"),), + supports_check_mode=True, + ) + + vultr = AnsibleVultrInstance( + module=module, + namespace="vultr_instance", + resource_path="/instances", + ressource_result_key_singular="instance", + resource_create_param_keys=[ + "label", + "hostname", + "plan", + "app_id", + "os_id", + "iso_id", + "image_id", + "script_id", + "region", + "enable_ipv6", + "reserved_ipv4", + "firewall_group_id", + "user_data", + "tags", + "activation_email", + "ddos_protection", + "ssh_keys", + "backups", + ], + resource_update_param_keys=[ + "plan", + "tags", + "firewall_group_id", + "user_data", + ], + resource_key_name="label", + ) + + state = module.params.get("state") # type: ignore + if state == "absent": + vultr.absent() + else: + vultr.present() + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/startup_script.py b/plugins/modules/startup_script.py index 9afe0c5..4211b1a 100644 --- a/plugins/modules/startup_script.py +++ b/plugins/modules/startup_script.py @@ -135,22 +135,14 @@ class AnsibleVultrStartupScript(AnsibleVultr): - def is_diff(self, data, resource): - for key, value in data.items(): - if value is not None: - try: - value = value.decode() - except (UnicodeDecodeError, AttributeError): - pass - - if resource[key] != value: - return True - return False - def configure(self): if self.module.params["script"]: self.module.params["script"] = base64.b64encode(self.module.params["script"].encode()) + def update(self, resource): + resource["script"] = resource["script"].encode() + return super(AnsibleVultrStartupScript, self).update(resource=resource) + def transform_result(self, resource): if resource: resource["script"] = base64.b64decode(resource["script"]).decode() diff --git a/tests/integration/targets/instance/aliases b/tests/integration/targets/instance/aliases new file mode 100644 index 0000000..c749ce7 --- /dev/null +++ b/tests/integration/targets/instance/aliases @@ -0,0 +1,3 @@ +cloud/vultr +needs/target/common +needs/target/cleanup diff --git a/tests/integration/targets/instance/defaults/main.yml b/tests/integration/targets/instance/defaults/main.yml new file mode 100644 index 0000000..bbc2170 --- /dev/null +++ b/tests/integration/targets/instance/defaults/main.yml @@ -0,0 +1,45 @@ +# Copyright (c) 2022, René Moser +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +vultr_instance_firewall_group: "{{ vultr_resource_prefix }}_instance_fw_group" +vultr_instance_ssh_key_name: "{{ vultr_resource_prefix }}_instance_sshkey" +vultr_instance_ssh_key: "ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAgEAyWYItY+3w5b8PdGRoz0oY5mufqydW96naE+VM3JSvJFAUS08rAjQQpQ03ymoALeHQy6JVZbcgecxn6p0pAOINQdqufn4udPtOPCtMjNiPGpkSM9ah/6X5+kvyWMNrvlf+Ld4OOoszP5sAkgQzIbrFQAm41XknBUha0zkewZwfrVhain4pnDjV7wCcChId/Q/Gbi4xMtXkisznWcAJcueBs3EEZDKhJ5q0VeWSJEhYJDLFN1sOxF0AIUnMrOhfKQ/LjgREXPB6uCl899INUTXRNNjRpeMXyJ2wMMmOAbua2qEd1r13Bu1n+6A823Hzb33fyMXuqWnJwBJ4DCvMlGuEsfuOK+xk7DaBfLHbcM6fsPk0/4psTE6YLgC41remr6+u5ZWsY/faMtSnNPie8Z8Ov0DIYGdhbJjUXk1HomxRV9+ZfZ2Ob8iCwlaAQAyEUM6fs3Kxt8pBD8dx1HOkhsfBWPvuDr5y+kqE7H8/MuPDTc0QgH2pjUMpmw/XBwNDHshVEjrZvtICOjOLUJxcowLO1ivNYwPwowQxfisMy56LfYdjsOslBiqsrkAqvNGm1zu8wKHeqVN9w5l3yUELpvubfm9NKIvYcl6yWF36T0c5vE+g0DU/Jy4XpTj0hZG9QV2mRQcLJnd2pxQtJT7cPFtrn/+tgRxzjEtbDXummDV4sE= ansible@example.com" + +vultr_instances: + - label: "{{ vultr_resource_prefix }}_os1" + hostname: myhostname + user_data: | + #cloud-config + packages: + - htop + user_data_update: | + #cloud-config + packages: + - htop + - vim + plan: vc2-1c-1gb + plan_update: vc2-1c-2gb + ddos_protection: true + ddos_protection_update: false + ssh_keys: + - "{{ vultr_instance_ssh_key_name }}" + tags: + - one + - two + tags_update: + - three + - four + region: ams + os: Debian 11 x64 (bullseye) + + - label: "{{ vultr_resource_prefix }}_app1" + plan: vc2-1c-1gb + plan_update: vc2-1c-2gb + region: ams + app: Docker on Ubuntu 20.04 x64 + + - label: "{{ vultr_resource_prefix }}_img1" + plan: vc2-1c-1gb + plan_update: vc2-1c-2gb + region: ams + image: Gitea on Ubuntu 20.04 diff --git a/tests/integration/targets/instance/meta/main.yml b/tests/integration/targets/instance/meta/main.yml new file mode 100644 index 0000000..2083f0e --- /dev/null +++ b/tests/integration/targets/instance/meta/main.yml @@ -0,0 +1,3 @@ +--- +dependencies: + - common diff --git a/tests/integration/targets/instance/tasks/absent.yml b/tests/integration/targets/instance/tasks/absent.yml new file mode 100644 index 0000000..828f7f9 --- /dev/null +++ b/tests/integration/targets/instance/tasks/absent.yml @@ -0,0 +1,38 @@ +--- +- name: instance info + ansible.builtin.debug: + var: instance + +- name: test absent instance in check mode + vultr.cloud.instance: + label: "{{ instance.label }}" + region: "{{ instance.region }}" + state: absent + register: result + check_mode: true +- name: verify test absent instance in check mode + ansible.builtin.assert: + that: + - result is changed + +- name: test absent instance + vultr.cloud.instance: + label: "{{ instance.label }}" + region: "{{ instance.region }}" + state: absent + register: result +- name: verify test absent instance + ansible.builtin.assert: + that: + - result is changed + +- name: test absent instance idempotence + vultr.cloud.instance: + label: "{{ instance.label }}" + region: "{{ instance.region }}" + state: absent + register: result +- name: verify test absent instance idempotence + ansible.builtin.assert: + that: + - result is not changed diff --git a/tests/integration/targets/instance/tasks/failures.yml b/tests/integration/targets/instance/tasks/failures.yml new file mode 100644 index 0000000..f0455b3 --- /dev/null +++ b/tests/integration/targets/instance/tasks/failures.yml @@ -0,0 +1,39 @@ +--- +- name: test fail if missing arguments + vultr.cloud.instance: + register: result + ignore_errors: true +- name: verify test fail if missing arguments + ansible.builtin.assert: + that: + - result is failed + - 'result.msg == "missing required arguments: label, region"' + +- name: test fail if missing arguments required one of + vultr.cloud.instance: + label: my label + plan: a plan + region: a region + register: result + ignore_errors: true +- name: verify test fail if missing arguments required one of + ansible.builtin.assert: + that: + - result is failed + - '"one of the following required" in result.msg' + +- name: test fail if ssh key not found + vultr.cloud.instance: + label: my label + plan: a plan + region: a region + os: Debian 11 x64 (bullseye) + ssh_keys: + - does-not-exist + register: result + ignore_errors: true +- name: verify test fail if ssh key not found + ansible.builtin.assert: + that: + - result is failed + - '"SSH key names not found: does-not-exist" in result.msg' diff --git a/tests/integration/targets/instance/tasks/main.yml b/tests/integration/targets/instance/tasks/main.yml new file mode 100644 index 0000000..de897b4 --- /dev/null +++ b/tests/integration/targets/instance/tasks/main.yml @@ -0,0 +1,13 @@ +--- +- block: + - ansible.builtin.import_tasks: tests.yml + always: + - ansible.builtin.import_role: + name: cleanup + tasks_from: cleanup_instance + - ansible.builtin.import_role: + name: cleanup + tasks_from: cleanup_firewall_group + - ansible.builtin.import_role: + name: cleanup + tasks_from: cleanup_ssh_key diff --git a/tests/integration/targets/instance/tasks/present.yml b/tests/integration/targets/instance/tasks/present.yml new file mode 100644 index 0000000..86721b6 --- /dev/null +++ b/tests/integration/targets/instance/tasks/present.yml @@ -0,0 +1,148 @@ +--- +- name: instance info + ansible.builtin.debug: + var: instance + +- name: test create instance in check mode + vultr.cloud.instance: + label: "{{ instance.label }}" + hostname: "{{ instance.hostname | default(omit) }}" + user_data: "{{ instance.user_data | default(omit) }}" + firewall_group: "{{ vultr_instance_firewall_group }}" + ssh_keys: "{{ instance.ssh_keys | default(omit) }}" + plan: "{{ instance.plan }}" + ddos_protection: "{{ instance.ddos_protection | default(omit) }}" + backups: "{{ instance.backups | default(omit) }}" + enable_ipv6: "{{ instance.enable_ipv6 | default(omit) }}" + tags: "{{ instance.tags | default(omit) }}" + region: "{{ instance.region }}" + os: "{{ instance.os | default(omit) }}" + app: "{{ instance.app | default(omit) }}" + image: "{{ instance.image | default(omit) }}" + register: result + check_mode: true +- name: verify test create instance in check mode + ansible.builtin.assert: + that: + - result is changed + +- name: test create instance + vultr.cloud.instance: + label: "{{ instance.label }}" + hostname: "{{ instance.hostname | default(omit) }}" + user_data: "{{ instance.user_data | default(omit) }}" + firewall_group: "{{ vultr_instance_firewall_group }}" + ssh_keys: "{{ instance.ssh_keys | default(omit) }}" + plan: "{{ instance.plan }}" + ddos_protection: "{{ instance.ddos_protection | default(omit) }}" + backups: "{{ instance.backups | default(omit) }}" + enable_ipv6: "{{ instance.enable_ipv6 | default(omit) }}" + tags: "{{ instance.tags | default(omit) }}" + region: "{{ instance.region }}" + os: "{{ instance.os | default(omit) }}" + app: "{{ instance.app | default(omit) }}" + image: "{{ instance.image | default(omit) }}" + register: result +- name: verify test create instance + ansible.builtin.assert: + that: + - result is changed + - result.vultr_instance.plan == instance.plan + - result.vultr_instance.region == instance.region + +- name: test create instance idempotence + vultr.cloud.instance: + label: "{{ instance.label }}" + hostname: "{{ instance.hostname | default(omit) }}" + user_data: "{{ instance.user_data | default(omit) }}" + firewall_group: "{{ vultr_instance_firewall_group }}" + ssh_keys: "{{ instance.ssh_keys | default(omit) }}" + plan: "{{ instance.plan }}" + ddos_protection: "{{ instance.ddos_protection | default(omit) }}" + backups: "{{ instance.backups | default(omit) }}" + enable_ipv6: "{{ instance.enable_ipv6 | default(omit) }}" + tags: "{{ instance.tags | default(omit) }}" + region: "{{ instance.region }}" + os: "{{ instance.os | default(omit) }}" + app: "{{ instance.app | default(omit) }}" + image: "{{ instance.image | default(omit) }}" + register: result +- name: verify test create instance idempotence + ansible.builtin.assert: + that: + - result is not changed + - result.vultr_instance.plan == instance.plan + - result.vultr_instance.region == instance.region + +- name: test update instance in check mode + vultr.cloud.instance: + label: "{{ instance.label }}" + hostname: "{{ instance.hostname | default(omit) }}" + user_data: "{{ instance.user_data_update | default(omit) }}" + firewall_group: "{{ vultr_instance_firewall_group }}" + ssh_keys: "{{ instance.ssh_keys | default(omit) }}" + plan: "{{ instance.plan_update }}" + ddos_protection: "{{ instance.ddos_protection_update | default(omit) }}" + backups: "{{ instance.backups_update | default(omit) }}" + enable_ipv6: "{{ instance.enable_ipv6_update | default(omit) }}" + tags: "{{ instance.tags_update | default(omit) }}" + region: "{{ instance.region }}" + os: "{{ instance.os | default(omit) }}" + app: "{{ instance.app | default(omit) }}" + image: "{{ instance.image | default(omit) }}" + register: result + check_mode: true +- name: verify test update instance in check mode + ansible.builtin.assert: + that: + - result is changed + - result.vultr_instance.plan == instance.plan + - result.vultr_instance.region == instance.region + +- name: test update instance + vultr.cloud.instance: + label: "{{ instance.label }}" + hostname: "{{ instance.hostname | default(omit) }}" + user_data: "{{ instance.user_data_update | default(omit) }}" + firewall_group: "{{ vultr_instance_firewall_group }}" + ssh_keys: "{{ instance.ssh_keys | default(omit) }}" + plan: "{{ instance.plan_update }}" + ddos_protection: "{{ instance.ddos_protection_update | default(omit) }}" + backups: "{{ instance.backups_update | default(omit) }}" + enable_ipv6: "{{ instance.enable_ipv6_update | default(omit) }}" + tags: "{{ instance.tags_update | default(omit) }}" + region: "{{ instance.region }}" + os: "{{ instance.os | default(omit) }}" + app: "{{ instance.app | default(omit) }}" + image: "{{ instance.image | default(omit) }}" + register: result +- name: verify test update instance + ansible.builtin.assert: + that: + - result is changed + - result.vultr_instance.plan == instance.plan_update + - result.vultr_instance.region == instance.region + +- name: test update instance idempotence + vultr.cloud.instance: + label: "{{ instance.label }}" + hostname: "{{ instance.hostname | default(omit) }}" + user_data: "{{ instance.user_data_update | default(omit) }}" + firewall_group: "{{ vultr_instance_firewall_group }}" + ssh_keys: "{{ instance.ssh_keys | default(omit) }}" + plan: "{{ instance.plan_update }}" + ddos_protection: "{{ instance.ddos_protection_update | default(omit) }}" + backups: "{{ instance.backups_update | default(omit) }}" + enable_ipv6: "{{ instance.enable_ipv6_update | default(omit) }}" + tags: "{{ instance.tags_update | default(omit) }}" + region: "{{ instance.region }}" + os: "{{ instance.os | default(omit) }}" + app: "{{ instance.app | default(omit) }}" + image: "{{ instance.image | default(omit) }}" + register: result +- name: verify test update instance idempotence + ansible.builtin.assert: + that: + - result is not changed + - result.vultr_instance.plan == instance.plan_update + - result.vultr_instance.region == instance.region diff --git a/tests/integration/targets/instance/tasks/stop-start.yml b/tests/integration/targets/instance/tasks/stop-start.yml new file mode 100644 index 0000000..23cc3cd --- /dev/null +++ b/tests/integration/targets/instance/tasks/stop-start.yml @@ -0,0 +1,78 @@ +--- +- name: instance info + ansible.builtin.debug: + var: instance + +- name: test stop instance in check mode + vultr.cloud.instance: + label: "{{ instance.label }}" + region: "{{ instance.region }}" + state: stopped + register: result + check_mode: true +- name: verify test stop instance in check mode + ansible.builtin.assert: + that: + - result is changed + - result.vultr_instance.power_status == "running" + +- name: test stop instance + vultr.cloud.instance: + label: "{{ instance.label }}" + region: "{{ instance.region }}" + state: stopped + register: result +- name: verify test stop instance + ansible.builtin.assert: + that: + - result is changed + - result.vultr_instance.power_status == "stopped" + +- name: test stop instance idempotence + vultr.cloud.instance: + label: "{{ instance.label }}" + region: "{{ instance.region }}" + state: stopped + register: result +- name: verify test stop instance idempotence + ansible.builtin.assert: + that: + - result is not changed + - result.vultr_instance.power_status == "stopped" + +- name: test start instance in check mode + vultr.cloud.instance: + label: "{{ instance.label }}" + region: "{{ instance.region }}" + state: started + register: result + check_mode: true +- name: verify test start instance in check mode + ansible.builtin.assert: + that: + - result is changed + - result.vultr_instance.power_status == "stopped" + +- name: test start instance + vultr.cloud.instance: + label: "{{ instance.label }}" + region: "{{ instance.region }}" + state: started + register: result +- name: verify test start instance + ansible.builtin.assert: + that: + - result is changed + - result.vultr_instance.power_status == "running" + +- name: test start instance idempotence + vultr.cloud.instance: + label: "{{ instance.label }}" + region: "{{ instance.region }}" + state: started + register: result +- name: verify test start instance idempotence + ansible.builtin.assert: + that: + - result is not changed + - result.vultr_instance.power_status == "running" diff --git a/tests/integration/targets/instance/tasks/tests.yml b/tests/integration/targets/instance/tasks/tests.yml new file mode 100644 index 0000000..4dd1bf8 --- /dev/null +++ b/tests/integration/targets/instance/tasks/tests.yml @@ -0,0 +1,47 @@ +# Copyright (c) 2022, René Moser +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: setup + vultr.cloud.instance: + label: "{{ instance.label }}" + region: "{{ instance.region }}" + state: absent + with_items: "{{ vultr_instances }}" + loop_control: + loop_var: instance + +- ansible.builtin.import_tasks: failures.yml + +- name: setup firewall group + vultr.cloud.firewall_group: + name: "{{ vultr_instance_firewall_group }}" + +- name: setup ssh key + vultr.cloud.ssh_key: + name: "{{ vultr_instance_ssh_key_name }}" + ssh_key: "{{ vultr_instance_ssh_key }}" + +- ansible.builtin.include_tasks: present.yml + with_items: "{{ vultr_instances }}" + loop_control: + loop_var: instance + +- ansible.builtin.include_tasks: stop-start.yml + with_items: "{{ vultr_instances }}" + loop_control: + loop_var: instance + +- ansible.builtin.include_tasks: absent.yml + with_items: "{{ vultr_instances }}" + loop_control: + loop_var: instance + +- name: cleanup firewall group + vultr.cloud.firewall_group: + name: "{{ vultr_instance_firewall_group }}" + state: absent + +- name: cleanup ssh key + vultr.cloud.ssh_key: + name: "{{ vultr_instance_ssh_key_name }}" + state: absent