diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 16:03:42 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-04-28 16:03:42 +0000 |
commit | 66cec45960ce1d9c794e9399de15c138acb18aed (patch) | |
tree | 59cd19d69e9d56b7989b080da7c20ef1a3fe2a5a /ansible_collections/community/digitalocean/plugins | |
parent | Initial commit. (diff) | |
download | ansible-66cec45960ce1d9c794e9399de15c138acb18aed.tar.xz ansible-66cec45960ce1d9c794e9399de15c138acb18aed.zip |
Adding upstream version 7.3.0+dfsg.upstream/7.3.0+dfsgupstream
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'ansible_collections/community/digitalocean/plugins')
58 files changed, 13809 insertions, 0 deletions
diff --git a/ansible_collections/community/digitalocean/plugins/doc_fragments/digital_ocean.py b/ansible_collections/community/digitalocean/plugins/doc_fragments/digital_ocean.py new file mode 100644 index 00000000..bc65ad38 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/doc_fragments/digital_ocean.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde (akasurde@redhat.com) +# 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 + + +class ModuleDocFragment(object): + # Parameters for DigitalOcean modules + DOCUMENTATION = r""" +options: + baseurl: + description: + - DigitalOcean API base url. + type: str + default: https://api.digitalocean.com/v2 + oauth_token: + description: + - DigitalOcean OAuth token. + - "There are several other environment variables which can be used to provide this value." + - "i.e., - 'DO_API_TOKEN', 'DO_API_KEY', 'DO_OAUTH_TOKEN' and 'OAUTH_TOKEN'" + type: str + aliases: [ api_token ] + timeout: + description: + - The timeout in seconds used for polling DigitalOcean's API. + type: int + default: 30 + validate_certs: + description: + - If set to C(no), the SSL certificates will not be validated. + - This should only set to C(no) used on personally controlled sites using self-signed certificates. + type: bool + default: yes +""" diff --git a/ansible_collections/community/digitalocean/plugins/inventory/digitalocean.py b/ansible_collections/community/digitalocean/plugins/inventory/digitalocean.py new file mode 100644 index 00000000..6aafb5f4 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/inventory/digitalocean.py @@ -0,0 +1,274 @@ +# -*- coding: utf-8 -*- + +# Copyright: (c), 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 + +DOCUMENTATION = r""" +name: digitalocean +author: + - Janos Gerzson (@grzs) + - Tadej BorovÅ¡ak (@tadeboro) + - Max Truxa (@maxtruxa) +short_description: DigitalOcean Inventory Plugin +version_added: "1.1.0" +description: + - DigitalOcean (DO) inventory plugin. + - Acquires droplet list from DO API. + - Uses configuration file that ends with '(do_hosts|digitalocean|digital_ocean).(yaml|yml)'. +extends_documentation_fragment: + - community.digitalocean.digital_ocean.documentation + - constructed + - inventory_cache +options: + plugin: + description: + - The name of the DigitalOcean Inventory Plugin, + this should always be C(community.digitalocean.digitalocean). + required: true + choices: ['community.digitalocean.digitalocean'] + api_token: + description: + - DigitalOcean OAuth token. + - Template expressions can be used in this field. + required: true + type: str + aliases: [ oauth_token ] + env: + - name: DO_API_TOKEN + attributes: + description: >- + Droplet attributes to add as host vars to each inventory host. + Check out the DO API docs for full list of attributes at + U(https://docs.digitalocean.com/reference/api/api-reference/#operation/list_all_droplets). + type: list + elements: str + default: + - id + - name + - networks + - region + - size_slug + var_prefix: + description: + - Prefix of generated varible names (e.g. C(tags) -> C(do_tags)) + type: str + default: 'do_' + pagination: + description: + - Maximum droplet objects per response page. + - If the number of droplets related to the account exceeds this value, + the query will be broken to multiple requests (pages). + - DigitalOcean currently allows a maximum of 200. + type: int + default: 200 + filters: + description: + - Filter hosts with Jinja templates. + - If no filters are specified, all hosts are added to the inventory. + type: list + elements: str + default: [] + version_added: '1.5.0' +""" + +EXAMPLES = r""" +# Using keyed groups and compose for hostvars +plugin: community.digitalocean.digitalocean +api_token: '{{ lookup("pipe", "./get-do-token.sh" }}' +attributes: + - id + - name + - memory + - vcpus + - disk + - size + - image + - networks + - volume_ids + - tags + - region +keyed_groups: + - key: do_region.slug + prefix: 'region' + separator: '_' + - key: do_tags | lower + prefix: '' + separator: '' +compose: + ansible_host: do_networks.v4 | selectattr('type','eq','public') + | map(attribute='ip_address') | first + class: do_size.description | lower + distro: do_image.distribution | lower +filters: + - '"kubernetes" in do_tags' + - 'do_region.slug == "fra1"' +""" + +import re +import json +from ansible.errors import AnsibleError, AnsibleParserError +from ansible.inventory.group import to_safe_group_name +from ansible.module_utils._text import to_native +from ansible.module_utils.urls import Request +from ansible.module_utils.six.moves.urllib.error import URLError, HTTPError +from ansible.plugins.inventory import BaseInventoryPlugin, Constructable, Cacheable + + +class InventoryModule(BaseInventoryPlugin, Constructable, Cacheable): + + NAME = "community.digitalocean.digitalocean" + + # Constructable methods use the following function to construct group names. By + # default, characters that are not valid in python variables, are always replaced by + # underscores. We are overriding this with a function that respects the + # TRANSFORM_INVALID_GROUP_CHARS configuration option and allows users to control the + # behavior. + _sanitize_group_name = staticmethod(to_safe_group_name) + + def verify_file(self, path): + valid = False + if super(InventoryModule, self).verify_file(path): + if path.endswith( + ( + "do_hosts.yaml", + "do_hosts.yml", + "digitalocean.yaml", + "digitalocean.yml", + "digital_ocean.yaml", + "digital_ocean.yml", + ) + ): + valid = True + else: + self.display.vvv( + "Skipping due to inventory source file name mismatch. " + "The file name has to end with one of the following: " + "do_hosts.yaml, do_hosts.yml " + "digitalocean.yaml, digitalocean.yml, " + "digital_ocean.yaml, digital_ocean.yml." + ) + return valid + + def _template_option(self, option): + value = self.get_option(option) + self.templar.available_variables = {} + return self.templar.template(value) + + def _get_payload(self): + # request parameters + api_token = self._template_option("api_token") + headers = { + "Content-Type": "application/json", + "Authorization": "Bearer {0}".format(api_token), + } + + # build url + pagination = self.get_option("pagination") + url = "https://api.digitalocean.com/v2" + if self.get_option("baseurl"): + url = self.get_option("baseurl") + url += "/droplets?per_page=" + str(pagination) + + # send request(s) + self.req = Request(headers=headers, timeout=self.get_option("timeout")) + payload = [] + try: + while url: + self.display.vvv("Sending request to {0}".format(url)) + response = json.load(self.req.get(url)) + payload.extend(response["droplets"]) + url = response.get("links", {}).get("pages", {}).get("next") + except ValueError: + raise AnsibleParserError("something went wrong with JSON loading") + except (URLError, HTTPError) as error: + raise AnsibleParserError(error) + + return payload + + def _populate(self, records): + attributes = self.get_option("attributes") + var_prefix = self.get_option("var_prefix") + strict = self.get_option("strict") + host_filters = self.get_option("filters") + for record in records: + + host_name = record.get("name") + if not host_name: + continue + + host_vars = {} + for k, v in record.items(): + if k in attributes: + host_vars[var_prefix + k] = v + + if not self._passes_filters(host_filters, host_vars, host_name, strict): + self.display.vvv("Host {0} did not pass all filters".format(host_name)) + continue + + # add host to inventory + self.inventory.add_host(host_name) + + # set variables for host + for k, v in host_vars.items(): + self.inventory.set_variable(host_name, k, v) + + self._set_composite_vars( + self.get_option("compose"), + self.inventory.get_host(host_name).get_vars(), + host_name, + strict, + ) + + # set composed and keyed groups + self._add_host_to_composed_groups( + self.get_option("groups"), dict(), host_name, strict + ) + self._add_host_to_keyed_groups( + self.get_option("keyed_groups"), dict(), host_name, strict + ) + + def _passes_filters(self, filters, variables, host, strict=False): + if filters and isinstance(filters, list): + for template in filters: + try: + if not self._compose(template, variables): + return False + except Exception as e: + if strict: + raise AnsibleError( + "Could not evaluate host filter {0} for host {1}: {2}".format( + template, host, to_native(e) + ) + ) + # Better be safe and not include any hosts by accident. + return False + return True + + def parse(self, inventory, loader, path, cache=True): + super(InventoryModule, self).parse(inventory, loader, path) + + self._read_config_data(path) + + # cache settings + cache_key = self.get_cache_key(path) + use_cache = self.get_option("cache") and cache + update_cache = self.get_option("cache") and not cache + + records = None + if use_cache: + try: + records = self._cache[cache_key] + except KeyError: + update_cache = True + + if records is None: + records = self._get_payload() + + if update_cache: + self._cache[cache_key] = records + + self._populate(records) diff --git a/ansible_collections/community/digitalocean/plugins/module_utils/digital_ocean.py b/ansible_collections/community/digitalocean/plugins/module_utils/digital_ocean.py new file mode 100644 index 00000000..44ca3ccd --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/module_utils/digital_ocean.py @@ -0,0 +1,305 @@ +# 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), Ansible Project 2017 +# 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 + +import json +import os +from ansible.module_utils.urls import fetch_url +from ansible.module_utils._text import to_text +from ansible.module_utils.basic import env_fallback + + +class Response(object): + def __init__(self, resp, info): + self.body = None + if resp: + self.body = resp.read() + self.info = info + + @property + def json(self): + if not self.body: + if "body" in self.info: + return json.loads(to_text(self.info["body"])) + return None + try: + return json.loads(to_text(self.body)) + except ValueError: + return None + + @property + def status_code(self): + return self.info["status"] + + +class DigitalOceanHelper: + baseurl = "https://api.digitalocean.com/v2" + + def __init__(self, module): + self.module = module + self.baseurl = module.params.get("baseurl", DigitalOceanHelper.baseurl) + self.timeout = module.params.get("timeout", 30) + self.oauth_token = module.params.get("oauth_token") + self.headers = { + "Authorization": "Bearer {0}".format(self.oauth_token), + "Content-type": "application/json", + } + + # Check if api_token is valid or not + response = self.get("account") + if response.status_code == 401: + self.module.fail_json( + msg="Failed to login using API token, please verify validity of API token." + ) + + def _url_builder(self, path): + if path[0] == "/": + path = path[1:] + return "%s/%s" % (self.baseurl, path) + + def send(self, method, path, data=None): + url = self._url_builder(path) + data = self.module.jsonify(data) + + if method == "DELETE": + if data == "null": + data = None + + resp, info = fetch_url( + self.module, + url, + data=data, + headers=self.headers, + method=method, + timeout=self.timeout, + ) + + return Response(resp, info) + + def get(self, path, data=None): + return self.send("GET", path, data) + + def put(self, path, data=None): + return self.send("PUT", path, data) + + def post(self, path, data=None): + return self.send("POST", path, data) + + def delete(self, path, data=None): + return self.send("DELETE", path, data) + + @staticmethod + def digital_ocean_argument_spec(): + return dict( + baseurl=dict( + type="str", required=False, default="https://api.digitalocean.com/v2" + ), + validate_certs=dict(type="bool", required=False, default=True), + oauth_token=dict( + no_log=True, + # Support environment variable for DigitalOcean OAuth Token + fallback=( + env_fallback, + ["DO_API_TOKEN", "DO_API_KEY", "DO_OAUTH_TOKEN", "OAUTH_TOKEN"], + ), + required=False, + aliases=["api_token"], + ), + timeout=dict(type="int", default=30), + ) + + def get_paginated_data( + self, + base_url=None, + data_key_name=None, + data_per_page=40, + expected_status_code=200, + ): + """ + Function to get all paginated data from given URL + Args: + base_url: Base URL to get data from + data_key_name: Name of data key value + data_per_page: Number results per page (Default: 40) + expected_status_code: Expected returned code from DigitalOcean (Default: 200) + Returns: List of data + + """ + page = 1 + has_next = True + ret_data = [] + status_code = None + response = None + while has_next or status_code != expected_status_code: + required_url = "{0}page={1}&per_page={2}".format( + base_url, page, data_per_page + ) + response = self.get(required_url) + status_code = response.status_code + # stop if any error during pagination + if status_code != expected_status_code: + break + page += 1 + ret_data.extend(response.json[data_key_name]) + try: + has_next = ( + "pages" in response.json["links"] + and "next" in response.json["links"]["pages"] + ) + except KeyError: + # There's a bug in the API docs: GET v2/cdn/endpoints doesn't return a "links" key + has_next = False + + if status_code != expected_status_code: + msg = "Failed to fetch %s from %s" % (data_key_name, base_url) + if response: + msg += " due to error : %s" % response.json["message"] + self.module.fail_json(msg=msg) + + return ret_data + + +class DigitalOceanProjects: + def __init__(self, module, rest): + self.module = module + self.rest = rest + self.get_all_projects() + + def get_all_projects(self): + """Fetches all projects.""" + self.projects = self.rest.get_paginated_data( + base_url="projects?", data_key_name="projects" + ) + + def get_default(self): + """Fetches the default project. + + Returns: + error_message -- project fetch error message (or "" if no error) + project -- project dictionary representation (or {} if error) + """ + project = [ + project for project in self.projects if project.get("is_default", False) + ] + if len(project) == 0: + return "Unexpected error; no default project found", {} + if len(project) > 1: + return "Unexpected error; more than one default project", {} + return "", project[0] + + def get_by_id(self, id): + """Fetches the project with the given id. + + Returns: + error_message -- project fetch error message (or "" if no error) + project -- project dictionary representation (or {} if error) + """ + project = [project for project in self.projects if project.get("id") == id] + if len(project) == 0: + return "No project with id {0} found".format(id), {} + elif len(project) > 1: + return "Unexpected error; more than one project with the same id", {} + return "", project[0] + + def get_by_name(self, name): + """Fetches the project with the given name. + + Returns: + error_message -- project fetch error message (or "" if no error) + project -- project dictionary representation (or {} if error) + """ + project = [project for project in self.projects if project.get("name") == name] + if len(project) == 0: + return "No project with name {0} found".format(name), {} + elif len(project) > 1: + return "Unexpected error; more than one project with the same name", {} + return "", project[0] + + def assign_to_project(self, project_name, urn): + """Assign resource (urn) to project (name). + + Keyword arguments: + project_name -- project name to associate the resource with + urn -- resource URN (has the form do:resource_type:resource_id) + + Returns: + assign_status -- ok, not_found, assigned, already_assigned, service_down + error_message -- assignment error message (empty on success) + resources -- resources assigned (or {} if error) + + Notes: + For URN examples, see https://docs.digitalocean.com/reference/api/api-reference/#tag/Project-Resources + + Projects resources are identified by uniform resource names or URNs. + A valid URN has the following format: do:resource_type:resource_id. + + The following resource types are supported: + Resource Type | Example URN + Database | do:dbaas:83c7a55f-0d84-4760-9245-aba076ec2fb2 + Domain | do:domain:example.com + Droplet | do:droplet:4126873 + Floating IP | do:floatingip:192.168.99.100 + Load Balancer | do:loadbalancer:39052d89-8dd4-4d49-8d5a-3c3b6b365b5b + Space | do:space:my-website-assets + Volume | do:volume:6fc4c277-ea5c-448a-93cd-dd496cfef71f + """ + error_message, project = self.get_by_name(project_name) + if not project: + return "", error_message, {} + + project_id = project.get("id", None) + if not project_id: + return ( + "", + "Unexpected error; cannot find project id for {0}".format(project_name), + {}, + ) + + data = {"resources": [urn]} + response = self.rest.post( + "projects/{0}/resources".format(project_id), data=data + ) + status_code = response.status_code + json = response.json + if status_code != 200: + message = json.get("message", "No error message returned") + return ( + "", + "Unable to assign resource {0} to project {1} [HTTP {2}: {3}]".format( + urn, project_name, status_code, message + ), + {}, + ) + + resources = json.get("resources", []) + if len(resources) == 0: + return ( + "", + "Unexpected error; no resources returned (but assignment was successful)", + {}, + ) + if len(resources) > 1: + return ( + "", + "Unexpected error; more than one resource returned (but assignment was successful)", + {}, + ) + + status = resources[0].get( + "status", + "Unexpected error; no status returned (but assignment was successful)", + ) + return ( + status, + "Assigned {0} to project {1}".format(urn, project_name), + resources[0], + ) diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean.py new file mode 100644 index 00000000..1c33f2c7 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean.py @@ -0,0 +1,525 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: 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 + + +DOCUMENTATION = r""" +--- +module: digital_ocean +short_description: Create/delete a droplet/SSH_key in DigitalOcean +deprecated: + removed_in: 2.0.0 # was Ansible 2.12 + why: Updated module to remove external dependency with increased functionality. + alternative: Use M(community.digitalocean.digital_ocean_droplet) instead. +description: + - Create/delete a droplet in DigitalOcean and optionally wait for it to be 'running', or deploy an SSH key. +author: "Vincent Viallet (@zbal)" +options: + command: + description: + - Which target you want to operate on. + default: droplet + choices: ['droplet', 'ssh'] + type: str + state: + description: + - Indicate desired state of the target. + default: present + choices: ['present', 'active', 'absent', 'deleted'] + type: str + api_token: + description: + - DigitalOcean api token. + type: str + aliases: + - API_TOKEN + id: + description: + - Numeric, the droplet id you want to operate on. + aliases: ['droplet_id'] + type: int + name: + description: + - String, this is the name of the droplet - must be formatted by hostname rules, or the name of a SSH key. + type: str + unique_name: + description: + - Bool, require unique hostnames. By default, DigitalOcean allows multiple hosts with the same name. Setting this to "yes" allows only one host + per name. Useful for idempotence. + type: bool + default: 'no' + size_id: + description: + - This is the slug of the size you would like the droplet created with. + type: str + image_id: + description: + - This is the slug of the image you would like the droplet created with. + type: str + region_id: + description: + - This is the slug of the region you would like your server to be created in. + type: str + ssh_key_ids: + description: + - Optional, array of SSH key (numeric) ID that you would like to be added to the server. + type: list + elements: str + virtio: + description: + - "Bool, turn on virtio driver in droplet for improved network and storage I/O." + type: bool + default: 'yes' + private_networking: + description: + - "Bool, add an additional, private network interface to droplet for inter-droplet communication." + type: bool + default: 'no' + backups_enabled: + description: + - Optional, Boolean, enables backups for your droplet. + type: bool + default: 'no' + user_data: + description: + - opaque blob of data which is made available to the droplet + type: str + ipv6: + description: + - Optional, Boolean, enable IPv6 for your droplet. + type: bool + default: 'no' + wait: + description: + - Wait for the droplet to be in state 'running' before returning. If wait is "no" an ip_address may not be returned. + type: bool + default: 'yes' + wait_timeout: + description: + - How long before wait gives up, in seconds. + default: 300 + type: int + ssh_pub_key: + description: + - The public SSH key you want to add to your account. + type: str + +notes: + - Two environment variables can be used, DO_API_KEY and DO_API_TOKEN. They both refer to the v2 token. + - As of Ansible 1.9.5 and 2.0, Version 2 of the DigitalOcean API is used, this removes C(client_id) and C(api_key) options in favor of C(api_token). + - If you are running Ansible 1.9.4 or earlier you might not be able to use the included version of this module as the API version used has been retired. + Upgrade Ansible or, if unable to, try downloading the latest version of this module from github and putting it into a 'library' directory. +requirements: + - "python >= 2.6" + - dopy +""" + + +EXAMPLES = r""" +# Ensure a SSH key is present +# If a key matches this name, will return the ssh key id and changed = False +# If no existing key matches this name, a new key is created, the ssh key id is returned and changed = False + +- name: Ensure a SSH key is present + community.digitalocean.digital_ocean: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + command: ssh + name: my_ssh_key + ssh_pub_key: 'ssh-rsa AAAA...' + +# Will return the droplet details including the droplet id (used for idempotence) +- name: Create a new Droplet + community.digitalocean.digital_ocean: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + command: droplet + name: mydroplet + size_id: 2gb + region_id: ams2 + image_id: fedora-19-x64 + wait_timeout: 500 + register: my_droplet + +- debug: + msg: "ID is {{ my_droplet.droplet.id }}" + +- debug: + msg: "IP is {{ my_droplet.droplet.ip_address }}" + +# Ensure a droplet is present +# If droplet id already exist, will return the droplet details and changed = False +# If no droplet matches the id, a new droplet will be created and the droplet details (including the new id) are returned, changed = True. + +- name: Ensure a droplet is present + community.digitalocean.digital_ocean: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + command: droplet + id: 123 + name: mydroplet + size_id: 2gb + region_id: ams2 + image_id: fedora-19-x64 + wait_timeout: 500 + +# Create a droplet with ssh key +# The ssh key id can be passed as argument at the creation of a droplet (see ssh_key_ids). +# Several keys can be added to ssh_key_ids as id1,id2,id3 +# The keys are used to connect as root to the droplet. + +- name: Create a droplet with ssh key + community.digitalocean.digital_ocean: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + ssh_key_ids: 123,456 + name: mydroplet + size_id: 2gb + region_id: ams2 + image_id: fedora-19-x64 +""" + +import os +import time +import traceback + +try: + from packaging.version import Version + + HAS_PACKAGING = True +except ImportError: + HAS_PACKAGING = False + +try: + # Imported as a dependency for dopy + import ansible.module_utils.six + + HAS_SIX = True +except ImportError: + HAS_SIX = False + +HAS_DOPY = False +try: + import dopy + from dopy.manager import DoError, DoManager + + # NOTE: Expressing Python dependencies isn't really possible: + # https://github.com/ansible/ansible/issues/62733#issuecomment-537098744 + if HAS_PACKAGING: + if Version(dopy.__version__) >= Version("0.3.2"): + HAS_DOPY = True + else: + if dopy.__version__ >= "0.3.2": # Naive lexographical check + HAS_DOPY = True +except ImportError: + pass + +from ansible.module_utils.basic import AnsibleModule, env_fallback + + +class TimeoutError(Exception): + def __init__(self, msg, id_): + super(TimeoutError, self).__init__(msg) + self.id = id_ + + +class JsonfyMixIn(object): + def to_json(self): + return self.__dict__ + + +class Droplet(JsonfyMixIn): + manager = None + + def __init__(self, droplet_json): + self.status = "new" + self.__dict__.update(droplet_json) + + def is_powered_on(self): + return self.status == "active" + + def update_attr(self, attrs=None): + if attrs: + for k, v in attrs.items(): + setattr(self, k, v) + networks = attrs.get("networks", {}) + for network in networks.get("v6", []): + if network["type"] == "public": + setattr(self, "public_ipv6_address", network["ip_address"]) + else: + setattr(self, "private_ipv6_address", network["ip_address"]) + else: + json = self.manager.show_droplet(self.id) + if json["ip_address"]: + self.update_attr(json) + + def power_on(self): + if self.status != "off": + raise AssertionError("Can only power on a closed one.") + json = self.manager.power_on_droplet(self.id) + self.update_attr(json) + + def ensure_powered_on(self, wait=True, wait_timeout=300): + if self.is_powered_on(): + return + if self.status == "off": # powered off + self.power_on() + + if wait: + end_time = time.monotonic() + wait_timeout + while time.monotonic() < end_time: + time.sleep(10) + self.update_attr() + if self.is_powered_on(): + if not self.ip_address: + raise TimeoutError("No ip is found.", self.id) + return + raise TimeoutError("Wait for droplet running timeout", self.id) + + def destroy(self): + return self.manager.destroy_droplet(self.id, scrub_data=True) + + @classmethod + def setup(cls, api_token): + cls.manager = DoManager(None, api_token, api_version=2) + + @classmethod + def add( + cls, + name, + size_id, + image_id, + region_id, + ssh_key_ids=None, + virtio=True, + private_networking=False, + backups_enabled=False, + user_data=None, + ipv6=False, + ): + private_networking_lower = str(private_networking).lower() + backups_enabled_lower = str(backups_enabled).lower() + ipv6_lower = str(ipv6).lower() + json = cls.manager.new_droplet( + name, + size_id, + image_id, + region_id, + ssh_key_ids=ssh_key_ids, + virtio=virtio, + private_networking=private_networking_lower, + backups_enabled=backups_enabled_lower, + user_data=user_data, + ipv6=ipv6_lower, + ) + droplet = cls(json) + return droplet + + @classmethod + def find(cls, id=None, name=None): + if not id and not name: + return False + + droplets = cls.list_all() + + # Check first by id. digital ocean requires that it be unique + for droplet in droplets: + if droplet.id == id: + return droplet + + # Failing that, check by hostname. + for droplet in droplets: + if droplet.name == name: + return droplet + + return False + + @classmethod + def list_all(cls): + json = cls.manager.all_active_droplets() + return list(map(cls, json)) + + +class SSH(JsonfyMixIn): + manager = None + + def __init__(self, ssh_key_json): + self.__dict__.update(ssh_key_json) + + update_attr = __init__ + + def destroy(self): + self.manager.destroy_ssh_key(self.id) + return True + + @classmethod + def setup(cls, api_token): + cls.manager = DoManager(None, api_token, api_version=2) + + @classmethod + def find(cls, name): + if not name: + return False + keys = cls.list_all() + for key in keys: + if key.name == name: + return key + return False + + @classmethod + def list_all(cls): + json = cls.manager.all_ssh_keys() + return list(map(cls, json)) + + @classmethod + def add(cls, name, key_pub): + json = cls.manager.new_ssh_key(name, key_pub) + return cls(json) + + +def core(module): + def getkeyordie(k): + v = module.params[k] + if v is None: + module.fail_json(msg="Unable to load %s" % k) + return v + + api_token = module.params["api_token"] + changed = True + command = module.params["command"] + state = module.params["state"] + + if command == "droplet": + Droplet.setup(api_token) + if state in ("active", "present"): + + # First, try to find a droplet by id. + droplet = Droplet.find(id=module.params["id"]) + + # If we couldn't find the droplet and the user is allowing unique + # hostnames, then check to see if a droplet with the specified + # hostname already exists. + if not droplet and module.params["unique_name"]: + droplet = Droplet.find(name=getkeyordie("name")) + + # If both of those attempts failed, then create a new droplet. + if not droplet: + droplet = Droplet.add( + name=getkeyordie("name"), + size_id=getkeyordie("size_id"), + image_id=getkeyordie("image_id"), + region_id=getkeyordie("region_id"), + ssh_key_ids=module.params["ssh_key_ids"], + virtio=module.params["virtio"], + private_networking=module.params["private_networking"], + backups_enabled=module.params["backups_enabled"], + user_data=module.params.get("user_data"), + ipv6=module.params["ipv6"], + ) + + if droplet.is_powered_on(): + changed = False + + droplet.ensure_powered_on( + wait=getkeyordie("wait"), wait_timeout=getkeyordie("wait_timeout") + ) + + module.exit_json(changed=changed, droplet=droplet.to_json()) + + elif state in ("absent", "deleted"): + # First, try to find a droplet by id. + droplet = Droplet.find(module.params["id"]) + + # If we couldn't find the droplet and the user is allowing unique + # hostnames, then check to see if a droplet with the specified + # hostname already exists. + if not droplet and module.params["unique_name"]: + droplet = Droplet.find(name=getkeyordie("name")) + + if not droplet: + module.exit_json(changed=False, msg="The droplet is not found.") + + droplet.destroy() + module.exit_json(changed=True) + + elif command == "ssh": + SSH.setup(api_token) + name = getkeyordie("name") + if state in ("active", "present"): + key = SSH.find(name) + if key: + module.exit_json(changed=False, ssh_key=key.to_json()) + key = SSH.add(name, getkeyordie("ssh_pub_key")) + module.exit_json(changed=True, ssh_key=key.to_json()) + + elif state in ("absent", "deleted"): + key = SSH.find(name) + if not key: + module.exit_json( + changed=False, + msg="SSH key with the name of %s is not found." % name, + ) + key.destroy() + module.exit_json(changed=True) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + command=dict(choices=["droplet", "ssh"], default="droplet"), + state=dict( + choices=["active", "present", "absent", "deleted"], default="present" + ), + api_token=dict( + aliases=["API_TOKEN"], + no_log=True, + fallback=(env_fallback, ["DO_API_TOKEN", "DO_API_KEY"]), + ), + name=dict(type="str"), + size_id=dict(), + image_id=dict(), + region_id=dict(), + ssh_key_ids=dict(type="list", elements="str", no_log=False), + virtio=dict(type="bool", default=True), + private_networking=dict(type="bool", default=False), + backups_enabled=dict(type="bool", default=False), + id=dict(aliases=["droplet_id"], type="int"), + unique_name=dict(type="bool", default=False), + user_data=dict(default=None), + ipv6=dict(type="bool", default=False), + wait=dict(type="bool", default=True), + wait_timeout=dict(default=300, type="int"), + ssh_pub_key=dict(type="str"), + ), + required_together=(["size_id", "image_id", "region_id"],), + mutually_exclusive=( + ["size_id", "ssh_pub_key"], + ["image_id", "ssh_pub_key"], + ["region_id", "ssh_pub_key"], + ), + required_one_of=(["id", "name"],), + ) + if not HAS_DOPY and not HAS_SIX: + module.fail_json( + msg="dopy >= 0.3.2 is required for this module. dopy requires six but six is not installed. " + "Make sure both dopy and six are installed." + ) + if not HAS_DOPY: + module.fail_json(msg="dopy >= 0.3.2 required for this module") + + try: + core(module) + except TimeoutError as e: + module.fail_json(msg=str(e), id=e.id) + except (DoError, Exception) as e: + module.fail_json(msg=str(e), exception=traceback.format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_account_facts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_account_facts.py new file mode 100644 index 00000000..46ccd3e5 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_account_facts.py @@ -0,0 +1,93 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_account_info +short_description: Gather information about DigitalOcean User account +description: + - This module can be used to gather information about User account. + - This module was called C(digital_ocean_account_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" + +requirements: + - "python >= 2.6" + +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about user account + community.digitalocean.digital_ocean_account_info: + oauth_token: "{{ oauth_token }}" +""" + + +RETURN = r""" +data: + description: DigitalOcean account information + returned: success + type: dict + sample: { + "droplet_limit": 10, + "email": "testuser1@gmail.com", + "email_verified": true, + "floating_ip_limit": 3, + "status": "active", + "status_message": "", + "uuid": "aaaaaaaaaaaaaa" + } +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + rest = DigitalOceanHelper(module) + + response = rest.get("account") + if response.status_code != 200: + module.fail_json( + msg="Failed to fetch 'account' information due to error : %s" + % response.json["message"] + ) + + module.exit_json(changed=False, data=response.json["account"]) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_account_facts", + "community.digitalocean.digital_ocean_account_facts", + ): + module.deprecate( + "The 'digital_ocean_account_facts' module has been renamed to 'digital_ocean_account_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_account_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_account_info.py new file mode 100644 index 00000000..46ccd3e5 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_account_info.py @@ -0,0 +1,93 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_account_info +short_description: Gather information about DigitalOcean User account +description: + - This module can be used to gather information about User account. + - This module was called C(digital_ocean_account_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" + +requirements: + - "python >= 2.6" + +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about user account + community.digitalocean.digital_ocean_account_info: + oauth_token: "{{ oauth_token }}" +""" + + +RETURN = r""" +data: + description: DigitalOcean account information + returned: success + type: dict + sample: { + "droplet_limit": 10, + "email": "testuser1@gmail.com", + "email_verified": true, + "floating_ip_limit": 3, + "status": "active", + "status_message": "", + "uuid": "aaaaaaaaaaaaaa" + } +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + rest = DigitalOceanHelper(module) + + response = rest.get("account") + if response.status_code != 200: + module.fail_json( + msg="Failed to fetch 'account' information due to error : %s" + % response.json["message"] + ) + + module.exit_json(changed=False, data=response.json["account"]) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_account_facts", + "community.digitalocean.digital_ocean_account_facts", + ): + module.deprecate( + "The 'digital_ocean_account_facts' module has been renamed to 'digital_ocean_account_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_balance_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_balance_info.py new file mode 100644 index 00000000..3aea5353 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_balance_info.py @@ -0,0 +1,73 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2021, Ansible Project +# Copyright: (c) 2021, Mark Mercado <mamercad@gmail.com> +# 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 = r""" +--- +module: digital_ocean_balance_info +short_description: Display DigitalOcean customer balance +description: + - This module can be used to display the DigitalOcean customer balance. +author: "Mark Mercado (@mamercad)" +version_added: 1.2.0 +extends_documentation_fragment: + - community.digitalocean.digital_ocean.documentation +""" + + +EXAMPLES = r""" +- name: Display DigitalOcean customer balance + community.digitalocean.digital_ocean_balance_info: + oauth_token: "{{ oauth_token }}" +""" + + +RETURN = r""" +# DigitalOcean API info https://docs.digitalocean.com/reference/api/api-reference/#operation/get_customer_balance +data: + description: DigitalOcean customer balance + returned: success + type: dict + sample: { + "account_balance": "-27.52", + "generated_at": "2021-04-11T05:08:24Z", + "month_to_date_balance": "-27.40", + "month_to_date_usage": "0.00" + } +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +def run(module): + rest = DigitalOceanHelper(module) + + response = rest.get("customers/my/balance") + if response.status_code != 200: + module.fail_json( + msg="Failed to fetch 'customers/my/balance' information due to error : %s" + % response.json["message"] + ) + + module.exit_json(changed=False, data=response.json) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_block_storage.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_block_storage.py new file mode 100644 index 00000000..8597eb1e --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_block_storage.py @@ -0,0 +1,411 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: 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 + + +DOCUMENTATION = r""" +--- +module: digital_ocean_block_storage +short_description: Create/destroy or attach/detach Block Storage volumes in DigitalOcean +description: + - Create/destroy Block Storage volume in DigitalOcean, or attach/detach Block Storage volume to a droplet. +options: + command: + description: + - Which operation do you want to perform. + choices: ['create', 'attach'] + required: true + type: str + state: + description: + - Indicate desired state of the target. + choices: ['present', 'absent'] + required: true + type: str + block_size: + description: + - The size of the Block Storage volume in gigabytes. + - Required when I(command=create) and I(state=present). + - If snapshot_id is included, this will be ignored. + - If block_size > current size of the volume, the volume is resized. + type: int + volume_name: + description: + - The name of the Block Storage volume. + type: str + required: true + description: + description: + - Description of the Block Storage volume. + type: str + region: + description: + - The slug of the region where your Block Storage volume should be located in. + - If I(snapshot_id) is included, this will be ignored. + type: str + snapshot_id: + description: + - The snapshot id you would like the Block Storage volume created with. + - If included, I(region) and I(block_size) will be ignored and changed to C(null). + type: str + droplet_id: + description: + - The droplet id you want to operate on. + - Required when I(command=attach). + type: int + project_name: + aliases: ["project"] + description: + - Project to assign the resource to (project name, not UUID). + - Defaults to the default project of the account (empty string). + - Currently only supported when C(command=create). + type: str + required: false + default: "" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +notes: + - Two environment variables can be used, DO_API_KEY and DO_API_TOKEN. + They both refer to the v2 token. + - If snapshot_id is used, region and block_size will be ignored and changed to null. + +author: + - "Harnek Sidhu (@harneksidhu)" +""" + +EXAMPLES = r""" +- name: Create new Block Storage + community.digitalocean.digital_ocean_block_storage: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + command: create + region: nyc1 + block_size: 10 + volume_name: nyc1-block-storage + +- name: Create new Block Storage (and assign to Project "test") + community.digitalocean.digital_ocean_block_storage: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + command: create + region: nyc1 + block_size: 10 + volume_name: nyc1-block-storage + project_name: test + +- name: Resize an existing Block Storage + community.digitalocean.digital_ocean_block_storage: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + command: create + region: nyc1 + block_size: 20 + volume_name: nyc1-block-storage + +- name: Delete Block Storage + community.digitalocean.digital_ocean_block_storage: + state: absent + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + command: create + region: nyc1 + volume_name: nyc1-block-storage + +- name: Attach Block Storage to a Droplet + community.digitalocean.digital_ocean_block_storage: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + command: attach + volume_name: nyc1-block-storage + region: nyc1 + droplet_id: <ID> + +- name: Detach Block Storage from a Droplet + community.digitalocean.digital_ocean_block_storage: + state: absent + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + command: attach + volume_name: nyc1-block-storage + region: nyc1 + droplet_id: <ID> +""" + +RETURN = r""" +id: + description: Unique identifier of a Block Storage volume returned during creation. + returned: changed + type: str + sample: "69b25d9a-494c-12e6-a5af-001f53126b44" +msg: + description: Informational or error message encountered during execution + returned: changed + type: str + sample: No project named test2 found +assign_status: + description: Assignment status (ok, not_found, assigned, already_assigned, service_down) + returned: changed + type: str + sample: assigned +resources: + description: Resource assignment involved in project assignment + returned: changed + type: dict + sample: + assigned_at: '2021-10-25T17:39:38Z' + links: + self: https://api.digitalocean.com/v2/volumes/8691c49e-35ba-11ec-9406-0a58ac1472b9 + status: assigned + urn: do:volume:8691c49e-35ba-11ec-9406-0a58ac1472b9 +""" + +import time +import traceback + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, + DigitalOceanProjects, +) + + +class DOBlockStorageException(Exception): + pass + + +class DOBlockStorage(object): + def __init__(self, module): + self.module = module + self.rest = DigitalOceanHelper(module) + if self.module.params.get("project"): + # only load for non-default project assignments + self.projects = DigitalOceanProjects(module, self.rest) + + def get_key_or_fail(self, k): + v = self.module.params[k] + if v is None: + self.module.fail_json(msg="Unable to load %s" % k) + return v + + def poll_action_for_complete_status(self, action_id): + url = "actions/{0}".format(action_id) + end_time = time.monotonic() + self.module.params["timeout"] + while time.monotonic() < end_time: + time.sleep(10) + response = self.rest.get(url) + status = response.status_code + json = response.json + if status == 200: + if json["action"]["status"] == "completed": + return True + elif json["action"]["status"] == "errored": + raise DOBlockStorageException(json["message"]) + raise DOBlockStorageException( + "Unable to reach the DigitalOcean API at %s" + % self.module.params.get("baseurl") + ) + + def get_block_storage_by_name(self, volume_name, region): + url = "volumes?name={0}®ion={1}".format(volume_name, region) + resp = self.rest.get(url) + if resp.status_code != 200: + raise DOBlockStorageException(resp.json["message"]) + + volumes = resp.json["volumes"] + if not volumes: + return None + + return volumes[0] + + def get_attached_droplet_ID(self, volume_name, region): + volume = self.get_block_storage_by_name(volume_name, region) + if not volume or not volume["droplet_ids"]: + return None + + return volume["droplet_ids"][0] + + def attach_detach_block_storage(self, method, volume_name, region, droplet_id): + data = { + "type": method, + "volume_name": volume_name, + "region": region, + "droplet_id": droplet_id, + } + response = self.rest.post("volumes/actions", data=data) + status = response.status_code + json = response.json + if status == 202: + return self.poll_action_for_complete_status(json["action"]["id"]) + elif status == 200: + return True + elif status == 404 and method == "detach": + return False # Already detached + elif status == 422: + return False + else: + raise DOBlockStorageException(json["message"]) + + def resize_block_storage(self, volume_name, region, desired_size): + if not desired_size: + return False + + volume = self.get_block_storage_by_name(volume_name, region) + if volume["size_gigabytes"] == desired_size: + return False + + data = { + "type": "resize", + "size_gigabytes": desired_size, + } + resp = self.rest.post( + "volumes/{0}/actions".format(volume["id"]), + data=data, + ) + if resp.status_code == 202: + return self.poll_action_for_complete_status(resp.json["action"]["id"]) + else: + # we'd get status 422 if desired_size <= current volume size + raise DOBlockStorageException(resp.json["message"]) + + def create_block_storage(self): + volume_name = self.get_key_or_fail("volume_name") + snapshot_id = self.module.params["snapshot_id"] + if snapshot_id: + self.module.params["block_size"] = None + self.module.params["region"] = None + block_size = None + region = None + else: + block_size = self.get_key_or_fail("block_size") + region = self.get_key_or_fail("region") + description = self.module.params["description"] + data = { + "size_gigabytes": block_size, + "name": volume_name, + "description": description, + "region": region, + "snapshot_id": snapshot_id, + } + response = self.rest.post("volumes", data=data) + status = response.status_code + json = response.json + if status == 201: + project_name = self.module.params.get("project") + if ( + project_name + ): # empty string is the default project, skip project assignment + urn = "do:volume:{0}".format(json["volume"]["id"]) + ( + assign_status, + error_message, + resources, + ) = self.projects.assign_to_project(project_name, urn) + self.module.exit_json( + changed=True, + id=json["volume"]["id"], + msg=error_message, + assign_status=assign_status, + resources=resources, + ) + else: + self.module.exit_json(changed=True, id=json["volume"]["id"]) + elif status == 409 and json["id"] == "conflict": + # The volume exists already, but it might not have the desired size + resized = self.resize_block_storage(volume_name, region, block_size) + self.module.exit_json(changed=resized) + else: + raise DOBlockStorageException(json["message"]) + + def delete_block_storage(self): + volume_name = self.get_key_or_fail("volume_name") + region = self.get_key_or_fail("region") + url = "volumes?name={0}®ion={1}".format(volume_name, region) + attached_droplet_id = self.get_attached_droplet_ID(volume_name, region) + if attached_droplet_id is not None: + self.attach_detach_block_storage( + "detach", volume_name, region, attached_droplet_id + ) + response = self.rest.delete(url) + status = response.status_code + json = response.json + if status == 204: + self.module.exit_json(changed=True) + elif status == 404: + self.module.exit_json(changed=False) + else: + raise DOBlockStorageException(json["message"]) + + def attach_block_storage(self): + volume_name = self.get_key_or_fail("volume_name") + region = self.get_key_or_fail("region") + droplet_id = self.get_key_or_fail("droplet_id") + attached_droplet_id = self.get_attached_droplet_ID(volume_name, region) + if attached_droplet_id is not None: + if attached_droplet_id == droplet_id: + self.module.exit_json(changed=False) + else: + self.attach_detach_block_storage( + "detach", volume_name, region, attached_droplet_id + ) + changed_status = self.attach_detach_block_storage( + "attach", volume_name, region, droplet_id + ) + self.module.exit_json(changed=changed_status) + + def detach_block_storage(self): + volume_name = self.get_key_or_fail("volume_name") + region = self.get_key_or_fail("region") + droplet_id = self.get_key_or_fail("droplet_id") + changed_status = self.attach_detach_block_storage( + "detach", volume_name, region, droplet_id + ) + self.module.exit_json(changed=changed_status) + + +def handle_request(module): + block_storage = DOBlockStorage(module) + command = module.params["command"] + state = module.params["state"] + if command == "create": + if state == "present": + block_storage.create_block_storage() + elif state == "absent": + block_storage.delete_block_storage() + elif command == "attach": + if state == "present": + block_storage.attach_block_storage() + elif state == "absent": + block_storage.detach_block_storage() + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + state=dict(choices=["present", "absent"], required=True), + command=dict(choices=["create", "attach"], required=True), + block_size=dict(type="int", required=False), + volume_name=dict(type="str", required=True), + description=dict(type="str"), + region=dict(type="str", required=False), + snapshot_id=dict(type="str", required=False), + droplet_id=dict(type="int"), + project_name=dict(type="str", aliases=["project"], required=False, default=""), + ) + + module = AnsibleModule(argument_spec=argument_spec) + + try: + handle_request(module) + except DOBlockStorageException as e: + module.fail_json(msg=str(e), exception=traceback.format_exc()) + except KeyError as e: + module.fail_json(msg="Unable to load %s" % e, exception=traceback.format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_cdn_endpoints.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_cdn_endpoints.py new file mode 100644 index 00000000..d3617758 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_cdn_endpoints.py @@ -0,0 +1,256 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2021, Ansible Project +# Copyright: (c) 2021, Mark Mercado <mamercad@gmail.com> +# 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 = r""" +--- +module: digital_ocean_cdn_endpoints +short_description: Create, update, and delete DigitalOcean CDN Endpoints +description: + - Create, update, and delete DigitalOcean CDN Endpoints +author: "Mark Mercado (@mamercad)" +version_added: 1.10.0 +options: + state: + description: + - The usual, C(present) to create, C(absent) to destroy + type: str + choices: ["present", "absent"] + default: present + origin: + description: + - The fully qualified domain name (FQDN) for the origin server which provides the content for the CDN. + - This is currently restricted to a Space. + type: str + required: true + ttl: + description: + - The amount of time the content is cached by the CDN's edge servers in seconds. + - TTL must be one of 60, 600, 3600, 86400, or 604800. + - Defaults to 3600 (one hour) when excluded. + type: int + choices: [60, 600, 3600, 86400, 604800] + default: 3600 + required: false + certificate_id: + description: + - The ID of a DigitalOcean managed TLS certificate used for SSL when a custom subdomain is provided. + type: str + required: false + custom_domain: + description: + - The fully qualified domain name (FQDN) of the custom subdomain used with the CDN endpoint. + type: str + required: false +extends_documentation_fragment: + - community.digitalocean.digital_ocean.documentation +""" + + +EXAMPLES = r""" +- name: Create DigitalOcean CDN Endpoint + community.digitalocean.digital_ocean_cdn_endpoints: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + origin: mamercad.nyc3.digitaloceanspaces.com + +- name: Update DigitalOcean CDN Endpoint (change ttl to 600, default is 3600) + community.digitalocean.digital_ocean_cdn_endpoints: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + origin: mamercad.nyc3.digitaloceanspaces.com + ttl: 600 + +- name: Delete DigitalOcean CDN Endpoint + community.digitalocean.digital_ocean_cdn_endpoints: + state: absent + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + origin: mamercad.nyc3.digitaloceanspaces.com +""" + + +RETURN = r""" +data: + description: DigitalOcean CDN Endpoints + returned: success + type: dict + sample: + data: + endpoint: + created_at: '2021-09-05T13:47:23Z' + endpoint: mamercad.nyc3.cdn.digitaloceanspaces.com + id: 01739563-3f50-4da4-a451-27f6d59d7573 + origin: mamercad.nyc3.digitaloceanspaces.com + ttl: 3600 +""" + + +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +class DOCDNEndpoint(object): + def __init__(self, module): + self.module = module + self.rest = DigitalOceanHelper(module) + # pop the oauth token so we don't include it in the POST data + self.token = self.module.params.pop("oauth_token") + + def get_cdn_endpoints(self): + cdns = self.rest.get_paginated_data( + base_url="cdn/endpoints?", data_key_name="endpoints" + ) + return cdns + + def get_cdn_endpoint(self): + cdns = self.rest.get_paginated_data( + base_url="cdn/endpoints?", data_key_name="endpoints" + ) + found = None + for cdn in cdns: + if cdn.get("origin") == self.module.params.get("origin"): + found = cdn + for key in ["ttl", "certificate_id"]: + if self.module.params.get(key) != cdn.get(key): + return found, True + return found, False + + def create(self): + cdn, needs_update = self.get_cdn_endpoint() + + if cdn is not None: + if not needs_update: + # Have it already + self.module.exit_json(changed=False, msg=cdn) + if needs_update: + # Check mode + if self.module.check_mode: + self.module.exit_json(changed=True) + + # Update it + request_params = dict(self.module.params) + + endpoint = "cdn/endpoints" + response = self.rest.put( + "{0}/{1}".format(endpoint, cdn.get("id")), data=request_params + ) + status_code = response.status_code + json_data = response.json + + # The API docs are wrong (they say 202 but return 200) + if status_code != 200: + self.module.fail_json( + changed=False, + msg="Failed to put {0} information due to error [HTTP {1}: {2}]".format( + endpoint, + status_code, + json_data.get("message", "(empty error message)"), + ), + ) + + self.module.exit_json(changed=True, data=json_data) + else: + # Check mode + if self.module.check_mode: + self.module.exit_json(changed=True) + + # Create it + request_params = dict(self.module.params) + + endpoint = "cdn/endpoints" + response = self.rest.post(endpoint, data=request_params) + status_code = response.status_code + json_data = response.json + + if status_code != 201: + self.module.fail_json( + changed=False, + msg="Failed to post {0} information due to error [HTTP {1}: {2}]".format( + endpoint, + status_code, + json_data.get("message", "(empty error message)"), + ), + ) + + self.module.exit_json(changed=True, data=json_data) + + def delete(self): + cdn, needs_update = self.get_cdn_endpoint() + if cdn is not None: + # Check mode + if self.module.check_mode: + self.module.exit_json(changed=True) + + # Delete it + endpoint = "cdn/endpoints/{0}".format(cdn.get("id")) + response = self.rest.delete(endpoint) + status_code = response.status_code + json_data = response.json + + if status_code != 204: + self.module.fail_json( + changed=False, + msg="Failed to delete {0} information due to error [HTTP {1}: {2}]".format( + endpoint, + status_code, + json_data.get("message", "(empty error message)"), + ), + ) + + self.module.exit_json( + changed=True, + msg="Deleted CDN Endpoint {0} ({1})".format( + cdn.get("origin"), cdn.get("id") + ), + ) + else: + self.module.exit_json(changed=False) + + +def run(module): + state = module.params.pop("state") + c = DOCDNEndpoint(module) + + # Pop these away (don't need them beyond DOCDNEndpoint) + module.params.pop("baseurl") + module.params.pop("validate_certs") + module.params.pop("timeout") + + if state == "present": + c.create() + elif state == "absent": + c.delete() + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + state=dict(choices=["present", "absent"], default="present"), + origin=dict(type="str", required=True), + ttl=dict( + type="int", + choices=[60, 600, 3600, 86400, 604800], + required=False, + default=3600, + ), + certificate_id=dict(type="str", default=""), + custom_domain=dict(type="str", default=""), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_cdn_endpoints_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_cdn_endpoints_info.py new file mode 100644 index 00000000..7c8de494 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_cdn_endpoints_info.py @@ -0,0 +1,93 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2021, Ansible Project +# Copyright: (c) 2021, Mark Mercado <mamercad@gmail.com> +# 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 = r""" +--- +module: digital_ocean_cdn_endpoints_info +short_description: Display DigitalOcean CDN Endpoints +description: + - Display DigitalOcean CDN Endpoints +author: "Mark Mercado (@mamercad)" +version_added: 1.10.0 +options: + state: + description: + - The usual, C(present) to create, C(absent) to destroy + type: str + choices: ["present", "absent"] + default: present +extends_documentation_fragment: + - community.digitalocean.digital_ocean.documentation +""" + + +EXAMPLES = r""" +- name: Display DigitalOcean CDN Endpoints + community.digitalocean.digital_ocean_cdn_endpoints_info: + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" +""" + + +RETURN = r""" +data: + description: DigitalOcean CDN Endpoints + returned: success + type: dict + sample: + data: + endpoints: + - created_at: '2021-09-05T13:47:23Z' + endpoint: mamercad.nyc3.cdn.digitaloceanspaces.com + id: 01739563-3f50-4da4-a451-27f6d59d7573 + origin: mamercad.nyc3.digitaloceanspaces.com + ttl: 3600 + meta: + total: 1 +""" + + +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +def run(module): + rest = DigitalOceanHelper(module) + + endpoint = "cdn/endpoints" + response = rest.get(endpoint) + json_data = response.json + status_code = response.status_code + + if status_code != 200: + module.fail_json( + changed=False, + msg="Failed to get {0} information due to error [HTTP {1}: {2}]".format( + endpoint, status_code, json_data.get("message", "(empty error message)") + ), + ) + + module.exit_json(changed=False, data=json_data) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update(state=dict(choices=["present", "absent"], default="present")) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_certificate.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_certificate.py new file mode 100644 index 00000000..60dd0fea --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_certificate.py @@ -0,0 +1,181 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2017, Abhijeet Kasurde <akasurde@redhat.com> +# +# 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 = r""" +--- +module: digital_ocean_certificate +short_description: Manage certificates in DigitalOcean +description: + - Create, Retrieve and remove certificates DigitalOcean. +author: "Abhijeet Kasurde (@Akasurde)" +options: + name: + description: + - The name of the certificate. + required: True + type: str + private_key: + description: + - A PEM-formatted private key content of SSL Certificate. + type: str + leaf_certificate: + description: + - A PEM-formatted public SSL Certificate. + type: str + certificate_chain: + description: + - The full PEM-formatted trust chain between the certificate authority's certificate and your domain's SSL certificate. + type: str + state: + description: + - Whether the certificate should be present or absent. + default: present + choices: ['present', 'absent'] + type: str +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +notes: + - Two environment variables can be used, DO_API_KEY, DO_OAUTH_TOKEN and DO_API_TOKEN. + They both refer to the v2 token. +""" + + +EXAMPLES = r""" +- name: Create a certificate + community.digitalocean.digital_ocean_certificate: + name: production + state: present + private_key: "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkM8OI7pRpgyj1I\n-----END PRIVATE KEY-----" + leaf_certificate: "-----BEGIN CERTIFICATE-----\nMIIFDmg2Iaw==\n-----END CERTIFICATE-----" + oauth_token: b7d03a6947b217efb6f3ec3bd365652 + +- name: Create a certificate using file lookup plugin + community.digitalocean.digital_ocean_certificate: + name: production + state: present + private_key: "{{ lookup('file', 'test.key') }}" + leaf_certificate: "{{ lookup('file', 'test.cert') }}" + oauth_token: "{{ oauth_token }}" + +- name: Create a certificate with trust chain + community.digitalocean.digital_ocean_certificate: + name: production + state: present + private_key: "{{ lookup('file', 'test.key') }}" + leaf_certificate: "{{ lookup('file', 'test.cert') }}" + certificate_chain: "{{ lookup('file', 'chain.cert') }}" + oauth_token: "{{ oauth_token }}" + +- name: Remove a certificate + community.digitalocean.digital_ocean_certificate: + name: production + state: absent + oauth_token: "{{ oauth_token }}" + +""" + + +RETURN = r""" # """ + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + state = module.params["state"] + name = module.params["name"] + + rest = DigitalOceanHelper(module) + + results = dict(changed=False) + + response = rest.get("certificates") + status_code = response.status_code + resp_json = response.json + + if status_code != 200: + module.fail_json(msg="Failed to retrieve certificates for DigitalOcean") + + if state == "present": + for cert in resp_json["certificates"]: + if cert["name"] == name: + module.fail_json(msg="Certificate name %s already exists" % name) + + # Certificate does not exist, let us create it + cert_data = dict( + name=name, + private_key=module.params["private_key"], + leaf_certificate=module.params["leaf_certificate"], + ) + + if module.params["certificate_chain"] is not None: + cert_data.update(certificate_chain=module.params["certificate_chain"]) + + response = rest.post("certificates", data=cert_data) + status_code = response.status_code + if status_code == 500: + module.fail_json( + msg="Failed to upload certificates as the certificates are malformed." + ) + + resp_json = response.json + if status_code == 201: + results.update(changed=True, response=resp_json) + elif status_code == 422: + results.update(changed=False, response=resp_json) + + elif state == "absent": + cert_id_del = None + for cert in resp_json["certificates"]: + if cert["name"] == name: + cert_id_del = cert["id"] + + if cert_id_del is not None: + url = "certificates/{0}".format(cert_id_del) + response = rest.delete(url) + if response.status_code == 204: + results.update(changed=True) + else: + results.update(changed=False) + else: + module.fail_json(msg="Failed to find certificate %s" % name) + + module.exit_json(**results) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + name=dict(type="str", required=True), + leaf_certificate=dict(type="str"), + private_key=dict(type="str", no_log=True), + state=dict(choices=["present", "absent"], default="present"), + certificate_chain=dict(type="str"), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[ + ("state", "present", ["leaf_certificate", "private_key"]), + ], + ) + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e)) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_certificate_facts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_certificate_facts.py new file mode 100644 index 00000000..c9125985 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_certificate_facts.py @@ -0,0 +1,126 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_certificate_info +short_description: Gather information about DigitalOcean certificates +description: + - This module can be used to gather information about DigitalOcean provided certificates. + - This module was called C(digital_ocean_certificate_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + certificate_id: + description: + - Certificate ID that can be used to identify and reference a certificate. + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all certificates + community.digitalocean.digital_ocean_certificate_info: + oauth_token: "{{ oauth_token }}" + +- name: Gather information about certificate with given id + community.digitalocean.digital_ocean_certificate_info: + oauth_token: "{{ oauth_token }}" + certificate_id: "892071a0-bb95-49bc-8021-3afd67a210bf" + +- name: Get not after information about certificate + community.digitalocean.digital_ocean_certificate_info: + register: resp_out +- set_fact: + not_after_date: "{{ item.not_after }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?name=='web-cert-01']" +- debug: + var: not_after_date +""" + + +RETURN = r""" +data: + description: DigitalOcean certificate information + returned: success + type: list + elements: dict + sample: [ + { + "id": "892071a0-bb95-49bc-8021-3afd67a210bf", + "name": "web-cert-01", + "not_after": "2017-02-22T00:23:00Z", + "sha1_fingerprint": "dfcc9f57d86bf58e321c2c6c31c7a971be244ac7", + "created_at": "2017-02-08T16:02:37Z" + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + certificate_id = module.params.get("certificate_id", None) + rest = DigitalOceanHelper(module) + + base_url = "certificates" + if certificate_id is not None: + response = rest.get("%s/%s" % (base_url, certificate_id)) + status_code = response.status_code + + if status_code != 200: + module.fail_json(msg="Failed to retrieve certificates for DigitalOcean") + + certificate = [response.json["certificate"]] + else: + certificate = rest.get_paginated_data( + base_url=base_url + "?", data_key_name="certificates" + ) + + module.exit_json(changed=False, data=certificate) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + certificate_id=dict(type="str", required=False), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_certificate_facts", + "community.digitalocean.digital_ocean_certificate_facts", + ): + module.deprecate( + "The 'digital_ocean_certificate_facts' module has been renamed to 'digital_ocean_certificate_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_certificate_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_certificate_info.py new file mode 100644 index 00000000..c9125985 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_certificate_info.py @@ -0,0 +1,126 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_certificate_info +short_description: Gather information about DigitalOcean certificates +description: + - This module can be used to gather information about DigitalOcean provided certificates. + - This module was called C(digital_ocean_certificate_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + certificate_id: + description: + - Certificate ID that can be used to identify and reference a certificate. + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all certificates + community.digitalocean.digital_ocean_certificate_info: + oauth_token: "{{ oauth_token }}" + +- name: Gather information about certificate with given id + community.digitalocean.digital_ocean_certificate_info: + oauth_token: "{{ oauth_token }}" + certificate_id: "892071a0-bb95-49bc-8021-3afd67a210bf" + +- name: Get not after information about certificate + community.digitalocean.digital_ocean_certificate_info: + register: resp_out +- set_fact: + not_after_date: "{{ item.not_after }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?name=='web-cert-01']" +- debug: + var: not_after_date +""" + + +RETURN = r""" +data: + description: DigitalOcean certificate information + returned: success + type: list + elements: dict + sample: [ + { + "id": "892071a0-bb95-49bc-8021-3afd67a210bf", + "name": "web-cert-01", + "not_after": "2017-02-22T00:23:00Z", + "sha1_fingerprint": "dfcc9f57d86bf58e321c2c6c31c7a971be244ac7", + "created_at": "2017-02-08T16:02:37Z" + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + certificate_id = module.params.get("certificate_id", None) + rest = DigitalOceanHelper(module) + + base_url = "certificates" + if certificate_id is not None: + response = rest.get("%s/%s" % (base_url, certificate_id)) + status_code = response.status_code + + if status_code != 200: + module.fail_json(msg="Failed to retrieve certificates for DigitalOcean") + + certificate = [response.json["certificate"]] + else: + certificate = rest.get_paginated_data( + base_url=base_url + "?", data_key_name="certificates" + ) + + module.exit_json(changed=False, data=certificate) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + certificate_id=dict(type="str", required=False), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_certificate_facts", + "community.digitalocean.digital_ocean_certificate_facts", + ): + module.deprecate( + "The 'digital_ocean_certificate_facts' module has been renamed to 'digital_ocean_certificate_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_database.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_database.py new file mode 100644 index 00000000..ffae82db --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_database.py @@ -0,0 +1,437 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: Ansible Project +# Copyright: (c) 2021, Mark Mercado <mmercado@digitalocean.com> +# 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 = r""" +--- +module: digital_ocean_database +short_description: Create and delete a DigitalOcean database +description: + - Create and delete a database in DigitalOcean and optionally wait for it to be online. + - DigitalOcean's managed database service simplifies the creation and management of highly available database clusters. + - Currently, it offers support for PostgreSQL, Redis, MySQL, and MongoDB. +version_added: 1.3.0 +author: "Mark Mercado (@mamercad)" +options: + state: + description: + - Indicates the desired state of the target. + default: present + choices: ['present', 'absent'] + type: str + id: + description: + - A unique ID that can be used to identify and reference a database cluster. + type: int + aliases: ['database_id'] + name: + description: + - A unique, human-readable name for the database cluster. + type: str + required: true + engine: + description: + - A slug representing the database engine used for the cluster. + - The possible values are C(pg) for PostgreSQL, C(mysql) for MySQL, C(redis) for Redis, and C(mongodb) for MongoDB. + type: str + required: true + choices: ['pg', 'mysql', 'redis', 'mongodb'] + version: + description: + - A string representing the version of the database engine in use for the cluster. + - For C(pg), versions are 10, 11 and 12. + - For C(mysql), version is 8. + - For C(redis), version is 5. + - For C(mongodb), version is 4. + type: str + size: + description: + - The slug identifier representing the size of the nodes in the database cluster. + - See U(https://docs.digitalocean.com/reference/api/api-reference/#operation/create_database_cluster) for supported sizes. + type: str + required: true + aliases: ['size_id'] + region: + description: + - The slug identifier for the region where the database cluster is located. + type: str + required: true + aliases: ['region_id'] + num_nodes: + description: + - The number of nodes in the database cluster. + - Valid choices are 1, 2 or 3. + type: int + default: 1 + choices: [1, 2, 3] + tags: + description: + - An array of tags that have been applied to the database cluster. + type: list + elements: str + private_network_uuid: + description: + - A string specifying the UUID of the VPC to which the database cluster is assigned. + type: str + wait: + description: + - Wait for the database to be online before returning. + required: False + default: True + type: bool + wait_timeout: + description: + - How long before wait gives up, in seconds, when creating a database. + default: 600 + type: int + project_name: + aliases: ["project"] + description: + - Project to assign the resource to (project name, not UUID). + - Defaults to the default project of the account (empty string). + - Currently only supported when creating databases. + type: str + required: false + default: "" +extends_documentation_fragment: + - community.digitalocean.digital_ocean.documentation +""" + + +EXAMPLES = r""" +- name: Create a Redis database + community.digitalocean.digital_ocean_database: + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_KEY') }}" + state: present + name: testdatabase1 + engine: redis + size: db-s-1vcpu-1gb + region: nyc1 + num_nodes: 1 + register: my_database + +- name: Create a Redis database (and assign to Project "test") + community.digitalocean.digital_ocean_database: + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_KEY') }}" + state: present + name: testdatabase1 + engine: redis + size: db-s-1vcpu-1gb + region: nyc1 + num_nodes: 1 + project_name: test + register: my_database +""" + + +RETURN = r""" +data: + description: A DigitalOcean database + returned: success + type: dict + sample: + database: + connection: + database: "" + host: testdatabase1-do-user-3097135-0.b.db.ondigitalocean.com + password: REDACTED + port: 25061 + protocol: rediss + ssl: true + uri: rediss://default:REDACTED@testdatabase1-do-user-3097135-0.b.db.ondigitalocean.com:25061 + user: default + created_at: "2021-04-21T15:41:14Z" + db_names: null + engine: redis + id: 37de10e4-808b-4f4b-b25f-7b5b3fd194ac + maintenance_window: + day: monday + hour: 11:33:47 + pending: false + name: testdatabase1 + num_nodes: 1 + private_connection: + database: "" + host: private-testdatabase1-do-user-3097135-0.b.db.ondigitalocean.com + password: REDIS + port: 25061 + protocol: rediss + ssl: true + uri: rediss://default:REDACTED@private-testdatabase1-do-user-3097135-0.b.db.ondigitalocean.com:25061 + user: default + private_network_uuid: 0db3519b-9efc-414a-8868-8f2e6934688c, + region: nyc1 + size: db-s-1vcpu-1gb + status: online + tags: null + users: null + version: 6 +msg: + description: Informational or error message encountered during execution + returned: changed + type: str + sample: No project named test2 found +assign_status: + description: Assignment status (ok, not_found, assigned, already_assigned, service_down) + returned: changed + type: str + sample: assigned +resources: + description: Resource assignment involved in project assignment + returned: changed + type: dict + sample: + assigned_at: '2021-10-25T17:39:38Z' + links: + self: https://api.digitalocean.com/v2/databases/126355fa-b147-40a6-850a-c44f5d2ad418 + status: assigned + urn: do:dbaas:126355fa-b147-40a6-850a-c44f5d2ad418 +""" + + +import json +import time +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, + DigitalOceanProjects, +) + + +class DODatabase(object): + def __init__(self, module): + self.module = module + self.rest = DigitalOceanHelper(module) + if self.module.params.get("project"): + # only load for non-default project assignments + self.projects = DigitalOceanProjects(module, self.rest) + # pop wait and wait_timeout so we don't include it in the POST data + self.wait = self.module.params.pop("wait", True) + self.wait_timeout = self.module.params.pop("wait_timeout", 600) + # pop the oauth token so we don't include it in the POST data + self.module.params.pop("oauth_token") + self.id = None + self.name = None + self.engine = None + self.version = None + self.num_nodes = None + self.region = None + self.status = None + self.size = None + + def get_by_id(self, database_id): + if database_id is None: + return None + response = self.rest.get("databases/{0}".format(database_id)) + json_data = response.json + if response.status_code == 200: + database = json_data.get("database", None) + if database is not None: + self.id = database.get("id", None) + self.name = database.get("name", None) + self.engine = database.get("engine", None) + self.version = database.get("version", None) + self.num_nodes = database.get("num_nodes", None) + self.region = database.get("region", None) + self.status = database.get("status", None) + self.size = database.get("size", None) + return json_data + return None + + def get_by_name(self, database_name): + if database_name is None: + return None + page = 1 + while page is not None: + response = self.rest.get("databases?page={0}".format(page)) + json_data = response.json + if response.status_code == 200: + databases = json_data.get("databases", None) + if databases is None or not isinstance(databases, list): + return None + for database in databases: + if database.get("name", None) == database_name: + self.id = database.get("id", None) + self.name = database.get("name", None) + self.engine = database.get("engine", None) + self.version = database.get("version", None) + self.status = database.get("status", None) + self.num_nodes = database.get("num_nodes", None) + self.region = database.get("region", None) + self.size = database.get("size", None) + return {"database": database} + if ( + "links" in json_data + and "pages" in json_data["links"] + and "next" in json_data["links"]["pages"] + ): + page += 1 + else: + page = None + return None + + def get_database(self): + json_data = self.get_by_id(self.module.params["id"]) + if not json_data: + json_data = self.get_by_name(self.module.params["name"]) + return json_data + + def ensure_online(self, database_id): + end_time = time.monotonic() + self.wait_timeout + while time.monotonic() < end_time: + response = self.rest.get("databases/{0}".format(database_id)) + json_data = response.json + database = json_data.get("database", None) + if database is not None: + status = database.get("status", None) + if status is not None: + if status == "online": + return json_data + time.sleep(10) + self.module.fail_json(msg="Waiting for database online timeout") + + def create(self): + json_data = self.get_database() + + if json_data is not None: + database = json_data.get("database", None) + if database is not None: + self.module.exit_json(changed=False, data=json_data) + else: + self.module.fail_json( + changed=False, msg="Unexpected error, please file a bug" + ) + + if self.module.check_mode: + self.module.exit_json(changed=True) + + request_params = dict(self.module.params) + del request_params["id"] + + response = self.rest.post("databases", data=request_params) + json_data = response.json + if response.status_code >= 400: + self.module.fail_json(changed=False, msg=json_data["message"]) + database = json_data.get("database", None) + if database is None: + self.module.fail_json( + changed=False, + msg="Unexpected error; please file a bug https://github.com/ansible-collections/community.digitalocean/issues", + ) + + database_id = database.get("id", None) + if database_id is None: + self.module.fail_json( + changed=False, + msg="Unexpected error; please file a bug https://github.com/ansible-collections/community.digitalocean/issues", + ) + + if self.wait: + json_data = self.ensure_online(database_id) + + project_name = self.module.params.get("project") + if project_name: # empty string is the default project, skip project assignment + urn = "do:dbaas:{0}".format(database_id) + assign_status, error_message, resources = self.projects.assign_to_project( + project_name, urn + ) + self.module.exit_json( + changed=True, + data=json_data, + msg=error_message, + assign_status=assign_status, + resources=resources, + ) + else: + self.module.exit_json(changed=True, data=json_data) + + def delete(self): + json_data = self.get_database() + if json_data is not None: + if self.module.check_mode: + self.module.exit_json(changed=True) + database = json_data.get("database", None) + database_id = database.get("id", None) + database_name = database.get("name", None) + database_region = database.get("region", None) + if database_id is not None: + response = self.rest.delete("databases/{0}".format(database_id)) + json_data = response.json + if response.status_code == 204: + self.module.exit_json( + changed=True, + msg="Deleted database {0} ({1}) in region {2}".format( + database_name, database_id, database_region + ), + ) + self.module.fail_json( + changed=False, + msg="Failed to delete database {0} ({1}) in region {2}: {3}".format( + database_name, + database_id, + database_region, + json_data["message"], + ), + ) + else: + self.module.fail_json( + changed=False, msg="Unexpected error, please file a bug" + ) + else: + self.module.exit_json( + changed=False, + msg="Database {0} in region {1} not found".format( + self.module.params["name"], self.module.params["region"] + ), + ) + + +def run(module): + state = module.params.pop("state") + database = DODatabase(module) + if state == "present": + database.create() + elif state == "absent": + database.delete() + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + state=dict(choices=["present", "absent"], default="present"), + id=dict(type="int", aliases=["database_id"]), + name=dict(type="str", required=True), + engine=dict(choices=["pg", "mysql", "redis", "mongodb"], required=True), + version=dict(type="str"), + size=dict(type="str", aliases=["size_id"], required=True), + region=dict(type="str", aliases=["region_id"], required=True), + num_nodes=dict(type="int", choices=[1, 2, 3], default=1), + tags=dict(type="list", elements="str"), + private_network_uuid=dict(type="str"), + wait=dict(type="bool", default=True), + wait_timeout=dict(default=600, type="int"), + project_name=dict(type="str", aliases=["project"], required=False, default=""), + ) + module = AnsibleModule( + argument_spec=argument_spec, + required_one_of=(["id", "name"],), + required_if=( + [ + ("state", "present", ["name", "size", "engine", "region"]), + ("state", "absent", ["name", "size", "engine", "region"]), + ] + ), + supports_check_mode=True, + ) + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_database_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_database_info.py new file mode 100644 index 00000000..cc599661 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_database_info.py @@ -0,0 +1,214 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: Ansible Project +# Copyright: (c) 2021, Mark Mercado <mmercado@digitalocean.com> +# 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 = r""" +--- +module: digital_ocean_database_info +short_description: Gather information about DigitalOcean databases +description: + - Gather information about DigitalOcean databases. +version_added: 1.3.0 +author: "Mark Mercado (@mamercad)" +options: + id: + description: + - A unique ID that can be used to identify and reference a database cluster. + type: int + aliases: ['database_id'] + required: false + name: + description: + - A unique, human-readable name for the database cluster. + type: str + required: false +extends_documentation_fragment: + - community.digitalocean.digital_ocean.documentation +""" + + +EXAMPLES = r""" +- name: Gather all DigitalOcean databases + community.digitalocean.digital_ocean_database_info: + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_KEY') }}" + register: my_databases +""" + + +RETURN = r""" +data: + description: List of DigitalOcean databases + returned: success + type: list + sample: [ + { + "connection": { + "database": "", + "host": "testdatabase1-do-user-3097135-0.b.db.ondigitalocean.com", + "password": "REDACTED", + "port": 25061, + "protocol":"rediss", + "ssl": true, + "uri": "rediss://default:REDACTED@testdatabase1-do-user-3097135-0.b.db.ondigitalocean.com:25061", + "user": "default" + }, + "created_at": "2021-04-21T15:41:14Z", + "db_names": null, + "engine": "redis", + "id": "37de10e4-808b-4f4b-b25f-7b5b3fd194ac", + "maintenance_window": { + "day": "monday", + "hour": "11:33:47", + "pending": false + }, + "name": "testdatabase1", + "num_nodes": 1, + "private_connection": { + "database": "", + "host": "private-testdatabase1-do-user-3097135-0.b.db.ondigitalocean.com", + "password": "REDACTED", + "port": 25061, + "protocol": "rediss", + "ssl": true, + "uri": "rediss://default:REDACTED@private-testdatabase1-do-user-3097135-0.b.db.ondigitalocean.com:25061", + "user": "default" + }, + "private_network_uuid": "0db3519b-9efc-414a-8868-8f2e6934688c", + "region": "nyc1", + "size": "db-s-1vcpu-1gb", + "status": "online", + "tags": null, + "users": null, + "version": "6" + }, + ... + ] +""" + + +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +class DODatabaseInfo(object): + def __init__(self, module): + self.module = module + self.rest = DigitalOceanHelper(module) + # pop the oauth token so we don't include it in the POST data + self.module.params.pop("oauth_token") + self.id = None + self.name = None + + def get_by_id(self, database_id): + if database_id is None: + return None + response = self.rest.get("databases/{0}".format(database_id)) + json_data = response.json + if response.status_code == 200: + database = json_data.get("database", None) + if database is not None: + self.id = database.get("id", None) + self.name = database.get("name", None) + return json_data + return None + + def get_by_name(self, database_name): + if database_name is None: + return None + page = 1 + while page is not None: + response = self.rest.get("databases?page={0}".format(page)) + json_data = response.json + if response.status_code == 200: + for database in json_data["databases"]: + if database.get("name", None) == database_name: + self.id = database.get("id", None) + self.name = database.get("name", None) + return {"database": database} + if ( + "links" in json_data + and "pages" in json_data["links"] + and "next" in json_data["links"]["pages"] + ): + page += 1 + else: + page = None + return None + + def get_database(self): + json_data = self.get_by_id(self.module.params["id"]) + if not json_data: + json_data = self.get_by_name(self.module.params["name"]) + return json_data + + def get_databases(self): + all_databases = [] + page = 1 + while page is not None: + response = self.rest.get("databases?page={0}".format(page)) + json_data = response.json + if response.status_code == 200: + databases = json_data.get("databases", None) + if databases is not None and isinstance(databases, list): + all_databases.append(databases) + if ( + "links" in json_data + and "pages" in json_data["links"] + and "next" in json_data["links"]["pages"] + ): + page += 1 + else: + page = None + return {"databases": all_databases} + + +def run(module): + id = module.params.get("id", None) + name = module.params.get("name", None) + + database = DODatabaseInfo(module) + + if id is not None or name is not None: + the_database = database.get_database() + if the_database: # Found it + module.exit_json(changed=False, data=the_database) + else: # Didn't find it + if id is not None and name is not None: + module.fail_json( + change=False, msg="Database {0} ({1}) not found".format(id, name) + ) + elif id is not None and name is None: + module.fail_json(change=False, msg="Database {0} not found".format(id)) + elif id is None and name is not None: + module.fail_json( + change=False, msg="Database {0} not found".format(name) + ) + else: + all_databases = database.get_databases() + module.exit_json(changed=False, data=all_databases) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + id=dict(type="int", aliases=["database_id"]), + name=dict(type="str"), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain.py new file mode 100644 index 00000000..234c6cf2 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain.py @@ -0,0 +1,325 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: 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 + + +DOCUMENTATION = r""" +--- +module: digital_ocean_domain +short_description: Create/delete a DNS domain in DigitalOcean +description: + - Create/delete a DNS domain in DigitalOcean. +author: "Michael Gregson (@mgregson)" +options: + state: + description: + - Indicate desired state of the target. + default: present + choices: ['present', 'absent'] + type: str + id: + description: + - The droplet id you want to operate on. + aliases: ['droplet_id'] + type: int + name: + description: + - The name of the droplet - must be formatted by hostname rules, or the name of a SSH key, or the name of a domain. + type: str + ip: + description: + - An 'A' record for '@' ($ORIGIN) will be created with the value 'ip'. 'ip' is an IP version 4 address. + type: str + aliases: ['ip4', 'ipv4'] + ip6: + description: + - An 'AAAA' record for '@' ($ORIGIN) will be created with the value 'ip6'. 'ip6' is an IP version 6 address. + type: str + aliases: ['ipv6'] + project_name: + aliases: ["project"] + description: + - Project to assign the resource to (project name, not UUID). + - Defaults to the default project of the account (empty string). + - Currently only supported when creating domains. + type: str + required: false + default: "" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +notes: + - Environment variables DO_OAUTH_TOKEN can be used for the oauth_token. + - As of Ansible 1.9.5 and 2.0, Version 2 of the DigitalOcean API is used, this removes C(client_id) and C(api_key) options in favor of C(oauth_token). + - If you are running Ansible 1.9.4 or earlier you might not be able to use the included version of this module as the API version used has been retired. + +requirements: + - "python >= 2.6" +""" + + +EXAMPLES = r""" +- name: Create a domain + community.digitalocean.digital_ocean_domain: + state: present + name: my.digitalocean.domain + ip: 127.0.0.1 + +- name: Create a domain (and associate to Project "test") + community.digitalocean.digital_ocean_domain: + state: present + name: my.digitalocean.domain + ip: 127.0.0.1 + project: test + +# Create a droplet and corresponding domain +- name: Create a droplet + community.digitalocean.digital_ocean: + state: present + name: test_droplet + size_id: 1gb + region_id: sgp1 + image_id: ubuntu-14-04-x64 + register: test_droplet + +- name: Create a corresponding domain + community.digitalocean.digital_ocean_domain: + state: present + name: "{{ test_droplet.droplet.name }}.my.domain" + ip: "{{ test_droplet.droplet.ip_address }}" + +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, + DigitalOceanProjects, +) +import time + +ZONE_FILE_ATTEMPTS = 5 +ZONE_FILE_SLEEP = 3 + + +class DoManager(DigitalOceanHelper, object): + def __init__(self, module): + super(DoManager, self).__init__(module) + self.domain_name = module.params.get("name", None) + self.domain_ip = module.params.get("ip", None) + self.domain_id = module.params.get("id", None) + + @staticmethod + def jsonify(response): + return response.status_code, response.json + + def all_domains(self): + resp = self.get("domains/") + return resp + + def find(self): + if self.domain_name is None and self.domain_id is None: + return None + + domains = self.all_domains() + status, json = self.jsonify(domains) + for domain in json["domains"]: + if domain["name"] == self.domain_name: + return domain + return None + + def add(self): + params = {"name": self.domain_name, "ip_address": self.domain_ip} + resp = self.post("domains/", data=params) + status = resp.status_code + json = resp.json + if status == 201: + return json["domain"] + else: + return json + + def all_domain_records(self): + resp = self.get("domains/%s/records/" % self.domain_name) + return resp.json + + def domain_record(self): + resp = self.get("domains/%s" % self.domain_name) + status, json = self.jsonify(resp) + return json + + def destroy_domain(self): + resp = self.delete("domains/%s" % self.domain_name) + status, json = self.jsonify(resp) + if status == 204: + return True + else: + return json + + def edit_domain_record(self, record): + if self.module.params.get("ip"): + params = {"name": "@", "data": self.module.params.get("ip")} + if self.module.params.get("ip6"): + params = {"name": "@", "data": self.module.params.get("ip6")} + + resp = self.put( + "domains/%s/records/%s" % (self.domain_name, record["id"]), data=params + ) + status, json = self.jsonify(resp) + + return json["domain_record"] + + def create_domain_record(self): + if self.module.params.get("ip"): + params = {"name": "@", "type": "A", "data": self.module.params.get("ip")} + if self.module.params.get("ip6"): + params = { + "name": "@", + "type": "AAAA", + "data": self.module.params.get("ip6"), + } + + resp = self.post("domains/%s/records" % (self.domain_name), data=params) + status, json = self.jsonify(resp) + + return json["domain_record"] + + +def run(module): + do_manager = DoManager(module) + state = module.params.get("state") + + if module.params.get("project"): + # only load for non-default project assignments + projects = DigitalOceanProjects(module, do_manager) + + domain = do_manager.find() + if state == "present": + if not domain: + domain = do_manager.add() + if "message" in domain: + module.fail_json(changed=False, msg=domain["message"]) + else: + # We're at the mercy of a backend process which we have no visibility into: + # https://docs.digitalocean.com/reference/api/api-reference/#operation/create_domain + # + # In particular: "Keep in mind that, upon creation, the zone_file field will + # have a value of null until a zone file is generated and propagated through + # an automatic process on the DigitalOcean servers." + # + # Arguably, it's nice to see the records versus null, so, we'll just try a + # few times before giving up and returning null. + + domain_name = module.params.get("name") + project_name = module.params.get("project") + urn = "do:domain:{0}".format(domain_name) + + for i in range(ZONE_FILE_ATTEMPTS): + record = do_manager.domain_record() + if record is not None and "domain" in record: + domain = record.get("domain", None) + if domain is not None and "zone_file" in domain: + if ( + project_name + ): # empty string is the default project, skip project assignment + ( + assign_status, + error_message, + resources, + ) = projects.assign_to_project(project_name, urn) + module.exit_json( + changed=True, + domain=domain, + msg=error_message, + assign_status=assign_status, + resources=resources, + ) + else: + module.exit_json(changed=True, domain=domain) + time.sleep(ZONE_FILE_SLEEP) + if ( + project_name + ): # empty string is the default project, skip project assignment + ( + assign_status, + error_message, + resources, + ) = projects.assign_to_project(project_name, urn) + module.exit_json( + changed=True, + domain=domain, + msg=error_message, + assign_status=assign_status, + resources=resources, + ) + else: + module.exit_json(changed=True, domain=domain) + else: + records = do_manager.all_domain_records() + if module.params.get("ip"): + at_record = None + for record in records["domain_records"]: + if record["name"] == "@" and record["type"] == "A": + at_record = record + + if not at_record: + do_manager.create_domain_record() + module.exit_json(changed=True, domain=do_manager.find()) + elif not at_record["data"] == module.params.get("ip"): + do_manager.edit_domain_record(at_record) + module.exit_json(changed=True, domain=do_manager.find()) + + if module.params.get("ip6"): + at_record = None + for record in records["domain_records"]: + if record["name"] == "@" and record["type"] == "AAAA": + at_record = record + + if not at_record: + do_manager.create_domain_record() + module.exit_json(changed=True, domain=do_manager.find()) + elif not at_record["data"] == module.params.get("ip6"): + do_manager.edit_domain_record(at_record) + module.exit_json(changed=True, domain=do_manager.find()) + + module.exit_json(changed=False, domain=do_manager.domain_record()) + + elif state == "absent": + if not domain: + module.exit_json(changed=False, msg="Domain not found") + else: + delete_event = do_manager.destroy_domain() + if not delete_event: + module.fail_json(changed=False, msg=delete_event["message"]) + else: + module.exit_json(changed=True, event=None) + delete_event = do_manager.destroy_domain() + module.exit_json(changed=delete_event) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + state=dict(choices=["present", "absent"], default="present"), + name=dict(type="str"), + id=dict(aliases=["droplet_id"], type="int"), + ip=dict(type="str", aliases=["ip4", "ipv4"]), + ip6=dict(type="str", aliases=["ipv6"]), + project_name=dict(type="str", aliases=["project"], required=False, default=""), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + required_one_of=(["id", "name"],), + mutually_exclusive=[("ip", "ip6")], + ) + + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain_facts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain_facts.py new file mode 100644 index 00000000..32382b28 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain_facts.py @@ -0,0 +1,152 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_domain_info +short_description: Gather information about DigitalOcean Domains +description: + - This module can be used to gather information about DigitalOcean provided Domains. + - This module was called C(digital_ocean_domain_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + domain_name: + description: + - Name of the domain to gather information for. + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all domains + community.digitalocean.digital_ocean_domain_info: + oauth_token: "{{ oauth_token }}" + +- name: Gather information about domain with given name + community.digitalocean.digital_ocean_domain_info: + oauth_token: "{{ oauth_token }}" + domain_name: "example.com" + +- name: Get ttl from domain + community.digitalocean.digital_ocean_domain_info: + register: resp_out +- set_fact: + domain_ttl: "{{ item.ttl }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?name=='example.com']" +- debug: + var: domain_ttl +""" + + +RETURN = r""" +data: + description: DigitalOcean Domain information + returned: success + elements: dict + type: list + sample: [ + { + "domain_records": [ + { + "data": "ns1.digitalocean.com", + "flags": null, + "id": 37826823, + "name": "@", + "port": null, + "priority": null, + "tag": null, + "ttl": 1800, + "type": "NS", + "weight": null + }, + ], + "name": "myexample123.com", + "ttl": 1800, + "zone_file": "myexample123.com. IN SOA ns1.digitalocean.com. hostmaster.myexample123.com. 1520702984 10800 3600 604800 1800\n", + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + domain_name = module.params.get("domain_name", None) + rest = DigitalOceanHelper(module) + domain_results = [] + + if domain_name is not None: + response = rest.get("domains/%s" % domain_name) + status_code = response.status_code + + if status_code != 200: + module.fail_json(msg="Failed to retrieve domain for DigitalOcean") + + resp_json = response.json + domains = [resp_json["domain"]] + else: + domains = rest.get_paginated_data(base_url="domains?", data_key_name="domains") + + for temp_domain in domains: + temp_domain_dict = { + "name": temp_domain["name"], + "ttl": temp_domain["ttl"], + "zone_file": temp_domain["zone_file"], + "domain_records": list(), + } + + base_url = "domains/%s/records?" % temp_domain["name"] + + temp_domain_dict["domain_records"] = rest.get_paginated_data( + base_url=base_url, data_key_name="domain_records" + ) + domain_results.append(temp_domain_dict) + + module.exit_json(changed=False, data=domain_results) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + domain_name=dict(type="str", required=False), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_domain_facts", + "community.digitalocean.digital_ocean_domain_facts", + ): + module.deprecate( + "The 'digital_ocean_domain_facts' module has been renamed to 'digital_ocean_domain_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain_info.py new file mode 100644 index 00000000..32382b28 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain_info.py @@ -0,0 +1,152 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_domain_info +short_description: Gather information about DigitalOcean Domains +description: + - This module can be used to gather information about DigitalOcean provided Domains. + - This module was called C(digital_ocean_domain_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + domain_name: + description: + - Name of the domain to gather information for. + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all domains + community.digitalocean.digital_ocean_domain_info: + oauth_token: "{{ oauth_token }}" + +- name: Gather information about domain with given name + community.digitalocean.digital_ocean_domain_info: + oauth_token: "{{ oauth_token }}" + domain_name: "example.com" + +- name: Get ttl from domain + community.digitalocean.digital_ocean_domain_info: + register: resp_out +- set_fact: + domain_ttl: "{{ item.ttl }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?name=='example.com']" +- debug: + var: domain_ttl +""" + + +RETURN = r""" +data: + description: DigitalOcean Domain information + returned: success + elements: dict + type: list + sample: [ + { + "domain_records": [ + { + "data": "ns1.digitalocean.com", + "flags": null, + "id": 37826823, + "name": "@", + "port": null, + "priority": null, + "tag": null, + "ttl": 1800, + "type": "NS", + "weight": null + }, + ], + "name": "myexample123.com", + "ttl": 1800, + "zone_file": "myexample123.com. IN SOA ns1.digitalocean.com. hostmaster.myexample123.com. 1520702984 10800 3600 604800 1800\n", + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + domain_name = module.params.get("domain_name", None) + rest = DigitalOceanHelper(module) + domain_results = [] + + if domain_name is not None: + response = rest.get("domains/%s" % domain_name) + status_code = response.status_code + + if status_code != 200: + module.fail_json(msg="Failed to retrieve domain for DigitalOcean") + + resp_json = response.json + domains = [resp_json["domain"]] + else: + domains = rest.get_paginated_data(base_url="domains?", data_key_name="domains") + + for temp_domain in domains: + temp_domain_dict = { + "name": temp_domain["name"], + "ttl": temp_domain["ttl"], + "zone_file": temp_domain["zone_file"], + "domain_records": list(), + } + + base_url = "domains/%s/records?" % temp_domain["name"] + + temp_domain_dict["domain_records"] = rest.get_paginated_data( + base_url=base_url, data_key_name="domain_records" + ) + domain_results.append(temp_domain_dict) + + module.exit_json(changed=False, data=domain_results) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + domain_name=dict(type="str", required=False), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_domain_facts", + "community.digitalocean.digital_ocean_domain_facts", + ): + module.deprecate( + "The 'digital_ocean_domain_facts' module has been renamed to 'digital_ocean_domain_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain_record.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain_record.py new file mode 100644 index 00000000..05bc4a45 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain_record.py @@ -0,0 +1,508 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: 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 + +DOCUMENTATION = """ +--- +module: digital_ocean_domain_record +author: "Adam Papai (@woohgit)" +version_added: 1.1.0 +short_description: Manage DigitalOcean domain records +description: + - Create/delete a domain record in DigitalOcean. +options: + state: + description: + - Indicate desired state of the target. + default: present + choices: [ present, absent ] + type: str + record_id: + description: + - Used with C(force_update=yes) and C(state='absent') to update or delete a specific record. + type: int + force_update: + description: + - If there is already a record with the same C(name) and C(type) force update it. + default: false + type: bool + domain: + description: + - Name of the domain. + required: true + type: str + type: + description: + - The type of record you would like to create. + choices: [ A, AAAA, CNAME, MX, TXT, SRV, NS, CAA ] + type: str + data: + description: + - This is the value of the record, depending on the record type. + default: "" + type: str + name: + description: + - Required for C(A, AAAA, CNAME, TXT, SRV) records. The host name, alias, or service being defined by the record. + default: "@" + type: str + priority: + description: + - The priority of the host for C(SRV, MX) records). + type: int + port: + description: + - The port that the service is accessible on for SRV records only. + type: int + weight: + description: + - The weight of records with the same priority for SRV records only. + type: int + ttl: + description: + - Time to live for the record, in seconds. + default: 1800 + type: int + flags: + description: + - An unsignedinteger between 0-255 used for CAA records. + type: int + tag: + description: + - The parameter tag for CAA records. + choices: [ issue, wildissue, iodef ] + type: str + oauth_token: + description: + - DigitalOcean OAuth token. Can be specified in C(DO_API_KEY), C(DO_API_TOKEN), or C(DO_OAUTH_TOKEN) environment variables + aliases: ['API_TOKEN'] + type: str + +notes: + - Version 2 of DigitalOcean API is used. + - The number of requests that can be made through the API is currently limited to 5,000 per hour per OAuth token. +""" + +EXAMPLES = """ +- name: Create default A record for example.com + community.digitalocean.digital_ocean_domain_record: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + domain: example.com + type: A + name: "@" + data: 127.0.0.1 + +- name: Create A record for www + community.digitalocean.digital_ocean_domain_record: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + domain: example.com + type: A + name: www + data: 127.0.0.1 + +- name: Update A record for www based on name/type/data + community.digitalocean.digital_ocean_domain_record: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + domain: example.com + type: A + name: www + data: 127.0.0.2 + force_update: yes + +- name: Update A record for www based on record_id + community.digitalocean.digital_ocean_domain_record: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + domain: example.com + record_id: 123456 + type: A + name: www + data: 127.0.0.2 + force_update: yes + +- name: Remove www record based on name/type/data + community.digitalocean.digital_ocean_domain_record: + state: absent + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + domain: example.com + type: A + name: www + data: 127.0.0.1 + +- name: Remove www record based on record_id + community.digitalocean.digital_ocean_domain_record: + state: absent + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + domain: example.com + record_id: 1234567 + +- name: Create MX record with priority 10 for example.com + community.digitalocean.digital_ocean_domain_record: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + domain: example.com + type: MX + data: mail1.example.com + priority: 10 +""" + +RETURN = r""" +data: + description: a DigitalOcean Domain Record + returned: success + type: dict + sample: { + "id": 3352896, + "type": "CNAME", + "name": "www", + "data": "192.168.0.1", + "priority": 10, + "port": 5556, + "ttl": 3600, + "weight": 10, + "flags": 16, + "tag": "issue" + } +""" + + +from ansible.module_utils.basic import env_fallback +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +class DigitalOceanDomainRecordManager(DigitalOceanHelper, object): + def __init__(self, module): + super(DigitalOceanDomainRecordManager, self).__init__(module) + self.module = module + self.domain = module.params.get("domain").lower() + self.records = self.__get_all_records() + self.payload = self.__build_payload() + self.force_update = module.params.get("force_update", False) + self.record_id = module.params.get("record_id", None) + + def check_credentials(self): + # Check if oauth_token is valid or not + response = self.get("account") + if response.status_code == 401: + self.module.fail_json( + msg="Failed to login using oauth_token, please verify validity of oauth_token" + ) + + def verify_domain(self): + # URL https://api.digitalocean.com/v2/domains/[NAME] + response = self.get("domains/%s" % self.domain) + status_code = response.status_code + json = response.json + + if status_code not in (200, 404): + self.module.fail_json( + msg="Error getting domain [%(status_code)s: %(json)s]" + % {"status_code": status_code, "json": json} + ) + elif status_code == 404: + self.module.fail_json( + msg="No domain named '%s' found. Please create a domain first" + % self.domain + ) + + def __get_all_records(self): + + records = [] + page = 1 + while True: + # GET /v2/domains/$DOMAIN_NAME/records + response = self.get( + "domains/%(domain)s/records?page=%(page)s" + % {"domain": self.domain, "page": page} + ) + status_code = response.status_code + json = response.json + + if status_code != 200: + self.module.fail_json( + msg="Error getting domain records [%(status_code)s: %(json)s]" + % {"status_code": status_code, "json": json} + ) + + for record in json["domain_records"]: + records.append(dict([(str(k), v) for k, v in record.items()])) + + if "pages" in json["links"] and "next" in json["links"]["pages"]: + page += 1 + else: + break + + return records + + def __normalize_data(self): + # for the MX, CNAME, SRV, CAA records make sure the data ends with a dot + if ( + self.payload["type"] in ["CNAME", "MX", "SRV", "CAA"] + and self.payload["data"] != "@" + and not self.payload["data"].endswith(".") + ): + data = "%s." % self.payload["data"] + else: + data = self.payload["data"] + + return data + + def __find_record_by_id(self, record_id): + for record in self.records: + if record["id"] == record_id: + return record + return None + + def __get_matching_records(self): + """Collect exact and similar records + + It returns an exact record if there is any match along with the record_id. + It also returns multiple records if there is no exact match + """ + + # look for exactly the same record used by (create, delete) + for record in self.records: + r = dict(record) + del r["id"] + # python3 does not have cmp so let's use the official workaround + if r == self.payload: + return r, record["id"], None + + # look for similar records used by (update) + similar_records = [] + for record in self.records: + if ( + record["type"] == self.payload["type"] + and record["name"] == self.payload["name"] + ): + similar_records.append(record) + + if similar_records: + return None, None, similar_records + + # if no exact neither similar records + return None, None, None + + def __create_record(self): + # before data comparison, we need to make sure that + # the payload['data'] is not normalized, but + # during create/update digitalocean expects normalized data + self.payload["data"] = self.__normalize_data() + + # POST /v2/domains/$DOMAIN_NAME/records + response = self.post("domains/%s/records" % self.domain, data=self.payload) + status_code = response.status_code + json = response.json + if status_code == 201: + changed = True + return changed, json["domain_record"] + else: + self.module.fail_json( + msg="Error creating domain record [%(status_code)s: %(json)s]" + % {"status_code": status_code, "json": json} + ) + + def create_or_update_record(self): + + # if record_id is given we need to update the record no matter what + if self.record_id: + changed, result = self.__update_record(self.record_id) + return changed, result + + record, record_id, similar_records = self.__get_matching_records() + + # create the record if no similar or exact record were found + if not record and not similar_records: + changed, result = self.__create_record() + return changed, result + + # no exact match, but we have similar records + # so if force_update == True we should update it + if not record and similar_records: + # if we have 1 similar record + if len(similar_records) == 1: + # update if we were told to do it so + if self.force_update: + record_id = similar_records[0]["id"] + changed, result = self.__update_record(record_id) + # if no update was given, create it + else: + changed, result = self.__create_record() + return changed, result + # we have multiple similar records, bun not exact match + else: + # we have multiple similar records, can't decide what to do + if self.force_update: + self.module.fail_json( + msg="Can't update record, too many similar records: %s" + % similar_records + ) + # create it + else: + changed, result = self.__create_record() + return changed, result + # record matches + else: + changed = False + result = "Record has been already created" + return changed, result + + def __update_record(self, record_id): + # before data comparison, we need to make sure that + # the payload['data'] is not normalized, but + # during create/update digitalocean expects normalized data + self.payload["data"] = self.__normalize_data() + + # double check if the record exist + record = self.__find_record_by_id(record_id) + + # record found + if record: + # PUT /v2/domains/$DOMAIN_NAME/records/$RECORD_ID + response = self.put( + "domains/%(domain)s/records/%(record_id)s" + % {"domain": self.domain, "record_id": record_id}, + data=self.payload, + ) + status_code = response.status_code + json = response.json + if status_code == 200: + changed = True + return changed, json["domain_record"] + else: + self.module.fail_json( + msg="Error updating domain record [%(status_code)s: %(json)s]" + % {"status_code": status_code, "json": json} + ) + # recond not found + else: + self.module.fail_json( + msg="Error updating domain record. Record does not exist. [%s]" + % record_id + ) + + def __build_payload(self): + + payload = dict( + data=self.module.params.get("data"), + flags=self.module.params.get("flags"), + name=self.module.params.get("name"), + port=self.module.params.get("port"), + priority=self.module.params.get("priority"), + type=self.module.params.get("type"), + tag=self.module.params.get("tag"), + ttl=self.module.params.get("ttl"), + weight=self.module.params.get("weight"), + ) + + # DigitalOcean stores every data in lowercase except TXT + if payload["type"] != "TXT" and payload["data"]: + payload["data"] = payload["data"].lower() + + # digitalocean stores data: '@' if the data=domain + if payload["data"] == self.domain: + payload["data"] = "@" + + return payload + + def delete_record(self): + + # if record_id is given, try to find the record based on the id + if self.record_id: + record = self.__find_record_by_id(self.record_id) + record_id = self.record_id + # if no record_id is given, try to a single matching record + else: + record, record_id, similar_records = self.__get_matching_records() + if not record and similar_records: + if len(similar_records) == 1: + record, record_id = similar_records[0], similar_records[0]["id"] + else: + self.module.fail_json( + msg="Can't delete record, too many similar records: %s" + % similar_records + ) + # record was not found, we're done + if not record: + changed = False + return changed, record + # record found, lets delete it + else: + # DELETE /v2/domains/$DOMAIN_NAME/records/$RECORD_ID. + response = self.delete( + "domains/%(domain)s/records/%(id)s" + % {"domain": self.domain, "id": record_id} + ) + status_code = response.status_code + json = response.json + if status_code == 204: + changed = True + msg = "Successfully deleted %s" % record["name"] + return changed, msg + else: + self.module.fail_json( + msg="Error deleting domain record. [%(status_code)s: %(json)s]" + % {"status_code": status_code, "json": json} + ) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + state=dict(choices=["present", "absent"], default="present"), + oauth_token=dict( + aliases=["API_TOKEN"], + no_log=True, + fallback=( + env_fallback, + ["DO_API_TOKEN", "DO_API_KEY", "DO_OAUTH_TOKEN"], + ), + ), + force_update=dict(type="bool", default=False), + record_id=dict(type="int"), + domain=dict(type="str", required=True), + type=dict(choices=["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "NS", "CAA"]), + name=dict(type="str", default="@"), + data=dict(type="str"), + priority=dict(type="int"), + port=dict(type="int"), + weight=dict(type="int"), + ttl=dict(type="int", default=1800), + tag=dict(choices=["issue", "wildissue", "iodef"]), + flags=dict(type="int"), + ), + # TODO + # somehow define the absent requirements: record_id OR ('name', 'type', 'data') + required_if=[("state", "present", ("type", "name", "data"))], + ) + + manager = DigitalOceanDomainRecordManager(module) + + # verify credentials and domain + manager.check_credentials() + manager.verify_domain() + + state = module.params.get("state") + + if state == "present": + changed, result = manager.create_or_update_record() + elif state == "absent": + changed, result = manager.delete_record() + + module.exit_json(changed=changed, result=result) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain_record_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain_record_info.py new file mode 100644 index 00000000..b42a7aaa --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_domain_record_info.py @@ -0,0 +1,227 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: 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 + +DOCUMENTATION = r""" +--- +module: digital_ocean_domain_record_info +short_description: Gather information about DigitalOcean domain records +description: + - Gather information about DigitalOcean domain records. +version_added: 1.16.0 +author: + - "Adam Papai (@woohgit)" + - Mark Mercado (@mamercad) +options: + state: + description: + - Indicate desired state of the target. + default: present + choices: ["present"] + type: str + name: + description: + - Name of the domain. + required: true + type: str + aliases: ["domain", "domain_name"] + record_id: + description: + - Used to retrieve a specific record. + type: int + type: + description: + - The type of record you would like to retrieve. + choices: ["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "NS", "CAA"] + type: str +extends_documentation_fragment: + - community.digitalocean.digital_ocean.documentation +notes: + - Version 2 of DigitalOcean API is used. + - The number of requests that can be made through the API is currently limited to 5,000 per hour per OAuth token. +""" + +EXAMPLES = r""" +- name: Retrieve all domain records for example.com + community.digitalocean.digital_ocean_domain_record_info: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + domain: example.com + +- name: Get specific domain record by ID + community.digitalocean.digital_ocean_domain_record_info: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + record_id: 12345789 + register: result + +- name: Retrieve all A domain records for example.com + community.digitalocean.digital_ocean_domain_record_info: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + domain: example.com + type: A +""" + +RETURN = r""" +data: + description: list of DigitalOcean domain records + returned: success + type: list + elements: dict + sample: + - data: ns1.digitalocean.com + flags: null + id: 296972269 + name: '@' + port: null + priority: null + tag: null + ttl: 1800 + type: NS + weight: null +""" + + +from ansible.module_utils.basic import env_fallback +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +class DigitalOceanDomainRecordManager(DigitalOceanHelper, object): + def __init__(self, module): + super(DigitalOceanDomainRecordManager, self).__init__(module) + self.module = module + self.domain = module.params.get("name").lower() + self.records = self.__get_all_records() + self.payload = self.__build_payload() + self.force_update = module.params.get("force_update", False) + self.record_id = module.params.get("record_id", None) + self.records_by_id = self.__find_record_by_id(self.record_id) + + def check_credentials(self): + # Check if oauth_token is valid or not + response = self.get("account") + if response.status_code == 401: + self.module.fail_json( + msg="Failed to login using oauth_token, please verify validity of oauth_token" + ) + + def __get_all_records(self): + + records = [] + page = 1 + while True: + # GET /v2/domains/$DOMAIN_NAME/records + type = self.module.params.get("type") + if type: + response = self.get( + "domains/%(domain)s/records?type=%(type)s&page=%(page)s" + % {"domain": self.domain, "type": type, "page": page} + ) + else: + response = self.get( + "domains/%(domain)s/records?page=%(page)s" + % {"domain": self.domain, "page": page} + ) + status_code = response.status_code + json = response.json + + if status_code != 200: + self.module.exit_json( + msg="Error getting domain records [%(status_code)s: %(json)s]" + % {"status_code": status_code, "json": json} + ) + + domain_records = json.get("domain_records", []) + for record in domain_records: + records.append(dict([(str(k), v) for k, v in record.items()])) + + links = json.get("links") + if links: + pages = links.get("pages") + if pages: + if "next" in pages: + page += 1 + else: + break + else: + break + else: + break + + return records + + def get_records(self): + return False, self.records + + def get_records_by_id(self): + if self.records_by_id: + return False, [self.records_by_id] + else: + return False, [] + + def __find_record_by_id(self, record_id): + for record in self.records: + if record["id"] == record_id: + return record + return None + + def __build_payload(self): + + payload = dict( + name=self.module.params.get("name"), + type=self.module.params.get("type"), + ) + + payload_data = payload.get("data") + if payload_data: + # digitalocean stores data: '@' if the data=domain + if payload["data"] == self.domain: + payload["data"] = "@" + + return payload + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + state=dict(choices=["present"], default="present"), + name=dict(type="str", aliases=["domain", "domain_name"], required=True), + record_id=dict(type="int"), + type=dict( + type="str", + choices=["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "NS", "CAA"], + ), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + ) + + manager = DigitalOceanDomainRecordManager(module) + + # verify credentials and domain + manager.check_credentials() + + state = module.params.get("state") + record_id = module.params.get("record_id") + + if state == "present": + if record_id: + changed, result = manager.get_records_by_id() + else: + changed, result = manager.get_records() + module.exit_json(changed=changed, data={"records": result}) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_droplet.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_droplet.py new file mode 100644 index 00000000..791f2891 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_droplet.py @@ -0,0 +1,918 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: 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 + +DOCUMENTATION = r""" +--- +module: digital_ocean_droplet +short_description: Create and delete a DigitalOcean droplet +description: + - Create and delete a droplet in DigitalOcean and optionally wait for it to be active. +author: + - Gurchet Rai (@gurch101) + - Mark Mercado (@mamercad) +options: + state: + description: + - Indicate desired state of the target. + - C(present) will create the named droplet; be mindful of the C(unique_name) parameter. + - C(absent) will delete the named droplet, if it exists. + - C(active) will create the named droplet (unless it exists) and ensure that it is powered on. + - C(inactive) will create the named droplet (unless it exists) and ensure that it is powered off. + default: present + choices: ["present", "absent", "active", "inactive"] + type: str + id: + description: + - The Droplet ID you want to operate on. + aliases: ["droplet_id"] + type: int + name: + description: + - This is the name of the Droplet. + - Must be formatted by hostname rules. + type: str + unique_name: + description: + - Require unique hostnames. + - By default, DigitalOcean allows multiple hosts with the same name. + - Setting this to C(true) allows only one host per name. + - Useful for idempotence. + default: false + type: bool + size: + description: + - This is the slug of the size you would like the Droplet created with. + - Please see U(https://slugs.do-api.dev/) for current slugs. + aliases: ["size_id"] + type: str + image: + description: + - This is the slug of the image you would like the Droplet created with. + aliases: ["image_id"] + type: str + region: + description: + - This is the slug of the region you would like your Droplet to be created in. + aliases: ["region_id"] + type: str + ssh_keys: + description: + - Array of SSH key fingerprints that you would like to be added to the Droplet. + required: false + type: list + elements: str + firewall: + description: + - Array of firewall names to apply to the Droplet. + - Omitting a firewall name that is currently applied to a droplet will remove it. + required: false + type: list + elements: str + private_networking: + description: + - Add an additional, private network interface to the Droplet (for inter-Droplet communication). + default: false + type: bool + vpc_uuid: + description: + - A string specifying the UUID of the VPC to which the Droplet will be assigned. + - If excluded, the Droplet will be assigned to the account's default VPC for the region. + type: str + version_added: 0.1.0 + user_data: + description: + - Opaque blob of data which is made available to the Droplet. + required: False + type: str + ipv6: + description: + - Enable IPv6 for the Droplet. + required: false + default: false + type: bool + wait: + description: + - Wait for the Droplet to be active before returning. + - If wait is C(false) an IP address may not be returned. + required: false + default: true + type: bool + wait_timeout: + description: + - How long before C(wait) gives up, in seconds, when creating a Droplet. + default: 120 + type: int + backups: + description: + - Indicates whether automated backups should be enabled. + required: false + default: false + type: bool + monitoring: + description: + - Indicates whether to install the DigitalOcean agent for monitoring. + required: false + default: false + type: bool + tags: + description: + - A list of tag names as strings to apply to the Droplet after it is created. + - Tag names can either be existing or new tags. + required: false + type: list + elements: str + volumes: + description: + - A list including the unique string identifier for each Block Storage volume to be attached to the Droplet. + required: False + type: list + elements: str + resize_disk: + description: + - Whether to increase disk size on resize. + - Only consulted if the C(unique_name) is C(true). + - Droplet C(size) must dictate an increase. + required: false + default: false + type: bool + project_name: + aliases: ["project"] + description: + - Project to assign the resource to (project name, not UUID). + - Defaults to the default project of the account (empty string). + - Currently only supported when creating. + type: str + required: false + default: "" + sleep_interval: + description: + - How long to C(sleep) in between action and status checks. + - Default is 10 seconds; this should be less than C(wait_timeout) and nonzero. + default: 10 + type: int +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation +""" + + +EXAMPLES = r""" +- name: Create a new Droplet + community.digitalocean.digital_ocean_droplet: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + name: mydroplet + size: s-1vcpu-1gb + region: sfo3 + image: ubuntu-20-04-x64 + wait_timeout: 500 + ssh_keys: [ .... ] + register: my_droplet + +- name: Show Droplet info + ansible.builtin.debug: + msg: | + Droplet ID is {{ my_droplet.data.droplet.id }} + First Public IPv4 is {{ (my_droplet.data.droplet.networks.v4 | selectattr('type', 'equalto', 'public')).0.ip_address | default('<none>', true) }} + First Private IPv4 is {{ (my_droplet.data.droplet.networks.v4 | selectattr('type', 'equalto', 'private')).0.ip_address | default('<none>', true) }} + +- name: Create a new Droplet (and assign to Project "test") + community.digitalocean.digital_ocean_droplet: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + name: mydroplet + size: s-1vcpu-1gb + region: sfo3 + image: ubuntu-20-04-x64 + wait_timeout: 500 + ssh_keys: [ .... ] + project: test + register: my_droplet + +- name: Ensure a Droplet is present + community.digitalocean.digital_ocean_droplet: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + id: 123 + name: mydroplet + size: s-1vcpu-1gb + region: sfo3 + image: ubuntu-20-04-x64 + wait_timeout: 500 + +- name: Ensure a Droplet is present and has firewall rules applied + community.digitalocean.digital_ocean_droplet: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + id: 123 + name: mydroplet + size: s-1vcpu-1gb + region: sfo3 + image: ubuntu-20-04-x64 + firewall: ['myfirewall', 'anotherfirewall'] + wait_timeout: 500 + +- name: Ensure a Droplet is present with SSH keys installed + community.digitalocean.digital_ocean_droplet: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + id: 123 + name: mydroplet + size: s-1vcpu-1gb + region: sfo3 + ssh_keys: ['1534404', '1784768'] + image: ubuntu-20-04-x64 + wait_timeout: 500 +""" + +RETURN = r""" +# Digital Ocean API info https://docs.digitalocean.com/reference/api/api-reference/#tag/Droplets +data: + description: a DigitalOcean Droplet + returned: changed + type: dict + sample: + ip_address: 104.248.118.172 + ipv6_address: 2604:a880:400:d1::90a:6001 + private_ipv4_address: 10.136.122.141 + droplet: + id: 3164494 + name: example.com + memory: 512 + vcpus: 1 + disk: 20 + locked: true + status: new + kernel: + id: 2233 + name: Ubuntu 14.04 x64 vmlinuz-3.13.0-37-generic + version: 3.13.0-37-generic + created_at: "2014-11-14T16:36:31Z" + features: ["virtio"] + backup_ids: [] + snapshot_ids: [] + image: {} + volume_ids: [] + size: {} + size_slug: 512mb + networks: {} + region: {} + tags: ["web"] +msg: + description: Informational or error message encountered during execution + returned: changed + type: str + sample: No project named test2 found +assign_status: + description: Assignment status (ok, not_found, assigned, already_assigned, service_down) + returned: changed + type: str + sample: assigned +resources: + description: Resource assignment involved in project assignment + returned: changed + type: dict + sample: + assigned_at: '2021-10-25T17:39:38Z' + links: + self: https://api.digitalocean.com/v2/droplets/3164494 + status: assigned + urn: do:droplet:3164494 +""" + +import time +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, + DigitalOceanProjects, +) + + +class DODroplet(object): + + failure_message = { + "empty_response": "Empty response from the DigitalOcean API; please try again or open a bug if it never " + "succeeds.", + "resizing_off": "Droplet must be off prior to resizing: " + "https://docs.digitalocean.com/reference/api/api-reference/#operation/post_droplet_action", + "unexpected": "Unexpected error [{0}]; please file a bug: " + "https://github.com/ansible-collections/community.digitalocean/issues", + "support_action": "Error status on Droplet action [{0}], please try again or contact DigitalOcean support: " + "https://docs.digitalocean.com/support/", + "failed_to": "Failed to {0} {1} [HTTP {2}: {3}]", + } + + def __init__(self, module): + self.rest = DigitalOceanHelper(module) + self.module = module + self.wait = self.module.params.pop("wait", True) + self.wait_timeout = self.module.params.pop("wait_timeout", 120) + self.unique_name = self.module.params.pop("unique_name", False) + # pop the oauth token so we don't include it in the POST data + self.module.params.pop("oauth_token") + self.id = None + self.name = None + self.size = None + self.status = None + if self.module.params.get("project"): + # only load for non-default project assignments + self.projects = DigitalOceanProjects(module, self.rest) + self.firewalls = self.get_firewalls() + self.sleep_interval = self.module.params.pop("sleep_interval", 10) + if self.wait: + if self.sleep_interval > self.wait_timeout: + self.module.fail_json( + msg="Sleep interval {0} should be less than {1}".format( + self.sleep_interval, self.wait_timeout + ) + ) + if self.sleep_interval <= 0: + self.module.fail_json( + msg="Sleep interval {0} should be greater than zero".format( + self.sleep_interval + ) + ) + + def get_firewalls(self): + response = self.rest.get("firewalls") + status_code = response.status_code + json_data = response.json + if status_code != 200: + self.module.fail_json(msg="Failed to get firewalls", data=json_data) + + return self.rest.get_paginated_data( + base_url="firewalls?", data_key_name="firewalls" + ) + + def get_firewall_by_name(self): + rule = {} + item = 0 + for firewall in self.firewalls: + for firewall_name in self.module.params["firewall"]: + if firewall_name in firewall["name"]: + rule[item] = {} + rule[item].update(firewall) + item += 1 + if len(rule) > 0: + return rule + return None + + def add_droplet_to_firewalls(self): + changed = False + rule = self.get_firewall_by_name() + if rule is None: + err = "Failed to find firewalls: {0}".format(self.module.params["firewall"]) + return err + json_data = self.get_droplet() + if json_data is not None: + request_params = {} + droplet = json_data.get("droplet", None) + droplet_id = droplet.get("id", None) + request_params["droplet_ids"] = [droplet_id] + for firewall in rule: + if droplet_id not in rule[firewall]["droplet_ids"]: + response = self.rest.post( + "firewalls/{0}/droplets".format(rule[firewall]["id"]), + data=request_params, + ) + json_data = response.json + status_code = response.status_code + if status_code != 204: + err = "Failed to add droplet {0} to firewall {1}".format( + droplet_id, rule[firewall]["id"] + ) + return err, changed + changed = True + return None, changed + + def remove_droplet_from_firewalls(self): + changed = False + json_data = self.get_droplet() + if json_data is not None: + request_params = {} + droplet = json_data.get("droplet", None) + droplet_id = droplet.get("id", None) + request_params["droplet_ids"] = [droplet_id] + for firewall in self.firewalls: + if ( + firewall["name"] not in self.module.params["firewall"] + and droplet_id in firewall["droplet_ids"] + ): + response = self.rest.delete( + "firewalls/{0}/droplets".format(firewall["id"]), + data=request_params, + ) + json_data = response.json + status_code = response.status_code + if status_code != 204: + err = "Failed to remove droplet {0} from firewall {1}".format( + droplet_id, firewall["id"] + ) + return err, changed + changed = True + return None, changed + + def get_by_id(self, droplet_id): + if not droplet_id: + return None + response = self.rest.get("droplets/{0}".format(droplet_id)) + status_code = response.status_code + json_data = response.json + if json_data is None: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["empty_response"], + ) + else: + if status_code == 200: + droplet = json_data.get("droplet", None) + if droplet is not None: + self.id = droplet.get("id", None) + self.name = droplet.get("name", None) + self.size = droplet.get("size_slug", None) + self.status = droplet.get("status", None) + return json_data + return None + + def get_by_name(self, droplet_name): + if not droplet_name: + return None + page = 1 + while page is not None: + response = self.rest.get("droplets?page={0}".format(page)) + json_data = response.json + status_code = response.status_code + if json_data is None: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["empty_response"], + ) + else: + if status_code == 200: + droplets = json_data.get("droplets", []) + for droplet in droplets: + if droplet.get("name", None) == droplet_name: + self.id = droplet.get("id", None) + self.name = droplet.get("name", None) + self.size = droplet.get("size_slug", None) + self.status = droplet.get("status", None) + return {"droplet": droplet} + if ( + "links" in json_data + and "pages" in json_data["links"] + and "next" in json_data["links"]["pages"] + ): + page += 1 + else: + page = None + return None + + def get_addresses(self, data): + """Expose IP addresses as their own property allowing users extend to additional tasks""" + _data = data + for k, v in data.items(): + setattr(self, k, v) + networks = _data["droplet"]["networks"] + for network in networks.get("v4", []): + if network["type"] == "public": + _data["ip_address"] = network["ip_address"] + else: + _data["private_ipv4_address"] = network["ip_address"] + for network in networks.get("v6", []): + if network["type"] == "public": + _data["ipv6_address"] = network["ip_address"] + else: + _data["private_ipv6_address"] = network["ip_address"] + return _data + + def get_droplet(self): + json_data = self.get_by_id(self.module.params["id"]) + if not json_data and self.unique_name: + json_data = self.get_by_name(self.module.params["name"]) + return json_data + + def resize_droplet(self, state, droplet_id): + if self.status != "off": + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["resizing_off"], + ) + + self.wait_action( + droplet_id, + { + "type": "resize", + "disk": self.module.params["resize_disk"], + "size": self.module.params["size"], + }, + ) + + if state == "active": + self.ensure_power_on(droplet_id) + + # Get updated Droplet data + json_data = self.get_droplet() + droplet = json_data.get("droplet", None) + if droplet is None: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["unexpected"].format("no Droplet"), + ) + + self.module.exit_json( + changed=True, + msg="Resized Droplet {0} ({1}) from {2} to {3}".format( + self.name, self.id, self.size, self.module.params["size"] + ), + data={"droplet": droplet}, + ) + + def wait_status(self, droplet_id, desired_statuses): + # Make sure Droplet is active first + end_time = time.monotonic() + self.wait_timeout + while time.monotonic() < end_time: + response = self.rest.get("droplets/{0}".format(droplet_id)) + json_data = response.json + status_code = response.status_code + message = json_data.get("message", "no error message") + droplet = json_data.get("droplet", None) + droplet_status = droplet.get("status", None) if droplet else None + + if droplet is None or droplet_status is None: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["unexpected"].format( + "no Droplet or status" + ), + ) + + if status_code >= 400: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["failed_to"].format( + "get", "Droplet", status_code, message + ), + ) + + if droplet_status in desired_statuses: + return + + time.sleep(self.sleep_interval) + + self.module.fail_json( + msg="Wait for Droplet [{0}] status timeout".format( + ",".join(desired_statuses) + ) + ) + + def wait_check_action(self, droplet_id, action_id): + end_time = time.monotonic() + self.wait_timeout + while time.monotonic() < end_time: + response = self.rest.get( + "droplets/{0}/actions/{1}".format(droplet_id, action_id) + ) + json_data = response.json + status_code = response.status_code + message = json_data.get("message", "no error message") + action = json_data.get("action", None) + action_id = action.get("id", None) + action_status = action.get("status", None) + + if action is None or action_id is None or action_status is None: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["unexpected"].format( + "no action, ID, or status" + ), + ) + + if status_code >= 400: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["failed_to"].format( + "get", "action", status_code, message + ), + ) + + if action_status == "errored": + self.module.fail_json( + changed=True, + msg=DODroplet.failure_message["support_action"].format(action_id), + ) + + if action_status == "completed": + return + + time.sleep(self.sleep_interval) + + self.module.fail_json(msg="Wait for Droplet action timeout") + + def wait_action(self, droplet_id, desired_action_data): + action_type = desired_action_data.get("type", "undefined") + + response = self.rest.post( + "droplets/{0}/actions".format(droplet_id), data=desired_action_data + ) + json_data = response.json + status_code = response.status_code + message = json_data.get("message", "no error message") + action = json_data.get("action", None) + action_id = action.get("id", None) + action_status = action.get("status", None) + + if action is None or action_id is None or action_status is None: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["unexpected"].format( + "no action, ID, or status" + ), + ) + + if status_code >= 400: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["failed_to"].format( + "post", "action", status_code, message + ), + ) + + # Keep checking till it is done or times out + self.wait_check_action(droplet_id, action_id) + + def ensure_power_on(self, droplet_id): + # Make sure Droplet is active or off first + self.wait_status(droplet_id, ["active", "off"]) + # Trigger power-on + self.wait_action(droplet_id, {"type": "power_on"}) + + def ensure_power_off(self, droplet_id): + # Make sure Droplet is active first + self.wait_status(droplet_id, ["active"]) + # Trigger power-off + self.wait_action(droplet_id, {"type": "power_off"}) + + def create(self, state): + json_data = self.get_droplet() + # We have the Droplet + if json_data is not None: + droplet = json_data.get("droplet", None) + droplet_id = droplet.get("id", None) + droplet_size = droplet.get("size_slug", None) + + if droplet_id is None or droplet_size is None: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["unexpected"].format( + "no Droplet ID or size" + ), + ) + + # Add droplet to a firewall if specified + if self.module.params["firewall"] is not None: + firewall_changed = False + if len(self.module.params["firewall"]) > 0: + firewall_add, add_changed = self.add_droplet_to_firewalls() + if firewall_add is not None: + self.module.fail_json( + changed=False, + msg=firewall_add, + data={"droplet": droplet, "firewall": firewall_add}, + ) + firewall_changed = firewall_changed or add_changed + firewall_remove, remove_changed = self.remove_droplet_from_firewalls() + if firewall_remove is not None: + self.module.fail_json( + changed=False, + msg=firewall_remove, + data={"droplet": droplet, "firewall": firewall_remove}, + ) + firewall_changed = firewall_changed or remove_changed + self.module.exit_json( + changed=firewall_changed, + data={"droplet": droplet}, + ) + + # Check mode + if self.module.check_mode: + self.module.exit_json(changed=False) + + # Ensure Droplet size + if droplet_size != self.module.params.get("size", None): + self.resize_droplet(state, droplet_id) + + # Ensure Droplet power state + droplet_data = self.get_addresses(json_data) + droplet_id = droplet.get("id", None) + droplet_status = droplet.get("status", None) + if droplet_id is not None and droplet_status is not None: + if state == "active" and droplet_status != "active": + self.ensure_power_on(droplet_id) + # Get updated Droplet data (fallback to current data) + json_data = self.get_droplet() + droplet = json_data.get("droplet", droplet) + self.module.exit_json(changed=True, data={"droplet": droplet}) + elif state == "inactive" and droplet_status != "off": + self.ensure_power_off(droplet_id) + # Get updated Droplet data (fallback to current data) + json_data = self.get_droplet() + droplet = json_data.get("droplet", droplet) + self.module.exit_json(changed=True, data={"droplet": droplet}) + else: + self.module.exit_json(changed=False, data={"droplet": droplet}) + + # We don't have the Droplet, create it + + # Check mode + if self.module.check_mode: + self.module.exit_json(changed=True) + + request_params = dict(self.module.params) + del request_params["id"] + + response = self.rest.post("droplets", data=request_params) + json_data = response.json + status_code = response.status_code + message = json_data.get("message", "no error message") + droplet = json_data.get("droplet", None) + + # Ensure that the Droplet is created + if status_code != 202: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["failed_to"].format( + "create", "Droplet", status_code, message + ), + ) + + droplet_id = droplet.get("id", None) + if droplet is None or droplet_id is None: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["unexpected"].format("no Droplet or ID"), + ) + + if status_code >= 400: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["failed_to"].format( + "create", "Droplet", status_code, message + ), + ) + + if self.wait: + if state == "present" or state == "active": + self.ensure_power_on(droplet_id) + if state == "inactive": + self.ensure_power_off(droplet_id) + else: + if state == "inactive": + self.ensure_power_off(droplet_id) + + # Get updated Droplet data (fallback to current data) + if self.wait: + json_data = self.get_by_id(droplet_id) + if json_data: + droplet = json_data.get("droplet", droplet) + + project_name = self.module.params.get("project") + if project_name: # empty string is the default project, skip project assignment + urn = "do:droplet:{0}".format(droplet_id) + assign_status, error_message, resources = self.projects.assign_to_project( + project_name, urn + ) + self.module.exit_json( + changed=True, + data={"droplet": droplet}, + msg=error_message, + assign_status=assign_status, + resources=resources, + ) + # Add droplet to firewall if specified + if self.module.params["firewall"] is not None: + # raise Exception(self.module.params["firewall"]) + firewall_add = self.add_droplet_to_firewalls() + if firewall_add is not None: + self.module.fail_json( + changed=False, + msg=firewall_add, + data={"droplet": droplet, "firewall": firewall_add}, + ) + firewall_remove = self.remove_droplet_from_firewalls() + if firewall_remove is not None: + self.module.fail_json( + changed=False, + msg=firewall_remove, + data={"droplet": droplet, "firewall": firewall_remove}, + ) + self.module.exit_json(changed=True, data={"droplet": droplet}) + + self.module.exit_json(changed=True, data={"droplet": droplet}) + + def delete(self): + # to delete a droplet we need to know the droplet id or unique name, ie + # name is not None and unique_name is True, but as "id or name" is + # enforced elsewhere, we only need to enforce "id or unique_name" here + if not self.module.params["id"] and not self.unique_name: + self.module.fail_json( + changed=False, + msg="id must be set or unique_name must be true for deletes", + ) + json_data = self.get_droplet() + if json_data is None: + self.module.exit_json(changed=False, msg="Droplet not found") + + # Check mode + if self.module.check_mode: + self.module.exit_json(changed=True) + + # Delete it + droplet = json_data.get("droplet", None) + droplet_id = droplet.get("id", None) + droplet_name = droplet.get("name", None) + + if droplet is None or droplet_id is None: + self.module.fail_json( + changed=False, + msg=DODroplet.failure_message["unexpected"].format( + "no Droplet, name, or ID" + ), + ) + + response = self.rest.delete("droplets/{0}".format(droplet_id)) + json_data = response.json + status_code = response.status_code + if status_code == 204: + self.module.exit_json( + changed=True, + msg="Droplet {0} ({1}) deleted".format(droplet_name, droplet_id), + ) + else: + self.module.fail_json( + changed=False, + msg="Failed to delete Droplet {0} ({1})".format( + droplet_name, droplet_id + ), + ) + + +def core(module): + state = module.params.pop("state") + droplet = DODroplet(module) + if state in ["present", "active", "inactive"]: + droplet.create(state) + elif state == "absent": + droplet.delete() + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + state=dict( + choices=["present", "absent", "active", "inactive"], default="present" + ), + name=dict(type="str"), + size=dict(aliases=["size_id"]), + image=dict(aliases=["image_id"]), + region=dict(aliases=["region_id"]), + ssh_keys=dict(type="list", elements="str", no_log=False), + private_networking=dict(type="bool", default=False), + vpc_uuid=dict(type="str"), + backups=dict(type="bool", default=False), + monitoring=dict(type="bool", default=False), + id=dict(aliases=["droplet_id"], type="int"), + user_data=dict(default=None), + ipv6=dict(type="bool", default=False), + volumes=dict(type="list", elements="str"), + tags=dict(type="list", elements="str"), + wait=dict(type="bool", default=True), + wait_timeout=dict(default=120, type="int"), + unique_name=dict(type="bool", default=False), + resize_disk=dict(type="bool", default=False), + project_name=dict(type="str", aliases=["project"], required=False, default=""), + firewall=dict(type="list", elements="str", default=None), + sleep_interval=dict(default=10, type="int"), + ) + module = AnsibleModule( + argument_spec=argument_spec, + required_one_of=(["id", "name"],), + required_if=( + [ + ("state", "present", ["name", "size", "image", "region"]), + ("state", "active", ["name", "size", "image", "region"]), + ("state", "inactive", ["name", "size", "image", "region"]), + ] + ), + supports_check_mode=True, + ) + + core(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_droplet_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_droplet_info.py new file mode 100644 index 00000000..474b9af2 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_droplet_info.py @@ -0,0 +1,266 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2020, Tyler Auerbeck <tauerbec@redhat.com> +# 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 = r""" +--- +module: digital_ocean_droplet_info +short_description: Gather information about DigitalOcean Droplets +description: + - This module can be used to gather information about Droplets. +author: "Tyler Auerbeck (@tylerauerbeck)" +version_added: 1.4.0 + +options: + id: + description: + - Droplet ID that can be used to identify and reference a droplet. + type: str + name: + description: + - Droplet name that can be used to identify and reference a droplet. + type: str + +extends_documentation_fragment: +- community.digitalocean.digital_ocean +""" + + +EXAMPLES = r""" +- name: Gather information about all droplets + community.digitalocean.digital_ocean_droplet_info: + oauth_token: "{{ oauth_token }}" + +- name: Gather information about a specific droplet by name + community.digitalocean.digital_ocean_droplet_info: + oauth_token: "{{ oauth_token }}" + name: my-droplet-name + +- name: Gather information about a specific droplet by id + community.digitalocean.digital_ocean_droplet_info: + oauth_token: "{{ oauth_token }}" + id: abc-123-d45 + +- name: Get information about all droplets to loop through + community.digitalocean.digital_ocean_droplet_info: + oauth_token: "{{ oauth_token }}" + register: droplets + +- name: Get number of droplets + set_fact: + droplet_count: "{{ droplets.data | length }}" +""" + +RETURN = r""" +data: + description: "DigitalOcean droplet information" + elements: dict + returned: success + sample: + - backup_ids: [] + created_at: "2021-04-07T00:44:53Z" + disk: 25 + features: + - private_networking + id: 123456789 + image: + created_at: "2020-10-20T08:49:55Z" + description: "Ubuntu 18.04 x86 image" + distribution: Ubuntu + id: 987654321 + min_disk_size: 15 + name: "18.04 (LTS) x64" + public: false + regions: [] + size_gigabytes: 0.34 + slug: ~ + status: retired + tags: [] + type: base + kernel: ~ + locked: false + memory: 1024 + name: my-droplet-01 + networks: + v4: + - gateway: "" + ip_address: "1.2.3.4" + netmask: "255.255.240.0" + type: private + - gateway: "5.6.7.8" + ip_address: "4.3.2.1" + netmask: "255.255.240.0" + type: public + v6: [] + next_backup_window: ~ + region: + available: true + features: + - backups + - ipv6 + - metadata + - install_agent + - storage + - image_transfer + name: "New York 1" + sizes: + - s-1vcpu-1gb + - s-1vcpu-1gb-intel + - s-1vcpu-2gb + - s-1vcpu-2gb-intel + - s-2vcpu-2gb + - s-2vcpu-2gb-intel + - s-2vcpu-4gb + - s-2vcpu-4gb-intel + - s-4vcpu-8gb + - c-2 + - c2-2vcpu-4gb + - s-4vcpu-8gb-intel + - g-2vcpu-8gb + - gd-2vcpu-8gb + - s-8vcpu-16gb + - m-2vcpu-16gb + - c-4 + - c2-4vcpu-8gb + - s-8vcpu-16gb-intel + - m3-2vcpu-16gb + - g-4vcpu-16gb + - so-2vcpu-16gb + - m6-2vcpu-16gb + - gd-4vcpu-16gb + - so1_5-2vcpu-16gb + - m-4vcpu-32gb + - c-8 + - c2-8vcpu-16gb + - m3-4vcpu-32gb + - g-8vcpu-32gb + - so-4vcpu-32gb + - m6-4vcpu-32gb + - gd-8vcpu-32gb + - so1_5-4vcpu-32gb + - m-8vcpu-64gb + - c-16 + - c2-16vcpu-32gb + - m3-8vcpu-64gb + - g-16vcpu-64gb + - so-8vcpu-64gb + - m6-8vcpu-64gb + - gd-16vcpu-64gb + - so1_5-8vcpu-64gb + - m-16vcpu-128gb + - c-32 + - c2-32vcpu-64gb + - m3-16vcpu-128gb + - m-24vcpu-192gb + - g-32vcpu-128gb + - so-16vcpu-128gb + - m6-16vcpu-128gb + - gd-32vcpu-128gb + - m3-24vcpu-192gb + - g-40vcpu-160gb + - so1_5-16vcpu-128gb + - m-32vcpu-256gb + - gd-40vcpu-160gb + - so-24vcpu-192gb + - m6-24vcpu-192gb + - m3-32vcpu-256gb + - so1_5-24vcpu-192gb + - so-32vcpu-256gb + - m6-32vcpu-256gb + - so1_5-32vcpu-256gb + slug: nyc1 + size: + available: true + description: Basic + disk: 25 + memory: 1024 + price_hourly: 0.00744 + price_monthly: 5.0 + regions: + - ams2 + - ams3 + - blr1 + - fra1 + - lon1 + - nyc1 + - nyc2 + - nyc3 + - sfo1 + - sfo3 + - sgp1 + - tor1 + slug: s-1vcpu-1gb + transfer: 1.0 + vcpus: 1 + size_slug: s-1vcpu-1gb + snapshot_ids: [] + status: active + tags: + - tag1 + vcpus: 1 + volume_ids: [] + vpc_uuid: 123-abc-567a + type: list +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +def run(module): + rest = DigitalOceanHelper(module) + + if module.params["id"]: + path = "droplets/" + module.params["id"] + response = rest.get(path) + if response.status_code != 200: + module.fail_json( + msg="Failed to fetch 'droplets' information due to error: %s" + % response.json["message"] + ) + else: + response = rest.get_paginated_data( + base_url="droplets?", data_key_name="droplets" + ) + + if module.params["id"]: + data = [response.json["droplet"]] + elif module.params["name"]: + data = [d for d in response if d["name"] == module.params["name"]] + if not data: + module.fail_json( + msg="Failed to fetch 'droplets' information due to error: Unable to find droplet with name %s" + % module.params["name"] + ) + else: + data = response + + module.exit_json(changed=False, data=data) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + name=dict(type="str", required=False, default=None), + id=dict(type="str", required=False, default=None), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + mutually_exclusive=[("id", "name")], + ) + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_firewall.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_firewall.py new file mode 100644 index 00000000..24b7c420 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_firewall.py @@ -0,0 +1,560 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Anthony Bond <ajbond2005@gmail.com> +# 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: digital_ocean_firewall +short_description: Manage cloud firewalls within DigitalOcean +description: + - This module can be used to add or remove firewalls on the DigitalOcean cloud platform. +author: + - Anthony Bond (@BondAnthony) + - Lucas Basquerotto (@lucasbasquerotto) +version_added: "1.1.0" +options: + name: + type: str + description: + - Name of the firewall rule to create or manage + required: true + state: + type: str + choices: ['present', 'absent'] + default: present + description: + - Assert the state of the firewall rule. Set to 'present' to create or update and 'absent' to remove. + droplet_ids: + type: list + elements: str + description: + - List of droplet ids to be assigned to the firewall + required: false + tags: + type: list + elements: str + description: + - List of tags to be assigned to the firewall + required: false + inbound_rules: + type: list + elements: dict + description: + - Firewall rules specifically targeting inbound network traffic into DigitalOcean + required: false + suboptions: + protocol: + type: str + choices: ['udp', 'tcp', 'icmp'] + default: tcp + description: + - Network protocol to be accepted. + required: false + ports: + type: str + description: + - The ports on which traffic will be allowed, single, range, or all + required: true + sources: + type: dict + description: + - Dictionary of locations from which inbound traffic will be accepted + required: true + suboptions: + addresses: + type: list + elements: str + description: + - List of strings containing the IPv4 addresses, IPv6 addresses, IPv4 CIDRs, + and/or IPv6 CIDRs to which the firewall will allow traffic + required: false + droplet_ids: + type: list + elements: str + description: + - List of integers containing the IDs of the Droplets to which the firewall will allow traffic + required: false + load_balancer_uids: + type: list + elements: str + description: + - List of strings containing the IDs of the Load Balancers to which the firewall will allow traffic + required: false + tags: + type: list + elements: str + description: + - List of strings containing the names of Tags corresponding to groups of Droplets to + which the Firewall will allow traffic + required: false + outbound_rules: + type: list + elements: dict + description: + - Firewall rules specifically targeting outbound network traffic from DigitalOcean + required: false + suboptions: + protocol: + type: str + choices: ['udp', 'tcp', 'icmp'] + default: tcp + description: + - Network protocol to be accepted. + required: false + ports: + type: str + description: + - The ports on which traffic will be allowed, single, range, or all + required: true + destinations: + type: dict + description: + - Dictionary of locations from which outbound traffic will be allowed + required: true + suboptions: + addresses: + type: list + elements: str + description: + - List of strings containing the IPv4 addresses, IPv6 addresses, IPv4 CIDRs, + and/or IPv6 CIDRs to which the firewall will allow traffic + required: false + droplet_ids: + type: list + elements: str + description: + - List of integers containing the IDs of the Droplets to which the firewall will allow traffic + required: false + load_balancer_uids: + type: list + elements: str + description: + - List of strings containing the IDs of the Load Balancers to which the firewall will allow traffic + required: false + tags: + type: list + elements: str + description: + - List of strings containing the names of Tags corresponding to groups of Droplets to + which the Firewall will allow traffic + required: false +extends_documentation_fragment: + - community.digitalocean.digital_ocean.documentation +""" + +EXAMPLES = """ +# Allows tcp connections to port 22 (SSH) from specific sources +# Allows tcp connections to ports 80 and 443 from any source +# Allows outbound access to any destination for protocols tcp, udp and icmp +# The firewall rules will be applied to any droplets with the tag "sample" +- name: Create a Firewall named my-firewall + digital_ocean_firewall: + name: my-firewall + state: present + inbound_rules: + - protocol: "tcp" + ports: "22" + sources: + addresses: ["1.2.3.4"] + droplet_ids: ["my_droplet_id_1", "my_droplet_id_2"] + load_balancer_uids: ["my_lb_id_1", "my_lb_id_2"] + tags: ["tag_1", "tag_2"] + - protocol: "tcp" + ports: "80" + sources: + addresses: ["0.0.0.0/0", "::/0"] + - protocol: "tcp" + ports: "443" + sources: + addresses: ["0.0.0.0/0", "::/0"] + outbound_rules: + - protocol: "tcp" + ports: "1-65535" + destinations: + addresses: ["0.0.0.0/0", "::/0"] + - protocol: "udp" + ports: "1-65535" + destinations: + addresses: ["0.0.0.0/0", "::/0"] + - protocol: "icmp" + ports: "1-65535" + destinations: + addresses: ["0.0.0.0/0", "::/0"] + droplet_ids: [] + tags: ["sample"] +""" + +RETURN = """ +data: + description: DigitalOcean firewall resource + returned: success + type: dict + sample: { + "created_at": "2020-08-11T18:41:30Z", + "droplet_ids": [], + "id": "7acd6ee2-257b-434f-8909-709a5816d4f9", + "inbound_rules": [ + { + "ports": "443", + "protocol": "tcp", + "sources": { + "addresses": [ + "1.2.3.4" + ], + "droplet_ids": [ + "my_droplet_id_1", + "my_droplet_id_2" + ], + "load_balancer_uids": [ + "my_lb_id_1", + "my_lb_id_2" + ], + "tags": [ + "tag_1", + "tag_2" + ] + } + }, + { + "sources": { + "addresses": [ + "0.0.0.0/0", + "::/0" + ] + }, + "ports": "80", + "protocol": "tcp" + }, + { + "sources": { + "addresses": [ + "0.0.0.0/0", + "::/0" + ] + }, + "ports": "443", + "protocol": "tcp" + } + ], + "name": "my-firewall", + "outbound_rules": [ + { + "destinations": { + "addresses": [ + "0.0.0.0/0", + "::/0" + ] + }, + "ports": "1-65535", + "protocol": "tcp" + }, + { + "destinations": { + "addresses": [ + "0.0.0.0/0", + "::/0" + ] + }, + "ports": "1-65535", + "protocol": "udp" + }, + { + "destinations": { + "addresses": [ + "0.0.0.0/0", + "::/0" + ] + }, + "ports": "1-65535", + "protocol": "icmp" + } + ], + "pending_changes": [], + "status": "succeeded", + "tags": ["sample"] + } +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + +address_spec = dict( + addresses=dict(type="list", elements="str", required=False), + droplet_ids=dict(type="list", elements="str", required=False), + load_balancer_uids=dict(type="list", elements="str", required=False), + tags=dict(type="list", elements="str", required=False), +) + +inbound_spec = dict( + protocol=dict(type="str", choices=["udp", "tcp", "icmp"], default="tcp"), + ports=dict(type="str", required=True), + sources=dict(type="dict", required=True, options=address_spec), +) + +outbound_spec = dict( + protocol=dict(type="str", choices=["udp", "tcp", "icmp"], default="tcp"), + ports=dict(type="str", required=True), + destinations=dict(type="dict", required=True, options=address_spec), +) + + +class DOFirewall(object): + def __init__(self, module): + self.rest = DigitalOceanHelper(module) + self.module = module + self.name = self.module.params.get("name") + self.baseurl = "firewalls" + self.firewalls = self.get_firewalls() + + def get_firewalls(self): + base_url = self.baseurl + "?" + response = self.rest.get("%s" % base_url) + status_code = response.status_code + status_code_success = 200 + + if status_code != status_code_success: + error = response.json + info = response.info + + if error: + error.update({"status_code": status_code}) + error.update({"status_code_success": status_code_success}) + self.module.fail_json(msg=error) + elif info: + info.update({"status_code_success": status_code_success}) + self.module.fail_json(msg=info) + else: + msg_error = "Failed to retrieve firewalls from DigitalOcean" + self.module.fail_json( + msg=msg_error + + " (url=" + + self.rest.baseurl + + "/" + + self.baseurl + + ", status=" + + str(status_code or "") + + " - expected:" + + str(status_code_success) + + ")" + ) + + return self.rest.get_paginated_data( + base_url=base_url, data_key_name="firewalls" + ) + + def get_firewall_by_name(self): + rule = {} + for firewall in self.firewalls: + if firewall["name"] == self.name: + rule.update(firewall) + return rule + return None + + def ordered(self, obj): + if isinstance(obj, dict): + return sorted((k, self.ordered(v)) for k, v in obj.items()) + if isinstance(obj, list): + return sorted(self.ordered(x) for x in obj) + else: + return obj + + def fill_protocol_defaults(self, obj): + if obj.get("protocol") is None: + obj["protocol"] = "tcp" + + return obj + + def fill_source_and_destination_defaults_inner(self, obj): + addresses = obj.get("addresses") or [] + + droplet_ids = obj.get("droplet_ids") or [] + droplet_ids = [str(droplet_id) for droplet_id in droplet_ids] + + load_balancer_uids = obj.get("load_balancer_uids") or [] + load_balancer_uids = [str(uid) for uid in load_balancer_uids] + + tags = obj.get("tags") or [] + + data = { + "addresses": addresses, + "droplet_ids": droplet_ids, + "load_balancer_uids": load_balancer_uids, + "tags": tags, + } + + return data + + def fill_sources_and_destinations_defaults(self, obj, prop): + value = obj.get(prop) + + if value is None: + value = {} + else: + value = self.fill_source_and_destination_defaults_inner(value) + + obj[prop] = value + + return obj + + def fill_data_defaults(self, obj): + inbound_rules = obj.get("inbound_rules") + + if inbound_rules is None: + inbound_rules = [] + else: + inbound_rules = [self.fill_protocol_defaults(x) for x in inbound_rules] + inbound_rules = [ + self.fill_sources_and_destinations_defaults(x, "sources") + for x in inbound_rules + ] + + outbound_rules = obj.get("outbound_rules") + + if outbound_rules is None: + outbound_rules = [] + else: + outbound_rules = [self.fill_protocol_defaults(x) for x in outbound_rules] + outbound_rules = [ + self.fill_sources_and_destinations_defaults(x, "destinations") + for x in outbound_rules + ] + + droplet_ids = obj.get("droplet_ids") or [] + droplet_ids = [str(droplet_id) for droplet_id in droplet_ids] + + tags = obj.get("tags") or [] + + data = { + "name": obj.get("name"), + "inbound_rules": inbound_rules, + "outbound_rules": outbound_rules, + "droplet_ids": droplet_ids, + "tags": tags, + } + + return data + + def data_to_compare(self, obj): + return self.ordered(self.fill_data_defaults(obj)) + + def update(self, obj, id): + if id is None: + status_code_success = 202 + resp = self.rest.post(path=self.baseurl, data=obj) + else: + status_code_success = 200 + resp = self.rest.put(path=self.baseurl + "/" + id, data=obj) + status_code = resp.status_code + if status_code != status_code_success: + error = resp.json + error.update( + { + "context": "error when trying to " + + ("create" if (id is None) else "update") + + " firewalls" + } + ) + error.update({"status_code": status_code}) + error.update({"status_code_success": status_code_success}) + self.module.fail_json(msg=error) + self.module.exit_json(changed=True, data=resp.json["firewall"]) + + def create(self): + rule = self.get_firewall_by_name() + data = { + "name": self.module.params.get("name"), + "inbound_rules": self.module.params.get("inbound_rules"), + "outbound_rules": self.module.params.get("outbound_rules"), + "droplet_ids": self.module.params.get("droplet_ids"), + "tags": self.module.params.get("tags"), + } + if rule is None: + self.update(data, None) + else: + rule_data = { + "name": rule.get("name"), + "inbound_rules": rule.get("inbound_rules"), + "outbound_rules": rule.get("outbound_rules"), + "droplet_ids": rule.get("droplet_ids"), + "tags": rule.get("tags"), + } + + user_data = { + "name": data.get("name"), + "inbound_rules": data.get("inbound_rules"), + "outbound_rules": data.get("outbound_rules"), + "droplet_ids": data.get("droplet_ids"), + "tags": data.get("tags"), + } + + if self.data_to_compare(user_data) == self.data_to_compare(rule_data): + self.module.exit_json(changed=False, data=rule) + else: + self.update(data, rule.get("id")) + + def destroy(self): + rule = self.get_firewall_by_name() + if rule is None: + self.module.exit_json(changed=False, data="Firewall does not exist") + else: + endpoint = self.baseurl + "/" + rule["id"] + resp = self.rest.delete(path=endpoint) + status_code = resp.status_code + if status_code != 204: + self.module.fail_json(msg="Failed to delete firewall") + self.module.exit_json( + changed=True, + data="Deleted firewall rule: {0} - {1}".format( + rule["name"], rule["id"] + ), + ) + + +def core(module): + state = module.params.get("state") + firewall = DOFirewall(module) + + if state == "present": + firewall.create() + elif state == "absent": + firewall.destroy() + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + name=dict(type="str", required=True), + state=dict(type="str", choices=["present", "absent"], default="present"), + droplet_ids=dict(type="list", elements="str", required=False), + tags=dict(type="list", elements="str", required=False), + inbound_rules=dict( + type="list", elements="dict", options=inbound_spec, required=False + ), + outbound_rules=dict( + type="list", elements="dict", options=outbound_spec, required=False + ), + ), + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[("state", "present", ["inbound_rules", "outbound_rules"])], + ) + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_firewall_facts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_firewall_facts.py new file mode 100644 index 00000000..44239667 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_firewall_facts.py @@ -0,0 +1,143 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Anthony Bond <ajbond2005@gmail.com> +# 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 = r""" +--- +module: digital_ocean_firewall_info +short_description: Gather information about DigitalOcean firewalls +description: + - This module can be used to gather information about DigitalOcean firewalls. + - This module was called C(digital_ocean_firewall_facts) before Ansible 2.9. The usage did not change. +author: "Anthony Bond (@BondAnthony)" +options: + name: + description: + - Firewall rule name that can be used to identify and reference a specific firewall rule. + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all firewalls + community.digitalocean.digital_ocean_firewall_info: + oauth_token: "{{ oauth_token }}" + +- name: Gather information about a specific firewall by name + community.digitalocean.digital_ocean_firewall_info: + oauth_token: "{{ oauth_token }}" + name: "firewall_name" + +- name: Gather information from a firewall rule + community.digitalocean.digital_ocean_firewall_info: + name: SSH + register: resp_out + +- set_fact: + firewall_id: "{{ resp_out.data.id }}" + +- debug: + msg: "{{ firewall_id }}" +""" + + +RETURN = r""" +data: + description: DigitalOcean firewall information + returned: success + type: list + elements: dict + sample: [ + { + "id": "435tbg678-1db53-32b6-t543-28322569t252", + "name": "metrics", + "status": "succeeded", + "inbound_rules": [ + { + "protocol": "tcp", + "ports": "9100", + "sources": { + "addresses": [ + "1.1.1.1" + ] + } + } + ], + "outbound_rules": [], + "created_at": "2018-01-15T07:04:25Z", + "droplet_ids": [ + 87426985 + ], + "tags": [], + "pending_changes": [] + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + firewall_name = module.params.get("name", None) + rest = DigitalOceanHelper(module) + base_url = "firewalls?" + + response = rest.get("%s" % base_url) + status_code = response.status_code + if status_code != 200: + module.fail_json(msg="Failed to retrieve firewalls from Digital Ocean") + firewalls = rest.get_paginated_data(base_url=base_url, data_key_name="firewalls") + + if firewall_name is not None: + rule = {} + for firewall in firewalls: + if firewall["name"] == firewall_name: + rule.update(firewall) + firewalls = [rule] + module.exit_json(changed=False, data=firewalls) + else: + module.exit_json(changed=False, data=firewalls) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + name=dict(type="str", required=False), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_firewall_facts", + "community.digitalocean.digital_ocean_firewall_facts", + ): + module.deprecate( + "The 'digital_ocean_firewall_facts' module has been renamed to 'digital_ocean_firewall_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_firewall_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_firewall_info.py new file mode 100644 index 00000000..44239667 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_firewall_info.py @@ -0,0 +1,143 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Anthony Bond <ajbond2005@gmail.com> +# 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 = r""" +--- +module: digital_ocean_firewall_info +short_description: Gather information about DigitalOcean firewalls +description: + - This module can be used to gather information about DigitalOcean firewalls. + - This module was called C(digital_ocean_firewall_facts) before Ansible 2.9. The usage did not change. +author: "Anthony Bond (@BondAnthony)" +options: + name: + description: + - Firewall rule name that can be used to identify and reference a specific firewall rule. + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all firewalls + community.digitalocean.digital_ocean_firewall_info: + oauth_token: "{{ oauth_token }}" + +- name: Gather information about a specific firewall by name + community.digitalocean.digital_ocean_firewall_info: + oauth_token: "{{ oauth_token }}" + name: "firewall_name" + +- name: Gather information from a firewall rule + community.digitalocean.digital_ocean_firewall_info: + name: SSH + register: resp_out + +- set_fact: + firewall_id: "{{ resp_out.data.id }}" + +- debug: + msg: "{{ firewall_id }}" +""" + + +RETURN = r""" +data: + description: DigitalOcean firewall information + returned: success + type: list + elements: dict + sample: [ + { + "id": "435tbg678-1db53-32b6-t543-28322569t252", + "name": "metrics", + "status": "succeeded", + "inbound_rules": [ + { + "protocol": "tcp", + "ports": "9100", + "sources": { + "addresses": [ + "1.1.1.1" + ] + } + } + ], + "outbound_rules": [], + "created_at": "2018-01-15T07:04:25Z", + "droplet_ids": [ + 87426985 + ], + "tags": [], + "pending_changes": [] + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + firewall_name = module.params.get("name", None) + rest = DigitalOceanHelper(module) + base_url = "firewalls?" + + response = rest.get("%s" % base_url) + status_code = response.status_code + if status_code != 200: + module.fail_json(msg="Failed to retrieve firewalls from Digital Ocean") + firewalls = rest.get_paginated_data(base_url=base_url, data_key_name="firewalls") + + if firewall_name is not None: + rule = {} + for firewall in firewalls: + if firewall["name"] == firewall_name: + rule.update(firewall) + firewalls = [rule] + module.exit_json(changed=False, data=firewalls) + else: + module.exit_json(changed=False, data=firewalls) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + name=dict(type="str", required=False), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_firewall_facts", + "community.digitalocean.digital_ocean_firewall_facts", + ): + module.deprecate( + "The 'digital_ocean_firewall_facts' module has been renamed to 'digital_ocean_firewall_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_floating_ip.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_floating_ip.py new file mode 100644 index 00000000..d4d6ff26 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_floating_ip.py @@ -0,0 +1,519 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# (c) 2015, Patrick F. Marques <patrickfmarques@gmail.com> +# 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 = r""" +--- +module: digital_ocean_floating_ip +short_description: Manage DigitalOcean Floating IPs +description: + - Create/delete/assign a floating IP. +author: + - "Patrick Marques (@pmarques)" + - "Daniel George (@danxg87)" +options: + state: + description: + - Indicate desired state of the target. + - If C(state=present) Create (and optionally attach) floating IP + - If C(state=absent) Delete floating IP + - If C(state=attached) attach floating IP to a droplet + - If C(state=detached) detach floating IP from a droplet + default: present + choices: ['present', 'absent', 'attached', 'detached'] + type: str + ip: + description: + - Public IP address of the Floating IP. Used to remove an IP + type: str + aliases: ['id'] + region: + description: + - The region that the Floating IP is reserved to. + type: str + droplet_id: + description: + - The Droplet that the Floating IP has been assigned to. + type: str + oauth_token: + description: + - DigitalOcean OAuth token. + required: true + type: str + timeout: + description: + - Floating IP creation timeout. + type: int + default: 30 + validate_certs: + description: + - If set to C(no), the SSL certificates will not be validated. + - This should only set to C(no) used on personally controlled sites using self-signed certificates. + type: bool + default: true + project_name: + aliases: ["project"] + description: + - Project to assign the resource to (project name, not UUID). + - Defaults to the default project of the account (empty string). + - Currently only supported when creating. + type: str + required: false + default: "" +notes: + - Version 2 of DigitalOcean API is used. +requirements: + - "python >= 2.6" +""" + + +EXAMPLES = r""" +- name: "Create a Floating IP in region lon1" + community.digitalocean.digital_ocean_floating_ip: + state: present + region: lon1 + +- name: Create a Floating IP in region lon1 (and assign to Project "test") + community.digitalocean.digital_ocean_floating_ip: + state: present + region: lon1 + project: test + +- name: "Create a Floating IP assigned to Droplet ID 123456" + community.digitalocean.digital_ocean_floating_ip: + state: present + droplet_id: 123456 + +- name: "Attach an existing Floating IP of 1.2.3.4 to Droplet ID 123456" + community.digitalocean.digital_ocean_floating_ip: + state: attached + ip: "1.2.3.4" + droplet_id: 123456 + +- name: "Detach an existing Floating IP of 1.2.3.4 from its Droplet" + community.digitalocean.digital_ocean_floating_ip: + state: detached + ip: "1.2.3.4" + +- name: "Delete a Floating IP with ip 1.2.3.4" + community.digitalocean.digital_ocean_floating_ip: + state: absent + ip: "1.2.3.4" + +""" + + +RETURN = r""" +# Digital Ocean API info https://docs.digitalocean.com/reference/api/api-reference/#tag/Floating-IPs +data: + description: a DigitalOcean Floating IP resource + returned: success and no resource constraint + type: dict + sample: + action: + id: 68212728 + status: in-progress + type: assign_ip + started_at: '2015-10-15T17:45:44Z' + completed_at: null + resource_id: 758603823 + resource_type: floating_ip + region: + name: New York 3 + slug: nyc3 + sizes: + - 512mb, + - 1gb, + - 2gb, + - 4gb, + - 8gb, + - 16gb, + - 32gb, + - 48gb, + - 64gb + features: + - private_networking + - backups + - ipv6 + - metadata + available: true + region_slug: nyc3 +msg: + description: Informational or error message encountered during execution + returned: changed + type: str + sample: No project named test2 found +assign_status: + description: Assignment status (ok, not_found, assigned, already_assigned, service_down) + returned: changed + type: str + sample: assigned +resources: + description: Resource assignment involved in project assignment + returned: changed + type: dict + sample: + assigned_at: '2021-10-25T17:39:38Z' + links: + self: https://api.digitalocean.com/v2/floating_ips/157.230.64.107 + status: assigned + urn: do:floatingip:157.230.64.107 +""" + +import json +import time + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import env_fallback +from ansible.module_utils.urls import fetch_url + +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, + DigitalOceanProjects, +) + + +class Response(object): + def __init__(self, resp, info): + self.body = None + if resp: + self.body = resp.read() + self.info = info + + @property + def json(self): + if not self.body: + if "body" in self.info: + return json.loads(self.info["body"]) + return None + try: + return json.loads(self.body) + except ValueError: + return None + + @property + def status_code(self): + return self.info["status"] + + +class Rest(object): + def __init__(self, module, headers): + self.module = module + self.headers = headers + self.baseurl = "https://api.digitalocean.com/v2" + + def _url_builder(self, path): + if path[0] == "/": + path = path[1:] + return "%s/%s" % (self.baseurl, path) + + def send(self, method, path, data=None, headers=None): + url = self._url_builder(path) + data = self.module.jsonify(data) + timeout = self.module.params["timeout"] + + resp, info = fetch_url( + self.module, + url, + data=data, + headers=self.headers, + method=method, + timeout=timeout, + ) + + # Exceptions in fetch_url may result in a status -1, the ensures a + if info["status"] == -1: + self.module.fail_json(msg=info["msg"]) + + return Response(resp, info) + + def get(self, path, data=None, headers=None): + return self.send("GET", path, data, headers) + + def put(self, path, data=None, headers=None): + return self.send("PUT", path, data, headers) + + def post(self, path, data=None, headers=None): + return self.send("POST", path, data, headers) + + def delete(self, path, data=None, headers=None): + return self.send("DELETE", path, data, headers) + + +def wait_action(module, rest, ip, action_id, timeout=60): + end_time = time.monotonic() + timeout + while time.monotonic() < end_time: + response = rest.get("floating_ips/{0}/actions/{1}".format(ip, action_id)) + json_data = response.json + status_code = response.status_code + status = response.json["action"]["status"] + if status_code == 200: + if status == "completed": + return json_data + elif status == "errored": + module.fail_json( + msg="Floating ip action error [ip: {0}: action: {1}]".format( + ip, action_id + ), + data=json, + ) + time.sleep(10) + module.fail_json( + msg="Floating ip action timeout [ip: {0}: action: {1}]".format(ip, action_id), + data=json, + ) + + +def core(module): + api_token = module.params["oauth_token"] + state = module.params["state"] + ip = module.params["ip"] + droplet_id = module.params["droplet_id"] + + rest = Rest( + module, + { + "Authorization": "Bearer {0}".format(api_token), + "Content-type": "application/json", + }, + ) + + if state in ("present"): + if droplet_id is not None and module.params["ip"] is not None: + # Lets try to associate the ip to the specified droplet + associate_floating_ips(module, rest) + else: + create_floating_ips(module, rest) + + elif state in ("attached"): + if droplet_id is not None and module.params["ip"] is not None: + associate_floating_ips(module, rest) + + elif state in ("detached"): + if module.params["ip"] is not None: + detach_floating_ips(module, rest, module.params["ip"]) + + elif state in ("absent"): + response = rest.delete("floating_ips/{0}".format(ip)) + status_code = response.status_code + json_data = response.json + if status_code == 204: + module.exit_json(changed=True) + elif status_code == 404: + module.exit_json(changed=False) + else: + module.exit_json(changed=False, data=json_data) + + +def get_floating_ip_details(module, rest): + ip = module.params["ip"] + + response = rest.get("floating_ips/{0}".format(ip)) + status_code = response.status_code + json_data = response.json + if status_code == 200: + return json_data["floating_ip"] + else: + module.fail_json( + msg="Error assigning floating ip [{0}: {1}]".format( + status_code, json_data["message"] + ), + region=module.params["region"], + ) + + +def assign_floating_id_to_droplet(module, rest): + ip = module.params["ip"] + + payload = { + "type": "assign", + "droplet_id": module.params["droplet_id"], + } + + response = rest.post("floating_ips/{0}/actions".format(ip), data=payload) + status_code = response.status_code + json_data = response.json + if status_code == 201: + json_data = wait_action(module, rest, ip, json_data["action"]["id"]) + + module.exit_json(changed=True, data=json_data) + else: + module.fail_json( + msg="Error creating floating ip [{0}: {1}]".format( + status_code, json_data["message"] + ), + region=module.params["region"], + ) + + +def detach_floating_ips(module, rest, ip): + payload = {"type": "unassign"} + response = rest.post("floating_ips/{0}/actions".format(ip), data=payload) + status_code = response.status_code + json_data = response.json + + if status_code == 201: + json_data = wait_action(module, rest, ip, json_data["action"]["id"]) + module.exit_json( + changed=True, msg="Detached floating ip {0}".format(ip), data=json_data + ) + action = json_data.get("action", None) + action_id = action.get("id", None) + if action is None: + module.fail_json( + changed=False, + msg="Error retrieving detach action. Got: {0}".format(action), + ) + if action_id is None: + module.fail_json( + changed=False, + msg="Error retrieving detach action ID. Got: {0}".format(action_id), + ) + else: + module.fail_json( + changed=False, + msg="Error detaching floating ip [{0}: {1}]".format( + status_code, json_data["message"] + ), + ) + + +def associate_floating_ips(module, rest): + floating_ip = get_floating_ip_details(module, rest) + droplet = floating_ip["droplet"] + + # TODO: If already assigned to a droplet verify if is one of the specified as valid + if droplet is not None and str(droplet["id"]) in [module.params["droplet_id"]]: + module.exit_json(changed=False) + else: + assign_floating_id_to_droplet(module, rest) + + +def create_floating_ips(module, rest): + payload = {} + + if module.params["region"] is not None: + payload["region"] = module.params["region"] + if module.params["droplet_id"] is not None: + payload["droplet_id"] = module.params["droplet_id"] + + # Get existing floating IPs + response = rest.get("floating_ips/") + status_code = response.status_code + json_data = response.json + + # Exit unchanged if any of them are assigned to this Droplet already + if status_code == 200: + floating_ips = json_data.get("floating_ips", []) + if len(floating_ips) != 0: + for floating_ip in floating_ips: + droplet = floating_ip.get("droplet", None) + if droplet is not None: + droplet_id = droplet.get("id", None) + if droplet_id is not None: + if str(droplet_id) == module.params["droplet_id"]: + ip = floating_ip.get("ip", None) + if ip is not None: + module.exit_json( + changed=False, data={"floating_ip": floating_ip} + ) + else: + module.fail_json( + changed=False, + msg="Unexpected error querying floating ip", + ) + + response = rest.post("floating_ips", data=payload) + status_code = response.status_code + json_data = response.json + if status_code == 202: + if module.params.get( + "project" + ): # only load for non-default project assignments + rest = DigitalOceanHelper(module) + projects = DigitalOceanProjects(module, rest) + project_name = module.params.get("project") + if ( + project_name + ): # empty string is the default project, skip project assignment + floating_ip = json_data.get("floating_ip") + ip = floating_ip.get("ip") + if ip: + urn = "do:floatingip:{0}".format(ip) + ( + assign_status, + error_message, + resources, + ) = projects.assign_to_project(project_name, urn) + module.exit_json( + changed=True, + data=json_data, + msg=error_message, + assign_status=assign_status, + resources=resources, + ) + else: + module.exit_json( + changed=True, + msg="Floating IP created but not assigned to the {0} Project (missing information from the API response)".format( + project_name + ), + data=json_data, + ) + else: + module.exit_json(changed=True, data=json_data) + else: + module.exit_json(changed=True, data=json_data) + else: + module.fail_json( + msg="Error creating floating ip [{0}: {1}]".format( + status_code, json_data["message"] + ), + region=module.params["region"], + ) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + state=dict( + choices=["present", "absent", "attached", "detached"], default="present" + ), + ip=dict(aliases=["id"], required=False), + region=dict(required=False), + droplet_id=dict(required=False), + oauth_token=dict( + no_log=True, + # Support environment variable for DigitalOcean OAuth Token + fallback=( + env_fallback, + ["DO_API_TOKEN", "DO_API_KEY", "DO_OAUTH_TOKEN"], + ), + required=True, + ), + validate_certs=dict(type="bool", default=True), + timeout=dict(type="int", default=30), + project_name=dict( + type="str", aliases=["project"], required=False, default="" + ), + ), + required_if=[ + ("state", "delete", ["ip"]), + ("state", "attached", ["ip", "droplet_id"]), + ("state", "detached", ["ip"]), + ], + mutually_exclusive=[["region", "droplet_id"]], + ) + + core(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_floating_ip_facts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_floating_ip_facts.py new file mode 100644 index 00000000..3d232a4a --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_floating_ip_facts.py @@ -0,0 +1,136 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (C) 2017-18, 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 + + +DOCUMENTATION = r""" +--- +module: digital_ocean_floating_ip_info +short_description: DigitalOcean Floating IPs information +description: + - This module can be used to fetch DigitalOcean Floating IPs information. + - This module was called C(digital_ocean_floating_ip_facts) before Ansible 2.9. The usage did not change. +author: "Patrick Marques (@pmarques)" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +notes: + - Version 2 of DigitalOcean API is used. +requirements: + - "python >= 2.6" +""" + + +EXAMPLES = r""" +- name: "Gather information about all Floating IPs" + community.digitalocean.digital_ocean_floating_ip_info: + register: result + +- name: "List of current floating ips" + debug: + var: result.floating_ips +""" + + +RETURN = r""" +# Digital Ocean API info https://docs.digitalocean.com/reference/api/api-reference/#tag/Floating-IPs +floating_ips: + description: a DigitalOcean Floating IP resource + returned: success and no resource constraint + type: list + sample: [ + { + "ip": "45.55.96.47", + "droplet": null, + "region": { + "name": "New York 3", + "slug": "nyc3", + "sizes": [ + "512mb", + "1gb", + "2gb", + "4gb", + "8gb", + "16gb", + "32gb", + "48gb", + "64gb" + ], + "features": [ + "private_networking", + "backups", + "ipv6", + "metadata" + ], + "available": true + }, + "locked": false + } + ] +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + rest = DigitalOceanHelper(module) + + page = 1 + has_next = True + floating_ips = [] + status_code = None + while has_next or status_code != 200: + response = rest.get("floating_ips?page={0}&per_page=20".format(page)) + status_code = response.status_code + # stop if any error during pagination + if status_code != 200: + break + page += 1 + floating_ips.extend(response.json["floating_ips"]) + has_next = ( + "pages" in response.json["links"] + and "next" in response.json["links"]["pages"] + ) + + if status_code == 200: + module.exit_json(changed=False, floating_ips=floating_ips) + else: + module.fail_json( + msg="Error fetching information [{0}: {1}]".format( + status_code, response.json["message"] + ) + ) + + +def main(): + module = AnsibleModule( + argument_spec=DigitalOceanHelper.digital_ocean_argument_spec(), + supports_check_mode=True, + ) + if module._name in ( + "digital_ocean_floating_ip_facts", + "community.digitalocean.digital_ocean_floating_ip_facts", + ): + module.deprecate( + "The 'digital_ocean_floating_ip_facts' module has been renamed to 'digital_ocean_floating_ip_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e)) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_floating_ip_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_floating_ip_info.py new file mode 100644 index 00000000..3d232a4a --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_floating_ip_info.py @@ -0,0 +1,136 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (C) 2017-18, 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 + + +DOCUMENTATION = r""" +--- +module: digital_ocean_floating_ip_info +short_description: DigitalOcean Floating IPs information +description: + - This module can be used to fetch DigitalOcean Floating IPs information. + - This module was called C(digital_ocean_floating_ip_facts) before Ansible 2.9. The usage did not change. +author: "Patrick Marques (@pmarques)" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +notes: + - Version 2 of DigitalOcean API is used. +requirements: + - "python >= 2.6" +""" + + +EXAMPLES = r""" +- name: "Gather information about all Floating IPs" + community.digitalocean.digital_ocean_floating_ip_info: + register: result + +- name: "List of current floating ips" + debug: + var: result.floating_ips +""" + + +RETURN = r""" +# Digital Ocean API info https://docs.digitalocean.com/reference/api/api-reference/#tag/Floating-IPs +floating_ips: + description: a DigitalOcean Floating IP resource + returned: success and no resource constraint + type: list + sample: [ + { + "ip": "45.55.96.47", + "droplet": null, + "region": { + "name": "New York 3", + "slug": "nyc3", + "sizes": [ + "512mb", + "1gb", + "2gb", + "4gb", + "8gb", + "16gb", + "32gb", + "48gb", + "64gb" + ], + "features": [ + "private_networking", + "backups", + "ipv6", + "metadata" + ], + "available": true + }, + "locked": false + } + ] +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + rest = DigitalOceanHelper(module) + + page = 1 + has_next = True + floating_ips = [] + status_code = None + while has_next or status_code != 200: + response = rest.get("floating_ips?page={0}&per_page=20".format(page)) + status_code = response.status_code + # stop if any error during pagination + if status_code != 200: + break + page += 1 + floating_ips.extend(response.json["floating_ips"]) + has_next = ( + "pages" in response.json["links"] + and "next" in response.json["links"]["pages"] + ) + + if status_code == 200: + module.exit_json(changed=False, floating_ips=floating_ips) + else: + module.fail_json( + msg="Error fetching information [{0}: {1}]".format( + status_code, response.json["message"] + ) + ) + + +def main(): + module = AnsibleModule( + argument_spec=DigitalOceanHelper.digital_ocean_argument_spec(), + supports_check_mode=True, + ) + if module._name in ( + "digital_ocean_floating_ip_facts", + "community.digitalocean.digital_ocean_floating_ip_facts", + ): + module.deprecate( + "The 'digital_ocean_floating_ip_facts' module has been renamed to 'digital_ocean_floating_ip_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e)) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_image_facts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_image_facts.py new file mode 100644 index 00000000..7feb3af0 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_image_facts.py @@ -0,0 +1,160 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_image_info +short_description: Gather information about DigitalOcean images +description: + - This module can be used to gather information about DigitalOcean provided images. + - These images can be either of type C(distribution), C(application) and C(private). + - This module was called C(digital_ocean_image_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + image_type: + description: + - Specifies the type of image information to be retrieved. + - If set to C(application), then information are gathered related to all application images. + - If set to C(distribution), then information are gathered related to all distribution images. + - If set to C(private), then information are gathered related to all private images. + - If not set to any of above, then information are gathered related to all images. + default: 'all' + choices: [ 'all', 'application', 'distribution', 'private' ] + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all images + community.digitalocean.digital_ocean_image_info: + image_type: all + oauth_token: "{{ oauth_token }}" + +- name: Gather information about application images + community.digitalocean.digital_ocean_image_info: + image_type: application + oauth_token: "{{ oauth_token }}" + +- name: Gather information about distribution images + community.digitalocean.digital_ocean_image_info: + image_type: distribution + oauth_token: "{{ oauth_token }}" + +- name: Get distribution about image with slug coreos-beta + community.digitalocean.digital_ocean_image_info: + register: resp_out +- set_fact: + distribution_name: "{{ item.distribution }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?slug=='coreos-beta']" +- debug: + var: distribution_name + +""" + + +RETURN = r""" +data: + description: DigitalOcean image information + returned: success + type: list + sample: [ + { + "created_at": "2018-02-02T07:11:43Z", + "distribution": "CoreOS", + "id": 31434061, + "min_disk_size": 20, + "name": "1662.1.0 (beta)", + "public": true, + "regions": [ + "nyc1", + "sfo1", + "nyc2", + "ams2", + "sgp1", + "lon1", + "nyc3", + "ams3", + "fra1", + "tor1", + "sfo2", + "blr1" + ], + "size_gigabytes": 0.42, + "slug": "coreos-beta", + "type": "snapshot" + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + image_type = module.params["image_type"] + + rest = DigitalOceanHelper(module) + + base_url = "images?" + if image_type == "distribution": + base_url += "type=distribution&" + elif image_type == "application": + base_url += "type=application&" + elif image_type == "private": + base_url += "private=true&" + + images = rest.get_paginated_data(base_url=base_url, data_key_name="images") + + module.exit_json(changed=False, data=images) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + image_type=dict( + type="str", + required=False, + choices=["all", "application", "distribution", "private"], + default="all", + ) + ) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_image_facts", + "community.digitalocean.digital_ocean_image_facts", + ): + module.deprecate( + "The 'digital_ocean_image_facts' module has been renamed to 'digital_ocean_image_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_image_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_image_info.py new file mode 100644 index 00000000..7feb3af0 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_image_info.py @@ -0,0 +1,160 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_image_info +short_description: Gather information about DigitalOcean images +description: + - This module can be used to gather information about DigitalOcean provided images. + - These images can be either of type C(distribution), C(application) and C(private). + - This module was called C(digital_ocean_image_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + image_type: + description: + - Specifies the type of image information to be retrieved. + - If set to C(application), then information are gathered related to all application images. + - If set to C(distribution), then information are gathered related to all distribution images. + - If set to C(private), then information are gathered related to all private images. + - If not set to any of above, then information are gathered related to all images. + default: 'all' + choices: [ 'all', 'application', 'distribution', 'private' ] + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all images + community.digitalocean.digital_ocean_image_info: + image_type: all + oauth_token: "{{ oauth_token }}" + +- name: Gather information about application images + community.digitalocean.digital_ocean_image_info: + image_type: application + oauth_token: "{{ oauth_token }}" + +- name: Gather information about distribution images + community.digitalocean.digital_ocean_image_info: + image_type: distribution + oauth_token: "{{ oauth_token }}" + +- name: Get distribution about image with slug coreos-beta + community.digitalocean.digital_ocean_image_info: + register: resp_out +- set_fact: + distribution_name: "{{ item.distribution }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?slug=='coreos-beta']" +- debug: + var: distribution_name + +""" + + +RETURN = r""" +data: + description: DigitalOcean image information + returned: success + type: list + sample: [ + { + "created_at": "2018-02-02T07:11:43Z", + "distribution": "CoreOS", + "id": 31434061, + "min_disk_size": 20, + "name": "1662.1.0 (beta)", + "public": true, + "regions": [ + "nyc1", + "sfo1", + "nyc2", + "ams2", + "sgp1", + "lon1", + "nyc3", + "ams3", + "fra1", + "tor1", + "sfo2", + "blr1" + ], + "size_gigabytes": 0.42, + "slug": "coreos-beta", + "type": "snapshot" + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + image_type = module.params["image_type"] + + rest = DigitalOceanHelper(module) + + base_url = "images?" + if image_type == "distribution": + base_url += "type=distribution&" + elif image_type == "application": + base_url += "type=application&" + elif image_type == "private": + base_url += "private=true&" + + images = rest.get_paginated_data(base_url=base_url, data_key_name="images") + + module.exit_json(changed=False, data=images) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + image_type=dict( + type="str", + required=False, + choices=["all", "application", "distribution", "private"], + default="all", + ) + ) + + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_image_facts", + "community.digitalocean.digital_ocean_image_facts", + ): + module.deprecate( + "The 'digital_ocean_image_facts' module has been renamed to 'digital_ocean_image_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_kubernetes.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_kubernetes.py new file mode 100644 index 00000000..eda9c424 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_kubernetes.py @@ -0,0 +1,493 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2020, 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 + + +DOCUMENTATION = r""" +--- +module: digital_ocean_kubernetes +short_description: Create and delete a DigitalOcean Kubernetes cluster +description: + - Create and delete a Kubernetes cluster in DigitalOcean (and optionally wait for it to be running). +version_added: 1.3.0 +author: Mark Mercado (@mamercad) +options: + oauth_token: + description: + - DigitalOcean OAuth token; can be specified in C(DO_API_KEY), C(DO_API_TOKEN), or C(DO_OAUTH_TOKEN) environment variables + type: str + aliases: ['API_TOKEN'] + required: true + state: + description: + - The usual, C(present) to create, C(absent) to destroy + type: str + choices: ['present', 'absent'] + default: present + name: + description: + - A human-readable name for a Kubernetes cluster. + type: str + required: true + region: + description: + - The slug identifier for the region where the Kubernetes cluster will be created. + type: str + aliases: ['region_id'] + default: nyc1 + version: + description: + - The slug identifier for the version of Kubernetes used for the cluster. See the /v2/kubernetes/options endpoint for available versions. + type: str + required: false + default: latest + auto_upgrade: + description: + - A boolean value indicating whether the cluster will be automatically upgraded to new patch releases during its maintenance window. + type: bool + required: false + default: false + surge_upgrade: + description: + - A boolean value indicating whether surge upgrade is enabled/disabled for the cluster. + - Surge upgrade makes cluster upgrades fast and reliable by bringing up new nodes before destroying the outdated nodes. + type: bool + required: false + default: false + tags: + description: + - A flat array of tag names as strings to be applied to the Kubernetes cluster. + - All clusters will be automatically tagged "k8s" and "k8s:$K8S_CLUSTER_ID" in addition to any tags provided by the user. + required: false + type: list + elements: str + maintenance_policy: + description: + - An object specifying the maintenance window policy for the Kubernetes cluster (see table below). + type: dict + required: false + node_pools: + description: + - An object specifying the details of the worker nodes available to the Kubernetes cluster (see table below). + type: list + elements: dict + suboptions: + name: + type: str + description: A human-readable name for the node pool. + size: + type: str + description: The slug identifier for the type of Droplet used as workers in the node pool. + count: + type: int + description: The number of Droplet instances in the node pool. + tags: + type: list + elements: str + description: + - An array containing the tags applied to the node pool. + - All node pools are automatically tagged C("k8s"), C("k8s-worker"), and C("k8s:$K8S_CLUSTER_ID"). + labels: + type: dict + description: An object containing a set of Kubernetes labels. The keys are user-defined. + taints: + type: list + elements: dict + description: + - An array of taints to apply to all nodes in a pool. + - Taints will automatically be applied to all existing nodes and any subsequent nodes added to the pool. + - When a taint is removed, it is removed from all nodes in the pool. + auto_scale: + type: bool + description: + - A boolean value indicating whether auto-scaling is enabled for this node pool. + min_nodes: + type: int + description: + - The minimum number of nodes that this node pool can be auto-scaled to. + - The value will be C(0) if C(auto_scale) is set to C(false). + max_nodes: + type: int + description: + - The maximum number of nodes that this node pool can be auto-scaled to. + - The value will be C(0) if C(auto_scale) is set to C(false). + default: + - name: worker-pool + size: s-1vcpu-2gb + count: 1 + tags: [] + labels: {} + taints: [] + auto_scale: false + min_nodes: 0 + max_nodes: 0 + vpc_uuid: + description: + - A string specifying the UUID of the VPC to which the Kubernetes cluster will be assigned. + - If excluded, the cluster will be assigned to your account's default VPC for the region. + type: str + required: false + return_kubeconfig: + description: + - Controls whether or not to return the C(kubeconfig). + type: bool + required: false + default: false + wait: + description: + - Wait for the cluster to be running before returning. + type: bool + required: false + default: true + wait_timeout: + description: + - How long before wait gives up, in seconds, when creating a cluster. + type: int + default: 600 + ha: + description: + - A boolean value indicating whether the control plane is run in a highly available configuration in the cluster. + - Highly available control planes incur less downtime. + type: bool + default: false +""" + + +EXAMPLES = r""" +- name: Create a new DigitalOcean Kubernetes cluster in New York 1 + community.digitalocean.digital_ocean_kubernetes: + state: present + oauth_token: "{{ lookup('env', 'DO_API_TOKEN') }}" + name: hacktoberfest + region: nyc1 + node_pools: + - name: hacktoberfest-workers + size: s-1vcpu-2gb + count: 3 + return_kubeconfig: yes + wait_timeout: 600 + register: my_cluster + +- name: Show the kubeconfig for the cluster we just created + debug: + msg: "{{ my_cluster.data.kubeconfig }}" + +- name: Destroy (delete) an existing DigitalOcean Kubernetes cluster + community.digitalocean.digital_ocean_kubernetes: + state: absent + oauth_token: "{{ lookup('env', 'DO_API_TOKEN') }}" + name: hacktoberfest +""" + + +# Digital Ocean API info https://docs.digitalocean.com/reference/api/api-reference/#tag/Kubernetes +# The only variance from the documented response is that the kubeconfig is (if return_kubeconfig is True) merged in at data['kubeconfig'] +RETURN = r""" +data: + description: A DigitalOcean Kubernetes cluster (and optional C(kubeconfig)) + returned: changed + type: dict + sample: + kubeconfig: |- + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: REDACTED + server: https://REDACTED.k8s.ondigitalocean.com + name: do-nyc1-hacktoberfest + contexts: + - context: + cluster: do-nyc1-hacktoberfest + user: do-nyc1-hacktoberfest-admin + name: do-nyc1-hacktoberfest + current-context: do-nyc1-hacktoberfest + kind: Config + preferences: {} + users: + - name: do-nyc1-hacktoberfest-admin + user: + token: REDACTED + kubernetes_cluster: + auto_upgrade: false + cluster_subnet: 10.244.0.0/16 + created_at: '2020-09-27T00:55:37Z' + endpoint: https://REDACTED.k8s.ondigitalocean.com + id: REDACTED + ipv4: REDACTED + maintenance_policy: + day: any + duration: 4h0m0s + start_time: '15:00' + name: hacktoberfest + node_pools: + - auto_scale: false + count: 1 + id: REDACTED + labels: null + max_nodes: 0 + min_nodes: 0 + name: hacktoberfest-workers + nodes: + - created_at: '2020-09-27T00:55:37Z' + droplet_id: '209555245' + id: REDACTED + name: hacktoberfest-workers-3tdq1 + status: + state: running + updated_at: '2020-09-27T00:58:36Z' + size: s-1vcpu-2gb + tags: + - k8s + - k8s:REDACTED + - k8s:worker + taints: [] + region: nyc1 + service_subnet: 10.245.0.0/16 + status: + state: running + surge_upgrade: false + tags: + - k8s + - k8s:REDACTED + updated_at: '2020-09-27T01:00:37Z' + version: 1.18.8-do.1 + vpc_uuid: REDACTED +""" + + +import traceback +import time +import json +from traceback import format_exc +from ansible.module_utils._text import to_native +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +class DOKubernetes(object): + def __init__(self, module): + self.rest = DigitalOceanHelper(module) + self.module = module + # Pop these values so we don't include them in the POST data + self.return_kubeconfig = self.module.params.pop("return_kubeconfig", False) + self.wait = self.module.params.pop("wait", True) + self.wait_timeout = self.module.params.pop("wait_timeout", 600) + self.module.params.pop("oauth_token") + self.cluster_id = None + + def get_by_id(self): + """Returns an existing DigitalOcean Kubernetes cluster matching on id""" + response = self.rest.get("kubernetes/clusters/{0}".format(self.cluster_id)) + json_data = response.json + if response.status_code == 200: + return json_data + return None + + def get_all_clusters(self): + """Returns all DigitalOcean Kubernetes clusters""" + response = self.rest.get("kubernetes/clusters") + json_data = response.json + if response.status_code == 200: + return json_data + return None + + def get_by_name(self, cluster_name): + """Returns an existing DigitalOcean Kubernetes cluster matching on name""" + if not cluster_name: + return None + clusters = self.get_all_clusters() + for cluster in clusters["kubernetes_clusters"]: + if cluster["name"] == cluster_name: + return cluster + return None + + def get_kubernetes_kubeconfig(self): + """Returns the kubeconfig for an existing DigitalOcean Kubernetes cluster""" + response = self.rest.get( + "kubernetes/clusters/{0}/kubeconfig".format(self.cluster_id) + ) + if response.status_code == 200: + return response.body + else: + self.module.fail_json(msg="Failed to retrieve kubeconfig") + + def get_kubernetes(self): + """Returns an existing DigitalOcean Kubernetes cluster by name""" + json_data = self.get_by_name(self.module.params["name"]) + if json_data: + self.cluster_id = json_data["id"] + return json_data + else: + return None + + def get_kubernetes_options(self): + """Fetches DigitalOcean Kubernetes options: regions, sizes, versions. + API reference: https://docs.digitalocean.com/reference/api/api-reference/#operation/list_kubernetes_options + """ + response = self.rest.get("kubernetes/options") + json_data = response.json + if response.status_code == 200: + return json_data + return None + + def ensure_running(self): + """Waits for the newly created DigitalOcean Kubernetes cluster to be running""" + end_time = time.monotonic() + self.wait_timeout + while time.monotonic() < end_time: + cluster = self.get_by_id() + if cluster["kubernetes_cluster"]["status"]["state"] == "running": + return cluster + time.sleep(10) + self.module.fail_json(msg="Wait for Kubernetes cluster to be running") + + def create(self): + """Creates a DigitalOcean Kubernetes cluster + API reference: https://docs.digitalocean.com/reference/api/api-reference/#operation/create_kubernetes_cluster + """ + # Get valid Kubernetes options (regions, sizes, versions) + kubernetes_options = self.get_kubernetes_options()["options"] + # Validate region + valid_regions = [str(x["slug"]) for x in kubernetes_options["regions"]] + if self.module.params.get("region") not in valid_regions: + self.module.fail_json( + msg="Invalid region {0} (valid regions are {1})".format( + self.module.params.get("region"), ", ".join(valid_regions) + ) + ) + # Validate version + valid_versions = [str(x["slug"]) for x in kubernetes_options["versions"]] + valid_versions.append("latest") + if self.module.params.get("version") not in valid_versions: + self.module.fail_json( + msg="Invalid version {0} (valid versions are {1})".format( + self.module.params.get("version"), ", ".join(valid_versions) + ) + ) + # Validate size + valid_sizes = [str(x["slug"]) for x in kubernetes_options["sizes"]] + for node_pool in self.module.params.get("node_pools"): + if node_pool["size"] not in valid_sizes: + self.module.fail_json( + msg="Invalid size {0} (valid sizes are {1})".format( + node_pool["size"], ", ".join(valid_sizes) + ) + ) + + # Create the Kubernetes cluster + json_data = self.get_kubernetes() + if json_data: + # Add the kubeconfig to the return + if self.return_kubeconfig: + json_data["kubeconfig"] = self.get_kubernetes_kubeconfig() + self.module.exit_json(changed=False, data=json_data) + if self.module.check_mode: + self.module.exit_json(changed=True) + request_params = dict(self.module.params) + response = self.rest.post("kubernetes/clusters", data=request_params) + json_data = response.json + if response.status_code >= 400: + self.module.fail_json(changed=False, msg=json_data) + # Set the cluster_id + self.cluster_id = json_data["kubernetes_cluster"]["id"] + if self.wait: + json_data = self.ensure_running() + # Add the kubeconfig to the return + if self.return_kubeconfig: + json_data["kubeconfig"] = self.get_kubernetes_kubeconfig() + self.module.exit_json(changed=True, data=json_data["kubernetes_cluster"]) + + def delete(self): + """Deletes a DigitalOcean Kubernetes cluster + API reference: https://docs.digitalocean.com/reference/api/api-reference/#operation/delete_kubernetes_cluster + """ + json_data = self.get_kubernetes() + if json_data: + if self.module.check_mode: + self.module.exit_json(changed=True) + response = self.rest.delete( + "kubernetes/clusters/{0}".format(json_data["id"]) + ) + if response.status_code == 204: + self.module.exit_json( + changed=True, data=json_data, msg="Kubernetes cluster deleted" + ) + self.module.fail_json( + changed=False, msg="Failed to delete Kubernetes cluster" + ) + json_data = response.json + else: + self.module.exit_json(changed=False, msg="Kubernetes cluster not found") + + +def run(module): + state = module.params.pop("state") + cluster = DOKubernetes(module) + if state == "present": + cluster.create() + elif state == "absent": + cluster.delete() + + +def main(): + module = AnsibleModule( + argument_spec=dict( + state=dict(choices=["present", "absent"], default="present"), + oauth_token=dict( + aliases=["API_TOKEN"], + no_log=True, + fallback=( + env_fallback, + ["DO_API_TOKEN", "DO_API_KEY", "DO_OAUTH_TOKEN"], + ), + required=True, + ), + name=dict(type="str", required=True), + region=dict(aliases=["region_id"], default="nyc1"), + version=dict(type="str", default="latest"), + auto_upgrade=dict(type="bool", default=False), + surge_upgrade=dict(type="bool", default=False), + tags=dict(type="list", elements="str"), + maintenance_policy=dict(type="dict"), + node_pools=dict( + type="list", + elements="dict", + default=[ + { + "name": "worker-pool", + "size": "s-1vcpu-2gb", + "count": 1, + "tags": [], + "labels": {}, + "taints": [], + "auto_scale": False, + "min_nodes": 0, + "max_nodes": 0, + } + ], + ), + vpc_uuid=dict(type="str"), + return_kubeconfig=dict(type="bool", default=False), + wait=dict(type="bool", default=True), + wait_timeout=dict(type="int", default=600), + ha=dict(type="bool", default=False), + ), + required_if=( + [ + ("state", "present", ["name", "region", "version", "node_pools"]), + ] + ), + supports_check_mode=True, + ) + + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_kubernetes_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_kubernetes_info.py new file mode 100644 index 00000000..d60e9b4a --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_kubernetes_info.py @@ -0,0 +1,234 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2020, 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 + + +DOCUMENTATION = r""" +--- +module: digital_ocean_kubernetes_info +short_description: Returns information about an existing DigitalOcean Kubernetes cluster +description: + - Returns information about an existing DigitalOcean Kubernetes cluster. +version_added: 1.3.0 +author: Mark Mercado (@mamercad) +options: + oauth_token: + description: + - DigitalOcean OAuth token; can be specified in C(DO_API_KEY), C(DO_API_TOKEN), or C(DO_OAUTH_TOKEN) environment variables + type: str + aliases: ['API_TOKEN'] + required: true + name: + description: + - A human-readable name for a Kubernetes cluster. + type: str + required: true + return_kubeconfig: + description: + - Controls whether or not to return the C(kubeconfig). + type: bool + required: false + default: false +""" + + +EXAMPLES = r""" +- name: Get information about an existing DigitalOcean Kubernetes cluster + community.digitalocean.digital_ocean_kubernetes_info: + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + name: hacktoberfest + return_kubeconfig: yes + register: my_cluster + +- ansible.builtin.debug: + msg: "Cluster name is {{ my_cluster.data.name }}, ID is {{ my_cluster.data.id }}" + +- ansible.builtin.debug: + msg: "Cluster kubeconfig is {{ my_cluster.data.kubeconfig }}" +""" + + +# Digital Ocean API info https://docs.digitalocean.com/reference/api/api-reference/#operation/list_all_kubernetes_clusters +# The only variance from the documented response is that the kubeconfig is (if return_kubeconfig is True) merged in at data['kubeconfig'] +RETURN = r""" +data: + description: A DigitalOcean Kubernetes cluster (and optional C(kubeconfig)) + returned: changed + type: dict + sample: + auto_upgrade: false + cluster_subnet: 10.244.0.0/16 + created_at: '2020-09-26T21:36:18Z' + endpoint: https://REDACTED.k8s.ondigitalocean.com + id: REDACTED + ipv4: REDACTED + kubeconfig: |- + apiVersion: v1 + clusters: + - cluster: + certificate-authority-data: REDACTED + server: https://REDACTED.k8s.ondigitalocean.com + name: do-nyc1-hacktoberfest + contexts: + - context: + cluster: do-nyc1-hacktoberfest + user: do-nyc1-hacktoberfest-admin + name: do-nyc1-hacktoberfest + current-context: do-nyc1-hacktoberfest + kind: Config + preferences: {} + users: + - name: do-nyc1-hacktoberfest-admin + user: + token: REDACTED + maintenance_policy: + day: any + duration: 4h0m0s + start_time: '13:00' + name: hacktoberfest + node_pools: + - auto_scale: false + count: 1 + id: REDACTED + labels: null + max_nodes: 0 + min_nodes: 0 + name: hacktoberfest-workers + nodes: + - created_at: '2020-09-26T21:36:18Z' + droplet_id: 'REDACTED' + id: REDACTED + name: hacktoberfest-workers-3tv46 + status: + state: running + updated_at: '2020-09-26T21:40:28Z' + size: s-1vcpu-2gb + tags: + - k8s + - k8s:REDACTED + - k8s:worker + taints: [] + region: nyc1 + service_subnet: 10.245.0.0/16 + status: + state: running + surge_upgrade: false + tags: + - k8s + - k8s:REDACTED + updated_at: '2020-09-26T21:42:29Z' + version: 1.18.8-do.0 + vpc_uuid: REDACTED +""" + + +import traceback +import time +import json +from traceback import format_exc +from ansible.module_utils._text import to_native +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +class DOKubernetesInfo(object): + def __init__(self, module): + self.rest = DigitalOceanHelper(module) + self.module = module + # Pop these values so we don't include them in the POST data + self.module.params.pop("oauth_token") + self.return_kubeconfig = self.module.params.pop("return_kubeconfig") + self.cluster_id = None + + def get_by_id(self): + """Returns an existing DigitalOcean Kubernetes cluster matching on id""" + response = self.rest.get("kubernetes/clusters/{0}".format(self.cluster_id)) + json_data = response.json + if response.status_code == 200: + return json_data + return None + + def get_all_clusters(self): + """Returns all DigitalOcean Kubernetes clusters""" + response = self.rest.get("kubernetes/clusters") + json_data = response.json + if response.status_code == 200: + return json_data + return None + + def get_by_name(self, cluster_name): + """Returns an existing DigitalOcean Kubernetes cluster matching on name""" + if not cluster_name: + return None + clusters = self.get_all_clusters() + for cluster in clusters["kubernetes_clusters"]: + if cluster["name"] == cluster_name: + return cluster + return None + + def get_kubernetes_kubeconfig(self): + """Returns the kubeconfig for an existing DigitalOcean Kubernetes cluster""" + response = self.rest.get( + "kubernetes/clusters/{0}/kubeconfig".format(self.cluster_id) + ) + if response.status_code == 200: + return response.body + else: + self.module.fail_json(msg="Failed to retrieve kubeconfig") + + def get_kubernetes(self): + """Returns an existing DigitalOcean Kubernetes cluster by name""" + json_data = self.get_by_name(self.module.params["name"]) + if json_data: + self.cluster_id = json_data["id"] + return json_data + else: + return None + + def get(self): + """Fetches an existing DigitalOcean Kubernetes cluster + API reference: https://docs.digitalocean.com/reference/api/api-reference/#operation/list_all_kubernetes_clusters + """ + json_data = self.get_kubernetes() + if json_data: + if self.return_kubeconfig: + json_data["kubeconfig"] = self.get_kubernetes_kubeconfig() + self.module.exit_json(changed=False, data=json_data) + self.module.fail_json(changed=False, msg="Kubernetes cluster not found") + + +def run(module): + cluster = DOKubernetesInfo(module) + cluster.get() + + +def main(): + module = AnsibleModule( + argument_spec=dict( + oauth_token=dict( + aliases=["API_TOKEN"], + no_log=True, + fallback=( + env_fallback, + ["DO_API_TOKEN", "DO_API_KEY", "DO_OAUTH_TOKEN"], + ), + required=True, + ), + name=dict(type="str", required=True), + return_kubeconfig=dict(type="bool", default=False), + ), + supports_check_mode=True, + ) + + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_load_balancer.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_load_balancer.py new file mode 100644 index 00000000..ecc9efa4 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_load_balancer.py @@ -0,0 +1,881 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Mark Mercado <mamercad@gmail.com> +# +# 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 = r""" +--- +module: digital_ocean_load_balancer +version_added: 1.10.0 +short_description: Manage DigitalOcean Load Balancers +description: + - Manage DigitalOcean Load Balancers +author: "Mark Mercado (@mamercad)" +options: + state: + description: + - The usual, C(present) to create, C(absent) to destroy + type: str + choices: ["present", "absent"] + default: present + name: + description: + - A human-readable name for a load balancer instance. + - Required and must be unique (current API documentation is not up-to-date for this parameter). + type: str + required: true + size: + description: + - The size of the load balancer. + - The available sizes are C(lb-small), C(lb-medium), or C(lb-large). + - You can resize load balancers after creation up to once per hour. + - You cannot resize a load balancer within the first hour of its creation. + - This field has been replaced by the C(size_unit) field for all regions except in C(ams2), C(nyc2), and C(sfo1). + - Each available load balancer size now equates to the load balancer having a set number of nodes. + - The formula is C(lb-small) = 1 node, C(lb-medium) = 3 nodes, C(lb-large) = 6 nodes. + required: false + type: str + choices: ["lb-small", "lb-medium", "lb-large"] + default: lb-small + size_unit: + description: + - How many nodes the load balancer contains. + - Each additional node increases the load balancer's ability to manage more connections. + - Load balancers can be scaled up or down, and you can change the number of nodes after creation up to once per hour. + - This field is currently not available in the C(ams2), C(nyc2), or C(sfo1) regions. + - Use the C(size) field to scale load balancers that reside in these regions. + - The value must be in the range 1-100. + required: false + type: int + default: 1 + droplet_ids: + description: + - An array containing the IDs of the Droplets assigned to the load balancer. + - Required when creating load balancers. + - Mutually exclusive with tag, you can either define tag or droplet_ids but not both. + required: false + type: list + elements: int + tag: + description: + - A tag associated with the droplets that you want to dynamically assign to the load balancer. + - Required when creating load balancers. + - Mutually exclusive with droplet_ids, you can either define tag or droplet_ids but not both. + required: false + type: str + region: + description: + - The slug identifier for the region where the resource will initially be available. + required: false + type: str + aliases: ["region_id"] + forwarding_rules: + description: + - An array of objects specifying the forwarding rules for a load balancer. + - Required when creating load balancers. + required: false + type: list + elements: dict + suboptions: + entry_protocol: + type: str + description: Entry protocol + default: http + entry_port: + type: int + description: Entry port + default: 8080 + target_protocol: + type: str + description: Target protocol + default: http + target_port: + type: int + description: Target port + default: 8080 + certificate_id: + type: str + description: Certificate ID + default: "" + tls_passthrough: + type: bool + description: TLS passthrough + default: false + default: + - entry_protocol: http + entry_port: 8080 + target_protocol: http + target_port: 8080 + certificate_id: "" + tls_passthrough: false + health_check: + description: + - An object specifying health check settings for the load balancer. + required: false + type: dict + suboptions: + protocol: + description: Protocol + type: str + required: false + default: http + port: + description: Port + type: int + required: false + default: 80 + path: + description: Path + type: str + required: false + default: / + check_interval_seconds: + description: Check interval seconds + type: int + required: false + default: 10 + response_timeout_seconds: + description: Response timeout seconds + type: int + required: false + default: 5 + healthy_threshold: + description: Healthy threshold + type: int + required: false + default: 5 + unhealthy_threshold: + description: Unhealthy threshold + type: int + required: false + default: 3 + default: + protocol: http + port: 80 + path: / + check_interval_seconds: 10 + response_timeout_seconds: 5 + healthy_threshold: 5 + unhealthy_threshold: 3 + sticky_sessions: + description: + - An object specifying sticky sessions settings for the load balancer. + required: false + type: dict + suboptions: + type: + description: Type + type: str + required: false + default: none + default: + type: none + redirect_http_to_https: + description: + - A boolean value indicating whether HTTP requests to the load balancer on port 80 will be redirected to HTTPS on port 443. + type: bool + required: false + default: false + enable_proxy_protocol: + description: + - A boolean value indicating whether PROXY Protocol is in use. + type: bool + required: false + default: false + enable_backend_keepalive: + description: + - A boolean value indicating whether HTTP keepalive connections are maintained to target Droplets. + type: bool + required: false + default: false + vpc_uuid: + description: + - A string specifying the UUID of the VPC to which the load balancer is assigned. + - If unspecified, uses the default VPC in the region. + type: str + required: false + wait: + description: + - Wait for the Load Balancer to be running before returning. + type: bool + required: false + default: true + wait_timeout: + description: + - How long before wait gives up, in seconds, when creating a Load Balancer. + type: int + default: 600 + project_name: + aliases: ["project"] + description: + - Project to assign the resource to (project name, not UUID). + - Defaults to the default project of the account (empty string). + - Currently only supported when creating. + type: str + required: false + default: "" +extends_documentation_fragment: + - community.digitalocean.digital_ocean.documentation +""" + + +EXAMPLES = r""" +- name: Create a Load Balancer + community.digitalocean.digital_ocean_load_balancer: + state: present + name: test-loadbalancer-1 + droplet_ids: + - 12345678 + region: nyc1 + forwarding_rules: + - entry_protocol: http + entry_port: 8080 + target_protocol: http + target_port: 8080 + certificate_id: "" + tls_passthrough: false + +- name: Create a Load Balancer (and assign to Project "test") + community.digitalocean.digital_ocean_load_balancer: + state: present + name: test-loadbalancer-1 + droplet_ids: + - 12345678 + region: nyc1 + forwarding_rules: + - entry_protocol: http + entry_port: 8080 + target_protocol: http + target_port: 8080 + certificate_id: "" + tls_passthrough: false + project: test + +- name: Create a Load Balancer and associate it with a tag + community.digitalocean.digital_ocean_load_balancer: + state: present + name: test-loadbalancer-1 + tag: test-tag + region: tor1 +""" + + +RETURN = r""" +data: + description: A DigitalOcean Load Balancer + returned: changed + type: dict + sample: + load_balancer: + algorithm: round_robin + created_at: '2021-08-22T14:23:41Z' + droplet_ids: + - 261172461 + enable_backend_keepalive: false + enable_proxy_protocol: false + forwarding_rules: + - certificate_id: '' + entry_port: 8080 + entry_protocol: http + target_port: 8080 + target_protocol: http + tls_passthrough: false + health_check: + check_interval_seconds: 10 + healthy_threshold: 5 + path: / + port: 80 + protocol: http + response_timeout_seconds: 5 + unhealthy_threshold: 3 + id: b4fdb507-70e8-4325-a89e-d02271b93618 + ip: 159.203.150.113 + name: test-loadbalancer-1 + redirect_http_to_https: false + region: + available: true + features: + - backups + - ipv6 + - metadata + - install_agent + - storage + - image_transfer + name: New York 3 + sizes: + - s-1vcpu-1gb + - s-1vcpu-1gb-amd + - s-1vcpu-1gb-intel + - s-1vcpu-2gb + - s-1vcpu-2gb-amd + - s-1vcpu-2gb-intel + - s-2vcpu-2gb + - s-2vcpu-2gb-amd + - s-2vcpu-2gb-intel + - s-2vcpu-4gb + - s-2vcpu-4gb-amd + - s-2vcpu-4gb-intel + - s-4vcpu-8gb + - c-2 + - c2-2vcpu-4gb + - s-4vcpu-8gb-amd + - s-4vcpu-8gb-intel + - g-2vcpu-8gb + - gd-2vcpu-8gb + - s-8vcpu-16gb + - m-2vcpu-16gb + - c-4 + - c2-4vcpu-8gb + - s-8vcpu-16gb-amd + - s-8vcpu-16gb-intel + - m3-2vcpu-16gb + - g-4vcpu-16gb + - so-2vcpu-16gb + - m6-2vcpu-16gb + - gd-4vcpu-16gb + - so1_5-2vcpu-16gb + - m-4vcpu-32gb + - c-8 + - c2-8vcpu-16gb + - m3-4vcpu-32gb + - g-8vcpu-32gb + - so-4vcpu-32gb + - m6-4vcpu-32gb + - gd-8vcpu-32gb + - so1_5-4vcpu-32gb + - m-8vcpu-64gb + - c-16 + - c2-16vcpu-32gb + - m3-8vcpu-64gb + - g-16vcpu-64gb + - so-8vcpu-64gb + - m6-8vcpu-64gb + - gd-16vcpu-64gb + - so1_5-8vcpu-64gb + - m-16vcpu-128gb + - c-32 + - c2-32vcpu-64gb + - m3-16vcpu-128gb + - m-24vcpu-192gb + - g-32vcpu-128gb + - so-16vcpu-128gb + - m6-16vcpu-128gb + - gd-32vcpu-128gb + - m3-24vcpu-192gb + - g-40vcpu-160gb + - so1_5-16vcpu-128gb + - m-32vcpu-256gb + - gd-40vcpu-160gb + - so-24vcpu-192gb + - m6-24vcpu-192gb + - m3-32vcpu-256gb + - so1_5-24vcpu-192gb + - m6-32vcpu-256gb + slug: nyc3 + size: lb-small + status: active + sticky_sessions: + type: none + tag: '' + vpc_uuid: b8fd9a58-d93d-4329-b54a-78a397d64855 +msg: + description: Informational or error message encountered during execution + returned: changed + type: str + sample: No project named test2 found +assign_status: + description: Assignment status (ok, not_found, assigned, already_assigned, service_down) + returned: changed + type: str + sample: assigned +resources: + description: Resource assignment involved in project assignment + returned: changed + type: dict + sample: + assigned_at: '2021-10-25T17:39:38Z' + links: + self: https://api.digitalocean.com/v2/load_balancers/17d171d0-8a8b-4251-9c18-c96cc515d36d + status: assigned + urn: do:loadbalancer:17d171d0-8a8b-4251-9c18-c96cc515d36d +""" + + +import time +from ansible.module_utils._text import to_native +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, + DigitalOceanProjects, +) + + +class DOLoadBalancer(object): + + # Regions which use 'size' versus 'size_unit' + size_regions = {"ams2", "nyc2", "sfo1"} + all_sizes = {"lb-small", "lb-medium", "lb-large"} + default_size = "lb-small" + min_size_unit = 1 + max_size_unit = 100 + default_size_unit = 1 + + def __init__(self, module): + self.rest = DigitalOceanHelper(module) + self.module = module + self.id = None + self.name = self.module.params.get("name") + self.region = self.module.params.get("region") + + # Handle size versus size_unit + if self.region in DOLoadBalancer.size_regions: + self.module.params.pop("size_unit") + # Ensure that we have size + size = self.module.params.get("size", None) + if not size: + self.module.fail_json(msg="Missing required 'size' parameter") + elif size not in DOLoadBalancer.all_sizes: + self.module.fail_json( + msg="Invalid 'size' parameter '{0}', must be one of: {1}".format( + size, ", ".join(DOLoadBalancer.all_sizes) + ) + ) + else: + self.module.params.pop("size") + # Ensure that we have size_unit + size_unit = self.module.params.get("size_unit", None) + if not size_unit: + self.module.fail_json(msg="Missing required 'size_unit' parameter") + elif ( + size_unit < DOLoadBalancer.min_size_unit + or size_unit > DOLoadBalancer.max_size_unit + ): + self.module.fail_json( + msg="Invalid 'size_unit' parameter '{0}', must be in range: {1}-{2}".format( + size_unit, + DOLoadBalancer.min_size_unit, + DOLoadBalancer.max_size_unit, + ) + ) + + self.updates = [] + + # Pop these values so we don't include them in the POST data + self.module.params.pop("oauth_token") + self.wait = self.module.params.pop("wait", True) + self.wait_timeout = self.module.params.pop("wait_timeout", 600) + if self.module.params.get("project"): + # only load for non-default project assignments + self.projects = DigitalOceanProjects(module, self.rest) + + def get_by_id(self): + """Fetch an existing DigitalOcean Load Balancer (by id) + API reference: https://docs.digitalocean.com/reference/api/api-reference/#operation/get_load_balancer + """ + response = self.rest.get("load_balancers/{0}".format(self.id)) + json_data = response.json + if response.status_code == 200: + # Found one with the given id: + lb = json_data.get("load_balancer", None) + if lb is not None: + self.lb = lb + return lb + else: + self.module.fail_json( + msg="Unexpected error; please file a bug: get_by_id" + ) + return None + + def get_by_name(self): + """Fetch all existing DigitalOcean Load Balancers + API reference: https://docs.digitalocean.com/reference/api/api-reference/#operation/list_all_load_balancers + """ + page = 1 + while page is not None: + response = self.rest.get("load_balancers?page={0}".format(page)) + json_data = response.json + if json_data is None: + self.module.fail_json( + msg="Empty response from the DigitalOcean API; please try again or open a bug if it never succeeds." + ) + if response.status_code == 200: + lbs = json_data.get("load_balancers", []) + for lb in lbs: + # Found one with the same name: + name = lb.get("name", None) + if name == self.name: + # Make sure the region is the same! + region = lb.get("region", None) + if region is not None: + region_slug = region.get("slug", None) + if region_slug is not None: + if region_slug == self.region: + self.lb = lb + return lb + else: + self.module.fail_json( + msg="Cannot change load balancer region -- delete and re-create" + ) + else: + self.module.fail_json( + msg="Unexpected error; please file a bug: get_by_name" + ) + else: + self.module.fail_json( + msg="Unexpected error; please file a bug: get_by_name" + ) + if ( + "links" in json_data + and "pages" in json_data["links"] + and "next" in json_data["links"]["pages"] + ): + page += 1 + else: + page = None + else: + self.module.fail_json( + msg="Unexpected error; please file a bug: get_by_name" + ) + return None + + def ensure_active(self): + """Wait for the existing Load Balancer to be active""" + end_time = time.monotonic() + self.wait_timeout + while time.monotonic() < end_time: + if self.get_by_id(): + status = self.lb.get("status", None) + if status is not None: + if status == "active": + return True + else: + self.module.fail_json( + msg="Unexpected error; please file a bug: ensure_active" + ) + else: + self.module.fail_json( + msg="Load Balancer {0} in {1} not found".format( + self.id, self.region + ) + ) + time.sleep(10) + self.module.fail_json( + msg="Timed out waiting for Load Balancer {0} in {1} to be active".format( + self.id, self.region + ) + ) + + def is_same(self, found_lb): + """Checks if exising Load Balancer is the same as requested""" + + check_attributes = [ + "droplet_ids", + "size", + "size_unit", + "forwarding_rules", + "health_check", + "sticky_sessions", + "redirect_http_to_https", + "enable_proxy_protocol", + "enable_backend_keepalive", + ] + + lb_region = found_lb.get("region", None) + if not lb_region: + self.module.fail_json( + msg="Unexpected error; please file a bug should this persist: empty load balancer region" + ) + + lb_region_slug = lb_region.get("slug", None) + if not lb_region_slug: + self.module.fail_json( + msg="Unexpected error; please file a bug should this persist: empty load balancer region slug" + ) + + for attribute in check_attributes: + if ( + attribute == "size" + and lb_region_slug not in DOLoadBalancer.size_regions + ): + continue + if ( + attribute == "size_unit" + and lb_region_slug in DOLoadBalancer.size_regions + ): + continue + if self.module.params.get(attribute, None) != found_lb.get(attribute, None): + self.updates.append(attribute) + + # Check if the VPC needs changing. + vpc_uuid = self.lb.get("vpc_uuid", None) + if vpc_uuid is not None: + if vpc_uuid != found_lb.get("vpc_uuid", None): + self.updates.append("vpc_uuid") + + if len(self.updates): + return False + else: + return True + + def update(self): + """Updates a DigitalOcean Load Balancer + API reference: https://docs.digitalocean.com/reference/api/api-reference/#operation/update_load_balancer + """ + request_params = dict(self.module.params) + self.id = self.lb.get("id", None) + self.name = self.lb.get("name", None) + self.vpc_uuid = self.lb.get("vpc_uuid", None) + if self.id is not None and self.name is not None and self.vpc_uuid is not None: + request_params["vpc_uuid"] = self.vpc_uuid + response = self.rest.put( + "load_balancers/{0}".format(self.id), data=request_params + ) + json_data = response.json + if response.status_code == 200: + self.module.exit_json( + changed=True, + msg="Load Balancer {0} ({1}) in {2} updated: {3}".format( + self.name, self.id, self.region, ", ".join(self.updates) + ), + ) + else: + self.module.fail_json( + changed=False, + msg="Error updating Load Balancer {0} ({1}) in {2}: {3}".format( + self.name, self.id, self.region, json_data["message"] + ), + ) + else: + self.module.fail_json(msg="Unexpected error; please file a bug: update") + + def create(self): + """Creates a DigitalOcean Load Balancer + API reference: https://docs.digitalocean.com/reference/api/api-reference/#operation/create_load_balancer + """ + + # Check if it exists already (the API docs aren't up-to-date right now, + # "name" is required and must be unique across the account. + found_lb = self.get_by_name() + if found_lb is not None: + # Do we need to update it? + if not self.is_same(found_lb): + if self.module.check_mode: + self.module.exit_json( + changed=False, + msg="Load Balancer {0} already exists in {1} (and needs changes)".format( + self.name, self.region + ), + data={"load_balancer": found_lb}, + ) + else: + self.update() + else: + self.module.exit_json( + changed=False, + msg="Load Balancer {0} already exists in {1} (and needs no changes)".format( + self.name, self.region + ), + data={"load_balancer": found_lb}, + ) + + # Check mode. + if self.module.check_mode: + self.module.exit_json( + changed=False, + msg="Would create Load Balancer {0} in {1}".format( + self.name, self.region + ), + ) + + # Create it. + request_params = dict(self.module.params) + response = self.rest.post("load_balancers", data=request_params) + json_data = response.json + if response.status_code != 202: + self.module.fail_json( + msg="Failed creating Load Balancer {0} in {1}: {2}".format( + self.name, self.region, json_data["message"] + ) + ) + + # Store it. + lb = json_data.get("load_balancer", None) + if lb is None: + self.module.fail_json( + msg="Unexpected error; please file a bug: create empty lb" + ) + + self.id = lb.get("id", None) + if self.id is None: + self.module.fail_json( + msg="Unexpected error; please file a bug: create missing id" + ) + + if self.wait: + self.ensure_active() + + project_name = self.module.params.get("project") + if project_name: # empty string is the default project, skip project assignment + urn = "do:loadbalancer:{0}".format(self.id) + ( + assign_status, + error_message, + resources, + ) = self.projects.assign_to_project(project_name, urn) + self.module.exit_json( + changed=True, + data=json_data, + msg=error_message, + assign_status=assign_status, + resources=resources, + ) + else: + self.module.exit_json(changed=True, data=json_data) + + def delete(self): + """Deletes a DigitalOcean Load Balancer + API reference: https://docs.digitalocean.com/reference/api/api-reference/#operation/delete_load_balancer + """ + + lb = self.get_by_name() + if lb is not None: + id = lb.get("id", None) + name = lb.get("name", None) + lb_region = lb.get("region", None) + if not lb_region: + self.module.fail_json( + msg="Unexpected error; please file a bug: delete missing region" + ) + lb_region_slug = lb_region.get("slug", None) + if id is None or name is None or lb_region_slug is None: + self.module.fail_json( + msg="Unexpected error; please file a bug: delete missing id, name, or region slug" + ) + else: + response = self.rest.delete("load_balancers/{0}".format(id)) + json_data = response.json + if response.status_code == 204: + # Response body should be empty + self.module.exit_json( + changed=True, + msg="Load Balancer {0} ({1}) in {2} deleted".format( + name, id, lb_region_slug + ), + ) + else: + message = json_data.get( + "message", "Empty failure message from the DigitalOcean API!" + ) + self.module.fail_json( + changed=False, + msg="Failed to delete Load Balancer {0} ({1}) in {2}: {3}".format( + name, id, lb_region_slug, message + ), + ) + else: + self.module.fail_json( + changed=False, + msg="Load Balancer {0} not found in {1}".format(self.name, self.region), + ) + + +def run(module): + state = module.params.pop("state") + lb = DOLoadBalancer(module) + if state == "present": + lb.create() + elif state == "absent": + lb.delete() + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + state=dict(choices=["present", "absent"], default="present"), + name=dict(type="str", required=True), + size=dict( + type="str", + choices=list(DOLoadBalancer.all_sizes), + required=False, + default=DOLoadBalancer.default_size, + ), + size_unit=dict( + type="int", + required=False, + default=DOLoadBalancer.default_size_unit, + ), + droplet_ids=dict(type="list", elements="int", required=False), + tag=dict(type="str", required=False), + region=dict( + aliases=["region_id"], + required=False, + ), + forwarding_rules=dict( + type="list", + elements="dict", + required=False, + default=[ + { + "entry_protocol": "http", + "entry_port": 8080, + "target_protocol": "http", + "target_port": 8080, + "certificate_id": "", + "tls_passthrough": False, + } + ], + ), + health_check=dict( + type="dict", + required=False, + default=dict( + { + "protocol": "http", + "port": 80, + "path": "/", + "check_interval_seconds": 10, + "response_timeout_seconds": 5, + "healthy_threshold": 5, + "unhealthy_threshold": 3, + } + ), + ), + sticky_sessions=dict( + type="dict", required=False, default=dict({"type": "none"}) + ), + redirect_http_to_https=dict(type="bool", required=False, default=False), + enable_proxy_protocol=dict(type="bool", required=False, default=False), + enable_backend_keepalive=dict(type="bool", required=False, default=False), + vpc_uuid=dict(type="str", required=False), + wait=dict(type="bool", default=True), + wait_timeout=dict(type="int", default=600), + project_name=dict(type="str", aliases=["project"], required=False, default=""), + ) + + module = AnsibleModule( + argument_spec=argument_spec, + required_if=( + [ + ("state", "present", ["forwarding_rules"]), + ("state", "present", ["tag", "droplet_ids"], True), + ] + ), + # Droplet ID and tag are mutually exclusive, check that both have not been defined + mutually_exclusive=( + [ + ("tag", "droplet_ids"), + ("size", "size_unit"), + ] + ), + supports_check_mode=True, + ) + + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_load_balancer_facts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_load_balancer_facts.py new file mode 100644 index 00000000..bc1f9250 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_load_balancer_facts.py @@ -0,0 +1,128 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_load_balancer_info +short_description: Gather information about DigitalOcean load balancers +description: + - This module can be used to gather information about DigitalOcean provided load balancers. + - This module was called C(digital_ocean_load_balancer_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + load_balancer_id: + description: + - Load balancer ID that can be used to identify and reference a load_balancer. + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all load balancers + community.digitalocean.digital_ocean_load_balancer_info: + oauth_token: "{{ oauth_token }}" + +- name: Gather information about load balancer with given id + community.digitalocean.digital_ocean_load_balancer_info: + oauth_token: "{{ oauth_token }}" + load_balancer_id: "4de7ac8b-495b-4884-9a69-1050c6793cd6" + +- name: Get name from load balancer id + community.digitalocean.digital_ocean_load_balancer_info: + register: resp_out +- set_fact: + load_balancer_name: "{{ item.name }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?id=='4de7ac8b-495b-4884-9a69-1050c6793cd6']" +- debug: + var: load_balancer_name +""" + + +RETURN = r""" +data: + description: DigitalOcean Load balancer information + returned: success + type: list + elements: dict + sample: [ + { + "id": "4de7ac8b-495b-4884-9a69-1050c6793cd6", + "name": "example-lb-01", + "ip": "104.131.186.241", + "algorithm": "round_robin", + "status": "new", + "created_at": "2017-02-01T22:22:58Z", + ... + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + load_balancer_id = module.params.get("load_balancer_id", None) + rest = DigitalOceanHelper(module) + + base_url = "load_balancers" + if load_balancer_id is not None: + response = rest.get("%s/%s" % (base_url, load_balancer_id)) + status_code = response.status_code + + if status_code != 200: + module.fail_json(msg="Failed to retrieve load balancers for DigitalOcean") + + load_balancer = [response.json["load_balancer"]] + else: + load_balancer = rest.get_paginated_data( + base_url=base_url + "?", data_key_name="load_balancers" + ) + + module.exit_json(changed=False, data=load_balancer) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + load_balancer_id=dict(type="str", required=False), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_load_balancer_facts", + "community.digitalocean.digital_ocean_load_balancer_facts", + ): + module.deprecate( + "The 'digital_ocean_load_balancer_facts' module has been renamed to 'digital_ocean_load_balancer_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_load_balancer_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_load_balancer_info.py new file mode 100644 index 00000000..bc1f9250 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_load_balancer_info.py @@ -0,0 +1,128 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_load_balancer_info +short_description: Gather information about DigitalOcean load balancers +description: + - This module can be used to gather information about DigitalOcean provided load balancers. + - This module was called C(digital_ocean_load_balancer_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + load_balancer_id: + description: + - Load balancer ID that can be used to identify and reference a load_balancer. + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all load balancers + community.digitalocean.digital_ocean_load_balancer_info: + oauth_token: "{{ oauth_token }}" + +- name: Gather information about load balancer with given id + community.digitalocean.digital_ocean_load_balancer_info: + oauth_token: "{{ oauth_token }}" + load_balancer_id: "4de7ac8b-495b-4884-9a69-1050c6793cd6" + +- name: Get name from load balancer id + community.digitalocean.digital_ocean_load_balancer_info: + register: resp_out +- set_fact: + load_balancer_name: "{{ item.name }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?id=='4de7ac8b-495b-4884-9a69-1050c6793cd6']" +- debug: + var: load_balancer_name +""" + + +RETURN = r""" +data: + description: DigitalOcean Load balancer information + returned: success + type: list + elements: dict + sample: [ + { + "id": "4de7ac8b-495b-4884-9a69-1050c6793cd6", + "name": "example-lb-01", + "ip": "104.131.186.241", + "algorithm": "round_robin", + "status": "new", + "created_at": "2017-02-01T22:22:58Z", + ... + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + load_balancer_id = module.params.get("load_balancer_id", None) + rest = DigitalOceanHelper(module) + + base_url = "load_balancers" + if load_balancer_id is not None: + response = rest.get("%s/%s" % (base_url, load_balancer_id)) + status_code = response.status_code + + if status_code != 200: + module.fail_json(msg="Failed to retrieve load balancers for DigitalOcean") + + load_balancer = [response.json["load_balancer"]] + else: + load_balancer = rest.get_paginated_data( + base_url=base_url + "?", data_key_name="load_balancers" + ) + + module.exit_json(changed=False, data=load_balancer) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + load_balancer_id=dict(type="str", required=False), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_load_balancer_facts", + "community.digitalocean.digital_ocean_load_balancer_facts", + ): + module.deprecate( + "The 'digital_ocean_load_balancer_facts' module has been renamed to 'digital_ocean_load_balancer_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_monitoring_alerts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_monitoring_alerts.py new file mode 100644 index 00000000..67825ccc --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_monitoring_alerts.py @@ -0,0 +1,325 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Mark Mercado <mamercad@gmail.com> +# +# 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 = r""" +--- +module: digital_ocean_monitoring_alerts +version_added: 1.10.0 +short_description: Programmatically retrieve metrics as well as configure alert policies based on these metrics +description: + - The DigitalOcean Monitoring API makes it possible to programmatically retrieve metrics as well as configure alert policies based on these metrics. + - The Monitoring API can help you gain insight into how your apps are performing and consuming resources. +author: "Mark Mercado (@mamercad)" +options: + oauth_token: + description: + - DigitalOcean OAuth token; can be specified in C(DO_API_KEY), C(DO_API_TOKEN), or C(DO_OAUTH_TOKEN) environment variables + type: str + aliases: ["API_TOKEN"] + required: true + state: + description: + - The usual, C(present) to create, C(absent) to destroy + type: str + choices: ["present", "absent"] + default: present + alerts: + description: + - Alert object, required for C(state=present) + - Supports C(email["email1", "email2", ...]) and C(slack[{"channel1", "url1"}, {"channel2", "url2"}, ...]) + type: dict + required: false + compare: + description: Alert comparison, required for C(state=present) + type: str + required: false + choices: ["GreaterThan", "LessThan"] + description: + description: Alert description, required for C(state=present) + type: str + required: false + enabled: + description: Enabled or not, required for C(state=present) + type: bool + required: false + entities: + description: Alert entities, required for C(state=present) + type: list + elements: str + required: false + tags: + description: Alert tags, required for C(state=present) + type: list + elements: str + required: false + type: + description: + - Alert type, required for C(state=present) + - See U(https://docs.digitalocean.com/reference/api/api-reference/#operation/create_alert_policy) for valid types + type: str + required: false + choices: + - v1/insights/droplet/load_1 + - v1/insights/droplet/load_5 + - v1/insights/droplet/load_15 + - v1/insights/droplet/memory_utilization_percent + - v1/insights/droplet/disk_utilization_percent + - v1/insights/droplet/cpu + - v1/insights/droplet/disk_read + - v1/insights/droplet/disk_write + - v1/insights/droplet/public_outbound_bandwidth + - v1/insights/droplet/public_inbound_bandwidth + - v1/insights/droplet/private_outbound_bandwidth + - v1/insights/droplet/private_inbound_bandwidth + value: + description: Alert threshold, required for C(state=present) + type: float + required: false + window: + description: Alert window, required for C(state=present) + type: str + choices: ["5m", "10m", "30m", "1h"] + required: false + uuid: + description: Alert uuid, required for C(state=absent) + type: str + required: false +""" + + +EXAMPLES = r""" +- name: Create Droplet Monitoring alerts policy + community.digitalocean.digital_ocean_monitoring_alerts: + state: present + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + alerts: + email: ["alerts@example.com"] + slack: [] + compare: GreaterThan + description: Droplet load1 alert + enabled: true + entities: ["{{ droplet_id }}"] + tags: ["my_alert_tag"] + type: v1/insights/droplet/load_1 + value: 3.14159 + window: 5m + register: monitoring_alert_policy + +- name: Delete Droplet Monitoring alerts policy + community.digitalocean.digital_ocean_monitoring_alerts: + state: absent + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + uuid: "{{ monitoring_alert_policy.data.uuid }}" +""" + + +RETURN = r""" +data: + description: A DigitalOcean Monitoring alerts policy + returned: changed + type: dict + sample: + alerts: + email: + - mamercad@gmail.com + slack: [] + compare: GreaterThan + description: Droplet load1 alert + enabled: true + entities: + - '262383737' + tags: + - my_alert_tag + type: v1/insights/droplet/load_1 + uuid: 9f988f00-4690-443d-b638-ed5a99bbad3b + value: 3.14159 + window: 5m +""" + + +from ansible.module_utils._text import to_native +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +alert_types = [ + "v1/insights/droplet/load_1", + "v1/insights/droplet/load_5", + "v1/insights/droplet/load_15", + "v1/insights/droplet/memory_utilization_percent", + "v1/insights/droplet/disk_utilization_percent", + "v1/insights/droplet/cpu", + "v1/insights/droplet/disk_read", + "v1/insights/droplet/disk_write", + "v1/insights/droplet/public_outbound_bandwidth", + "v1/insights/droplet/public_inbound_bandwidth", + "v1/insights/droplet/private_outbound_bandwidth", + "v1/insights/droplet/private_inbound_bandwidth", +] + +alert_keys = [ + "alerts", + "compare", + "description", + "enabled", + "entities", + "tags", + "type", + "value", + "window", +] + +alert_windows = ["5m", "10m", "30m", "1h"] + + +class DOMonitoringAlerts(object): + def __init__(self, module): + self.rest = DigitalOceanHelper(module) + self.module = module + # Pop these values so we don't include them in the POST data + self.module.params.pop("oauth_token") + + def get_alerts(self): + alerts = self.rest.get_paginated_data( + base_url="monitoring/alerts?", data_key_name="policies" + ) + return alerts + + def get_alert(self): + alerts = self.rest.get_paginated_data( + base_url="monitoring/alerts?", data_key_name="policies" + ) + for alert in alerts: + for alert_key in alert_keys: + if alert.get(alert_key, None) != self.module.params.get( + alert_key, None + ): + break # This key doesn't match, try the next alert. + else: + return alert # Didn't hit break, this alert matches. + return None + + def create(self): + # Check for an existing (same) one. + alert = self.get_alert() + if alert is not None: + self.module.exit_json( + changed=False, + data=alert, + ) + + # Check mode + if self.module.check_mode: + self.module.exit_json(changed=True) + + # Create it. + request_params = dict(self.module.params) + response = self.rest.post("monitoring/alerts", data=request_params) + if response.status_code == 200: + alert = self.get_alert() + if alert is not None: + self.module.exit_json( + changed=True, + data=alert, + ) + else: + self.module.fail_json( + changed=False, msg="Unexpected error; please file a bug: create" + ) + else: + self.module.fail_json( + msg="Create Monitoring Alert '{0}' failed [HTTP {1}: {2}]".format( + self.module.params.get("description"), + response.status_code, + response.json.get("message", None), + ) + ) + + def delete(self): + uuid = self.module.params.get("uuid", None) + if uuid is not None: + + # Check mode + if self.module.check_mode: + self.module.exit_json(changed=True) + + # Delete it + response = self.rest.delete("monitoring/alerts/{0}".format(uuid)) + if response.status_code == 204: + self.module.exit_json( + changed=True, + msg="Deleted Monitoring Alert {0}".format(uuid), + ) + else: + self.module.fail_json( + msg="Delete Monitoring Alert {0} failed [HTTP {1}: {2}]".format( + uuid, + response.status_code, + response.json.get("message", None), + ) + ) + else: + self.module.fail_json( + changed=False, msg="Unexpected error; please file a bug: delete" + ) + + +def run(module): + state = module.params.pop("state") + alerts = DOMonitoringAlerts(module) + if state == "present": + alerts.create() + else: + alerts.delete() + + +def main(): + module = AnsibleModule( + argument_spec=dict( + oauth_token=dict( + aliases=["API_TOKEN"], + no_log=True, + fallback=( + env_fallback, + ["DO_API_TOKEN", "DO_API_KEY", "DO_OAUTH_TOKEN"], + ), + required=True, + ), + state=dict( + choices=["present", "absent"], default="present", required=False + ), + alerts=dict(type="dict", required=False), + compare=dict( + type="str", choices=["GreaterThan", "LessThan"], required=False + ), + description=dict(type="str", required=False), + enabled=dict(type="bool", required=False), + entities=dict(type="list", elements="str", required=False), + tags=dict(type="list", elements="str", required=False), + type=dict(type="str", choices=alert_types, required=False), + value=dict(type="float", required=False), + window=dict(type="str", choices=alert_windows, required=False), + uuid=dict(type="str", required=False), + ), + required_if=( + [ + ("state", "present", alert_keys), + ("state", "absent", ["uuid"]), + ] + ), + supports_check_mode=True, + ) + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_monitoring_alerts_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_monitoring_alerts_info.py new file mode 100644 index 00000000..a5d87ad6 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_monitoring_alerts_info.py @@ -0,0 +1,155 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Mark Mercado <mamercad@gmail.com> +# +# 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 = r""" +--- +module: digital_ocean_monitoring_alerts_info +version_added: 1.10.0 +short_description: Programmatically retrieve metrics as well as configure alert policies based on these metrics +description: + - The DigitalOcean Monitoring API makes it possible to programmatically retrieve metrics as well as configure alert policies based on these metrics. + - The Monitoring API can help you gain insight into how your apps are performing and consuming resources. +author: "Mark Mercado (@mamercad)" +options: + state: + description: + - C(present) to return alerts + type: str + choices: ["present"] + default: present + oauth_token: + description: + - DigitalOcean OAuth token; can be specified in C(DO_API_KEY), C(DO_API_TOKEN), or C(DO_OAUTH_TOKEN) environment variables + type: str + aliases: ["API_TOKEN"] + required: true + uuid: + description: + - Alert uuid (if specified only returns the specific alert policy) + type: str + required: false +""" + + +EXAMPLES = r""" +- name: Get Droplet Monitoring alerts polices + community.digitalocean.digital_ocean_monitoring_alerts_info: + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + register: monitoring_alerts + +- name: Get specific Droplet Monitoring alerts policy + community.digitalocean.digital_ocean_monitoring_alerts_info: + oauth_token: "{{ lookup('ansible.builtin.env', 'DO_API_TOKEN') }}" + uuid: ec48b0e7-23bb-4a7f-95f2-d83da62fcd60 + register: monitoring_alert +""" + + +RETURN = r""" +data: + description: DigitalOcean Monitoring alerts policies + returned: changed + type: dict + sample: + data: + - alerts: + email: + - mamercad@gmail.com + slack: [] + compare: GreaterThan + description: Droplet load1 alert + enabled: true + entities: + - '262383737' + tags: + - my_alert_tag + type: v1/insights/droplet/load_1 + uuid: ec48b0e7-23bb-4a7f-95f2-d83da62fcd60 + value: 3.14159 + window: 5m +""" + + +from ansible.module_utils._text import to_native +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +class DOMonitoringAlertsInfo(object): + def __init__(self, module): + self.rest = DigitalOceanHelper(module) + self.module = module + # Pop these values so we don't include them in the POST data + self.module.params.pop("oauth_token") + + def get_alerts(self): + alerts = self.rest.get_paginated_data( + base_url="monitoring/alerts?", data_key_name="policies" + ) + self.module.exit_json( + changed=False, + data=alerts, + ) + + def get_alert(self, uuid): + alerts = self.rest.get_paginated_data( + base_url="monitoring/alerts?", data_key_name="policies" + ) + for alert in alerts: + alert_uuid = alert.get("uuid", None) + if alert_uuid is not None: + if alert_uuid == uuid: + self.module.exit_json( + changed=False, + data=alert, + ) + else: + self.module.fail_json( + changed=False, msg="Unexpected error; please file a bug: get_alert" + ) + self.module.exit_json( + changed=False, + data=[], + ) + + +def run(module): + alerts = DOMonitoringAlertsInfo(module) + uuid = module.params.get("uuid", None) + if uuid is None: + alerts.get_alerts() + else: + alerts.get_alert(uuid) + + +def main(): + module = AnsibleModule( + argument_spec=dict( + state=dict(choices=["present"], default="present"), + oauth_token=dict( + aliases=["API_TOKEN"], + no_log=True, + fallback=( + env_fallback, + ["DO_API_TOKEN", "DO_API_KEY", "DO_OAUTH_TOKEN"], + ), + required=True, + ), + uuid=dict(type="str", required=False), + ), + supports_check_mode=True, + ) + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_project.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_project.py new file mode 100644 index 00000000..e0333883 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_project.py @@ -0,0 +1,315 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: 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 + +DOCUMENTATION = r""" +--- +module: digital_ocean_project +short_description: Manage a DigitalOcean project +description: + - Manage a project in DigitalOcean +author: "Tyler Auerbeck (@tylerauerbeck)" +version_added: 1.6.0 + +options: + state: + description: + - Indicate desired state of the target. + - C(present) will create the project + - C(absent) will delete the project, if it exists. + default: present + choices: ['present', 'absent'] + type: str + oauth_token: + description: + - DigitalOcean OAuth token. Can be specified in C(DO_API_KEY), C(DO_API_TOKEN), or C(DO_OAUTH_TOKEN) environment variables + aliases: ['API_TOKEN'] + type: str + required: true + environment: + description: + - The environment of the projects resources. + choices: ['Development', 'Staging', 'Production'] + type: str + is_default: + description: + - If true, all resources will be added to this project if no project is specified. + default: False + type: bool + name: + description: + - The human-readable name for the project. The maximum length is 175 characters and the name must be unique. + type: str + id: + description: + - UUID of the project + type: str + purpose: + description: + - The purpose of the project. The maximum length is 255 characters + - Required if state is C(present) + - If not one of DO provided purposes, will be prefixed with C(Other) + - DO provided purposes can be found below + - C(Just trying out DigitalOcean) + - C(Class project/Educational Purposes) + - C(Website or blog) + - C(Web Application) + - C(Service or API) + - C(Mobile Application) + - C(Machine Learning/AI/Data Processing) + - C(IoT) + - C(Operational/Developer tooling) + type: str + description: + description: + - The description of the project. The maximum length is 255 characters. + type: str +""" + + +EXAMPLES = r""" +# Creates a new project +- community.digitalocean.digital_ocean_project: + name: "TestProj" + state: "present" + description: "This is a test project" + purpose: "IoT" + environment: "Development" + +# Updates the existing project with the new environment +- community.digitalocean.digital_ocean_project: + name: "TestProj" + state: "present" + description: "This is a test project" + purpose: "IoT" + environment: "Production" + +# This renames an existing project by utilizing the id of the project +- community.digitalocean.digital_ocean_project: + name: "TestProj2" + id: "12312312-abcd-efgh-ijkl-123123123123" + state: "present" + description: "This is a test project" + purpose: "IoT" + environment: "Development" + +# This creates a project that results with a purpose of "Other: My Prod App" +- community.digitalocean.digital_ocean_project: + name: "ProdProj" + state: "present" + description: "This is a prod app" + purpose: "My Prod App" + environment: "Production" + +# This removes a project +- community.digitalocean.digital_ocean_project: + name: "ProdProj" + state: "absent" +""" + +RETURN = r""" +# Digital Ocean API info https://docs.digitalocean.com/reference/api/api-reference/#tag/Projects +data: + description: a DigitalOcean Project + returned: changed + type: dict + sample: { + "project": { + "created_at": "2021-05-28T00:00:00Z", + "description": "This is a test description", + "environment": "Development", + "id": "12312312-abcd-efgh-1234-abcdefgh123", + "is_default": false, + "name": "Test123", + "owner_id": 1234567, + "owner_uuid": "12312312-1234-5678-abcdefghijklm", + "purpose": "IoT", + "updated_at": "2021-05-29T00:00:00Z", + } + } +""" + +import time +import json +from ansible.module_utils.basic import AnsibleModule, env_fallback +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +class DOProject(object): + def __init__(self, module): + self.rest = DigitalOceanHelper(module) + self.module = module + # pop the oauth token so we don't include it in the POST data + self.module.params.pop("oauth_token") + self.id = None + self.name = None + self.purpose = None + self.description = None + self.environment = None + self.is_default = None + + def get_by_id(self, project_id): + if not project_id: + return None + response = self.rest.get("projects/{0}".format(project_id)) + json_data = response.json + if response.status_code == 200: + project = json_data.get("project", None) + if project is not None: + self.id = project.get("id", None) + self.name = project.get("name", None) + self.purpose = project.get("purpose", None) + self.description = project.get("description", None) + self.environment = project.get("environment", None) + self.is_default = project.get("is_default", None) + return json_data + return None + + def get_by_name(self, project_name): + if not project_name: + return None + page = 1 + while page is not None: + response = self.rest.get("projects?page={0}".format(page)) + json_data = response.json + if response.status_code == 200: + for project in json_data["projects"]: + if project.get("name", None) == project_name: + self.id = project.get("id", None) + self.name = project.get("name", None) + self.description = project.get("description", None) + self.purpose = project.get("purpose", None) + self.environment = project.get("environment", None) + self.is_default = project.get("is_default", None) + return {"project": project} + if ( + "links" in json_data + and "pages" in json_data["links"] + and "next" in json_data["links"]["pages"] + ): + page += 1 + else: + page = None + return None + + def get_project(self): + json_data = self.get_by_id(self.module.params["id"]) + if not json_data: + json_data = self.get_by_name(self.module.params["name"]) + return json_data + + def create(self, state): + json_data = self.get_project() + request_params = dict(self.module.params) + + if json_data is not None: + changed = False + valid_purpose = [ + "Just trying out DigitalOcean", + "Class project/Educational Purposes", + "Website or blog", + "Web Application", + "Service or API", + "Mobile Application", + "Machine Learning/AI/Data Processing", + "IoT", + "Operational/Developer tooling", + ] + for key in request_params.keys(): + if ( + key == "purpose" + and request_params[key] is not None + and request_params[key] not in valid_purpose + ): + param = "Other: " + request_params[key] + else: + param = request_params[key] + + if json_data["project"][key] != param and param is not None: + changed = True + + if changed: + response = self.rest.put( + "projects/{0}".format(json_data["project"]["id"]), + data=request_params, + ) + if response.status_code != 200: + self.module.fail_json(changed=False, msg="Unable to update project") + self.module.exit_json(changed=True, data=response.json) + else: + self.module.exit_json(changed=False, data=json_data) + else: + response = self.rest.post("projects", data=request_params) + + if response.status_code != 201: + self.module.fail_json(changed=False, msg="Unable to create project") + self.module.exit_json(changed=True, data=response.json) + + def delete(self): + json_data = self.get_project() + if json_data: + if self.module.check_mode: + self.module.exit_json(changed=True) + response = self.rest.delete( + "projects/{0}".format(json_data["project"]["id"]) + ) + json_data = response.json + if response.status_code == 204: + self.module.exit_json(changed=True, msg="Project deleted") + self.module.fail_json(changed=False, msg="Failed to delete project") + else: + self.module.exit_json(changed=False, msg="Project not found") + + +def core(module): + state = module.params.pop("state") + project = DOProject(module) + if state == "present": + project.create(state) + elif state == "absent": + project.delete() + + +def main(): + module = AnsibleModule( + argument_spec=dict( + state=dict(choices=["present", "absent"], default="present", type="str"), + oauth_token=dict( + aliases=["API_TOKEN"], + no_log=True, + fallback=( + env_fallback, + ["DO_API_TOKEN", "DO_API_KEY", "DO_OAUTH_TOKEN"], + ), + required=True, + ), + name=dict(type="str"), + id=dict(type="str"), + description=dict(type="str"), + purpose=dict(type="str"), + is_default=dict(type="bool", default=False), + environment=dict( + choices=["Development", "Staging", "Production"], type="str" + ), + ), + required_one_of=(["id", "name"],), + required_if=( + [ + ("state", "present", ["purpose"]), + ] + ), + ) + + core(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_project_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_project_info.py new file mode 100644 index 00000000..0c6ac670 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_project_info.py @@ -0,0 +1,121 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2020, Tyler Auerbeck <tauerbec@redhat.com> +# 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 = r""" +--- +module: digital_ocean_project_info +short_description: Gather information about DigitalOcean Projects +description: + - This module can be used to gather information about Projects. +author: "Tyler Auerbeck (@tylerauerbeck)" +version_added: 1.6.0 + +options: + id: + description: + - Project ID that can be used to identify and reference a project. + type: str + name: + description: + - Project name that can be used to identify and reference a project. + type: str + +extends_documentation_fragment: +- community.digitalocean.digital_ocean +""" + + +EXAMPLES = r""" +# Get specific project by id +- community.digitalocean.digital_ocean_project_info: + id: cb1ef55e-3cd8-4c7c-aa5d-07c32bf41627 + +# Get specific project by name +- community.digitalocean.digital_ocean_project_info: + name: my-project-name + +# Get all projects +- community.digitalocean.digital_ocean_project_info: + register: projects +""" + +RETURN = r""" +data: + description: "DigitalOcean project information" + elements: dict + returned: success + type: list + sample: + - created_at: "2021-03-11T00:00:00Z" + description: "My project description" + environment: "Development" + id: "12345678-abcd-efgh-5678-10111213" + is_default: false + name: "my-test-project" + owner_id: 12345678 + owner_uuid: "12345678-1234-4321-abcd-20212223" + purpose: "" + updated_at: "2021-03-11T00:00:00Z" +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +def run(module): + rest = DigitalOceanHelper(module) + + if module.params["id"]: + response = rest.get("projects/{0}".format(module.params["id"])) + if response.status_code != 200: + module.fail_json( + msg="Failed to fetch 'projects' information due to error: %s" + % response.json["message"] + ) + else: + response = rest.get_paginated_data( + base_url="projects?", data_key_name="projects" + ) + + if module.params["id"]: + data = [response.json["project"]] + elif module.params["name"]: + data = [d for d in response if d["name"] == module.params["name"]] + if not data: + module.fail_json( + msg="Failed to fetch 'projects' information due to error: Unable to find project with name %s" + % module.params["name"] + ) + else: + data = response + + module.exit_json(changed=False, data=data) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + name=dict(type="str", required=False, default=None), + id=dict(type="str", required=False, default=None), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + mutually_exclusive=[("id", "name")], + ) + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_region_facts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_region_facts.py new file mode 100644 index 00000000..9ffb8ff3 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_region_facts.py @@ -0,0 +1,125 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_region_info +short_description: Gather information about DigitalOcean regions +description: + - This module can be used to gather information about regions. + - This module was called C(digital_ocean_region_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +requirements: + - "python >= 2.6" +""" + + +EXAMPLES = r""" +- name: Gather information about all regions + community.digitalocean.digital_ocean_region_info: + oauth_token: "{{ oauth_token }}" + +- name: Get Name of region where slug is known + community.digitalocean.digital_ocean_region_info: + oauth_token: "{{ oauth_token }}" + register: resp_out +- debug: var=resp_out +- set_fact: + region_slug: "{{ item.name }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?slug==`nyc1`]" +- debug: + var: region_slug +""" + + +RETURN = r""" +data: + description: DigitalOcean regions information + returned: success + type: list + sample: [ + { + "available": true, + "features": [ + "private_networking", + "backups", + "ipv6", + "metadata", + "install_agent", + "storage" + ], + "name": "New York 1", + "sizes": [ + "512mb", + "s-1vcpu-1gb", + "1gb", + "s-3vcpu-1gb", + "s-1vcpu-2gb", + "s-2vcpu-2gb", + "2gb", + "s-1vcpu-3gb", + "s-2vcpu-4gb", + "4gb", + "c-2", + "m-1vcpu-8gb", + "8gb", + "s-4vcpu-8gb", + "s-6vcpu-16gb", + "16gb" + ], + "slug": "nyc1" + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + rest = DigitalOceanHelper(module) + + base_url = "regions?" + regions = rest.get_paginated_data(base_url=base_url, data_key_name="regions") + + module.exit_json(changed=False, data=regions) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_region_facts", + "community.digitalocean.digital_ocean_region_facts", + ): + module.deprecate( + "The 'digital_ocean_region_facts' module has been renamed to 'digital_ocean_region_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_region_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_region_info.py new file mode 100644 index 00000000..9ffb8ff3 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_region_info.py @@ -0,0 +1,125 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_region_info +short_description: Gather information about DigitalOcean regions +description: + - This module can be used to gather information about regions. + - This module was called C(digital_ocean_region_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +requirements: + - "python >= 2.6" +""" + + +EXAMPLES = r""" +- name: Gather information about all regions + community.digitalocean.digital_ocean_region_info: + oauth_token: "{{ oauth_token }}" + +- name: Get Name of region where slug is known + community.digitalocean.digital_ocean_region_info: + oauth_token: "{{ oauth_token }}" + register: resp_out +- debug: var=resp_out +- set_fact: + region_slug: "{{ item.name }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?slug==`nyc1`]" +- debug: + var: region_slug +""" + + +RETURN = r""" +data: + description: DigitalOcean regions information + returned: success + type: list + sample: [ + { + "available": true, + "features": [ + "private_networking", + "backups", + "ipv6", + "metadata", + "install_agent", + "storage" + ], + "name": "New York 1", + "sizes": [ + "512mb", + "s-1vcpu-1gb", + "1gb", + "s-3vcpu-1gb", + "s-1vcpu-2gb", + "s-2vcpu-2gb", + "2gb", + "s-1vcpu-3gb", + "s-2vcpu-4gb", + "4gb", + "c-2", + "m-1vcpu-8gb", + "8gb", + "s-4vcpu-8gb", + "s-6vcpu-16gb", + "16gb" + ], + "slug": "nyc1" + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + rest = DigitalOceanHelper(module) + + base_url = "regions?" + regions = rest.get_paginated_data(base_url=base_url, data_key_name="regions") + + module.exit_json(changed=False, data=regions) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_region_facts", + "community.digitalocean.digital_ocean_region_facts", + ): + module.deprecate( + "The 'digital_ocean_region_facts' module has been renamed to 'digital_ocean_region_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_size_facts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_size_facts.py new file mode 100644 index 00000000..19aba33e --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_size_facts.py @@ -0,0 +1,124 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_size_info +short_description: Gather information about DigitalOcean Droplet sizes +description: + - This module can be used to gather information about droplet sizes. + - This module was called C(digital_ocean_size_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all droplet sizes + community.digitalocean.digital_ocean_size_info: + oauth_token: "{{ oauth_token }}" + +- name: Get droplet Size Slug where vcpus is 1 + community.digitalocean.digital_ocean_size_info: + oauth_token: "{{ oauth_token }}" + register: resp_out +- debug: var=resp_out +- set_fact: + size_slug: "{{ item.slug }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?vcpus==`1`]" +- debug: + var: size_slug + + +""" + + +RETURN = r""" +data: + description: DigitalOcean droplet size information + returned: success + type: list + sample: [ + { + "available": true, + "disk": 20, + "memory": 512, + "price_hourly": 0.00744, + "price_monthly": 5.0, + "regions": [ + "ams2", + "ams3", + "blr1", + "fra1", + "lon1", + "nyc1", + "nyc2", + "nyc3", + "sfo1", + "sfo2", + "sgp1", + "tor1" + ], + "slug": "512mb", + "transfer": 1.0, + "vcpus": 1 + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + rest = DigitalOceanHelper(module) + + response = rest.get("sizes") + if response.status_code != 200: + module.fail_json( + msg="Failed to fetch 'sizes' information due to error : %s" + % response.json["message"] + ) + + module.exit_json(changed=False, data=response.json["sizes"]) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_size_facts", + "community.digitalocean.digital_ocean_size_facts", + ): + module.deprecate( + "The 'digital_ocean_size_facts' module has been renamed to 'digital_ocean_size_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_size_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_size_info.py new file mode 100644 index 00000000..19aba33e --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_size_info.py @@ -0,0 +1,124 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_size_info +short_description: Gather information about DigitalOcean Droplet sizes +description: + - This module can be used to gather information about droplet sizes. + - This module was called C(digital_ocean_size_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all droplet sizes + community.digitalocean.digital_ocean_size_info: + oauth_token: "{{ oauth_token }}" + +- name: Get droplet Size Slug where vcpus is 1 + community.digitalocean.digital_ocean_size_info: + oauth_token: "{{ oauth_token }}" + register: resp_out +- debug: var=resp_out +- set_fact: + size_slug: "{{ item.slug }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?vcpus==`1`]" +- debug: + var: size_slug + + +""" + + +RETURN = r""" +data: + description: DigitalOcean droplet size information + returned: success + type: list + sample: [ + { + "available": true, + "disk": 20, + "memory": 512, + "price_hourly": 0.00744, + "price_monthly": 5.0, + "regions": [ + "ams2", + "ams3", + "blr1", + "fra1", + "lon1", + "nyc1", + "nyc2", + "nyc3", + "sfo1", + "sfo2", + "sgp1", + "tor1" + ], + "slug": "512mb", + "transfer": 1.0, + "vcpus": 1 + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + rest = DigitalOceanHelper(module) + + response = rest.get("sizes") + if response.status_code != 200: + module.fail_json( + msg="Failed to fetch 'sizes' information due to error : %s" + % response.json["message"] + ) + + module.exit_json(changed=False, data=response.json["sizes"]) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_size_facts", + "community.digitalocean.digital_ocean_size_facts", + ): + module.deprecate( + "The 'digital_ocean_size_facts' module has been renamed to 'digital_ocean_size_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_snapshot.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_snapshot.py new file mode 100644 index 00000000..67dc0100 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_snapshot.py @@ -0,0 +1,309 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2021, Mark Mercado <mamercad@gmail.com> +# 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 = r""" +--- +module: digital_ocean_snapshot +short_description: Create and delete DigitalOcean snapshots +version_added: 1.7.0 +description: + - This module can be used to create and delete DigitalOcean Droplet and volume snapshots. +author: "Mark Mercado (@mamercad)" +options: + state: + description: + - Whether the snapshot should be present (created) or absent (deleted). + default: present + choices: + - present + - absent + type: str + snapshot_type: + description: + - Specifies the type of snapshot information to be create or delete. + - If set to C(droplet), then a Droplet snapshot is created. + - If set to C(volume), then a volume snapshot is created. + choices: + - droplet + - volume + default: droplet + type: str + snapshot_name: + description: + - Name of the snapshot to create. + type: str + snapshot_tags: + description: + - List of tags to apply to the volume snapshot. + - Only applies to volume snapshots (not Droplets). + type: list + elements: str + droplet_id: + description: + - Droplet ID to snapshot. + type: str + volume_id: + description: + - Volume ID to snapshot. + type: str + snapshot_id: + description: + - Snapshot ID to delete. + type: str + wait: + description: + - Wait for the snapshot to be created before returning. + default: True + type: bool + wait_timeout: + description: + - How long before wait gives up, in seconds, when creating a snapshot. + default: 120 + type: int +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Snapshot a Droplet + community.digitalocean.digital_ocean_snapshot: + state: present + snapshot_type: droplet + droplet_id: 250329179 + register: result + +- name: Delete a Droplet snapshot + community.digitalocean.digital_ocean_snapshot: + state: absent + snapshot_type: droplet + snapshot_id: 85905825 + register: result + +- name: Snapshot a Volume + community.digitalocean.digital_ocean_snapshot: + state: present + snapshot_type: volume + snapshot_name: mysnapshot1 + volume_id: 9db5e329-cc68-11eb-b027-0a58ac144f91 + +- name: Delete a Volume snapshot + community.digitalocean.digital_ocean_snapshot: + state: absent + snapshot_type: volume + snapshot_id: a902cdba-cc68-11eb-a701-0a58ac145708 +""" + + +RETURN = r""" +data: + description: Snapshot creation or deletion action. + returned: success + type: dict + sample: + - completed_at: '2021-06-14T12:36:00Z' + id: 1229119156 + region: + available: true + features: + - backups + - ipv6 + - metadata + - install_agent + - storage + - image_transfer + name: New York 1 + sizes: + - s-1vcpu-1gb + - s-1vcpu-1gb-amd + - s-1vcpu-1gb-intel + - <snip> + slug: nyc1 + region_slug: nyc1 + resource_id: 250445117 + resource_type: droplet + started_at: '2021-06-14T12:35:25Z' + status: completed + type: snapshot + - created_at: '2021-06-14T12:55:10Z' + id: c06d4a86-cd0f-11eb-b13c-0a58ac145472 + min_disk_size: 1 + name: my-snapshot-1 + regions: + - nbg1 + resource_id: f0adea7e-cd0d-11eb-b027-0a58ac144f91 + resource_type: volume + size_gigabytes: 0 + tags: + - tag1 + - tag2 +""" + + +import time +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +class DOSnapshot(object): + def __init__(self, module): + self.rest = DigitalOceanHelper(module) + self.module = module + self.wait = self.module.params.pop("wait", True) + self.wait_timeout = self.module.params.pop("wait_timeout", 120) + # pop the oauth token so we don't include it in the POST data + self.module.params.pop("oauth_token") + self.snapshot_type = module.params["snapshot_type"] + self.snapshot_name = module.params["snapshot_name"] + self.snapshot_tags = module.params["snapshot_tags"] + self.snapshot_id = module.params["snapshot_id"] + self.volume_id = module.params["volume_id"] + + def wait_finished(self): + current_time = time.monotonic() + end_time = current_time + self.wait_timeout + while current_time < end_time: + response = self.rest.get("actions/{0}".format(str(self.action_id))) + status = response.status_code + if status != 200: + self.module.fail_json( + msg="Unable to find action {0}, please file a bug".format( + str(self.action_id) + ) + ) + json = response.json + if json["action"]["status"] == "completed": + return json + time.sleep(10) + self.module.fail_json( + msg="Timed out waiting for snapshot, action {0}".format(str(self.action_id)) + ) + + def create(self): + if self.module.check_mode: + return self.module.exit_json(changed=True) + + if self.snapshot_type == "droplet": + droplet_id = self.module.params["droplet_id"] + data = { + "type": "snapshot", + } + if self.snapshot_name is not None: + data["name"] = self.snapshot_name + response = self.rest.post( + "droplets/{0}/actions".format(str(droplet_id)), data=data + ) + status = response.status_code + json = response.json + if status == 201: + self.action_id = json["action"]["id"] + if self.wait: + json = self.wait_finished() + self.module.exit_json( + changed=True, + msg="Created snapshot, action {0}".format(self.action_id), + data=json["action"], + ) + self.module.exit_json( + changed=True, + msg="Created snapshot, action {0}".format(self.action_id), + data=json["action"], + ) + else: + self.module.fail_json( + changed=False, + msg="Failed to create snapshot: {0}".format(json["message"]), + ) + elif self.snapshot_type == "volume": + data = { + "name": self.snapshot_name, + "tags": self.snapshot_tags, + } + response = self.rest.post( + "volumes/{0}/snapshots".format(str(self.volume_id)), data=data + ) + status = response.status_code + json = response.json + if status == 201: + self.module.exit_json( + changed=True, + msg="Created snapshot, snapshot {0}".format(json["snapshot"]["id"]), + data=json["snapshot"], + ) + else: + self.module.fail_json( + changed=False, + msg="Failed to create snapshot: {0}".format(json["message"]), + ) + + def delete(self): + if self.module.check_mode: + return self.module.exit_json(changed=True) + + response = self.rest.delete("snapshots/{0}".format(str(self.snapshot_id))) + status = response.status_code + if status == 204: + self.module.exit_json( + changed=True, + msg="Deleted snapshot {0}".format(str(self.snapshot_id)), + ) + else: + json = response.json + self.module.fail_json( + changed=False, + msg="Failed to delete snapshot {0}: {1}".format( + self.snapshot_id, json["message"] + ), + ) + + +def run(module): + state = module.params.pop("state") + snapshot = DOSnapshot(module) + if state == "present": + snapshot.create() + elif state == "absent": + snapshot.delete() + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + state=dict(choices=["present", "absent"], default="present"), + snapshot_type=dict( + type="str", required=False, choices=["droplet", "volume"], default="droplet" + ), + snapshot_name=dict(type="str"), + snapshot_tags=dict(type="list", elements="str", default=[]), + droplet_id=dict(type="str"), + volume_id=dict(type="str"), + snapshot_id=dict(type="str"), + wait=dict(type="bool", default=True), + wait_timeout=dict(default=120, type="int"), + ) + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[ + ["state", "present", ["droplet_id", "volume_id"], True], + ["state", "absent", ["snapshot_id"]], + ], + mutually_exclusive=[["droplet_id", "volume_id"]], + supports_check_mode=True, + ) + + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_snapshot_facts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_snapshot_facts.py new file mode 100644 index 00000000..19d3c77d --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_snapshot_facts.py @@ -0,0 +1,180 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_snapshot_info +short_description: Gather information about DigitalOcean Snapshot +description: + - This module can be used to gather information about snapshot information based upon provided values such as droplet, volume and snapshot id. + - This module was called C(digital_ocean_snapshot_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + snapshot_type: + description: + - Specifies the type of snapshot information to be retrieved. + - If set to C(droplet), then information are gathered related to snapshots based on Droplets only. + - If set to C(volume), then information are gathered related to snapshots based on volumes only. + - If set to C(by_id), then information are gathered related to snapshots based on snapshot id only. + - If not set to any of the above, then information are gathered related to all snapshots. + default: 'all' + choices: [ 'all', 'droplet', 'volume', 'by_id'] + required: false + type: str + snapshot_id: + description: + - To retrieve information about a snapshot, please specify this as a snapshot id. + - If set to actual snapshot id, then information are gathered related to that particular snapshot only. + - This is required parameter, if C(snapshot_type) is set to C(by_id). + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all snapshots + community.digitalocean.digital_ocean_snapshot_info: + snapshot_type: all + oauth_token: "{{ oauth_token }}" + +- name: Gather information about droplet snapshots + community.digitalocean.digital_ocean_snapshot_info: + snapshot_type: droplet + oauth_token: "{{ oauth_token }}" + +- name: Gather information about volume snapshots + community.digitalocean.digital_ocean_snapshot_info: + snapshot_type: volume + oauth_token: "{{ oauth_token }}" + +- name: Gather information about snapshot by snapshot id + community.digitalocean.digital_ocean_snapshot_info: + snapshot_type: by_id + snapshot_id: 123123123 + oauth_token: "{{ oauth_token }}" + +- name: Get information about snapshot named big-data-snapshot1 + community.digitalocean.digital_ocean_snapshot_info: + register: resp_out +- set_fact: + snapshot_id: "{{ item.id }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?name=='big-data-snapshot1']" +- debug: + var: snapshot_id + +""" + + +RETURN = r""" +data: + description: DigitalOcean snapshot information + returned: success + elements: dict + type: list + sample: [ + { + "id": "4f60fc64-85d1-11e6-a004-000f53315871", + "name": "big-data-snapshot1", + "regions": [ + "nyc1" + ], + "created_at": "2016-09-28T23:14:30Z", + "resource_id": "89bcc42f-85cf-11e6-a004-000f53315871", + "resource_type": "volume", + "min_disk_size": 10, + "size_gigabytes": 0 + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + snapshot_type = module.params["snapshot_type"] + + rest = DigitalOceanHelper(module) + + base_url = "snapshots" + snapshot = [] + + if snapshot_type == "by_id": + base_url += "/{0}".format(module.params.get("snapshot_id")) + response = rest.get(base_url) + status_code = response.status_code + + if status_code != 200: + module.fail_json( + msg="Failed to fetch snapshot information due to error : %s" + % response.json["message"] + ) + + snapshot.extend(response.json["snapshots"]) + else: + if snapshot_type == "droplet": + base_url += "?resource_type=droplet&" + elif snapshot_type == "volume": + base_url += "?resource_type=volume&" + else: + base_url += "?" + + snapshot = rest.get_paginated_data(base_url=base_url, data_key_name="snapshots") + module.exit_json(changed=False, data=snapshot) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + snapshot_type=dict( + type="str", + required=False, + choices=["all", "droplet", "volume", "by_id"], + default="all", + ), + snapshot_id=dict(type="str", required=False), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ["snapshot_type", "by_id", ["snapshot_id"]], + ], + ) + if module._name in ( + "digital_ocean_snapshot_facts", + "community.digitalocean.digital_ocean_snapshot_facts", + ): + module.deprecate( + "The 'digital_ocean_snapshot_facts' module has been renamed to 'digital_ocean_snapshot_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_snapshot_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_snapshot_info.py new file mode 100644 index 00000000..19d3c77d --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_snapshot_info.py @@ -0,0 +1,180 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_snapshot_info +short_description: Gather information about DigitalOcean Snapshot +description: + - This module can be used to gather information about snapshot information based upon provided values such as droplet, volume and snapshot id. + - This module was called C(digital_ocean_snapshot_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + snapshot_type: + description: + - Specifies the type of snapshot information to be retrieved. + - If set to C(droplet), then information are gathered related to snapshots based on Droplets only. + - If set to C(volume), then information are gathered related to snapshots based on volumes only. + - If set to C(by_id), then information are gathered related to snapshots based on snapshot id only. + - If not set to any of the above, then information are gathered related to all snapshots. + default: 'all' + choices: [ 'all', 'droplet', 'volume', 'by_id'] + required: false + type: str + snapshot_id: + description: + - To retrieve information about a snapshot, please specify this as a snapshot id. + - If set to actual snapshot id, then information are gathered related to that particular snapshot only. + - This is required parameter, if C(snapshot_type) is set to C(by_id). + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all snapshots + community.digitalocean.digital_ocean_snapshot_info: + snapshot_type: all + oauth_token: "{{ oauth_token }}" + +- name: Gather information about droplet snapshots + community.digitalocean.digital_ocean_snapshot_info: + snapshot_type: droplet + oauth_token: "{{ oauth_token }}" + +- name: Gather information about volume snapshots + community.digitalocean.digital_ocean_snapshot_info: + snapshot_type: volume + oauth_token: "{{ oauth_token }}" + +- name: Gather information about snapshot by snapshot id + community.digitalocean.digital_ocean_snapshot_info: + snapshot_type: by_id + snapshot_id: 123123123 + oauth_token: "{{ oauth_token }}" + +- name: Get information about snapshot named big-data-snapshot1 + community.digitalocean.digital_ocean_snapshot_info: + register: resp_out +- set_fact: + snapshot_id: "{{ item.id }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?name=='big-data-snapshot1']" +- debug: + var: snapshot_id + +""" + + +RETURN = r""" +data: + description: DigitalOcean snapshot information + returned: success + elements: dict + type: list + sample: [ + { + "id": "4f60fc64-85d1-11e6-a004-000f53315871", + "name": "big-data-snapshot1", + "regions": [ + "nyc1" + ], + "created_at": "2016-09-28T23:14:30Z", + "resource_id": "89bcc42f-85cf-11e6-a004-000f53315871", + "resource_type": "volume", + "min_disk_size": 10, + "size_gigabytes": 0 + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + snapshot_type = module.params["snapshot_type"] + + rest = DigitalOceanHelper(module) + + base_url = "snapshots" + snapshot = [] + + if snapshot_type == "by_id": + base_url += "/{0}".format(module.params.get("snapshot_id")) + response = rest.get(base_url) + status_code = response.status_code + + if status_code != 200: + module.fail_json( + msg="Failed to fetch snapshot information due to error : %s" + % response.json["message"] + ) + + snapshot.extend(response.json["snapshots"]) + else: + if snapshot_type == "droplet": + base_url += "?resource_type=droplet&" + elif snapshot_type == "volume": + base_url += "?resource_type=volume&" + else: + base_url += "?" + + snapshot = rest.get_paginated_data(base_url=base_url, data_key_name="snapshots") + module.exit_json(changed=False, data=snapshot) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + snapshot_type=dict( + type="str", + required=False, + choices=["all", "droplet", "volume", "by_id"], + default="all", + ), + snapshot_id=dict(type="str", required=False), + ) + module = AnsibleModule( + argument_spec=argument_spec, + supports_check_mode=True, + required_if=[ + ["snapshot_type", "by_id", ["snapshot_id"]], + ], + ) + if module._name in ( + "digital_ocean_snapshot_facts", + "community.digitalocean.digital_ocean_snapshot_facts", + ): + module.deprecate( + "The 'digital_ocean_snapshot_facts' module has been renamed to 'digital_ocean_snapshot_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_spaces.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_spaces.py new file mode 100644 index 00000000..0d101b50 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_spaces.py @@ -0,0 +1,241 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: 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 + + +DOCUMENTATION = r""" +--- +module: digital_ocean_spaces +short_description: Create and remove DigitalOcean Spaces. +description: + - Create and remove DigitalOcean Spaces. +author: Mark Mercado (@mamercad) +version_added: 1.15.0 +options: + state: + description: + - Whether the Space should be present or absent. + default: present + type: str + choices: ["present", "absent"] + name: + description: + - The name of the Spaces to create or delete. + required: true + type: str + region: + description: + - The region to create or delete the Space in. + aliases: ["region_id"] + required: true + type: str + aws_access_key_id: + description: + - The AWS_ACCESS_KEY_ID to use. + required: true + type: str + aliases: ["AWS_ACCESS_KEY_ID"] + aws_secret_access_key: + description: + - The AWS_SECRET_ACCESS_KEY to use. + required: true + type: str + aliases: ["AWS_SECRET_ACCESS_KEY"] +requirements: + - boto3 +extends_documentation_fragment: + - community.digitalocean.digital_ocean.documentation +""" + + +EXAMPLES = r""" +- name: Create a Space in nyc3 + community.digitalocean.digital_ocean_spaces: + state: present + name: my-space + region: nyc3 + +- name: Delete a Space in nyc3 + community.digitalocean.digital_ocean_spaces: + state: absent + name: my-space + region: nyc3 +""" + + +RETURN = r""" +data: + description: DigitalOcean Space + returned: present + type: dict + sample: + space: + endpoint_url: https://nyc3.digitaloceanspaces.com + name: gh-ci-space-1 + region: nyc3 + space_url: https://gh-ci-space-1.nyc3.digitaloceanspaces.com +msg: + description: Informational message + returned: always + type: str + sample: Created Space gh-ci-space-1 in nyc3 +""" + +from ansible.module_utils.basic import ( + AnsibleModule, + missing_required_lib, + env_fallback, + to_native, +) +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from traceback import format_exc + +try: + import boto3 + + HAS_BOTO3 = True +except Exception: + HAS_BOTO3 = False + + +def run(module): + state = module.params.get("state") + name = module.params.get("name") + region = module.params.get("region") + aws_access_key_id = module.params.get("aws_access_key_id") + aws_secret_access_key = module.params.get("aws_secret_access_key") + + try: + session = boto3.session.Session() + client = session.client( + "s3", + region_name=region, + endpoint_url=f"https://{region}.digitaloceanspaces.com", + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + ) + response = client.list_buckets() + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + response_metadata = response.get("ResponseMetadata") + http_status_code = response_metadata.get("HTTPStatusCode") + + if http_status_code == 200: + spaces = [ + { + "name": space["Name"], + "region": region, + "endpoint_url": f"https://{region}.digitaloceanspaces.com", + "space_url": f"https://{space['Name']}.{region}.digitaloceanspaces.com", + } + for space in response["Buckets"] + ] + else: + module.fail_json(changed=False, msg=f"Failed to list Spaces in {region}") + + if state == "present": + for space in spaces: + if space["name"] == name: + module.exit_json(changed=False, data={"space": space}) + + if module.check_mode: + module.exit_json(changed=True, msg=f"Would create Space {name} in {region}") + + try: + response = client.create_bucket(Bucket=name) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + response_metadata = response.get("ResponseMetadata") + http_status_code = response_metadata.get("HTTPStatusCode") + if http_status_code == 200: + module.exit_json( + changed=True, + msg=f"Created Space {name} in {region}", + data={ + "space": { + "name": name, + "region": region, + "endpoint_url": f"https://{region}.digitaloceanspaces.com", + "space_url": f"https://{name}.{region}.digitaloceanspaces.com", + } + }, + ) + + module.fail_json( + changed=False, msg=f"Failed to create Space {name} in {region}" + ) + + elif state == "absent": + have_it = False + for space in spaces: + if space["name"] == name: + have_it = True + + if module.check_mode: + if have_it: + module.exit_json( + changed=True, msg=f"Would delete Space {name} in {region}" + ) + else: + module.exit_json(changed=False, msg=f"No Space {name} in {region}") + + if have_it: + try: + reponse = client.delete_bucket(Bucket=name) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + response_metadata = response.get("ResponseMetadata") + http_status_code = response_metadata.get("HTTPStatusCode") + if http_status_code == 200: + module.exit_json(changed=True, msg=f"Deleted Space {name} in {region}") + + module.fail_json( + changed=True, msg=f"Failed to delete Space {name} in {region}" + ) + + module.exit_json(changed=False, msg=f"No Space {name} in {region}") + + +def main(): + + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + state=dict(type="str", choices=["present", "absent"], default="present"), + name=dict(type="str", required=True), + region=dict(type="str", aliases=["region_id"], required=True), + aws_access_key_id=dict( + type="str", + aliases=["AWS_ACCESS_KEY_ID"], + fallback=(env_fallback, ["AWS_ACCESS_KEY_ID"]), + required=True, + no_log=True, + ), + aws_secret_access_key=dict( + type="str", + aliases=["AWS_SECRET_ACCESS_KEY"], + fallback=(env_fallback, ["AWS_SECRET_ACCESS_KEY"]), + required=True, + no_log=True, + ), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + if not HAS_BOTO3: + module.fail_json(msg=missing_required_lib("boto3")) + + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_spaces_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_spaces_info.py new file mode 100644 index 00000000..50f05002 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_spaces_info.py @@ -0,0 +1,160 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: 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 + + +DOCUMENTATION = r""" +--- +module: digital_ocean_spaces_info +short_description: List DigitalOcean Spaces. +description: + - List DigitalOcean Spaces. +author: Mark Mercado (@mamercad) +version_added: 1.15.0 +options: + state: + description: + - Only present is supported. + default: present + type: str + choices: ["present"] + region: + description: + - The region from which to list Spaces. + aliases: ["region_id"] + required: true + type: str + aws_access_key_id: + description: + - The AWS_ACCESS_KEY_ID to use. + required: true + type: str + aliases: ["AWS_ACCESS_KEY_ID"] + aws_secret_access_key: + description: + - The AWS_SECRET_ACCESS_KEY to use. + required: true + type: str + aliases: ["AWS_SECRET_ACCESS_KEY"] +requirements: + - boto3 +extends_documentation_fragment: + - community.digitalocean.digital_ocean.documentation +""" + + +EXAMPLES = r""" +- name: List all Spaces in nyc3 + community.digitalocean.digital_ocean_spaces_info: + state: present + region: nyc3 +""" + + +RETURN = r""" +data: + description: List of DigitalOcean Spaces + returned: always + type: dict + sample: + spaces: + - endpoint_url: https://nyc3.digitaloceanspaces.com + name: gh-ci-space + region: nyc3 + space_url: https://gh-ci-space.nyc3.digitaloceanspaces.com +""" + +from ansible.module_utils.basic import ( + AnsibleModule, + missing_required_lib, + env_fallback, + to_native, +) +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from traceback import format_exc + +try: + import boto3 + + HAS_BOTO3 = True +except Exception: + HAS_BOTO3 = False + + +def run(module): + state = module.params.get("state") + region = module.params.get("region") + aws_access_key_id = module.params.get("aws_access_key_id") + aws_secret_access_key = module.params.get("aws_secret_access_key") + + if state == "present": + try: + session = boto3.session.Session() + client = session.client( + "s3", + region_name=region, + endpoint_url=f"https://{region}.digitaloceanspaces.com", + aws_access_key_id=aws_access_key_id, + aws_secret_access_key=aws_secret_access_key, + ) + response = client.list_buckets() + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + response_metadata = response.get("ResponseMetadata") + http_status_code = response_metadata.get("HTTPStatusCode") + + if http_status_code == 200: + spaces = [ + { + "name": space["Name"], + "region": region, + "endpoint_url": f"https://{region}.digitaloceanspaces.com", + "space_url": f"https://{space['Name']}.{region}.digitaloceanspaces.com", + } + for space in response["Buckets"] + ] + module.exit_json(changed=False, data={"spaces": spaces}) + + module.fail_json(changed=False, msg=f"Failed to list Spaces in {region}") + + +def main(): + + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + state=dict(type="str", choices=["present"], default="present"), + region=dict(type="str", aliases=["region_id"], required=True), + aws_access_key_id=dict( + type="str", + aliases=["AWS_ACCESS_KEY_ID"], + fallback=(env_fallback, ["AWS_ACCESS_KEY_ID"]), + required=True, + no_log=True, + ), + aws_secret_access_key=dict( + type="str", + aliases=["AWS_SECRET_ACCESS_KEY"], + fallback=(env_fallback, ["AWS_SECRET_ACCESS_KEY"]), + required=True, + no_log=True, + ), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + + if not HAS_BOTO3: + module.fail_json(msg=missing_required_lib("boto3")) + + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_sshkey.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_sshkey.py new file mode 100644 index 00000000..3a7e662b --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_sshkey.py @@ -0,0 +1,282 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: 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 + + +DOCUMENTATION = r""" +--- +module: digital_ocean_sshkey +short_description: Manage DigitalOcean SSH keys +description: + - Create/delete DigitalOcean SSH keys. +author: "Patrick Marques (@pmarques)" +options: + state: + description: + - Indicate desired state of the target. + default: present + choices: ['present', 'absent'] + type: str + fingerprint: + description: + - This is a unique identifier for the SSH key used to delete a key + aliases: ['id'] + type: str + name: + description: + - The name for the SSH key + type: str + ssh_pub_key: + description: + - The Public SSH key to add. + type: str +notes: + - Version 2 of DigitalOcean API is used. +requirements: + - "python >= 2.6" +extends_documentation_fragment: + - community.digitalocean.digital_ocean.documentation +""" + + +EXAMPLES = r""" +- name: "Create ssh key" + community.digitalocean.digital_ocean_sshkey: + oauth_token: "{{ oauth_token }}" + name: "My SSH Public Key" + ssh_pub_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAQQDDHr/jh2Jy4yALcK4JyWbVkPRaWmhck3IgCoeOO3z1e2dBowLh64QAM+Qb72pxekALga2oi4GvT+TlWNhzPH4V example" + state: present + register: result + +- name: "Delete ssh key" + community.digitalocean.digital_ocean_sshkey: + oauth_token: "{{ oauth_token }}" + state: "absent" + fingerprint: "3b:16:bf:e4:8b:00:8b:b8:59:8c:a9:d3:f0:19:45:fa" +""" + + +RETURN = r""" +# Digital Ocean API info https://docs.digitalocean.com/reference/api/api-reference/#tag/SSH-Keys +data: + description: This is only present when C(state=present) + returned: when C(state=present) + type: dict + sample: { + "ssh_key": { + "id": 512189, + "fingerprint": "3b:16:bf:e4:8b:00:8b:b8:59:8c:a9:d3:f0:19:45:fa", + "name": "My SSH Public Key", + "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAQQDDHr/jh2Jy4yALcK4JyWbVkPRaWmhck3IgCoeOO3z1e2dBowLh64QAM+Qb72pxekALga2oi4GvT+TlWNhzPH4V example" + } + } +""" + +import json +import hashlib +import base64 + +from ansible.module_utils.basic import env_fallback +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.urls import fetch_url + +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +class Response(object): + def __init__(self, resp, info): + self.body = None + if resp: + self.body = resp.read() + self.info = info + + @property + def json(self): + if not self.body: + if "body" in self.info: + return json.loads(self.info["body"]) + return None + try: + return json.loads(self.body) + except ValueError: + return None + + @property + def status_code(self): + return self.info["status"] + + +class Rest(object): + def __init__(self, module, headers): + self.module = module + self.headers = headers + self.baseurl = module.params.get("baseurl") + + def _url_builder(self, path): + if path[0] == "/": + path = path[1:] + return "%s/%s" % (self.baseurl, path) + + def send(self, method, path, data=None, headers=None): + url = self._url_builder(path) + data = self.module.jsonify(data) + timeout = self.module.params["timeout"] + + resp, info = fetch_url( + self.module, + url, + data=data, + headers=self.headers, + method=method, + timeout=timeout, + ) + + # Exceptions in fetch_url may result in a status -1, the ensures a + if info["status"] == -1: + self.module.fail_json(msg=info["msg"]) + + return Response(resp, info) + + def get(self, path, data=None, headers=None): + return self.send("GET", path, data, headers) + + def put(self, path, data=None, headers=None): + return self.send("PUT", path, data, headers) + + def post(self, path, data=None, headers=None): + return self.send("POST", path, data, headers) + + def delete(self, path, data=None, headers=None): + return self.send("DELETE", path, data, headers) + + +def core(module): + api_token = module.params["oauth_token"] + state = module.params["state"] + fingerprint = module.params["fingerprint"] + name = module.params["name"] + ssh_pub_key = module.params["ssh_pub_key"] + + rest = Rest( + module, + { + "Authorization": "Bearer {0}".format(api_token), + "Content-type": "application/json", + }, + ) + + fingerprint = fingerprint or ssh_key_fingerprint(module, ssh_pub_key) + response = rest.get("account/keys/{0}".format(fingerprint)) + status_code = response.status_code + json = response.json + + if status_code not in (200, 404): + module.fail_json( + msg="Error getting ssh key [{0}: {1}]".format( + status_code, response.json["message"] + ), + fingerprint=fingerprint, + ) + + if state in ("present"): + if status_code == 404: + # IF key not found create it! + + if module.check_mode: + module.exit_json(changed=True) + + payload = {"name": name, "public_key": ssh_pub_key} + response = rest.post("account/keys", data=payload) + status_code = response.status_code + json = response.json + if status_code == 201: + module.exit_json(changed=True, data=json) + + module.fail_json( + msg="Error creating ssh key [{0}: {1}]".format( + status_code, response.json["message"] + ) + ) + + elif status_code == 200: + # If key found was found, check if name needs to be updated + if name is None or json["ssh_key"]["name"] == name: + module.exit_json(changed=False, data=json) + + if module.check_mode: + module.exit_json(changed=True) + + payload = { + "name": name, + } + response = rest.put("account/keys/{0}".format(fingerprint), data=payload) + status_code = response.status_code + json = response.json + if status_code == 200: + module.exit_json(changed=True, data=json) + + module.fail_json( + msg="Error updating ssh key name [{0}: {1}]".format( + status_code, response.json["message"] + ), + fingerprint=fingerprint, + ) + + elif state in ("absent"): + if status_code == 404: + module.exit_json(changed=False) + + if module.check_mode: + module.exit_json(changed=True) + + response = rest.delete("account/keys/{0}".format(fingerprint)) + status_code = response.status_code + json = response.json + if status_code == 204: + module.exit_json(changed=True) + + module.fail_json( + msg="Error creating ssh key [{0}: {1}]".format( + status_code, response.json["message"] + ) + ) + + +def ssh_key_fingerprint(module, ssh_pub_key): + try: + key = ssh_pub_key.split(None, 2)[1] + fingerprint = hashlib.md5(base64.b64decode(key)).hexdigest() + return ":".join(a + b for a, b in zip(fingerprint[::2], fingerprint[1::2])) + except IndexError: + module.fail_json( + msg="This does not appear to be a valid public key. Please verify the format and value provided in ssh_public_key." + ) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + state=dict(choices=["present", "absent"], default="present"), + fingerprint=dict(aliases=["id"], required=False), + name=dict(required=False), + ssh_pub_key=dict(required=False), + ) + module = AnsibleModule( + argument_spec=argument_spec, + required_one_of=(("fingerprint", "ssh_pub_key"),), + supports_check_mode=True, + ) + + core(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_sshkey_facts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_sshkey_facts.py new file mode 100644 index 00000000..cfc61310 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_sshkey_facts.py @@ -0,0 +1,107 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: 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 + + +DOCUMENTATION = r""" +--- +module: digital_ocean_sshkey_info +short_description: Gather information about DigitalOcean SSH keys +description: + - This module can be used to gather information about DigitalOcean SSH keys. + - This module replaces the C(digital_ocean_sshkey_facts) module. +author: "Patrick Marques (@pmarques)" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +notes: + - Version 2 of DigitalOcean API is used. +requirements: + - "python >= 2.6" +""" + + +EXAMPLES = r""" +- name: Gather information about DigitalOcean SSH keys + community.digitalocean.digital_ocean_sshkey_info: + oauth_token: "{{ my_do_key }}" + register: ssh_keys + +- name: Set facts based on the gathered information + set_fact: + pubkey: "{{ item.public_key }}" + loop: "{{ ssh_keys.data | community.general.json_query(ssh_pubkey) }}" + vars: + ssh_pubkey: "[?name=='ansible_ctrl']" + +- name: Print SSH public key + debug: + msg: "{{ pubkey }}" +""" + + +RETURN = r""" +# Digital Ocean API info https://docs.digitalocean.com/reference/api/api-reference/#operation/list_all_keys +data: + description: List of SSH keys on DigitalOcean + returned: success and no resource constraint + type: list + elements: dict + sample: [ + { + "id": 512189, + "fingerprint": "3b:16:bf:e4:8b:00:8b:b8:59:8c:a9:d3:f0:19:45:fa", + "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAQQDDHr/jh2Jy4yALcK4JyWbVkPRaWmhck3IgCoeOO3z1e2dBowLh64QAM+Qb72pxekALga2oi4GvT+TlWNhzPH4V example", + "name": "My SSH Public Key" + } + ] +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +def core(module): + rest = DigitalOceanHelper(module) + + response = rest.get("account/keys") + status_code = response.status_code + json = response.json + if status_code == 200: + module.exit_json(changed=False, data=json["ssh_keys"]) + else: + module.fail_json( + msg="Error fetching SSH Key information [{0}: {1}]".format( + status_code, response.json["message"] + ) + ) + + +def main(): + module = AnsibleModule( + argument_spec=DigitalOceanHelper.digital_ocean_argument_spec(), + supports_check_mode=True, + ) + if module._name in ( + "digital_ocean_sshkey_facts", + "community.digitalocean.digital_ocean_sshkey_facts", + ): + module.deprecate( + "The 'digital_ocean_sshkey_facts' module has been renamed to 'digital_ocean_sshkey_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + core(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_sshkey_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_sshkey_info.py new file mode 100644 index 00000000..cfc61310 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_sshkey_info.py @@ -0,0 +1,107 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright: 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 + + +DOCUMENTATION = r""" +--- +module: digital_ocean_sshkey_info +short_description: Gather information about DigitalOcean SSH keys +description: + - This module can be used to gather information about DigitalOcean SSH keys. + - This module replaces the C(digital_ocean_sshkey_facts) module. +author: "Patrick Marques (@pmarques)" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +notes: + - Version 2 of DigitalOcean API is used. +requirements: + - "python >= 2.6" +""" + + +EXAMPLES = r""" +- name: Gather information about DigitalOcean SSH keys + community.digitalocean.digital_ocean_sshkey_info: + oauth_token: "{{ my_do_key }}" + register: ssh_keys + +- name: Set facts based on the gathered information + set_fact: + pubkey: "{{ item.public_key }}" + loop: "{{ ssh_keys.data | community.general.json_query(ssh_pubkey) }}" + vars: + ssh_pubkey: "[?name=='ansible_ctrl']" + +- name: Print SSH public key + debug: + msg: "{{ pubkey }}" +""" + + +RETURN = r""" +# Digital Ocean API info https://docs.digitalocean.com/reference/api/api-reference/#operation/list_all_keys +data: + description: List of SSH keys on DigitalOcean + returned: success and no resource constraint + type: list + elements: dict + sample: [ + { + "id": 512189, + "fingerprint": "3b:16:bf:e4:8b:00:8b:b8:59:8c:a9:d3:f0:19:45:fa", + "public_key": "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAQQDDHr/jh2Jy4yALcK4JyWbVkPRaWmhck3IgCoeOO3z1e2dBowLh64QAM+Qb72pxekALga2oi4GvT+TlWNhzPH4V example", + "name": "My SSH Public Key" + } + ] +""" + +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +def core(module): + rest = DigitalOceanHelper(module) + + response = rest.get("account/keys") + status_code = response.status_code + json = response.json + if status_code == 200: + module.exit_json(changed=False, data=json["ssh_keys"]) + else: + module.fail_json( + msg="Error fetching SSH Key information [{0}: {1}]".format( + status_code, response.json["message"] + ) + ) + + +def main(): + module = AnsibleModule( + argument_spec=DigitalOceanHelper.digital_ocean_argument_spec(), + supports_check_mode=True, + ) + if module._name in ( + "digital_ocean_sshkey_facts", + "community.digitalocean.digital_ocean_sshkey_facts", + ): + module.deprecate( + "The 'digital_ocean_sshkey_facts' module has been renamed to 'digital_ocean_sshkey_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + core(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_tag.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_tag.py new file mode 100644 index 00000000..083129a9 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_tag.py @@ -0,0 +1,219 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright: 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 + + +DOCUMENTATION = r""" +--- +module: digital_ocean_tag +short_description: Create and remove tag(s) to DigitalOcean resource. +description: + - Create and remove tag(s) to DigitalOcean resource. +author: "Victor Volle (@kontrafiktion)" +options: + name: + description: + - The name of the tag. The supported characters for names include + alphanumeric characters, dashes, and underscores. + required: true + type: str + resource_id: + description: + - The ID of the resource to operate on. + - The data type of resource_id is changed from integer to string since Ansible 2.5. + aliases: ['droplet_id'] + type: str + resource_type: + description: + - The type of resource to operate on. Currently, only tagging of + droplets is supported. + default: droplet + choices: ['droplet'] + type: str + state: + description: + - Whether the tag should be present or absent on the resource. + default: present + type: str + choices: ['present', 'absent'] +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +notes: + - Two environment variables can be used, DO_API_KEY and DO_API_TOKEN. + They both refer to the v2 token. + - As of Ansible 2.0, Version 2 of the DigitalOcean API is used. + +requirements: + - "python >= 2.6" +""" + + +EXAMPLES = r""" +- name: Create a tag + community.digitalocean.digital_ocean_tag: + name: production + state: present + +- name: Tag a resource; creating the tag if it does not exist + community.digitalocean.digital_ocean_tag: + name: "{{ item }}" + resource_id: "73333005" + state: present + loop: + - staging + - dbserver + +- name: Untag a resource + community.digitalocean.digital_ocean_tag: + name: staging + resource_id: "73333005" + state: absent + +# Deleting a tag also untags all the resources that have previously been +# tagged with it +- name: Remove a tag + community.digitalocean.digital_ocean_tag: + name: dbserver + state: absent +""" + + +RETURN = r""" +data: + description: a DigitalOcean Tag resource + returned: success and no resource constraint + type: dict + sample: { + "tag": { + "name": "awesome", + "resources": { + "droplets": { + "count": 0, + "last_tagged": null + } + } + } + } +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + state = module.params["state"] + name = module.params["name"] + resource_id = module.params["resource_id"] + resource_type = module.params["resource_type"] + + rest = DigitalOceanHelper(module) + response = rest.get("tags/{0}".format(name)) + + if state == "present": + status_code = response.status_code + resp_json = response.json + changed = False + if status_code == 200 and resp_json["tag"]["name"] == name: + changed = False + else: + # Ensure Tag exists + response = rest.post("tags", data={"name": name}) + status_code = response.status_code + resp_json = response.json + if status_code == 201: + changed = True + elif status_code == 422: + changed = False + else: + module.exit_json(changed=False, data=resp_json) + + if resource_id is None: + # No resource defined, we're done. + module.exit_json(changed=changed, data=resp_json) + else: + # Check if resource is already tagged or not + found = False + url = "{0}?tag_name={1}".format(resource_type, name) + if resource_type == "droplet": + url = "droplets?tag_name={0}".format(name) + response = rest.get(url) + status_code = response.status_code + resp_json = response.json + if status_code == 200: + for resource in resp_json["droplets"]: + if not found and resource["id"] == int(resource_id): + found = True + break + if not found: + # If resource is not tagged, tag a resource + url = "tags/{0}/resources".format(name) + payload = { + "resources": [ + {"resource_id": resource_id, "resource_type": resource_type} + ] + } + response = rest.post(url, data=payload) + if response.status_code == 204: + module.exit_json(changed=True) + else: + module.fail_json( + msg="error tagging resource '{0}': {1}".format( + resource_id, response.json["message"] + ) + ) + else: + # Already tagged resource + module.exit_json(changed=False) + else: + # Unable to find resource specified by user + module.fail_json(msg=resp_json["message"]) + + elif state == "absent": + if response.status_code == 200: + if resource_id: + url = "tags/{0}/resources".format(name) + payload = { + "resources": [ + {"resource_id": resource_id, "resource_type": resource_type} + ] + } + response = rest.delete(url, data=payload) + else: + url = "tags/{0}".format(name) + response = rest.delete(url) + if response.status_code == 204: + module.exit_json(changed=True) + else: + module.exit_json(changed=False) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + name=dict(type="str", required=True), + resource_id=dict(aliases=["droplet_id"], type="str"), + resource_type=dict(choices=["droplet"], default="droplet"), + state=dict(choices=["present", "absent"], default="present"), + ) + + module = AnsibleModule(argument_spec=argument_spec) + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_tag_facts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_tag_facts.py new file mode 100644 index 00000000..43ccaa9c --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_tag_facts.py @@ -0,0 +1,126 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_tag_info +short_description: Gather information about DigitalOcean tags +description: + - This module can be used to gather information about DigitalOcean provided tags. + - This module was called C(digital_ocean_tag_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + tag_name: + description: + - Tag name that can be used to identify and reference a tag. + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all tags + community.digitalocean.digital_ocean_tag_info: + oauth_token: "{{ oauth_token }}" + +- name: Gather information about tag with given name + community.digitalocean.digital_ocean_tag_info: + oauth_token: "{{ oauth_token }}" + tag_name: "extra_awesome_tag" + +- name: Get resources from tag name + community.digitalocean.digital_ocean_tag_info: + register: resp_out +- set_fact: + resources: "{{ item.resources }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?name=='extra_awesome_tag']" +- debug: + var: resources +""" + + +RETURN = r""" +data: + description: DigitalOcean tag information + returned: success + type: list + elements: dict + sample: [ + { + "name": "extra-awesome", + "resources": { + "droplets": { + "count": 1, + ... + } + } + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + tag_name = module.params.get("tag_name", None) + rest = DigitalOceanHelper(module) + + base_url = "tags" + if tag_name is not None: + response = rest.get("%s/%s" % (base_url, tag_name)) + status_code = response.status_code + + if status_code != 200: + module.fail_json(msg="Failed to retrieve tags for DigitalOcean") + + tag = [response.json["tag"]] + else: + tag = rest.get_paginated_data(base_url=base_url + "?", data_key_name="tags") + + module.exit_json(changed=False, data=tag) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + tag_name=dict(type="str", required=False), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_tag_facts", + "community.digitalocean.digital_ocean_tag_facts", + ): + module.deprecate( + "The 'digital_ocean_tag_facts' module has been renamed to 'digital_ocean_tag_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_tag_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_tag_info.py new file mode 100644 index 00000000..43ccaa9c --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_tag_info.py @@ -0,0 +1,126 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_tag_info +short_description: Gather information about DigitalOcean tags +description: + - This module can be used to gather information about DigitalOcean provided tags. + - This module was called C(digital_ocean_tag_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + tag_name: + description: + - Tag name that can be used to identify and reference a tag. + required: false + type: str +requirements: + - "python >= 2.6" +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all tags + community.digitalocean.digital_ocean_tag_info: + oauth_token: "{{ oauth_token }}" + +- name: Gather information about tag with given name + community.digitalocean.digital_ocean_tag_info: + oauth_token: "{{ oauth_token }}" + tag_name: "extra_awesome_tag" + +- name: Get resources from tag name + community.digitalocean.digital_ocean_tag_info: + register: resp_out +- set_fact: + resources: "{{ item.resources }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?name=='extra_awesome_tag']" +- debug: + var: resources +""" + + +RETURN = r""" +data: + description: DigitalOcean tag information + returned: success + type: list + elements: dict + sample: [ + { + "name": "extra-awesome", + "resources": { + "droplets": { + "count": 1, + ... + } + } + }, + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + tag_name = module.params.get("tag_name", None) + rest = DigitalOceanHelper(module) + + base_url = "tags" + if tag_name is not None: + response = rest.get("%s/%s" % (base_url, tag_name)) + status_code = response.status_code + + if status_code != 200: + module.fail_json(msg="Failed to retrieve tags for DigitalOcean") + + tag = [response.json["tag"]] + else: + tag = rest.get_paginated_data(base_url=base_url + "?", data_key_name="tags") + + module.exit_json(changed=False, data=tag) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + tag_name=dict(type="str", required=False), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_tag_facts", + "community.digitalocean.digital_ocean_tag_facts", + ): + module.deprecate( + "The 'digital_ocean_tag_facts' module has been renamed to 'digital_ocean_tag_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_volume_facts.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_volume_facts.py new file mode 100644 index 00000000..4e2cc179 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_volume_facts.py @@ -0,0 +1,149 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_volume_info +short_description: Gather information about DigitalOcean volumes +description: + - This module can be used to gather information about DigitalOcean provided volumes. + - This module was called C(digital_ocean_volume_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + region_name: + description: + - Name of region to restrict results to volumes available in a specific region. + - Please use M(community.digitalocean.digital_ocean_region_info) for getting valid values related regions. + required: false + type: str +requirements: + - "python >= 2.6" + +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all volume + community.digitalocean.digital_ocean_volume_info: + oauth_token: "{{ oauth_token }}" + +- name: Gather information about volume in given region + community.digitalocean.digital_ocean_volume_info: + region_name: nyc1 + oauth_token: "{{ oauth_token }}" + +- name: Get information about volume named nyc3-test-volume + community.digitalocean.digital_ocean_volume_info: + register: resp_out +- set_fact: + volume_id: "{{ item.id }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?name=='nyc3-test-volume']" +- debug: var=volume_id +""" + + +RETURN = r""" +data: + description: DigitalOcean volume information + returned: success + type: list + sample: [ + { + "id": "506f78a4-e098-11e5-ad9f-000f53306ae1", + "region": { + "name": "New York 1", + "slug": "nyc1", + "sizes": [ + "s-1vcpu-1gb", + "s-1vcpu-2gb", + "s-1vcpu-3gb", + "s-2vcpu-2gb", + "s-3vcpu-1gb", + "s-2vcpu-4gb", + "s-4vcpu-8gb", + "s-6vcpu-16gb", + "s-8vcpu-32gb", + "s-12vcpu-48gb", + "s-16vcpu-64gb", + "s-20vcpu-96gb", + "s-24vcpu-128gb", + "s-32vcpu-192gb" + ], + "features": [ + "private_networking", + "backups", + "ipv6", + "metadata" + ], + "available": true + }, + "droplet_ids": [ + + ], + "name": "example", + "description": "Block store for examples", + "size_gigabytes": 10, + "created_at": "2016-03-02T17:00:49Z" + } + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + region_name = module.params.get("region_name", None) + + rest = DigitalOceanHelper(module) + + base_url = "volumes?" + if region_name is not None: + base_url += "region=%s&" % region_name + + volumes = rest.get_paginated_data(base_url=base_url, data_key_name="volumes") + + module.exit_json(changed=False, data=volumes) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + region_name=dict(type="str", required=False), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_volume_facts", + "community.digitalocean.digital_ocean_volume_facts", + ): + module.deprecate( + "The 'digital_ocean_volume_facts' module has been renamed to 'digital_ocean_volume_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_volume_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_volume_info.py new file mode 100644 index 00000000..4e2cc179 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_volume_info.py @@ -0,0 +1,149 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2018, Abhijeet Kasurde <akasurde@redhat.com> +# 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 = r""" +--- +module: digital_ocean_volume_info +short_description: Gather information about DigitalOcean volumes +description: + - This module can be used to gather information about DigitalOcean provided volumes. + - This module was called C(digital_ocean_volume_facts) before Ansible 2.9. The usage did not change. +author: "Abhijeet Kasurde (@Akasurde)" +options: + region_name: + description: + - Name of region to restrict results to volumes available in a specific region. + - Please use M(community.digitalocean.digital_ocean_region_info) for getting valid values related regions. + required: false + type: str +requirements: + - "python >= 2.6" + +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Gather information about all volume + community.digitalocean.digital_ocean_volume_info: + oauth_token: "{{ oauth_token }}" + +- name: Gather information about volume in given region + community.digitalocean.digital_ocean_volume_info: + region_name: nyc1 + oauth_token: "{{ oauth_token }}" + +- name: Get information about volume named nyc3-test-volume + community.digitalocean.digital_ocean_volume_info: + register: resp_out +- set_fact: + volume_id: "{{ item.id }}" + loop: "{{ resp_out.data | community.general.json_query(name) }}" + vars: + name: "[?name=='nyc3-test-volume']" +- debug: var=volume_id +""" + + +RETURN = r""" +data: + description: DigitalOcean volume information + returned: success + type: list + sample: [ + { + "id": "506f78a4-e098-11e5-ad9f-000f53306ae1", + "region": { + "name": "New York 1", + "slug": "nyc1", + "sizes": [ + "s-1vcpu-1gb", + "s-1vcpu-2gb", + "s-1vcpu-3gb", + "s-2vcpu-2gb", + "s-3vcpu-1gb", + "s-2vcpu-4gb", + "s-4vcpu-8gb", + "s-6vcpu-16gb", + "s-8vcpu-32gb", + "s-12vcpu-48gb", + "s-16vcpu-64gb", + "s-20vcpu-96gb", + "s-24vcpu-128gb", + "s-32vcpu-192gb" + ], + "features": [ + "private_networking", + "backups", + "ipv6", + "metadata" + ], + "available": true + }, + "droplet_ids": [ + + ], + "name": "example", + "description": "Block store for examples", + "size_gigabytes": 10, + "created_at": "2016-03-02T17:00:49Z" + } + ] +""" + +from traceback import format_exc +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) +from ansible.module_utils._text import to_native + + +def core(module): + region_name = module.params.get("region_name", None) + + rest = DigitalOceanHelper(module) + + base_url = "volumes?" + if region_name is not None: + base_url += "region=%s&" % region_name + + volumes = rest.get_paginated_data(base_url=base_url, data_key_name="volumes") + + module.exit_json(changed=False, data=volumes) + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + region_name=dict(type="str", required=False), + ) + module = AnsibleModule(argument_spec=argument_spec, supports_check_mode=True) + if module._name in ( + "digital_ocean_volume_facts", + "community.digitalocean.digital_ocean_volume_facts", + ): + module.deprecate( + "The 'digital_ocean_volume_facts' module has been renamed to 'digital_ocean_volume_info'", + version="2.0.0", + collection_name="community.digitalocean", + ) # was Ansible 2.13 + + try: + core(module) + except Exception as e: + module.fail_json(msg=to_native(e), exception=format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_vpc.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_vpc.py new file mode 100644 index 00000000..598ec2bf --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_vpc.py @@ -0,0 +1,284 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2021, Mark Mercado <mamercad@gmail.com> +# 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 = r""" +--- +module: digital_ocean_vpc +short_description: Create and delete DigitalOcean VPCs +version_added: 1.7.0 +description: + - This module can be used to create and delete DigitalOcean VPCs. +author: "Mark Mercado (@mamercad)" +options: + state: + description: + - Whether the VPC should be present (created) or absent (deleted). + default: present + choices: + - present + - absent + type: str + name: + description: + - The name of the VPC. + - Must be unique and contain alphanumeric characters, dashes, and periods only. + type: str + required: true + description: + description: + - A free-form text field for describing the VPC's purpose. + - It may be a maximum of 255 characters. + type: str + default: + description: + - A boolean value indicating whether or not the VPC is the default network for the region. + - All applicable resources are placed into the default VPC network unless otherwise specified during their creation. + - The C(default) field cannot be unset from C(true). + - If you want to set a new default VPC network, update the C(default) field of another VPC network in the same region. + - The previous network's C(default) field will be set to C(false) when a new default VPC has been defined. + type: bool + default: false + region: + description: + - The slug identifier for the region where the VPC will be created. + type: str + ip_range: + description: + - The requested range of IP addresses for the VPC in CIDR notation. + - Network ranges cannot overlap with other networks in the same account and must be in range of private addresses as defined in RFC1918. + - It may not be smaller than /24 nor larger than /16. + - If no IP range is specified, a /20 network range is generated that won't conflict with other VPC networks in your account. + type: str +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation + +""" + + +EXAMPLES = r""" +- name: Create a VPC + community.digitalocean.digital_ocean_vpc: + state: present + name: myvpc1 + region: nyc1 + +- name: Create a VPC (choose IP range) + community.digitalocean.digital_ocean_vpc: + state: present + name: myvpc1 + region: nyc1 + ip_range: 192.168.192.0/24 + +- name: Update a VPC (make it default) + community.digitalocean.digital_ocean_vpc: + state: present + name: myvpc1 + region: nyc1 + default: true + +- name: Update a VPC (change description) + community.digitalocean.digital_ocean_vpc: + state: present + name: myvpc1 + region: nyc1 + description: myvpc + +- name: Delete a VPC + community.digitalocean.digital_ocean_vpc: + state: absent + name: myvpc1 +""" + + +RETURN = r""" +data: + description: A DigitalOcean VPC. + returned: success + type: dict + sample: + msg: Created VPC myvpc1 in nyc1 + vpc: + created_at: '2021-06-17T11:43:12.12121565Z' + default: false + description: '' + id: a3b72d97-192f-4984-9d71-08a5faf2e0c7 + ip_range: 10.116.16.0/20 + name: testvpc1 + region: nyc1 + urn: do:vpc:a3b72d97-192f-4984-9d71-08a5faf2e0c7 +""" + + +import time +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +class DOVPC(object): + def __init__(self, module): + self.rest = DigitalOceanHelper(module) + self.module = module + # pop the oauth token so we don't include it in the POST data + self.module.params.pop("oauth_token") + self.name = module.params.get("name", None) + self.description = module.params.get("description", None) + self.default = module.params.get("default", False) + self.region = module.params.get("region", None) + self.ip_range = module.params.get("ip_range", None) + self.vpc_id = module.params.get("vpc_id", None) + + def get_by_name(self): + page = 1 + while page is not None: + response = self.rest.get("vpcs?page={0}".format(page)) + json_data = response.json + if response.status_code == 200: + for vpc in json_data["vpcs"]: + if vpc.get("name", None) == self.name: + return vpc + if ( + "links" in json_data + and "pages" in json_data["links"] + and "next" in json_data["links"]["pages"] + ): + page += 1 + else: + page = None + return None + + def create(self): + if self.module.check_mode: + return self.module.exit_json(changed=True) + + vpc = self.get_by_name() + if vpc is not None: # update + vpc_id = vpc.get("id", None) + if vpc_id is not None: + data = { + "name": self.name, + } + if self.description is not None: + data["description"] = self.description + if self.default is not False: + data["default"] = True + response = self.rest.put("vpcs/{0}".format(vpc_id), data=data) + json = response.json + if response.status_code != 200: + self.module.fail_json( + msg="Failed to update VPC {0} in {1}: {2}".format( + self.name, self.region, json["message"] + ) + ) + else: + self.module.exit_json( + changed=False, + data=json, + msg="Updated VPC {0} in {1}".format(self.name, self.region), + ) + else: + self.module.fail_json( + changed=False, msg="Unexpected error, please file a bug" + ) + + else: # create + data = { + "name": self.name, + "region": self.region, + } + if self.description is not None: + data["description"] = self.description + if self.ip_range is not None: + data["ip_range"] = self.ip_range + + response = self.rest.post("vpcs", data=data) + status = response.status_code + json = response.json + if status == 201: + self.module.exit_json( + changed=True, + data=json, + msg="Created VPC {0} in {1}".format(self.name, self.region), + ) + else: + self.module.fail_json( + changed=False, + msg="Failed to create VPC {0} in {1}: {2}".format( + self.name, self.region, json["message"] + ), + ) + + def delete(self): + if self.module.check_mode: + return self.module.exit_json(changed=True) + + vpc = self.get_by_name() + if vpc is None: + self.module.fail_json( + msg="Unable to find VPC {0} in {1}".format(self.name, self.region) + ) + else: + vpc_id = vpc.get("id", None) + if vpc_id is not None: + response = self.rest.delete("vpcs/{0}".format(str(vpc_id))) + status = response.status_code + json = response.json + if status == 204: + self.module.exit_json( + changed=True, + msg="Deleted VPC {0} in {1} ({2})".format( + self.name, self.region, vpc_id + ), + ) + else: + json = response.json + self.module.fail_json( + changed=False, + msg="Failed to delete VPC {0} ({1}): {2}".format( + self.name, vpc_id, json["message"] + ), + ) + + +def run(module): + state = module.params.pop("state") + vpc = DOVPC(module) + if state == "present": + vpc.create() + elif state == "absent": + vpc.delete() + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + state=dict(choices=["present", "absent"], default="present"), + name=dict(type="str", required=True), + description=dict(type="str"), + default=dict(type="bool", default=False), + region=dict(type="str"), + ip_range=dict(type="str"), + ) + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[ + ["state", "present", ["name", "region"]], + ["state", "absent", ["name"]], + ], + supports_check_mode=True, + ) + + run(module) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_vpc_info.py b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_vpc_info.py new file mode 100644 index 00000000..c2b11078 --- /dev/null +++ b/ansible_collections/community/digitalocean/plugins/modules/digital_ocean_vpc_info.py @@ -0,0 +1,161 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# Copyright: (c) 2018, Ansible Project +# Copyright: (c) 2021, Mark Mercado <mamercad@gmail.com> +# 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 = r""" +--- +module: digital_ocean_vpc_info +short_description: Gather information about DigitalOcean VPCs +version_added: 1.7.0 +description: + - This module can be used to gather information about DigitalOcean VPCs. +author: "Mark Mercado (@mamercad)" +options: + members: + description: + - Return VPC members (instead of all VPCs). + type: bool + default: False + name: + description: + - The name of the VPC. + type: str +extends_documentation_fragment: +- community.digitalocean.digital_ocean.documentation +""" + + +EXAMPLES = r""" +- name: Fetch all VPCs + community.digitalocean.digital_ocean_vpc_info: + register: my_vpcs + +- name: Fetch members of a VPC + community.digitalocean.digital_ocean_vpc_info: + members: true + name: myvpc1 + register: my_vpc_members +""" + + +RETURN = r""" +data: + description: All DigitalOcean VPCs, or, members of a VPC (with C(members=True)). + returned: success + type: dict + sample: + - created_at: '2021-02-06T17:57:22Z' + default: true + description: '' + id: 0db3519b-9efc-414a-8868-8f2e6934688c + ip_range: 10.116.0.0/20 + name: default-nyc1 + region: nyc1 + urn: do:vpc:0db3519b-9efc-414a-8868-8f2e6934688c + - links: {} + members: [] + meta: + total: 0 +""" + + +import time +from ansible.module_utils.basic import AnsibleModule +from ansible_collections.community.digitalocean.plugins.module_utils.digital_ocean import ( + DigitalOceanHelper, +) + + +class DOVPCInfo(object): + def __init__(self, module): + self.rest = DigitalOceanHelper(module) + self.module = module + # pop the oauth token so we don't include it in the POST data + self.module.params.pop("oauth_token") + self.name = self.module.params.pop("name", "") + self.members = self.module.params.pop("members", False) + + def get_by_name(self): + page = 1 + while page is not None: + response = self.rest.get("vpcs?page={0}".format(page)) + json_data = response.json + if response.status_code == 200: + for vpc in json_data["vpcs"]: + if vpc.get("name", None) == self.name: + return vpc + if ( + "links" in json_data + and "pages" in json_data["links"] + and "next" in json_data["links"]["pages"] + ): + page += 1 + else: + page = None + return None + + def get(self): + if self.module.check_mode: + return self.module.exit_json(changed=False) + + if not self.members: + base_url = "vpcs?" + vpcs = self.rest.get_paginated_data(base_url=base_url, data_key_name="vpcs") + self.module.exit_json(changed=False, data=vpcs) + else: + vpc = self.get_by_name() + if vpc is not None: + vpc_id = vpc.get("id", None) + if vpc_id is not None: + response = self.rest.get("vpcs/{0}/members".format(vpc_id)) + json = response.json + if response.status_code != 200: + self.module.fail_json( + msg="Failed to find VPC named {0}: {1}".format( + self.name, json["message"] + ) + ) + else: + self.module.exit_json(changed=False, data=json) + else: + self.module.fail_json( + changed=False, msg="Unexpected error, please file a bug" + ) + else: + self.module.fail_json( + changed=False, + msg="Could not find a VPC named {0}".format(self.name), + ) + + +def run(module): + vpcs = DOVPCInfo(module) + vpcs.get() + + +def main(): + argument_spec = DigitalOceanHelper.digital_ocean_argument_spec() + argument_spec.update( + members=dict(type="bool", default=False), + name=dict(type="str"), + ) + module = AnsibleModule( + argument_spec=argument_spec, + required_if=[ + ["members", True, ["name"]], + ], + supports_check_mode=True, + ) + + run(module) + + +if __name__ == "__main__": + main() |