From 2547932e3d99b7ce667e1da775403acccc33ecaa Mon Sep 17 00:00:00 2001
From: Edward Hilgendorf <edward@hilgendorf.me>
Date: Sat, 11 Dec 2021 12:14:32 -0800
Subject: [PATCH] add dnsimple_info module, see issue #3569 (#3739)

* add dnsimple_info module, see issue #3569

https://github.com/ansible-collections/community.general/issues/3569#issuecomment-945002861

* Update plugins/modules/net_tools/dnsimple_info.py

Update dnsimple_info.py

Update dnsimple_info.py

Update dnsimple_info.py

Update BOTMETA.yml

Update dnsimple_info.py

Create dnsimple_info.py

Create dnsimple_info.py

pep8

Update dnsimple_info.py

Update dnsimple_info.py

Update dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update dnsimple_info.py

add returns

pep8 spacing

Update dnsimple_info.py

Update dnsimple_info.py

change return results to list

fix time stamps

Update dnsimple_info.py

remove extra comma

Update plugins/modules/net_tools/dnsimple_info.py

Update test_dnsimple_info.py

Update dnsimple_info.py

fix descriptions

Update dnsimple_info.py

Update dnsimple_info.py

Update dnsimple_info.py

Update dnsimple_info.py

Update dnsimple_info.py

Update dnsimple_info.py

Update dnsimple_info.py

Update dnsimple_info.py

missing punctuation throughout docs

Update dnsimple_info.py

add elements in descriptions

Update dnsimple_info.py

indentation error

Update dnsimple_info.py

Update dnsimple_info.py

Update dnsimple_info.py

Update dnsimple_info.py

Update dnsimple_info.py

refactor, remove unneeded arguments

refactor and error handling

formatting

add unit test

Update test_dnsimple_info.py

Update test_dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update plugins/modules/net_tools/dnsimple_info.py

Update test_dnsimple_info.py

Update test_dnsimple_info.py

Update test_dnsimple_info.py

Update test_dnsimple_info.py

Update test_dnsimple_info.py

Update test_dnsimple_info.py

assert fail/exit

Update test_dnsimple_info.py

pep8 fixes

Update test_dnsimple_info.py

Update test_dnsimple_info.py

Update test_dnsimple_info.py

Update test_dnsimple_info.py

Co-Authored-By: Felix Fontein <felix@fontein.de>

Co-authored-by: Felix Fontein <felix@fontein.de>
---
 .github/BOTMETA.yml                           |   2 +
 plugins/modules/dnsimple_info.py              |   1 +
 plugins/modules/net_tools/dnsimple_info.py    | 335 ++++++++++++++++++
 .../modules/net_tools/test_dnsimple_info.py   | 113 ++++++
 4 files changed, 451 insertions(+)
 create mode 120000 plugins/modules/dnsimple_info.py
 create mode 100644 plugins/modules/net_tools/dnsimple_info.py
 create mode 100644 tests/unit/plugins/modules/net_tools/test_dnsimple_info.py

diff --git a/.github/BOTMETA.yml b/.github/BOTMETA.yml
index 4ae85b214d..02f6408696 100644
--- a/.github/BOTMETA.yml
+++ b/.github/BOTMETA.yml
@@ -619,6 +619,8 @@ files:
     labels: cloudflare_dns
   $modules/net_tools/dnsimple.py:
     maintainers: drcapulet
+  $modules/net_tools/dnsimple_info.py:
+    maintainers: edhilgendorf
   $modules/net_tools/dnsmadeeasy.py:
     maintainers: briceburg
   $modules/net_tools/gandi_livedns.py:
diff --git a/plugins/modules/dnsimple_info.py b/plugins/modules/dnsimple_info.py
new file mode 120000
index 0000000000..853fbaa533
--- /dev/null
+++ b/plugins/modules/dnsimple_info.py
@@ -0,0 +1 @@
+./net_tools/dnsimple_info.py
\ No newline at end of file
diff --git a/plugins/modules/net_tools/dnsimple_info.py b/plugins/modules/net_tools/dnsimple_info.py
new file mode 100644
index 0000000000..4ac22be0cb
--- /dev/null
+++ b/plugins/modules/net_tools/dnsimple_info.py
@@ -0,0 +1,335 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+#  Copyright: Edward Hilgendorf, <edward@hilgendorf.me>
+# 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: dnsimple_info
+
+short_description: Pull basic info from DNSimple API
+
+version_added: "4.2.0"
+
+description: Retrieve existing records and domains from DNSimple API.
+
+options:
+    name:
+        description:
+          - The domain name to retrieve info from.
+          - Will return all associated records for this domain if specified.
+          - If not specified, will return all domains associated with the account ID.
+        type: str
+
+    account_id:
+        description: The account ID to query.
+        required: true
+        type: str
+
+    api_key:
+        description: The API key to use.
+        required: true
+        type: str
+
+    record:
+        description:
+          - The record to find.
+          - If specified, only this record will be returned instead of all records.
+        required: false
+        type: str
+
+    sandbox:
+        description: Whether or not to use sandbox environment.
+        required: false
+        default: false
+        type: bool
+
+author:
+    -  Edward Hilgendorf (@edhilgendorf)
+'''
+
+EXAMPLES = r'''
+- name: Get all domains from an account
+  community.general.dnsimple_info:
+    account_id: "1234"
+    api_key: "1234"
+
+- name: Get all records from a domain
+  community.general.dnsimple_info:
+    name: "example.com"
+    account_id: "1234"
+    api_key: "1234"
+
+- name: Get all info from a matching record
+  community.general.dnsimple_info:
+    name: "example.com"
+    record: "subdomain"
+    account_id: "1234"
+    api_key: "1234"
+'''
+
+RETURN = r'''
+dnsimple_domain_info:
+    description: Returns a list of dictionaries of all domains associated with the supplied account ID.
+    type: list
+    elements: dict
+    returned: success when I(name) is not specified
+    sample:
+    - account_id: 1234
+      created_at: '2021-10-16T21:25:42Z'
+      id: 123456
+      last_transferred_at:
+      name: example.com
+      reverse: false
+      secondary: false
+      updated_at: '2021-11-10T20:22:50Z'
+    contains:
+      account_id:
+        description: The account ID.
+        type: int
+      created_at:
+        description: When the domain entry was created.
+        type: str
+      id:
+        description: ID of the entry.
+        type: int
+      last_transferred_at:
+        description: Date the domain was transferred, or empty if not.
+        type: str
+      name:
+        description: Name of the record.
+        type: str
+      reverse:
+        description: Whether or not it is a reverse zone record.
+        type: bool
+      updated_at:
+        description: When the domain entry was updated.
+        type: str
+
+dnsimple_records_info:
+    description: Returns a list of dictionaries with all records for the domain supplied.
+    type: list
+    elements: dict
+    returned: success when I(name) is specified, but I(record) is not
+    sample:
+    - content: ns1.dnsimple.com admin.dnsimple.com
+      created_at: '2021-10-16T19:07:34Z'
+      id: 12345
+      name: 'catheadbiscuit'
+      parent_id: null
+      priority: null
+      regions:
+        - global
+      system_record: true
+      ttl: 3600
+      type: SOA
+      updated_at: '2021-11-15T23:55:51Z'
+      zone_id: example.com
+    contains:
+      content:
+        description:  Content of the returned record.
+        type: str
+      created_at:
+        description: When the domain entry was created.
+        type: str
+      id:
+        description: ID of the entry.
+        type: int
+      name:
+        description: Name of the record.
+        type: str
+      parent_id:
+        description: Parent record or null.
+        type: int
+      priority:
+        description: Priority setting of the record.
+        type: str
+      regions:
+        description: List of regions where the record is available.
+        type: list
+      system_record:
+        description: Whether or not it is a system record.
+        type: bool
+      ttl:
+        description: Record TTL.
+        type: int
+      type:
+        description: Record type.
+        type: str
+      updated_at:
+        description: When the domain entry was updated.
+        type: str
+      zone_id:
+        description: ID of the zone that the record is associated with.
+        type: str
+dnsimple_record_info:
+    description: Returns a list of dictionaries that match the record supplied.
+    returned: success when I(name) and I(record) are specified
+    type: list
+    elements: dict
+    sample:
+    - content: 1.2.3.4
+      created_at: '2021-11-15T23:55:51Z'
+      id: 123456
+      name: catheadbiscuit
+      parent_id: null
+      priority: null
+      regions:
+        - global
+      system_record: false
+      ttl: 3600
+      type: A
+      updated_at: '2021-11-15T23:55:51Z'
+      zone_id: example.com
+    contains:
+      content:
+        description:  Content of the returned record.
+        type: str
+      created_at:
+        description: When the domain entry was created.
+        type: str
+      id:
+        description: ID of the entry.
+        type: int
+      name:
+        description: Name of the record.
+        type: str
+      parent_id:
+        description: Parent record or null.
+        type: int
+      priority:
+        description: Priority setting of the record.
+        type: str
+      regions:
+        description: List of regions where the record is available.
+        type: list
+      system_record:
+        description: Whether or not it is a system record.
+        type: bool
+      ttl:
+        description: Record TTL.
+        type: int
+      type:
+        description: Record type.
+        type: str
+      updated_at:
+        description: When the domain entry was updated.
+        type: str
+      zone_id:
+        description: ID of the zone that the record is associated with.
+        type: str
+'''
+
+import traceback
+from ansible.module_utils.basic import AnsibleModule
+from ansible.module_utils.basic import missing_required_lib
+import json
+
+try:
+    from requests import Request, Session
+except ImportError:
+    HAS_ANOTHER_LIBRARY = False
+    ANOTHER_LIBRARY_IMPORT_ERROR = traceback.format_exc()
+else:
+    HAS_ANOTHER_LIBRARY = True
+
+
+def build_url(account, key, is_sandbox):
+    headers = {'Accept': 'application/json',
+               'Authorization': 'Bearer ' + key}
+    url = 'https://api{sandbox}.dnsimple.com/'.format(
+        sandbox=".sandbox" if is_sandbox else "") + 'v2/' + account
+    req = Request(url=url, headers=headers)
+    prepped_request = req.prepare()
+    return prepped_request
+
+
+def iterate_data(module, request_object):
+    base_url = request_object.url
+    response = Session().send(request_object)
+    if 'pagination' in response.json():
+        data = response.json()["data"]
+        pages = response.json()["pagination"]["total_pages"]
+        if int(pages) > 1:
+            for page in range(1, pages):
+                page = page + 1
+                request_object.url = base_url + '&page=' + str(page)
+                new_results = Session().send(request_object)
+                data = data + new_results.json()["data"]
+        return(data)
+    else:
+        module.fail_json('API Call failed, check ID, key and sandbox values')
+
+
+def record_info(dnsimple_mod, req_obj):
+    req_obj.url, req_obj.method = req_obj.url + '/zones/' + dnsimple_mod.params["name"] + '/records?name=' + dnsimple_mod.params["record"], 'GET'
+    return iterate_data(dnsimple_mod, req_obj)
+
+
+def domain_info(dnsimple_mod, req_obj):
+    req_obj.url, req_obj.method = req_obj.url + '/zones/' + dnsimple_mod.params["name"] + '/records?per_page=100', 'GET'
+    return iterate_data(dnsimple_mod, req_obj)
+
+
+def account_info(dnsimple_mod, req_obj):
+    req_obj.url, req_obj.method = req_obj.url + '/zones/?per_page=100', 'GET'
+    return iterate_data(dnsimple_mod, req_obj)
+
+
+def main():
+    # define available arguments/parameters a user can pass to the module
+    fields = {
+        "account_id": {"required": True, "type": "str"},
+        "api_key": {"required": True, "type": "str", "no_log": True},
+        "name": {"required": False, "type": "str"},
+        "record": {"required": False, "type": "str"},
+        "sandbox": {"required": False, "type": "bool", "default": False}
+    }
+
+    result = {
+        'changed': False
+    }
+
+    module = AnsibleModule(
+        argument_spec=fields,
+        supports_check_mode=True
+    )
+
+    params = module.params
+    req = build_url(params['account_id'],
+                    params['api_key'],
+                    params['sandbox'])
+
+    if not HAS_ANOTHER_LIBRARY:
+        # Needs: from ansible.module_utils.basic import missing_required_lib
+        module.exit_json(
+            msg=missing_required_lib('another_library'),
+            exception=ANOTHER_LIBRARY_IMPORT_ERROR)
+
+    # At minimum we need account and key
+    if params['account_id'] and params['api_key']:
+        # If we have a record return info on that record
+        if params['name'] and params['record']:
+            result['dnsimple_record_info'] = record_info(module, req)
+            module.exit_json(**result)
+
+            # If we have the account only and domain, return records for the domain
+        elif params['name']:
+            result['dnsimple_records_info'] = domain_info(module, req)
+            module.exit_json(**result)
+
+            # If we have the account only, return domains
+        else:
+            result['dnsimple_domain_info'] = account_info(module, req)
+            module.exit_json(**result)
+    else:
+        module.fail_json(msg="Need at least account_id and api_key")
+
+
+if __name__ == '__main__':
+    main()
diff --git a/tests/unit/plugins/modules/net_tools/test_dnsimple_info.py b/tests/unit/plugins/modules/net_tools/test_dnsimple_info.py
new file mode 100644
index 0000000000..158f38f352
--- /dev/null
+++ b/tests/unit/plugins/modules/net_tools/test_dnsimple_info.py
@@ -0,0 +1,113 @@
+# -*- coding: utf-8 -*-
+
+# 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
+
+from ansible_collections.community.general.plugins.modules.net_tools import dnsimple_info
+from ansible_collections.community.general.tests.unit.plugins.modules.utils import AnsibleFailJson, ModuleTestCase, set_module_args, AnsibleExitJson
+from httmock import response
+from httmock import with_httmock
+from httmock import urlmatch
+import pytest
+
+
+dnsimple = pytest.importorskip('dnsimple_info')
+
+
+@urlmatch(netloc='(.)*dnsimple.com(.)*',
+          path='/v2/[0-9]*/zones/')
+def zones_resp(url, request):
+    """return domains"""
+    headers = {'content-type': 'application/json'}
+    data_content = {"data":
+                    [{"account_id": "1234", }, ],
+                    "pagination": {"total_pages": 1}}
+    content = data_content
+    return response(200, content, headers, None, 5, request)
+
+
+@urlmatch(netloc='(.)*dnsimple.com(.)*',
+          path='/v2/[0-9]*/zones/(.)*/records(.*)')
+def records_resp(url, request):
+    """return record(s)"""
+    headers = {'content-type': 'application/json'}
+    data_content = {"data":
+                    [{"content": "example",
+                      "name": "example.com"}],
+                    "pagination": {"total_pages": 1}}
+    content = data_content
+    return response(200, content, headers, None, 5, request)
+
+
+class TestDNSimple_Info(ModuleTestCase):
+    """Main class for testing dnsimple module."""
+
+    def setUp(self):
+
+        """Setup."""
+        super(TestDNSimple_Info, self).setUp()
+        self.module = dnsimple_info
+
+    def tearDown(self):
+        """Teardown."""
+        super(TestDNSimple_Info, self).tearDown()
+
+    def test_with_no_parameters(self):
+        """Failure must occurs when all parameters are missing"""
+        with self.assertRaises(AnsibleFailJson):
+            set_module_args({})
+            self.module.main()
+
+    @with_httmock(zones_resp)
+    def test_only_key_and_account(self):
+        """key and account will pass, returns domains"""
+        account_id = "1234"
+        with self.assertRaises(AnsibleExitJson) as exc_info:
+            set_module_args({
+                "api_key": "abcd1324",
+                "account_id": account_id
+            })
+            self.module.main()
+        result = exc_info.exception.args[0]
+        # nothing should change
+        self.assertFalse(result['changed'])
+        # we should return at least one item with the matching account ID
+        assert result['dnsimple_domain_info'][0]["account_id"] == account_id
+
+    @with_httmock(records_resp)
+    def test_only_name_without_record(self):
+        """name and no record should not fail, returns the record"""
+        name = "example.com"
+        with self.assertRaises(AnsibleExitJson) as exc_info:
+            set_module_args({
+                "api_key": "abcd1324",
+                "name": "example.com",
+                "account_id": "1234"
+            })
+            self.module.main()
+        result = exc_info.exception.args[0]
+        # nothing should change
+        self.assertFalse(result['changed'])
+        # we should return at least one item with mathing domain
+        assert result['dnsimple_records_info'][0]['name'] == name
+
+    @with_httmock(records_resp)
+    def test_name_and_record(self):
+        """name and record should not fail, returns the record"""
+        record = "example"
+        with self.assertRaises(AnsibleExitJson) as exc_info:
+            set_module_args({
+                "api_key": "abcd1324",
+                "account_id": "1234",
+                "name": "example.com",
+                "record": "example"
+            })
+            self.module.main()
+        result = exc_info.exception.args[0]
+        # nothing should change
+        self.assertFalse(result['changed'])
+        # we should return at least one item and content should match
+        assert result['dnsimple_record_info'][0]['content'] == record