diff options
Diffstat (limited to 'ansible_collections/community/dns')
188 files changed, 49388 insertions, 0 deletions
diff --git a/ansible_collections/community/dns/.github/dependabot.yml b/ansible_collections/community/dns/.github/dependabot.yml new file mode 100644 index 000000000..2f4ff900d --- /dev/null +++ b/ansible_collections/community/dns/.github/dependabot.yml @@ -0,0 +1,11 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/ansible_collections/community/dns/.github/patchback.yml b/ansible_collections/community/dns/.github/patchback.yml new file mode 100644 index 000000000..5ee7812ed --- /dev/null +++ b/ansible_collections/community/dns/.github/patchback.yml @@ -0,0 +1,9 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +backport_branch_prefix: patchback/backports/ +backport_label_prefix: backport- +target_branch_prefix: stable- +... diff --git a/ansible_collections/community/dns/.github/workflows/ansible-test.yml b/ansible_collections/community/dns/.github/workflows/ansible-test.yml new file mode 100644 index 000000000..6de639005 --- /dev/null +++ b/ansible_collections/community/dns/.github/workflows/ansible-test.yml @@ -0,0 +1,163 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# For the comprehensive list of the inputs supported by the ansible-community/ansible-test-gh-action GitHub Action, see +# https://github.com/marketplace/actions/ansible-test + +name: CI +on: + # Run CI against all pushes (direct commits, also merged PRs), Pull Requests + push: + branches: + - main + - stable-* + pull_request: + # Run CI once per day (at 04:30 UTC) + schedule: + - cron: '30 4 * * *' +env: + NAMESPACE: community + COLLECTION_NAME: dns + +jobs: + sanity: + name: Sanity (Ⓐ${{ matrix.ansible }}) + strategy: + matrix: + ansible: + # It's important that Sanity is tested against all stable-X.Y branches + # Testing against `devel` may fail as new tests are added. + - stable-2.9 + - stable-2.10 + - stable-2.11 + - stable-2.12 + - stable-2.13 + - stable-2.14 + - stable-2.15 + - devel + # Ansible-test on various stable branches does not yet work well with cgroups v2. + # Since ubuntu-latest now uses Ubuntu 22.04, we need to fall back to the ubuntu-20.04 + # image for these stable branches. The list of branches where this is necessary will + # shrink over time, check out https://github.com/ansible-collections/news-for-maintainers/issues/28 + # for the latest list. + runs-on: >- + ${{ contains(fromJson( + '["stable-2.9", "stable-2.10", "stable-2.11"]' + ), matrix.ansible) && 'ubuntu-20.04' || 'ubuntu-latest' }} + steps: + - name: Perform sanity testing + uses: felixfontein/ansible-test-gh-action@main + with: + ansible-core-version: ${{ matrix.ansible }} + testing-type: sanity + + units: + # Ansible-test on various stable branches does not yet work well with cgroups v2. + # Since ubuntu-latest now uses Ubuntu 22.04, we need to fall back to the ubuntu-20.04 + # image for these stable branches. The list of branches where this is necessary will + # shrink over time, check out https://github.com/ansible-collections/news-for-maintainers/issues/28 + # for the latest list. + runs-on: >- + ${{ contains(fromJson( + '["stable-2.9", "stable-2.10", "stable-2.11"]' + ), matrix.ansible) && 'ubuntu-20.04' || 'ubuntu-latest' }} + name: Units (Ⓐ${{ matrix.ansible }}) + strategy: + # As soon as the first unit test fails, cancel the others to free up the CI queue + fail-fast: true + matrix: + ansible: + - stable-2.9 + - stable-2.10 + - stable-2.11 + - stable-2.12 + - stable-2.13 + - stable-2.14 + - stable-2.15 + - devel + + steps: + - name: >- + Perform unit testing against + Ansible version ${{ matrix.ansible }} + uses: felixfontein/ansible-test-gh-action@main + with: + ansible-core-version: ${{ matrix.ansible }} + testing-type: units + # NOTE: we're installing with git to work around Galaxy being a huge PITA (https://github.com/ansible/galaxy/issues/2429) + pre-test-cmd: >- + git clone --depth=1 --single-branch https://github.com/ansible-collections/community.internal_test_tools.git ../../community/internal_test_tools + + integration: + # Ansible-test on various stable branches does not yet work well with cgroups v2. + # Since ubuntu-latest now uses Ubuntu 22.04, we need to fall back to the ubuntu-20.04 + # image for these stable branches. The list of branches where this is necessary will + # shrink over time, check out https://github.com/ansible-collections/news-for-maintainers/issues/28 + # for the latest list. + runs-on: >- + ${{ contains(fromJson( + '["stable-2.9", "stable-2.10", "stable-2.11"]' + ), matrix.ansible) && 'ubuntu-20.04' || 'ubuntu-latest' }} + name: I (Ⓐ${{ matrix.ansible }}+py${{ matrix.python }}) + strategy: + fail-fast: false + matrix: + ansible: + - devel + python: + - 2.7 + - 3.6 + - 3.7 + - 3.8 + - 3.9 + - "3.10" + - "3.11" + include: + # 2.9 + - ansible: stable-2.9 + python: "2.7" + - ansible: stable-2.9 + python: "3.6" + # 2.10 + - ansible: stable-2.10 + python: "3.5" + # 2.11 + - ansible: stable-2.11 + python: "2.7" + # 2.12 + - ansible: stable-2.12 + python: "2.6" + - ansible: stable-2.12 + python: "3.5" + # 2.13 + - ansible: stable-2.13 + python: "3.6" + # 2.14 + - ansible: stable-2.14 + python: "3.9" + # 2.15 + - ansible: stable-2.15 + python: "2.7" + - ansible: stable-2.15 + python: "3.5" + - ansible: stable-2.15 + python: "3.11" + + steps: + - name: >- + Perform integration testing against + Ansible version ${{ matrix.ansible }} + under Python ${{ matrix.python }} + uses: felixfontein/ansible-test-gh-action@main + with: + ansible-core-version: ${{ matrix.ansible }} + integration-continue-on-error: 'false' + integration-diff: 'false' + integration-retry-on-error: 'true' + # NOTE: we're installing with git to work around Galaxy being a huge PITA (https://github.com/ansible/galaxy/issues/2429) + pre-test-cmd: >- + git clone --depth=1 --single-branch https://github.com/ansible-collections/community.general.git ../../community/general + target-python-version: ${{ matrix.python }} + testing-type: integration diff --git a/ansible_collections/community/dns/.github/workflows/check-psl.yml b/ansible_collections/community/dns/.github/workflows/check-psl.yml new file mode 100644 index 000000000..145d47041 --- /dev/null +++ b/ansible_collections/community/dns/.github/workflows/check-psl.yml @@ -0,0 +1,27 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +name: Check for Public Suffix List updates +on: + push: + branches: + - main + paths: + - plugins/public_suffix_list.dat + # Run CI once per day (at 04:30 UTC) + schedule: + - cron: '30 4 * * *' + workflow_dispatch: + +jobs: + update_check: + name: Check for Public Suffix List updates + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Update PSL (returns exit code 1 if something changed) + run: ./update-psl.sh diff --git a/ansible_collections/community/dns/.github/workflows/docs-pr.yml b/ansible_collections/community/dns/.github/workflows/docs-pr.yml new file mode 100644 index 000000000..cc71d2492 --- /dev/null +++ b/ansible_collections/community/dns/.github/workflows/docs-pr.yml @@ -0,0 +1,92 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +name: Collection Docs +concurrency: + group: docs-pr-${{ github.head_ref }} + cancel-in-progress: true +on: + pull_request_target: + types: [opened, synchronize, reopened, closed] + +env: + GHP_BASE_URL: https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }} + +jobs: + build-docs: + permissions: + contents: read + name: Build Ansible Docs + uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-pr.yml@main + with: + collection-name: community.dns + init-lenient: false + init-fail-on-error: true + squash-hierarchy: true + init-project: Community.Dns Collection + init-copyright: Community.Dns Contributors + init-title: Community.Dns Collection Documentation + init-html-short-title: Community.Dns Collection Docs + init-extra-html-theme-options: | + documentation_home_url=https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/branch/main/ + render-file-line: '> * `$<status>` [$<path_tail>](https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/pr/${{ github.event.number }}/$<path_tail>)' + + publish-docs-gh-pages: + # for now we won't run this on forks + if: github.repository == 'ansible-collections/community.dns' + permissions: + contents: write + needs: [build-docs] + name: Publish Ansible Docs + uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-publish-gh-pages.yml@main + with: + artifact-name: ${{ needs.build-docs.outputs.artifact-name }} + action: ${{ (github.event.action == 'closed' || needs.build-docs.outputs.changed != 'true') && 'teardown' || 'publish' }} + secrets: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + comment: + permissions: + pull-requests: write + runs-on: ubuntu-latest + needs: [build-docs, publish-docs-gh-pages] + name: PR comments + steps: + - name: PR comment + uses: ansible-community/github-docs-build/actions/ansible-docs-build-comment@main + with: + body-includes: '## Docs Build' + reactions: heart + action: ${{ needs.build-docs.outputs.changed != 'true' && 'remove' || '' }} + on-closed-body: | + ## Docs Build 📝 + + This PR is closed and any previously published docsite has been unpublished. + on-merged-body: | + ## Docs Build 📝 + + Thank you for contribution!✨ + + This PR has been merged and the docs are now incorporated into `main`: + ${{ env.GHP_BASE_URL }}/branch/main + body: | + ## Docs Build 📝 + + Thank you for contribution!✨ + + The docs for **this PR** have been published here: + ${{ env.GHP_BASE_URL }}/pr/${{ github.event.number }} + + You can compare to the docs for the `main` branch here: + ${{ env.GHP_BASE_URL }}/branch/main + + The docsite for **this PR** is also available for download as an artifact from this run: + ${{ needs.build-docs.outputs.artifact-url }} + + File changes: + + ${{ needs.build-docs.outputs.diff-files-rendered }} + + ${{ needs.build-docs.outputs.diff-rendered }} diff --git a/ansible_collections/community/dns/.github/workflows/docs-push.yml b/ansible_collections/community/dns/.github/workflows/docs-push.yml new file mode 100644 index 000000000..85274e17d --- /dev/null +++ b/ansible_collections/community/dns/.github/workflows/docs-push.yml @@ -0,0 +1,52 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +name: Collection Docs +concurrency: + group: docs-push-${{ github.sha }} + cancel-in-progress: true +on: + push: + branches: + - main + - stable-* + tags: + - '*' + # Run CI once per day (at 04:30 UTC) + schedule: + - cron: '30 4 * * *' + # Allow manual trigger (for newer antsibull-docs, sphinx-ansible-theme, ... versions) + workflow_dispatch: + +jobs: + build-docs: + permissions: + contents: read + name: Build Ansible Docs + uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-push.yml@main + with: + collection-name: community.dns + init-lenient: false + init-fail-on-error: true + squash-hierarchy: true + init-project: Community.Dns Collection + init-copyright: Community.Dns Contributors + init-title: Community.Dns Collection Documentation + init-html-short-title: Community.Dns Collection Docs + init-extra-html-theme-options: | + documentation_home_url=https://${{ github.repository_owner }}.github.io/${{ github.event.repository.name }}/branch/main/ + + publish-docs-gh-pages: + # for now we won't run this on forks + if: github.repository == 'ansible-collections/community.dns' + permissions: + contents: write + needs: [build-docs] + name: Publish Ansible Docs + uses: ansible-community/github-docs-build/.github/workflows/_shared-docs-build-publish-gh-pages.yml@main + with: + artifact-name: ${{ needs.build-docs.outputs.artifact-name }} + secrets: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/ansible_collections/community/dns/.github/workflows/ee.yml b/ansible_collections/community/dns/.github/workflows/ee.yml new file mode 100644 index 000000000..90dcab306 --- /dev/null +++ b/ansible_collections/community/dns/.github/workflows/ee.yml @@ -0,0 +1,175 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +name: execution environment +on: + # Run CI against all pushes (direct commits, also merged PRs), Pull Requests + push: + branches: + - main + - stable-* + pull_request: + # Run CI once per day (at 04:30 UTC) + # This ensures that even if there haven't been commits that we are still testing against latest version of ansible-builder + schedule: + - cron: '30 4 * * *' + +env: + NAMESPACE: community + COLLECTION_NAME: dns + +jobs: + build: + name: Build and test EE (${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + name: + - '' + ansible_core: + - '' + ansible_runner: + - '' + base_image: + - '' + pre_base: + - '' + extra_vars: + - '' + other_deps: + - '' + exclude: + - ansible_core: '' + include: + - name: ansible-core devel @ RHEL UBI 9 + ansible_core: https://github.com/ansible/ansible/archive/devel.tar.gz + ansible_runner: ansible-runner + base_image: docker.io/redhat/ubi9:latest + pre_base: '"#"' + - name: ansible-core 2.15 @ Rocky Linux 9 + ansible_core: https://github.com/ansible/ansible/archive/stable-2.15.tar.gz + ansible_runner: ansible-runner + base_image: quay.io/rockylinux/rockylinux:9 + pre_base: '"#"' + - name: ansible-core 2.14 @ CentOS Stream 9 + ansible_core: https://github.com/ansible/ansible/archive/stable-2.14.tar.gz + ansible_runner: ansible-runner + base_image: quay.io/centos/centos:stream9 + pre_base: '"#"' + - name: ansible-core 2.13 @ RHEL UBI 8 + ansible_core: https://github.com/ansible/ansible/archive/stable-2.13.tar.gz + ansible_runner: ansible-runner + other_deps: |2 + python_interpreter: + package_system: python39 python39-pip python39-wheel python39-cryptography + base_image: docker.io/redhat/ubi8:latest + pre_base: '"#"' + - name: ansible-core 2.12 @ CentOS Stream 8 + ansible_core: https://github.com/ansible/ansible/archive/stable-2.12.tar.gz + ansible_runner: ansible-runner + other_deps: |2 + python_interpreter: + package_system: python39 python39-pip python39-wheel python39-cryptography + base_image: quay.io/centos/centos:stream8 + pre_base: '"#"' + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v3 + with: + path: ansible_collections/${{ env.NAMESPACE }}/${{ env.COLLECTION_NAME }} + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.11' + + - name: Install ansible-builder and ansible-navigator + run: pip install ansible-builder ansible-navigator + + - name: Verify requirements + run: ansible-builder introspect --sanitize . + + - name: Make sure galaxy.yml has version entry + run: >- + python -c + 'import yaml ; + f = open("galaxy.yml", "rb") ; + data = yaml.safe_load(f) ; + f.close() ; + data["version"] = data.get("version") or "0.0.1" ; + f = open("galaxy.yml", "wb") ; + f.write(yaml.dump(data).encode("utf-8")) ; + f.close() ; + ' + working-directory: ansible_collections/${{ env.NAMESPACE }}/${{ env.COLLECTION_NAME }} + + - name: Build collection + run: | + ansible-galaxy collection build --output-path ../../../ + working-directory: ansible_collections/${{ env.NAMESPACE }}/${{ env.COLLECTION_NAME }} + + - name: Create files for building execution environment + run: | + COLLECTION_FILENAME="$(ls "${{ env.NAMESPACE }}-${{ env.COLLECTION_NAME }}"-*.tar.gz)" + + # EE config + cat > execution-environment.yml <<EOF + --- + version: 3 + dependencies: + ansible_core: + package_pip: ${{ matrix.ansible_core }} + ansible_runner: + package_pip: ${{ matrix.ansible_runner }} + galaxy: requirements.yml + ${{ matrix.other_deps }} + + images: + base_image: + name: ${{ matrix.base_image }} + + additional_build_files: + - src: ${COLLECTION_FILENAME} + dest: src + + additional_build_steps: + prepend_base: + - ${{ matrix.pre_base }} + EOF + echo "::group::execution-environment.yml" + cat execution-environment.yml + echo "::endgroup::" + + # Requirements + cat > requirements.yml <<EOF + --- + collections: + - name: src/${COLLECTION_FILENAME} + type: file + EOF + echo "::group::requirements.yml" + cat requirements.yml + echo "::endgroup::" + + - name: Build image based on ${{ matrix.base_image }} + run: | + ansible-builder build --verbosity 3 --tag test-ee:latest --container-runtime docker + + - name: Show images + run: docker image ls + + - name: Run basic tests + run: > + ansible-navigator run + --mode stdout + --container-engine docker + --pull-policy never + --set-environment-variable ANSIBLE_PRIVATE_ROLE_VARS=true + --execution-environment-image test-ee:latest + -v + all.yml + ${{ matrix.extra_vars }} + working-directory: ansible_collections/${{ env.NAMESPACE }}/${{ env.COLLECTION_NAME }}/tests/ee diff --git a/ansible_collections/community/dns/.github/workflows/extra-tests.yml b/ansible_collections/community/dns/.github/workflows/extra-tests.yml new file mode 100644 index 000000000..3f1625615 --- /dev/null +++ b/ansible_collections/community/dns/.github/workflows/extra-tests.yml @@ -0,0 +1,48 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +name: extra-tests +on: + # Run CI against all pushes (direct commits, also merged PRs), Pull Requests + push: + branches: + - main + - stable-* + pull_request: + # Run CI once per day (at 04:30 UTC) + # This ensures that even if there haven't been commits that we are still testing against latest version of ansible-test for each ansible-base version + schedule: + - cron: '30 4 * * *' +env: + NAMESPACE: community + COLLECTION_NAME: dns + +jobs: + extra-sanity: + name: Extra Sanity + runs-on: ubuntu-latest + steps: + + - name: Check out code + uses: actions/checkout@v3 + with: + path: ansible_collections/${{env.NAMESPACE}}/${{env.COLLECTION_NAME}} + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install ansible-core + run: pip install https://github.com/ansible/ansible/archive/devel.tar.gz --disable-pip-version-check + + - name: Install collection dependencies + run: git clone --depth=1 --single-branch https://github.com/ansible-collections/community.internal_test_tools.git ./ansible_collections/community/internal_test_tools + # NOTE: we're installing with git to work around Galaxy being a huge PITA (https://github.com/ansible/galaxy/issues/2429) + # run: ansible-galaxy collection install community.internal_test_tools -p . + + - name: Run sanity tests + run: ../../community/internal_test_tools/tools/run.py --color + working-directory: ./ansible_collections/${{env.NAMESPACE}}/${{env.COLLECTION_NAME}} diff --git a/ansible_collections/community/dns/.github/workflows/import-galaxy.yml b/ansible_collections/community/dns/.github/workflows/import-galaxy.yml new file mode 100644 index 000000000..61b08e0f3 --- /dev/null +++ b/ansible_collections/community/dns/.github/workflows/import-galaxy.yml @@ -0,0 +1,88 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +name: import-galaxy +on: + # Run CI against all pushes (direct commits, also merged PRs) to main, and all Pull Requests + push: + branches: + - main + - stable-* + pull_request: + +env: + # Adjust this to your collection + NAMESPACE: community + COLLECTION_NAME: dns + +jobs: + build-collection: + name: Build collection artifact + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v3 + with: + path: ./checkout + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install ansible-core + run: pip install https://github.com/ansible/ansible/archive/devel.tar.gz --disable-pip-version-check + + - name: Make sure galaxy.yml has version entry + run: >- + python -c + 'import yaml ; + f = open("galaxy.yml", "rb") ; + data = yaml.safe_load(f) ; + f.close() ; + data["version"] = data.get("version") or "0.0.1" ; + f = open("galaxy.yml", "wb") ; + f.write(yaml.dump(data).encode("utf-8")) ; + f.close() ; + ' + working-directory: ./checkout + + - name: Build collection + run: ansible-galaxy collection build + working-directory: ./checkout + + - name: Copy artifact into subdirectory + run: mkdir ./artifact && mv ./checkout/${{ env.NAMESPACE }}-${{ env.COLLECTION_NAME }}-*.tar.gz ./artifact + + - name: Upload artifact + uses: actions/upload-artifact@v3 + with: + name: ${{ env.NAMESPACE }}-${{ env.COLLECTION_NAME }}-${{ github.sha }} + path: ./artifact/ + + import-galaxy: + name: Import artifact with Galaxy importer + runs-on: ubuntu-latest + needs: + - build-collection + steps: + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Install ansible-core + run: pip install https://github.com/ansible/ansible/archive/devel.tar.gz --disable-pip-version-check + + - name: Install galaxy-importer + run: pip install galaxy-importer --disable-pip-version-check + + - name: Download artifact + uses: actions/download-artifact@v3 + with: + name: ${{ env.NAMESPACE }}-${{ env.COLLECTION_NAME }}-${{ github.sha }} + + - name: Run Galaxy importer + run: python -m galaxy_importer.main ${{ env.NAMESPACE }}-${{ env.COLLECTION_NAME }}-*.tar.gz diff --git a/ansible_collections/community/dns/.github/workflows/reuse.yml b/ansible_collections/community/dns/.github/workflows/reuse.yml new file mode 100644 index 000000000..8d9ebde8d --- /dev/null +++ b/ansible_collections/community/dns/.github/workflows/reuse.yml @@ -0,0 +1,32 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +name: Verify REUSE + +on: + push: + branches: [main] + pull_request: + branches: [main] + # Run CI once per day (at 04:30 UTC) + schedule: + - cron: '30 4 * * *' + +jobs: + check: + permissions: + contents: read + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + + - name: Install dependencies + run: | + pip install reuse + + - name: Check REUSE compliance + run: | + reuse lint diff --git a/ansible_collections/community/dns/.reuse/dep5 b/ansible_collections/community/dns/.reuse/dep5 new file mode 100644 index 000000000..0c3745ebf --- /dev/null +++ b/ansible_collections/community/dns/.reuse/dep5 @@ -0,0 +1,5 @@ +Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ + +Files: changelogs/fragments/* +Copyright: Ansible Project +License: GPL-3.0-or-later diff --git a/ansible_collections/community/dns/CHANGELOG.rst b/ansible_collections/community/dns/CHANGELOG.rst new file mode 100644 index 000000000..bc3b3ffd9 --- /dev/null +++ b/ansible_collections/community/dns/CHANGELOG.rst @@ -0,0 +1,633 @@ +====================================== +Community DNS Collection Release Notes +====================================== + +.. contents:: Topics + + +v2.5.5 +====== + +Release Summary +--------------- + +Maintenance release with updated PSL. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.5.4 +====== + +Release Summary +--------------- + +Maintenance release with updated PSL. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.5.3 +====== + +Release Summary +--------------- + +Maintenance release with updated PSL. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.5.2 +====== + +Release Summary +--------------- + +Maintenance release with improved documentation and updated PSL. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.5.1 +====== + +Release Summary +--------------- + +Maintenance release (updated PSL). + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.5.0 +====== + +Release Summary +--------------- + +Feature and bugfix release with updated PSL. + +Minor Changes +------------- + +- hosttech inventory plugin - allow to configure token, username, and password with ``ANSIBLE_HOSTTECH_DNS_TOKEN``, ``ANSIBLE_HOSTTECH_API_USERNAME``, and ``ANSIBLE_HOSTTECH_API_PASSWORD`` environment variables, respectively (https://github.com/ansible-collections/community.dns/pull/131). +- various modules and inventory plugins - add new option ``txt_character_encoding`` which controls whether numeric escape sequences are interpreted as octals or decimals when ``txt_transformation=quoted`` (https://github.com/ansible-collections/community.dns/pull/134). + +Deprecated Features +------------------- + +- The default of the newly added option ``txt_character_encoding`` will change from ``octal`` to ``decimal`` in community.dns 3.0.0. The new default will be compatible with `RFC 1035 <https://www.ietf.org/rfc/rfc1035.txt>`__ (https://github.com/ansible-collections/community.dns/pull/134). + +Bugfixes +-------- + +- Update Public Suffix List. +- inventory plugins - document ``plugin`` option used by the ``ansible.builtin.auto`` inventory plugin and mention required file ending in the documentation (https://github.com/ansible-collections/community.dns/issues/130, https://github.com/ansible-collections/community.dns/pull/131). + +v2.4.2 +====== + +Release Summary +--------------- + +Maintenance release with updated Public Suffix List. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.4.1 +====== + +Release Summary +--------------- + +Regular maintenance release. + +Bugfixes +-------- + +- Update Public Suffix List. +- wait_for_txt - also retrieve IPv6 addresses of nameservers. Prevents failures with IPv6 only nameservers (https://github.com/ansible-collections/community.dns/issues/120, https://github.com/ansible-collections/community.dns/pull/121). + +v2.4.0 +====== + +Release Summary +--------------- + +Feature and maintenance release. + +Minor Changes +------------- + +- Added a ``community.dns.hetzner`` module defaults group / action group. Use with ``group/community.dns.hetzner`` to provide options for all Hetzner DNS modules (https://github.com/ansible-collections/community.dns/pull/119). +- Added a ``community.dns.hosttech`` module defaults group / action group. Use with ``group/community.dns.hosttech`` to provide options for all Hosttech DNS modules (https://github.com/ansible-collections/community.dns/pull/119). +- wait_for_txt - the module now supports check mode. The only practical change in behavior is that in check mode, the module is now executed instead of skipped. Since the module does not change anything, it should have been marked as supporting check mode since it was originally added (https://github.com/ansible-collections/community.dns/pull/119). + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.3.4 +====== + +Release Summary +--------------- + +Maintenance release with updated Public Suffix List. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.3.3 +====== + +Release Summary +--------------- + +Maintenance release including an updated Public Suffix List. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.3.2 +====== + +Release Summary +--------------- + +Maintenance release with updated Public Suffix List. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.3.1 +====== + +Release Summary +--------------- + +Maintenance release including an updated Public Suffix List. + +Minor Changes +------------- + +- The collection repository conforms to the `REUSE specification <https://reuse.software/spec/>`__ except for the changelog fragments (https://github.com/ansible-collections/community.dns/pull/112). + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.3.0 +====== + +Release Summary +--------------- + +Maintenance release including an updated Public Suffix List. + +Minor Changes +------------- + +- All software licenses are now in the ``LICENSES/`` directory of the collection root. Moreover, ``SPDX-License-Identifier:`` is used to declare the applicable license for every file that is not automatically generated (https://github.com/ansible-collections/community.dns/pull/109). + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.2.1 +====== + +Release Summary +--------------- + +Maintenance release with updated Public Suffix List. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.2.0 +====== + +Release Summary +--------------- + +Feature release. + +Minor Changes +------------- + +- hetzner_dns_records and hosttech_dns_records inventory plugins - allow to template provider-specific credentials and the ``zone_name``, ``zone_id`` options (https://github.com/ansible-collections/community.dns/pull/106). +- wait_for_txt - improve error messages so that in case of SERVFAILs or other DNS errors it is clear which record was queried from which DNS server (https://github.com/ansible-collections/community.dns/pull/105). + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.1.1 +====== + +Release Summary +--------------- + +Maintenance release with updated Public Suffix List. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.1.0 +====== + +Release Summary +--------------- + +Feature and maintenance release with updated PSL. + +Minor Changes +------------- + +- Prepare collection for inclusion in an Execution Environment by declaring its dependencies (https://github.com/ansible-collections/community.dns/pull/93). + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.0.9 +====== + +Release Summary +--------------- + +Maintenance release with updated Public Suffix List and added collection links file. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.0.8 +====== + +Release Summary +--------------- + +Maintenance release with updated Public Suffix List. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.0.7 +====== + +Release Summary +--------------- + +Maintenance release with updated Public Suffix List. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.0.6 +====== + +Release Summary +--------------- + +Bugfix release. + +Bugfixes +-------- + +- Update Public Suffix List. +- wait_for_txt - do not fail if ``NXDOMAIN`` result is returned. Also do not succeed if no nameserver can be found (https://github.com/ansible-collections/community.dns/issues/81, https://github.com/ansible-collections/community.dns/pull/82). + +v2.0.5 +====== + +Release Summary +--------------- + +Maintenance release with updated Public Suffix List. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.0.4 +====== + +Release Summary +--------------- + +Maintenance release with updated Public Suffix List. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.0.3 +====== + +Release Summary +--------------- + +Bugfix release. + +Minor Changes +------------- + +- HTTP API module utils - fix usage of ``fetch_url`` with changes in latest ansible-core ``devel`` branch (https://github.com/ansible-collections/community.dns/pull/73). + +v2.0.2 +====== + +Release Summary +--------------- + +Regular maintenance release. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.0.1 +====== + +Release Summary +--------------- + +Maintenance release with Public Suffix List updates. + +Bugfixes +-------- + +- Update Public Suffix List. + +v2.0.0 +====== + +Release Summary +--------------- + +This release contains many new features, modules and plugins, but also has several breaking changes to the 1.x.y versions. Please read the changelog carefully to determine what to change if you used an earlier version of this collection. + +Minor Changes +------------- + +- Add support for Hetzner DNS (https://github.com/ansible-collections/community.dns/pull/27). +- Added a ``txt_transformation`` option to all modules and plugins working with DNS records (https://github.com/ansible-collections/community.dns/issues/48, https://github.com/ansible-collections/community.dns/pull/57, https://github.com/ansible-collections/community.dns/pull/60). +- The hosttech_dns_records module has been renamed to hosttech_dns_record_sets (https://github.com/ansible-collections/community.dns/pull/31). +- The internal API now supports bulk DNS record changes, if supported by the API (https://github.com/ansible-collections/community.dns/pull/39). +- The internal record API allows to manage extra data (https://github.com/ansible-collections/community.dns/pull/63). +- Use HTTP helper class to make API implementations work for both plugins and modules. Make WSDL API use ``fetch_url`` instead of ``open_url`` for modules (https://github.com/ansible-collections/community.dns/pull/36). +- hetzner_dns_record and hosttech_dns_record - when not using check mode, use actual return data for diff, instead of input data, so that extra data can be shown (https://github.com/ansible-collections/community.dns/pull/63). +- hetzner_dns_zone_info - the ``legacy_ns`` return value is now sorted, since its order is unstable (https://github.com/ansible-collections/community.dns/pull/46). +- hosttech_dns_* modules - rename ``zone`` parameter to ``zone_name``. The old name ``zone`` can still be used as an alias (https://github.com/ansible-collections/community.dns/pull/32). +- hosttech_dns_record_set - ``value`` is no longer required when ``state=absent`` and ``overwrite=true`` (https://github.com/ansible-collections/community.dns/pull/31). +- hosttech_dns_record_sets - ``records`` has been renamed to ``record_sets``. The old name ``records`` can still be used as an alias (https://github.com/ansible-collections/community.dns/pull/31). +- hosttech_dns_zone_info - return extra information as ``zone_info`` (https://github.com/ansible-collections/community.dns/pull/38). + +Breaking Changes / Porting Guide +-------------------------------- + +- All Hetzner modules and plugins which handle DNS records now work with unquoted TXT values by default. The old behavior can be obtained by setting ``txt_transformation=api`` (https://github.com/ansible-collections/community.dns/issues/48, https://github.com/ansible-collections/community.dns/pull/57, https://github.com/ansible-collections/community.dns/pull/60). +- Hosttech API creation - now requires a ``ModuleOptionProvider`` object instead of an ``AnsibleModule`` object. Alternatively an Ansible plugin instance can be passed (https://github.com/ansible-collections/community.dns/pull/37). +- The hetzner_dns_record_info and hosttech_dns_record_info modules have been renamed to hetzner_dns_record_set_info and hosttech_dns_record_set_info, respectively (https://github.com/ansible-collections/community.dns/pull/54). +- The hosttech_dns_record module has been renamed to hosttech_dns_record_set (https://github.com/ansible-collections/community.dns/pull/31). +- The internal bulk record updating helper (``bulk_apply_changes``) now also returns the records that were deleted, created or updated (https://github.com/ansible-collections/community.dns/pull/63). +- The internal record API no longer allows to manage comments explicitly (https://github.com/ansible-collections/community.dns/pull/63). +- When using the internal modules API, now a zone ID type and a provider information object must be passed (https://github.com/ansible-collections/community.dns/pull/27). +- hetzner_dns_record* modules - implement correct handling of default TTL. The value ``none`` is now accepted and returned in this case (https://github.com/ansible-collections/community.dns/pull/52, https://github.com/ansible-collections/community.dns/issues/50). +- hetzner_dns_record, hetzner_dns_record_set, hetzner_dns_record_sets - the default TTL is now 300 and no longer 3600, which equals the default in the web console (https://github.com/ansible-collections/community.dns/pull/43). +- hosttech_dns_record_set - the option ``overwrite`` was replaced by a new option ``on_existing``. Specifying ``overwrite=true`` is equivalent to ``on_existing=replace`` (the new default). Specifying ``overwrite=false`` with ``state=present`` is equivalent to ``on_existing=keep_and_fail``, and specifying ``overwrite=false`` with ``state=absent`` is equivalent to ``on_existing=keep`` (https://github.com/ansible-collections/community.dns/pull/31). + +Deprecated Features +------------------- + +- The hosttech_dns_records module has been renamed to hosttech_dns_record_sets. The old name will stop working in community.dns 3.0.0 (https://github.com/ansible-collections/community.dns/pull/31). + +Bugfixes +-------- + +- Hetzner API - interpret missing TTL as 300, which is what the web console also does (https://github.com/ansible-collections/community.dns/pull/42). +- Update Public Suffix List. +- Update Public Suffix List. +- Update Public Suffix List. +- hetzner API code - make sure to also handle errors returned by the API if the HTTP status code indicates success. This sometimes happens for 500 Internal Server Error (https://github.com/ansible-collections/community.dns/pull/58). +- hosttech_dns_zone_info - make sure that full information is returned both when requesting a zone by ID or by name (https://github.com/ansible-collections/community.dns/pull/56). +- wait_for_txt - fix handling of too long TXT values (https://github.com/ansible-collections/community.dns/pull/65). +- wait_for_txt - resolving nameservers sometimes resulted in an empty list, yielding wrong results (https://github.com/ansible-collections/community.dns/pull/64). + +New Plugins +----------- + +Inventory +~~~~~~~~~ + +- community.dns.hetzner_dns_records - Create inventory from Hetzner DNS records +- community.dns.hosttech_dns_records - Create inventory from Hosttech DNS records + +New Modules +----------- + +- community.dns.hetzner_dns_record - Add or delete a single record in Hetzner DNS service +- community.dns.hetzner_dns_record_info - Retrieve records in Hetzner DNS service +- community.dns.hetzner_dns_record_set - Add or delete record sets in Hetzner DNS service +- community.dns.hetzner_dns_record_set_info - Retrieve record sets in Hetzner DNS service +- community.dns.hetzner_dns_record_sets - Bulk synchronize DNS record sets in Hetzner DNS service +- community.dns.hetzner_dns_zone_info - Retrieve zone information in Hetzner DNS service +- community.dns.hosttech_dns_record - Add or delete a single record in Hosttech DNS service +- community.dns.hosttech_dns_record_info - Retrieve records in Hosttech DNS service +- community.dns.hosttech_dns_record_set - Add or delete record sets in Hosttech DNS service +- community.dns.hosttech_dns_record_sets - Bulk synchronize DNS record sets in Hosttech DNS service + +v1.2.0 +====== + +Release Summary +--------------- + +Last minor 1.x.0 version. The 2.0.0 version will have some backwards incompatible changes to the ``hosttech_dns_record`` and ``hosttech_dns_records`` modules which will require user intervention. These changes should result in a better UX. + + +Minor Changes +------------- + +- hosttech modules - add ``api_token`` alias for ``hosttech_token`` (https://github.com/ansible-collections/community.dns/pull/26). +- hosttech_dns_record - in ``diff`` mode, also return ``diff`` data structure when ``changed`` is ``false`` (https://github.com/ansible-collections/community.dns/pull/28). +- module utils - add default implementation for some zone/record API functions, and move common JSON API code to helper class (https://github.com/ansible-collections/community.dns/pull/26). + +Bugfixes +-------- + +- Update Public Suffix List. +- hosttech_dns_record - correctly handle quoting in CAA records for JSON API (https://github.com/ansible-collections/community.dns/pull/30). + +v1.1.0 +====== + +Release Summary +--------------- + +Regular maintenance release. + +Minor Changes +------------- + +- Avoid internal ansible-core module_utils in favor of equivalent public API available since at least Ansible 2.9 (https://github.com/ansible-collections/community.dns/pull/24). + +Bugfixes +-------- + +- Update Public Suffix List. + +v1.0.1 +====== + +Release Summary +--------------- + +Regular maintenance release. + +Bugfixes +-------- + +- Update Public Suffix List. + +v1.0.0 +====== + +Release Summary +--------------- + +First stable release. + +Bugfixes +-------- + +- Update Public Suffix List. + +v0.3.0 +====== + +Release Summary +--------------- + +Fixes bugs, adds rate limiting for Hosttech JSON API, and adds a new bulk synchronization module. + +Minor Changes +------------- + +- hosttech_dns_* - handle ``419 Too Many Requests`` with proper rate limiting for JSON API (https://github.com/ansible-collections/community.dns/pull/14). + +Bugfixes +-------- + +- Avoid converting ASCII labels which contain underscores or other printable ASCII characters outside ``[a-zA-Z0-9-]`` to alabels during normalization (https://github.com/ansible-collections/community.dns/pull/13). +- Updated Public Suffix List. + +New Modules +----------- + +- community.dns.hosttech_dns_records - Bulk synchronize DNS records in Hosttech DNS service + +v0.2.0 +====== + +Release Summary +--------------- + +Major refactoring release, which adds a zone information module and supports HostTech's new REST API. + +Major Changes +------------- + +- hosttech_* modules - support the new JSON API at https://api.ns1.hosttech.eu/api/documentation/ (https://github.com/ansible-collections/community.dns/pull/4). + +Minor Changes +------------- + +- hosttech_dns_record* modules - allow to specify ``prefix`` instead of ``record`` (https://github.com/ansible-collections/community.dns/pull/8). +- hosttech_dns_record* modules - allow to specify zone by ID with the ``zone_id`` parameter, alternatively to the ``zone`` parameter (https://github.com/ansible-collections/community.dns/pull/7). +- hosttech_dns_record* modules - return ``zone_id`` on success (https://github.com/ansible-collections/community.dns/pull/7). +- hosttech_dns_record* modules - support IDN domain names and prefixes (https://github.com/ansible-collections/community.dns/pull/9). +- hosttech_dns_record_info - also return ``prefix`` for a record set (https://github.com/ansible-collections/community.dns/pull/8). +- hosttech_record - allow to delete records without querying their content first by specifying ``overwrite=true`` (https://github.com/ansible-collections/community.dns/pull/4). + +Breaking Changes / Porting Guide +-------------------------------- + +- hosttech_* module_utils - completely rewrite and refactor to support new JSON API and allow to re-use provider-independent module logic (https://github.com/ansible-collections/community.dns/pull/4). + +Bugfixes +-------- + +- Update Public Suffix List. +- hosttech_record - fix diff mode for ``state=absent`` (https://github.com/ansible-collections/community.dns/pull/4). +- hosttech_record_info - fix authentication error handling (https://github.com/ansible-collections/community.dns/pull/4). + +New Modules +----------- + +- community.dns.hosttech_dns_zone_info - Retrieve zone information in Hosttech DNS service + +v0.1.0 +====== + +Release Summary +--------------- + +Initial public release. + +New Plugins +----------- + +Filter +~~~~~~ + +- community.dns.get_public_suffix - Returns the public suffix of a DNS name +- community.dns.get_registrable_domain - Returns the registrable domain name of a DNS name +- community.dns.remove_public_suffix - Removes the public suffix from a DNS name +- community.dns.remove_registrable_domain - Removes the registrable domain name from a DNS name + +New Modules +----------- + +- community.dns.hosttech_dns_record - Add or delete entries in Hosttech DNS service +- community.dns.hosttech_dns_record_info - Retrieve entries in Hosttech DNS service +- community.dns.wait_for_txt - Wait for TXT entries to be available on all authoritative nameservers diff --git a/ansible_collections/community/dns/CHANGELOG.rst.license b/ansible_collections/community/dns/CHANGELOG.rst.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/CHANGELOG.rst.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/CODE_OF_CONDUCT.md b/ansible_collections/community/dns/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..35c9e3684 --- /dev/null +++ b/ansible_collections/community/dns/CODE_OF_CONDUCT.md @@ -0,0 +1,9 @@ +<!-- +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +--> + +# Community Code of Conduct + +Please see the official [Ansible Community Code of Conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html). diff --git a/ansible_collections/community/dns/COPYING b/ansible_collections/community/dns/COPYING new file mode 100644 index 000000000..f288702d2 --- /dev/null +++ b/ansible_collections/community/dns/COPYING @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/ansible_collections/community/dns/FILES.json b/ansible_collections/community/dns/FILES.json new file mode 100644 index 000000000..28b4fc42f --- /dev/null +++ b/ansible_collections/community/dns/FILES.json @@ -0,0 +1,1685 @@ +{ + "files": [ + { + "name": ".", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": ".github", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": ".github/workflows", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": ".github/workflows/ansible-test.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "57f43a933f9c97400b40a433e63641f054cd983d9dbe80634f46a03cca350224", + "format": 1 + }, + { + "name": ".github/workflows/check-psl.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "328ab30e4396fbcd5256cbd489493fe4fbd60d5e91285c843066102762cc8d13", + "format": 1 + }, + { + "name": ".github/workflows/docs-pr.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "fa2c8b94ec1355012d9b65d624ad400b327dfbb062cdc7407eb780a892524316", + "format": 1 + }, + { + "name": ".github/workflows/docs-push.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "88f3e91fc95a639fc831039a0503838f9850c51aedc52de0ef7adcfc7939ba4a", + "format": 1 + }, + { + "name": ".github/workflows/ee.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "fc968cc46f8bfe4e3a61e9bd923514e7c3a740f7561e5f954b88c0ff278852cd", + "format": 1 + }, + { + "name": ".github/workflows/extra-tests.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c563072f09b977b1e3f5de4bef418a4985efc2be2e9b8c3ce5f3ded827e0f829", + "format": 1 + }, + { + "name": ".github/workflows/import-galaxy.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3adc031272832a2901eeca54ab1e98f08f65682fd3046ec6d8f79885c6d21e97", + "format": 1 + }, + { + "name": ".github/workflows/reuse.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e8a5666a792db2dbbb984d861032728af7523bd05eb64f72d5fe24d7888c4a85", + "format": 1 + }, + { + "name": ".github/dependabot.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f314f2fce46d346ef9559fdeae3a55ce6d799bab45a255092446c31e17e6874d", + "format": 1 + }, + { + "name": ".github/patchback.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6a23e48e2562604318540e6ddcac75213ad2c367258d76fc75914e9b939d380e", + "format": 1 + }, + { + "name": ".reuse", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": ".reuse/dep5", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9d8cb20d72d7e81aaf2e7f0ddce7eacdb3d47a890541717d7fae08a6cab7ebed", + "format": 1 + }, + { + "name": "LICENSES", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "LICENSES/GPL-3.0-or-later.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986", + "format": 1 + }, + { + "name": "LICENSES/MPL-2.0.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1f256ecad192880510e84ad60474eab7589218784b9a50bc7ceee34c2b91f1d5", + "format": 1 + }, + { + "name": "changelogs", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "changelogs/fragments", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "changelogs/fragments/.keep", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "format": 1 + }, + { + "name": "changelogs/changelog.yaml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "87340f9738ad8fd68d3b353331f75224c62db5146a60dc8ec73aee23e127de8c", + "format": 1 + }, + { + "name": "changelogs/changelog.yaml.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "changelogs/config.yaml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "eaf381e2fe7a048e89338e704fb11ed2be9366b56f9b0c1aecb5c69200d245ae", + "format": 1 + }, + { + "name": "docs", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "docs/docsite", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "docs/docsite/rst", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "docs/docsite/rst/filter_guide.rst", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4951821c42291a5155c07ab8890b797891a797a48d56177f5c43fa1f68e40ed3", + "format": 1 + }, + { + "name": "docs/docsite/rst/hetzner_guide.rst", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9f1add331b70dab5095c92502c08e1315ed425ab313aa0d140dd34a53215dc80", + "format": 1 + }, + { + "name": "docs/docsite/rst/hosttech_guide.rst", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a5a52988e830180e5ce155bfbcdb1a7f6b4f32d49ac36a9754af2c2ab41b0dbe", + "format": 1 + }, + { + "name": "docs/docsite/extra-docs.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "04f74a6b6253e67e3e04dcb84b4e596e2fe9eb790ecfda83eff11816bc002f46", + "format": 1 + }, + { + "name": "docs/docsite/links.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "59ea749c099e62b8a30dfeca4a1d56b9d66a94f76399ac9d5b8bb6138cc49c5b", + "format": 1 + }, + { + "name": "meta", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "meta/ee-requirements.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "2a7c1287d2dcdd5debc23d12ce53de5ffccacc5632ff5d551af984e98db6c237", + "format": 1 + }, + { + "name": "meta/execution-environment.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "acef6a0f697d7eb98f771428fc62ec80223e69febd5ba82ab56e2e85a600b398", + "format": 1 + }, + { + "name": "meta/runtime.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a0f15da8558a0fcbe370883adaa3a3982a99ae3d93e785523b126d1912bcc30a", + "format": 1 + }, + { + "name": "plugins", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/doc_fragments", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/doc_fragments/attributes.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "dd9ec67d269021239ff8dd4ea66b002acba7f9ccda3a3a6fbf160fabd396a6be", + "format": 1 + }, + { + "name": "plugins/doc_fragments/filters.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b96398417b1b6574669976db5fb6db1d797ca98c4966973ea28c7d90a8e89d45", + "format": 1 + }, + { + "name": "plugins/doc_fragments/hetzner.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d533aa9b538b4b516f61b1bc279cc41f5182b61768982a589b60b5fb93d7e826", + "format": 1 + }, + { + "name": "plugins/doc_fragments/hosttech.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c688311bad0e2ef03270326ec7c5395c6a337dc9c6858085723c543f330634dd", + "format": 1 + }, + { + "name": "plugins/doc_fragments/inventory_records.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9901b4cc5e7285e71f460b2284c135f4dce22089ef4cf0594d1425cc1f49f4a1", + "format": 1 + }, + { + "name": "plugins/doc_fragments/module_record.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3b2487963454cf78d4603577d87fa09f021682fa63b91c87a5bf7d37a18f6188", + "format": 1 + }, + { + "name": "plugins/doc_fragments/module_record_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a6de9b0cc1556bcfb4ec0db4ae29d8bd16e0b618ecb58754d5c1a9bdda7ee541", + "format": 1 + }, + { + "name": "plugins/doc_fragments/module_record_set.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6239eb43284dd8561616e048e1fd96aeaadaf5c0e9a43db9a8d203622f4a6d4b", + "format": 1 + }, + { + "name": "plugins/doc_fragments/module_record_set_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b10b9465de88c3b79b2f76e181745559ca662166e0a4036c29b1311af9ac44a7", + "format": 1 + }, + { + "name": "plugins/doc_fragments/module_record_sets.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1898824cddb3853becd5c2207e42f6d2ccc18cdae5539ebf3911e8b3ca7b2f89", + "format": 1 + }, + { + "name": "plugins/doc_fragments/module_zone_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "91f80f14cdef9da9c08c1f969ed9ffb07f097070efac7447c5f30c11394d8031", + "format": 1 + }, + { + "name": "plugins/doc_fragments/options.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "10ac33fbbbeb9be9afaa107f62ade852ef9ee471e829588e4ee4e108ba873b05", + "format": 1 + }, + { + "name": "plugins/filter", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/filter/domain_suffix.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "68b1447a4932c366c4b00e3d368a2b9a0b9030ee59ef299486a49173bb27fb4b", + "format": 1 + }, + { + "name": "plugins/filter/get_public_suffix.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "79312629932c3a463d2d26948af18fe6d0e13e8ebd0360c98eb51b5540adbb0e", + "format": 1 + }, + { + "name": "plugins/filter/get_registrable_domain.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "2c48600982731c3de10e9a79bbe8bc3f067a86559de61934cd6eb9e97f58b402", + "format": 1 + }, + { + "name": "plugins/filter/remove_public_suffix.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4dde06a48c10a30a716dd31c4a689e8d6b37aee181c0e348dfc540fc05d03feb", + "format": 1 + }, + { + "name": "plugins/filter/remove_registrable_domain.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "fe3ad175b7092172931b942f83f4a449c1b7f4af1c77fa1baf3b07082911c534", + "format": 1 + }, + { + "name": "plugins/inventory", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/inventory/hetzner_dns_records.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b4ef8ec66c251b7703b461ae5d782b0f3208c96bff19df643bb29b618d172724", + "format": 1 + }, + { + "name": "plugins/inventory/hosttech_dns_records.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7dcc6657d43c6d2004c4a0c45d5a0f704481e1536673d154d8e3bba0e85494e9", + "format": 1 + }, + { + "name": "plugins/module_utils", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/module_utils/conversion", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/module_utils/conversion/base.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "367dc827f5a728b0be61be559a7832734303878c536b7b78c72ffaf597ded859", + "format": 1 + }, + { + "name": "plugins/module_utils/conversion/converter.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "06a3d891a5c81bc1552ba2be7d7bca8cae84c1d7a9e51fb0bd9d7e957e02b575", + "format": 1 + }, + { + "name": "plugins/module_utils/conversion/txt.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f4ef8ea4cc71f015b68f1d5e964790321c963c9a4e4e29e24c424539c0e874f2", + "format": 1 + }, + { + "name": "plugins/module_utils/hetzner", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/module_utils/hetzner/api.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "81ed1edf6084ac812acb297dc25b94d3ff870620c5eceade34fb3ecf09845a87", + "format": 1 + }, + { + "name": "plugins/module_utils/hosttech", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/module_utils/hosttech/api.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "2cb0eb795f9aa0454c4784354a10c7515587b2a5e3a4574c27cf725d8cef9ee4", + "format": 1 + }, + { + "name": "plugins/module_utils/hosttech/json_api.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "121877319d9f911269e27166f795502715b1f7dabc9fc0620e2d72e466dd3b1e", + "format": 1 + }, + { + "name": "plugins/module_utils/hosttech/wsdl_api.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a0c41c40f7d567dfb37192b0124e35bcdf93ece0bac8e02e7a71058b370b8283", + "format": 1 + }, + { + "name": "plugins/module_utils/module", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/module_utils/module/_utils.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0bcc8b3b5ed936db422444a56d474325e3022b04d5fd3210fd3896feef87402f", + "format": 1 + }, + { + "name": "plugins/module_utils/module/record.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7ed89c77bd88ead7d4e0e6ed641fe901ed87585aa66ab5d2c6510b3c45ff382f", + "format": 1 + }, + { + "name": "plugins/module_utils/module/record_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "249916650636d73bb90a0fc2a3c1693f99fccaff46c51b37cb0efd1bfd0cfe9a", + "format": 1 + }, + { + "name": "plugins/module_utils/module/record_set.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "aa9c5b93c15a5595c9d88d253fed9c7f1ff4b231a0c65e6b919ab76100d31889", + "format": 1 + }, + { + "name": "plugins/module_utils/module/record_set_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1009327094eec8a21d82cda8435b7eb52110c50683f987716fece62579e531f5", + "format": 1 + }, + { + "name": "plugins/module_utils/module/record_sets.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "edad80e1887bc604922f2068fb738d9eea05708ba4f8658effbe0f64570a1a76", + "format": 1 + }, + { + "name": "plugins/module_utils/module/zone_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0aba59adb65f8d56e04e15d8e8ab850f4d6c0f09f2efea3e793a3229fff77fa9", + "format": 1 + }, + { + "name": "plugins/module_utils/argspec.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "873ec9729773e3d8ae4618998a087c36bca63f76e6123234449bf3c3b16026fe", + "format": 1 + }, + { + "name": "plugins/module_utils/http.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f6e60896ab9557f54b702025c41b7906b483eecf5191807cd69d3d9396e0192a", + "format": 1 + }, + { + "name": "plugins/module_utils/json_api_helper.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "279703fb75395dbd5d39c39abc60c744a5c5ddc9688bce210ae73346f04c9a1f", + "format": 1 + }, + { + "name": "plugins/module_utils/names.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "bbec38ad96836d7d200e41080608d5bb837d5050e4da34f7f384425746fc4445", + "format": 1 + }, + { + "name": "plugins/module_utils/options.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9ab338a1f293a6bb6b041a8a01d99885dc6388b672a8ca01f78ac37615089f09", + "format": 1 + }, + { + "name": "plugins/module_utils/provider.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "894a0fb9f647b50f80ec6cad79181804cc0a2be8aa5fb3c9875d0524647445f3", + "format": 1 + }, + { + "name": "plugins/module_utils/record.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f6ea73eb2d6eb10619fb886a5fbc0ffa57d9e3726118234f236369afae69e280", + "format": 1 + }, + { + "name": "plugins/module_utils/resolver.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d2780e267283aee2fa56258926a9a7d89b63ee56838e7ea8c108f4067347a346", + "format": 1 + }, + { + "name": "plugins/module_utils/wsdl.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "126806ab154964d97f417cf034e72779bf041599faf157261950fe4b0e950017", + "format": 1 + }, + { + "name": "plugins/module_utils/zone.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "12c4cdb76308bf5630efe057bd0d4675bde480e47699f19c03efe75940a359f4", + "format": 1 + }, + { + "name": "plugins/module_utils/zone_record_api.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4c8a396cd9d5ccb99c3ef85deee3dfef1cad9cbbdafd7bcc49789914f8c61817", + "format": 1 + }, + { + "name": "plugins/module_utils/zone_record_helpers.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9b2b9aab72062fe177da968c554fe7a8155803848b40f98c3a0c4d1b40e3bc7a", + "format": 1 + }, + { + "name": "plugins/modules", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/modules/hosttech_dns_records.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "542133e245b31b09c514d862ae33a41df558c3d36e3fff047fbb8a5b41465074", + "format": 1 + }, + { + "name": "plugins/modules/hetzner_dns_record.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1af221c28f0c4686cb2695434bb792f2f153c1d2454a9f8d427b1d5d8953c7f8", + "format": 1 + }, + { + "name": "plugins/modules/hetzner_dns_record_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "05ddf1bfd4c7afe5760b05f1bdc5693839fc302af7f76adb0e1be1e271561608", + "format": 1 + }, + { + "name": "plugins/modules/hetzner_dns_record_set.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c4c0a209ff43962f5030cb44ddd1e7deabfea3f82d848bd460d7307059027972", + "format": 1 + }, + { + "name": "plugins/modules/hetzner_dns_record_set_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1e24dd296cbbc1210759b5e60a9f754b8e78fb5b017333aba4b343ecf5c57cd1", + "format": 1 + }, + { + "name": "plugins/modules/hetzner_dns_record_sets.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0801be20ea6bdc1b9af8ce0d04e57ae8b32b8f0bfe20656021b850a2a3a83907", + "format": 1 + }, + { + "name": "plugins/modules/hetzner_dns_zone_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "336ed14aae54e4a0e6604914b40ff8867a49eb84c33308b78c58aa4a1b0f53cd", + "format": 1 + }, + { + "name": "plugins/modules/hosttech_dns_record.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "1edf1a6d7e0ea5939606ae153a2b563834dc2de960a23a98a018dfcddbaef409", + "format": 1 + }, + { + "name": "plugins/modules/hosttech_dns_record_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "24a8c3b3a5197c1530f1c7f8f2f0dea1117bf5ca05f3027f36b1ef6b8b6b14f0", + "format": 1 + }, + { + "name": "plugins/modules/hosttech_dns_record_set.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "18230d597c1cd3d7d150f7cb07d417b2b68a12276813c88cf3456c80a63e6eaa", + "format": 1 + }, + { + "name": "plugins/modules/hosttech_dns_record_set_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6751d18dfa5c640f35da77a20d2465338ece071ba8ac8b5303e9eda396737a04", + "format": 1 + }, + { + "name": "plugins/modules/hosttech_dns_record_sets.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "542133e245b31b09c514d862ae33a41df558c3d36e3fff047fbb8a5b41465074", + "format": 1 + }, + { + "name": "plugins/modules/hosttech_dns_zone_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "8f9c3a5fd7836a5fc5cdfd571db72a7998fcc62e05acddf8e607a046da022e0d", + "format": 1 + }, + { + "name": "plugins/modules/wait_for_txt.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "eca0b0120be5426a13aafb56265d82666b12068b75d21732e45625a1005dc8a8", + "format": 1 + }, + { + "name": "plugins/plugin_utils", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/plugin_utils/inventory", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "plugins/plugin_utils/inventory/records.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "2de808c5923d8e4b84e41eeb43b91c5ad8e7bf4400b03f359afcd0c49237ff03", + "format": 1 + }, + { + "name": "plugins/plugin_utils/public_suffix.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "8b7b056d7248974d7d839745271244dfaa35a66f46ff3146a8330cc36a913541", + "format": 1 + }, + { + "name": "plugins/plugin_utils/templated_options.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "513a321c0f6520841dec4c16a2e4fbaf993db51415526e253bb2dc0b190e2225", + "format": 1 + }, + { + "name": "plugins/public_suffix_list.dat", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c21461ca3f4ac0267bf3dc8d64c5c0b3dcdc27a43cae7e1cf2677ed951d26ecc", + "format": 1 + }, + { + "name": "plugins/public_suffix_list.dat.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ff0d5e4e08c84d23c3e3f42701b25a6d73886425fcc292538c7dca8304a56b56", + "format": 1 + }, + { + "name": "tests", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/ee", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/ee/roles", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/ee/roles/filter_domain_suffix", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/ee/roles/filter_domain_suffix/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/ee/roles/filter_domain_suffix/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7b5e19fa25ce10c2898d31c3be3c83933c755ac16431a6babb1a97e4f2399214", + "format": 1 + }, + { + "name": "tests/ee/roles/wait_for_txt", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/ee/roles/wait_for_txt/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/ee/roles/wait_for_txt/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "93362d607677767f4a6b1c9ed53b1d8888f7a2fb84e24647768f2ebf2008c76d", + "format": 1 + }, + { + "name": "tests/ee/all.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "15124f745ece7c6afe94201049f4b301a64c1ebaef64568ec1c92f429c27e0de", + "format": 1 + }, + { + "name": "tests/integration", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/filter_domain_suffix", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/filter_domain_suffix/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/filter_domain_suffix/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7b5e19fa25ce10c2898d31c3be3c83933c755ac16431a6babb1a97e4f2399214", + "format": 1 + }, + { + "name": "tests/integration/targets/filter_domain_suffix/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "92c856873b6b98a3a818dc09e1ba4cdd2e0e01449f1a28d6716eff605f1d2b01", + "format": 1 + }, + { + "name": "tests/integration/targets/hetzner", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/hetzner/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/hetzner/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d3d0cd90b65e0f4677a7e35146b9861675c69ccc5cf75b47975c6afd66ebf6ca", + "format": 1 + }, + { + "name": "tests/integration/targets/hetzner/tasks/record-sets.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "5fb999bd12a542c08d297573261203036bfc623b54d84f6d14680ce31cc7192c", + "format": 1 + }, + { + "name": "tests/integration/targets/hetzner/tasks/records.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c327b9d257fc76f34240b21d2478bb55640d9144e07013232bc6d91e3bd20fd1", + "format": 1 + }, + { + "name": "tests/integration/targets/hetzner/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f6edd7b06c54b48f404081a4ca467d0ee28cb7d3c708369e45c082ea4b55eb49", + "format": 1 + }, + { + "name": "tests/integration/targets/hosttech", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/hosttech/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/hosttech/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ce5958c2a6ad7db15ce0ce31186ddd91cccc83abe5e4fc0f0a3f0fda4d92f224", + "format": 1 + }, + { + "name": "tests/integration/targets/hosttech/tasks/token.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6e0af2fb3bcc58a19cc6e6be44d83c6169cfa2e3fde80ae59a876f21bf883c1d", + "format": 1 + }, + { + "name": "tests/integration/targets/hosttech/tasks/username-password.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "af41756343f22a3783753e7883d4425cacb4e12cd179508cf9d634c6351d2253", + "format": 1 + }, + { + "name": "tests/integration/targets/hosttech/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3b06e33095ef3dcb730bc28f44eaa0c785ce3c5c4b2444bff511ca6d8dc35cbf", + "format": 1 + }, + { + "name": "tests/integration/targets/required_module_params", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/required_module_params/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/required_module_params/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "24e44ac57c12d6031027ce59940a20f721e6ae98453fac196a366b187335b46a", + "format": 1 + }, + { + "name": "tests/integration/targets/required_module_params/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "56d7b0e3bb4d848f6a5fb3d740480c01bc5d252f9647b48187deadd2b6335760", + "format": 1 + }, + { + "name": "tests/integration/targets/wait_for_txt", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/wait_for_txt/tasks", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/integration/targets/wait_for_txt/tasks/main.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "a08332f1fc96503af7257974f9b0cb006a259961b4a5ef3137f6766a4d310704", + "format": 1 + }, + { + "name": "tests/integration/targets/wait_for_txt/aliases", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "22aabe027192db7fa8fcbb72d5ae66aa903097169aba7e68a6a3fc5e7fc577d5", + "format": 1 + }, + { + "name": "tests/integration/targets/wait_for_txt/runme.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "af4e97f0430418d1e5ca825f0f42e739c645b62ffd32de00f6b6c033f2f50aa2", + "format": 1 + }, + { + "name": "tests/integration/targets/wait_for_txt/runme.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f1153fc402d8c568c711113c99130006d3fedc0f8b81cb6f80eb25eb1578c239", + "format": 1 + }, + { + "name": "tests/integration/integration_config.yml.hetzner-template", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "16daaa2db3d94f40abd5d91871dee781b04c139f3692ed7057ea768ea0458fe2", + "format": 1 + }, + { + "name": "tests/integration/integration_config.yml.hosttech-template", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "62bb2d772cba011fc9151a14393d616fcd55554b400bb8dc0ef56f9cdc7c19ea", + "format": 1 + }, + { + "name": "tests/integration/requirements.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b14479393bcd69f46ef31b6688ae0fab07ff8fe96128e151f034f6f4fad13e98", + "format": 1 + }, + { + "name": "tests/sanity", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/sanity/extra", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/sanity/extra/extra-docs.json", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6c7fbc8a07fa803ce062387232eb0d0198a311f5347493f06c19a07aa45a9bf6", + "format": 1 + }, + { + "name": "tests/sanity/extra/extra-docs.json.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "tests/sanity/extra/extra-docs.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "0fbd87476e9c35e4c5feb31be4aa1e8fc6aebf0de13058e5a267879f741ec0bf", + "format": 1 + }, + { + "name": "tests/sanity/extra/licenses.json", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "8c111eb62fa6f1b6c8a1260e9ff06c8b76ef8244428c6289969d79678093618f", + "format": 1 + }, + { + "name": "tests/sanity/extra/licenses.json.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "tests/sanity/extra/licenses.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "8adeffdb7fedcea9fdd07c742ea91b009f1fb2777f343c6c80f2e6ca8a617943", + "format": 1 + }, + { + "name": "tests/sanity/extra/licenses.py.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "88f745b5d91e1371369c207e3392877af6f3e1de48fbaca63a728d4dcf79e03c", + "format": 1 + }, + { + "name": "tests/sanity/extra/no-unwanted-files.json", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "539bb91a9d308ea589a20dd257e67c278acb19b5eba4e188cd27e7a9bcbf900d", + "format": 1 + }, + { + "name": "tests/sanity/extra/no-unwanted-files.json.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "tests/sanity/extra/no-unwanted-files.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "fd865c473a618b973b4b7f3b52d422f12056e3ee62c94074836bd23379720639", + "format": 1 + }, + { + "name": "tests/sanity/extra/update-docs-fragments.json", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ea8b16c74e220a76d6871fba73d5e3ceacb535bc20a5a01ef3880e26f6da1c11", + "format": 1 + }, + { + "name": "tests/sanity/extra/update-docs-fragments.json.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "tests/sanity/extra/update-docs-fragments.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "bd8fbbf59c41ac60cd0051428dc7cdef48d3e9e3b1ea1b997e5a933046105b4e", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.10.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e6ba33006d3aa232443f145cbbc66c1eb5a4c06e75b6a9c63e89972c990d2786", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.10.txt.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.11.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e6ba33006d3aa232443f145cbbc66c1eb5a4c06e75b6a9c63e89972c990d2786", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.11.txt.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.12.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e6ba33006d3aa232443f145cbbc66c1eb5a4c06e75b6a9c63e89972c990d2786", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.12.txt.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.13.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e6ba33006d3aa232443f145cbbc66c1eb5a4c06e75b6a9c63e89972c990d2786", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.13.txt.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.14.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e6ba33006d3aa232443f145cbbc66c1eb5a4c06e75b6a9c63e89972c990d2786", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.14.txt.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.15.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e6ba33006d3aa232443f145cbbc66c1eb5a4c06e75b6a9c63e89972c990d2786", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.15.txt.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.16.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e6ba33006d3aa232443f145cbbc66c1eb5a4c06e75b6a9c63e89972c990d2786", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.16.txt.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.9.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e6ba33006d3aa232443f145cbbc66c1eb5a4c06e75b6a9c63e89972c990d2786", + "format": 1 + }, + { + "name": "tests/sanity/ignore-2.9.txt.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "tests/unit", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins/inventory", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins/inventory/test_hetzner_dns_records.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "75a38f930f1989126cfd8c06d1389e4fd5c648486dc4359147b8bf9d9d553e1f", + "format": 1 + }, + { + "name": "tests/unit/plugins/inventory/test_hosttech_dns_records.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "eb49251839b33f89c0d18f218c1862ad42a33c1b8665f6dd741ffeb5d05fbdda", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/conversion", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/conversion/test_converter.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "55c05e8661c3f00ab39dd70bf6cb7bd3392ab01b713b560bb70e5a61a97c802c", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/conversion/test_txt.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d0334105e803f8b137097c4cbaef351f12484f7a578f35f0d186a6f63e98c23d", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/hetzner", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/hetzner/test_api.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "2e1259ab481dba9935a59327e823ddff44b7c2564798b061e4e10d67ef851678", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/hosttech", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/hosttech/test_api.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "63facf9cf5d7a52b9344d341467357fb337879b81b7a3fd27568ff08c92e8da5", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/hosttech/test_json_api.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f75818ea177ccf84d68191e244e60bf17e40d109e9ab47cad3a3b260281e49be", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/module", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/module/test__utils.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "7f7ad24c00dec14eb70cd6e4e954f25f8fa3c6b8b3bccb464e745825ff2a242c", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/helper.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "d72019d2fc8b08be16ebfdbd04edcc2940ae7d13304d00751597eae1105bfdf3", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/resolver_helper.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e9527f31a6eda7f9b1e2fca62e9325a9c9b964343a5704c1f94e304cec4c2b3b", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/test_argspec.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9f48241316fe3d00ff4659c720426690dcfd6b7f74bf9a2cf212c31968e55635", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/test_json_api_helper.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "70a79de1f2ccf6058911ca40be783ddc194cb7aa3b6b3f850707d9adc99bcdfd", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/test_names.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "8ca7c79f8ead91d33787c012d3f798270a22c20608c615ed9ba12e025986e8ac", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/test_provider.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9ad523ad0c53271cb830f7de8fc55cea86f0d96ad2521fdaac344d1befce0db6", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/test_record.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "bae67faf0716603929539e4571a7f5c58f386759c8c3ebb890e21fb8d0b71e5b", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/test_resolver.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "dd95bd83c9cab2adc7698ed7a6968298ff3334cdbbae8db56b85777801753ada", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/test_wsdl.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "495b3d24e608c4d4af37ed8f4b587d50af605ef8af3e698fb5015c88d9173b40", + "format": 1 + }, + { + "name": "tests/unit/plugins/module_utils/test_zone.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6a21bcf9bbc6e92e0ded343e56c92cacb5304f9816073dc439ae72b8b648229f", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/hetzner.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "202dcd7d3c2b2fd623784c2ff3acaf38ac58a68233c8c041ccc6875c74cc092a", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/hosttech.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3d4d196831b506859c03990d4818e9a8eba086679fa7195f622873404b865539", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/test_hetzner_dns_record.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "47927ed1a1ae168ff44c4a422adb394aa1fae7c85703e55fbac1197f719ccc91", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/test_hetzner_dns_record_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "8572de1092d80d5806ac23d2ed790b66b6ccac077e310acb0c4c889172744df2", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/test_hetzner_dns_record_set.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "201bff16311f5e86ffd07368c008f7037a9029d8172a9c5373732db57c03248a", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/test_hetzner_dns_record_set_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "f69a48413b461b5b7232b3eb6aab1ce61475922d47372bb621052366ce9e185d", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/test_hetzner_dns_record_sets.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "75be0316720cea56d851e85e2dbac9d512d63762ab99a8d41f9b2b395867f20e", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/test_hetzner_dns_zone_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "027b4e069b3b8d1caf5a5aae5a323f259d885ba5655b5e99d4b445db1c796b5e", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/test_hosttech_dns_record.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "b10399fe4e832c4a8c45589fd5a5ded321309a2395a8c858a8ffe05646c1121f", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/test_hosttech_dns_record_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "106106a2d9e1a84ee29160cedc532c83684f2b10cb5b15d7c89ee81419e55e2f", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/test_hosttech_dns_record_set.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3d02d789bded7c30dd0ce5ec244f9fc70ca80f4c3342b1391fabea9e9db3dc9c", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/test_hosttech_dns_record_set_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "ab623e1ac37ec9b150cdd7c288f908b7040993c06ae89597a36a1a694f6ec274", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/test_hosttech_dns_record_sets.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c89d4c461bdc106eda8fb8b74019bfe57b6fbb5f67685d90517006ff42684739", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/test_hosttech_dns_zone_info.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "9191c12b63b011b1f1906087e42582c9a2a0f60e57acc338a178801e967a95ef", + "format": 1 + }, + { + "name": "tests/unit/plugins/modules/test_wait_for_txt.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "822afe1474b99d4865a523118ae616d36e9793eb6693add50034986d05b8aeaa", + "format": 1 + }, + { + "name": "tests/unit/plugins/plugin_utils", + "ftype": "dir", + "chksum_type": null, + "chksum_sha256": null, + "format": 1 + }, + { + "name": "tests/unit/plugins/plugin_utils/test_public_suffix.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "4f197883a311ac0c0161187019f3329f6b0266bb1ba5daf347bb9193e46486e6", + "format": 1 + }, + { + "name": "tests/unit/requirements.txt", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3d0ac34bebc9bdedbd7bd3bcd3342985b276839ae82cc75442c297609b01cf25", + "format": 1 + }, + { + "name": "tests/unit/requirements.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "999b6bb4a4234b1f38abb63cbd57d40af2d93bcff2260ab088e09157a055abaf", + "format": 1 + }, + { + "name": "tests/config.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "498d46cf08b5abb09880088fa8fd142315f7f775e94cdc7d41364ed20fc2cd65", + "format": 1 + }, + { + "name": "CHANGELOG.rst", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "c3789f0c3f34ccaee424dff34293d3172ac44dcfd15393861c9c8e805e2d33a4", + "format": 1 + }, + { + "name": "CHANGELOG.rst.license", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6eb915239f9f35407fa68fdc41ed6522f1fdcce11badbdcd6057548023179ac1", + "format": 1 + }, + { + "name": "CODE_OF_CONDUCT.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3205f85abef3ac060ccadd140dcc9b09cbc8ca3b1b3cb20c3d0df5ae6f822529", + "format": 1 + }, + { + "name": "COPYING", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "3972dc9744f6499f0f9b2dbf76696f2ae7ad8af9b23dde66d6af86c9dfb36986", + "format": 1 + }, + { + "name": "README.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "6986b272ce1b0a3e2842a19405c8f44b0df445ec1c563e076260d7bc5622080d", + "format": 1 + }, + { + "name": "TESTING.md", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "bf217c75c839421ad71568b1f0486d8b0daa4f7dc455c36899d56ef12df7ea2a", + "format": 1 + }, + { + "name": "codecov.yml", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "38fb959844ae36310ad5247f1220eb98fef7bf4059ff669c138e4db792ed3df8", + "format": 1 + }, + { + "name": "update-docs-fragments.py", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "e92bad2be3a0fd524207e52e587cebed402ec87dd723ca7e33f48725121ea18f", + "format": 1 + }, + { + "name": "update-psl.sh", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "36b681cf6d7811b0e5ddad99892c7720f2aebc65a2d61c8d22361219e4333978", + "format": 1 + } + ], + "format": 1 +}
\ No newline at end of file diff --git a/ansible_collections/community/dns/LICENSES/GPL-3.0-or-later.txt b/ansible_collections/community/dns/LICENSES/GPL-3.0-or-later.txt new file mode 100644 index 000000000..f288702d2 --- /dev/null +++ b/ansible_collections/community/dns/LICENSES/GPL-3.0-or-later.txt @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/> + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + <one line to give the program's name and a brief idea of what it does.> + Copyright (C) <year> <name of author> + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see <https://www.gnu.org/licenses/>. + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + <program> Copyright (C) <year> <name of author> + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +<https://www.gnu.org/licenses/>. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +<https://www.gnu.org/licenses/why-not-lgpl.html>. diff --git a/ansible_collections/community/dns/LICENSES/MPL-2.0.txt b/ansible_collections/community/dns/LICENSES/MPL-2.0.txt new file mode 100644 index 000000000..a612ad981 --- /dev/null +++ b/ansible_collections/community/dns/LICENSES/MPL-2.0.txt @@ -0,0 +1,373 @@ +Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. diff --git a/ansible_collections/community/dns/MANIFEST.json b/ansible_collections/community/dns/MANIFEST.json new file mode 100644 index 000000000..133510541 --- /dev/null +++ b/ansible_collections/community/dns/MANIFEST.json @@ -0,0 +1,37 @@ +{ + "collection_info": { + "namespace": "community", + "name": "dns", + "version": "2.5.5", + "authors": [ + "Felix Fontein (github.com/felixfontein)", + "Markus Bergholz (github.com/markuman)" + ], + "readme": "README.md", + "tags": [ + "community", + "dns", + "hosttech", + "hetzner" + ], + "description": "Plugins and modules for working with DNS", + "license": [ + "GPL-3.0-or-later", + "MPL-2.0" + ], + "license_file": null, + "dependencies": {}, + "repository": "https://github.com/ansible-collections/community.dns", + "documentation": "https://docs.ansible.com/ansible/devel/collections/community/dns/", + "homepage": "https://github.com/ansible-collections/community.dns", + "issues": "https://github.com/ansible-collections/community.dns/issues" + }, + "file_manifest_file": { + "name": "FILES.json", + "ftype": "file", + "chksum_type": "sha256", + "chksum_sha256": "fa3df6e39a948ef0e253b1076133edf0f43b1119e56063a3e2a35ed2f8c5344e", + "format": 1 + }, + "format": 1 +}
\ No newline at end of file diff --git a/ansible_collections/community/dns/README.md b/ansible_collections/community/dns/README.md new file mode 100644 index 000000000..5796339b4 --- /dev/null +++ b/ansible_collections/community/dns/README.md @@ -0,0 +1,115 @@ +<!-- +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +--> + +# Community DNS Collection +[![Documentation](https://img.shields.io/badge/docs-brightgreen.svg)](https://docs.ansible.com/ansible/devel/collections/community/dns/) +[![CI](https://github.com/ansible-collections/community.dns/workflows/CI/badge.svg?event=push)](https://github.com/ansible-collections/community.dns/actions) +[![Public Suffix List up-to-date](https://github.com/ansible-collections/community.dns/workflows/Check%20for%20Public%20Suffix%20List%20updates/badge.svg?branch=main)](https://github.com/ansible-collections/community.dns/actions?query=workflow%3A%22Check+for+Public+Suffix+List+updates%22+branch%3Amain) +[![Codecov](https://img.shields.io/codecov/c/github/ansible-collections/community.dns)](https://codecov.io/gh/ansible-collections/community.dns) + +This repository contains the `community.dns` Ansible Collection. The collection includes plugins and modules to work with DNS. + +Please note that this collection does **not** support Windows targets. + +## Tested with Ansible + +Tested with the current Ansible 2.9, ansible-base 2.10, ansible-core 2.11, ansible-core 2.12, ansible-core 2.13, and ansible-core 2.14 releases and the current development version of ansible-core. Ansible versions before 2.9.10 are not supported. + +## External requirements + +Depends on the plugin or module used. + +## Collection Documentation + +Browsing the [**latest** collection documentation](https://docs.ansible.com/ansible/latest/collections/community/dns) will show docs for the _latest version released in the Ansible package_, not the latest version of the collection released on Galaxy. + +Browsing the [**devel** collection documentation](https://docs.ansible.com/ansible/devel/collections/community/dns) shows docs for the _latest version released on Galaxy_. + +We also separately publish [**latest commit** collection documentation](https://ansible-collections.github.io/community.dns/branch/main/) which shows docs for the _latest commit in the `main` branch_. + +If you use the Ansible package and do not update collections independently, use **latest**. If you install or update this collection directly from Galaxy, use **devel**. If you are looking to contribute, use **latest commit**. + +## Included content + +- Modules: + - `hetzner_dns_record_info`: retrieve information on DNS records from Hetzner DNS. + - `hetzner_dns_record`: create/update/delete single DNS records with Hetzner DNS. + - `hetzner_dns_record_set_info`: retrieve information on DNS record sets from Hetzner DNS. + - `hetzner_dns_record_set`: create/update/delete DNS record sets with Hetzner DNS. + - `hetzner_dns_record_sets`: bulk synchronize DNS record sets in Hetzner DNS service. + - `hetzner_dns_zone_info`: retrieve zone information from Hetzner DNS. + - `hosttech_dns_record_info`: retrieve information on DNS records from HostTech DNS. + - `hosttech_dns_record`: create/update/delete single DNS records with HostTech DNS. + - `hosttech_dns_record_set_info`: retrieve information on DNS record sets from HostTech DNS. + - `hosttech_dns_record_set`: create/update/delete DNS record sets with HostTech DNS. + - `hosttech_dns_record_set`: bulk synchronize DNS record sets in Hosttech DNS service. + - `hosttech_dns_zone_info`: retrieve zone information from HostTech DNS. + - `wait_for_txt`: wait for TXT records to propagate to all name servers. +- Inventory plugins: + - `hetzner_dns_records`: create inventory from Hetzner DNS records + - `hosttech_dns_records`: create inventory from HostTech DNS records +- Filters: + - `get_public_suffix`: given a domain name, returns the public suffix. For example, `"www.ansible.com" | community.dns.get_public_suffix == ".com"` and `"some.random.prefixes.ansible.co.uk" | community.dns.get_public_suffix == ".co.uk"`. + - `get_registrable_domain`: given a domain name, returns the *registrable domain name* (also called *registered domain name*). For example, `"www.ansible.com" | community.dns.get_registrable_domain == "ansible.com"` and `"some.random.prefixes.ansible.co.uk" | community.dns.get_registrable_domain == "ansible.co.uk"`. + - `remove_public_suffix`: given a domain name, returns the part before the public suffix. For example, `"www.ansible.com" | community.dns.remove_public_suffix == "www.ansible"` and `"some.random.prefixes.ansible.co.uk" | community.dns.remove_public_suffix == "some.random.prefixes.ansible"`. + - `remove_registrable_domain`: given a domain name, returns the part before the DNS zone. For example, `"www.ansible.com" | community.dns.remove_registrable_domain == "www"` and `"some.random.prefixes.ansible.co.uk" | community.dns.remove_registrable_domain == "some.random.prefixes"`. + +## Using this collection + +Before using the General community collection, you need to install the collection with the `ansible-galaxy` CLI: + + ansible-galaxy collection install community.dns + +You can also include it in a `requirements.yml` file and install it via `ansible-galaxy collection install -r requirements.yml` using the format: + +```yaml +collections: +- name: community.dns +``` + +See [Ansible Using collections](https://docs.ansible.com/ansible/latest/user_guide/collections_using.html) for more details. + +## Contributing to this collection + +If you want to develop new content for this collection or improve what is already here, the easiest way to work on the collection is to clone it into one of the configured [`COLLECTIONS_PATH`](https://docs.ansible.com/ansible/latest/reference_appendices/config.html#collections-paths), and work on it there. + +See [TESTING.md](https://github.com/ansible-collections/community.dns/tree/main/TESTING.md) for information on running the tests. + +You can find more information in the [developer guide for collections](https://docs.ansible.com/ansible/devel/dev_guide/developing_collections.html#contributing-to-collections), and in the [Ansible Community Guide](https://docs.ansible.com/ansible/latest/community/index.html). + +## Release notes + +See the [changelog](https://github.com/ansible-collections/community.dns/tree/main/CHANGELOG.rst). + +## Releasing, Versioning and Deprecation + +This collection follows [Semantic Versioning](https://semver.org/). More details on versioning can be found [in the Ansible docs](https://docs.ansible.com/ansible/latest/dev_guide/developing_collections.html#collection-versions). + +We plan to regularly release new minor or bugfix versions once new features or bugfixes have been implemented. + +Releasing the current major version happens from the `main` branch. We will create a `stable-1` branch for 1.x.y versions once we start working on a 2.0.0 release, to allow backporting bugfixes and features from the 2.0.0 branch (`main`) to `stable-1`. A `stable-2` branch will be created once we work on a 3.0.0 release, and so on. + +We currently are not planning any deprecations or new major releases like 2.0.0 containing backwards incompatible changes. If backwards incompatible changes are needed, we plan to deprecate the old behavior as early as possible. We also plan to backport at least bugfixes for the old major version for some time after releasing a new major version. We will not block community members from backporting other bugfixes and features from the latest stable version to older release branches, under the condition that these backports are of reasonable quality. + +## More information + +- [Ansible Collection overview](https://github.com/ansible-collections/overview) +- [Ansible User guide](https://docs.ansible.com/ansible/latest/user_guide/index.html) +- [Ansible Developer guide](https://docs.ansible.com/ansible/latest/dev_guide/index.html) +- [Ansible Collections Checklist](https://github.com/ansible-collections/overview/blob/master/collection_requirements.rst) +- [Ansible Community code of conduct](https://docs.ansible.com/ansible/latest/community/code_of_conduct.html) +- [The Bullhorn (the Ansible Contributor newsletter)](https://us19.campaign-archive.com/home/?u=56d874e027110e35dea0e03c1&id=d6635f5420) +- [Changes impacting Contributors](https://github.com/ansible-collections/overview/issues/45) + +## Licensing + +This collection is primarily licensed and distributed as a whole under the GNU General Public License v3.0 or later. + +See [LICENSES/GPL-3.0-or-later.txt](https://github.com/ansible-collections/community.dns/blob/main/COPYING) for the full text. + +The only content of this collection that is not GPL v3.0+ licensed is `plugins/public_suffix_list.dat`, which is subject to the terms of the Mozilla Public License, v. 2.0. See [LICENSES/MPL-2.0.txt](https://github.com/ansible-collections/community.dns/blob/main/LICENSES/MPL-2.0.txt) for the full text. + +All files have a machine readable `SDPX-License-Identifier:` comment denoting its respective license(s) or an equivalent entry in an accompanying `.license` file. Only changelog fragments (which will not be part of a release) are covered by a blanket statement in `.reuse/dep5`. This conforms to the [REUSE specification](https://reuse.software/spec/). diff --git a/ansible_collections/community/dns/TESTING.md b/ansible_collections/community/dns/TESTING.md new file mode 100644 index 000000000..845357701 --- /dev/null +++ b/ansible_collections/community/dns/TESTING.md @@ -0,0 +1,27 @@ +<!-- +Copyright (c) Ansible Project +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +--> + +# Running tests + +## HostTech DNS modules + +The CI (based on GitHub Actions) does not run integration tests for the HostTech modules, because they need access to HostTech API credentials. If you have some, copy [`tests/integration/integration_config.yml.hosttech-template`](https://github.com/ansible-collections/community.dns/blob/main/tests/integration/integration_config.yml.hosttech-template) to `integration_config.yml` in the same directory, and insert username, key, a test zone (`domain.ch`) and test record (`foo.domain.ch`). Then run `ansible-test integration --allow-unsupported hosttech`. Please note that the test record will be deleted, (re-)created, and finally deleted, so do not use any record you actually need! + +To run the tests with Python 3.8: +``` +ansible-test integration --docker default --python 3.8 --allow-unsupported hosttech +``` +You can adjust the Python version, remove `--python 3.8` completely, use a different docker container, or remove `--docker default` completely. + +## Hetzner DNS modules + +The CI (based on GitHub Actions) does not run integration tests for the Hetzner modules, because they need access to Hetzner API credentials. If you have some, copy [`tests/integration/integration_config.yml.hetzner-template`](https://github.com/ansible-collections/community.dns/blob/main/tests/integration/integration_config.yml.hetzner-template) to `integration_config.yml` in the same directory, and insert API key and a test zone (`domain.de`). Then run `ansible-test integration --allow-unsupported hetzner`. Please note that the test zone will be modified, so do not use a zone you actually need! + +To run the tests with Python 3.8: +``` +ansible-test integration --docker default --python 3.8 --allow-unsupported hetzner +``` +You can adjust the Python version, remove `--python 3.8` completely, use a different docker container, or remove `--docker default` completely. diff --git a/ansible_collections/community/dns/changelogs/changelog.yaml b/ansible_collections/community/dns/changelogs/changelog.yaml new file mode 100644 index 000000000..b287c50db --- /dev/null +++ b/ansible_collections/community/dns/changelogs/changelog.yaml @@ -0,0 +1,619 @@ +ancestor: null +releases: + 0.1.0: + changes: + release_summary: Initial public release. + fragments: + - 0.1.0.yml + - domain_suffix.yml + modules: + - description: Add or delete entries in Hosttech DNS service + name: hosttech_dns_record + namespace: '' + - description: Retrieve entries in Hosttech DNS service + name: hosttech_dns_record_info + namespace: '' + - description: Wait for TXT entries to be available on all authoritative nameservers + name: wait_for_txt + namespace: '' + plugins: + filter: + - description: Returns the public suffix of a DNS name + name: get_public_suffix + namespace: null + - description: Returns the registrable domain name of a DNS name + name: get_registrable_domain + namespace: null + - description: Removes the public suffix from a DNS name + name: remove_public_suffix + namespace: null + - description: Removes the registrable domain name from a DNS name + name: remove_registrable_domain + namespace: null + release_date: '2021-04-07' + 0.2.0: + changes: + breaking_changes: + - hosttech_* module_utils - completely rewrite and refactor to support new JSON + API and allow to re-use provider-independent module logic (https://github.com/ansible-collections/community.dns/pull/4). + bugfixes: + - Update Public Suffix List. + - hosttech_record - fix diff mode for ``state=absent`` (https://github.com/ansible-collections/community.dns/pull/4). + - hosttech_record_info - fix authentication error handling (https://github.com/ansible-collections/community.dns/pull/4). + major_changes: + - hosttech_* modules - support the new JSON API at https://api.ns1.hosttech.eu/api/documentation/ + (https://github.com/ansible-collections/community.dns/pull/4). + minor_changes: + - hosttech_dns_record* modules - allow to specify ``prefix`` instead of ``record`` + (https://github.com/ansible-collections/community.dns/pull/8). + - hosttech_dns_record* modules - allow to specify zone by ID with the ``zone_id`` + parameter, alternatively to the ``zone`` parameter (https://github.com/ansible-collections/community.dns/pull/7). + - hosttech_dns_record* modules - return ``zone_id`` on success (https://github.com/ansible-collections/community.dns/pull/7). + - hosttech_dns_record* modules - support IDN domain names and prefixes (https://github.com/ansible-collections/community.dns/pull/9). + - hosttech_dns_record_info - also return ``prefix`` for a record set (https://github.com/ansible-collections/community.dns/pull/8). + - hosttech_record - allow to delete records without querying their content first + by specifying ``overwrite=true`` (https://github.com/ansible-collections/community.dns/pull/4). + release_summary: Major refactoring release, which adds a zone information module + and supports HostTech's new REST API. + fragments: + - 0.2.0.yml + - 7-hosttech-zone_id.yml + - 8-hosttech-prefix.yml + - 9-idn.yml + - rewrite.yml + - update-psl.yml + modules: + - description: Retrieve zone information in Hosttech DNS service + name: hosttech_dns_zone_info + namespace: '' + release_date: '2021-04-25' + 0.3.0: + changes: + bugfixes: + - Avoid converting ASCII labels which contain underscores or other printable + ASCII characters outside ``[a-zA-Z0-9-]`` to alabels during normalization + (https://github.com/ansible-collections/community.dns/pull/13). + - Updated Public Suffix List. + minor_changes: + - hosttech_dns_* - handle ``419 Too Many Requests`` with proper rate limiting + for JSON API (https://github.com/ansible-collections/community.dns/pull/14). + release_summary: Fixes bugs, adds rate limiting for Hosttech JSON API, and adds + a new bulk synchronization module. + fragments: + - 0.3.0.yml + - 13-alabel-confusion.yml + - 14-hosttech-json-rate-limiting.yml + - psl.yml + modules: + - description: Bulk synchronize DNS records in Hosttech DNS service + name: hosttech_dns_records + namespace: '' + release_date: '2021-05-02' + 1.0.0: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: First stable release. + fragments: + - 1.0.0.yml + - update-psl.yml + release_date: '2021-05-09' + 1.0.1: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Regular maintenance release. + fragments: + - 1.0.1.yml + - update-psl.yml + release_date: '2021-06-05' + 1.1.0: + changes: + bugfixes: + - Update Public Suffix List. + minor_changes: + - Avoid internal ansible-core module_utils in favor of equivalent public API + available since at least Ansible 2.9 (https://github.com/ansible-collections/community.dns/pull/24). + release_summary: Regular maintenance release. + fragments: + - 1.1.0.yml + - ansible-core-_text.yml + - update-psl.yml + release_date: '2021-07-06' + 1.2.0: + changes: + bugfixes: + - Update Public Suffix List. + - hosttech_dns_record - correctly handle quoting in CAA records for JSON API + (https://github.com/ansible-collections/community.dns/pull/30). + minor_changes: + - hosttech modules - add ``api_token`` alias for ``hosttech_token`` (https://github.com/ansible-collections/community.dns/pull/26). + - hosttech_dns_record - in ``diff`` mode, also return ``diff`` data structure + when ``changed`` is ``false`` (https://github.com/ansible-collections/community.dns/pull/28). + - module utils - add default implementation for some zone/record API functions, + and move common JSON API code to helper class (https://github.com/ansible-collections/community.dns/pull/26). + release_summary: 'Last minor 1.x.0 version. The 2.0.0 version will have some + backwards incompatible changes to the ``hosttech_dns_record`` and ``hosttech_dns_records`` + modules which will require user intervention. These changes should result + in a better UX. + + ' + fragments: + - 1.2.0.yml + - 26-refactoring.yml + - 28-record-diff.yml + - 30-caa-records-quoting.yml + - update-psl.yml + release_date: '2021-07-12' + 2.0.0: + changes: + bugfixes: + - Update Public Suffix List. + - wait_for_txt - fix handling of too long TXT values (https://github.com/ansible-collections/community.dns/pull/65). + - wait_for_txt - resolving nameservers sometimes resulted in an empty list, + yielding wrong results (https://github.com/ansible-collections/community.dns/pull/64). + release_summary: This release contains many new features, modules and plugins, + but also has several breaking changes to the 1.x.y versions. Please read the + changelog carefully to determine what to change if you used an earlier version + of this collection. + fragments: + - 2.0.0.yml + - 65-wait_for_txt-ns.yml + - 66-wait_for_txt.yml + - update-psl.yml + release_date: '2021-09-22' + 2.0.0-a1: + changes: + breaking_changes: + - Hosttech API creation - now requires a ``ModuleOptionProvider`` object instead + of an ``AnsibleModule`` object. Alternatively an Ansible plugin instance can + be passed (https://github.com/ansible-collections/community.dns/pull/37). + - The hosttech_dns_record module has been renamed to hosttech_dns_record_set + (https://github.com/ansible-collections/community.dns/pull/31). + - When using the internal modules API, now a zone ID type and a provider information + object must be passed (https://github.com/ansible-collections/community.dns/pull/27). + - hosttech_dns_record_set - the option ``overwrite`` was replaced by a new option + ``on_existing``. Specifying ``overwrite=true`` is equivalent to ``on_existing=replace`` + (the new default). Specifying ``overwrite=false`` with ``state=present`` is + equivalent to ``on_existing=keep_and_fail``, and specifying ``overwrite=false`` + with ``state=absent`` is equivalent to ``on_existing=keep`` (https://github.com/ansible-collections/community.dns/pull/31). + deprecated_features: + - The hosttech_dns_records module has been renamed to hosttech_dns_record_sets. + The old name will stop working in community.dns 3.0.0 (https://github.com/ansible-collections/community.dns/pull/31). + minor_changes: + - Add support for Hetzner DNS (https://github.com/ansible-collections/community.dns/pull/27). + - The hosttech_dns_records module has been renamed to hosttech_dns_record_sets + (https://github.com/ansible-collections/community.dns/pull/31). + - The internal API now supports bulk DNS record changes, if supported by the + API (https://github.com/ansible-collections/community.dns/pull/39). + - Use HTTP helper class to make API implementations work for both plugins and + modules. Make WSDL API use ``fetch_url`` instead of ``open_url`` for modules + (https://github.com/ansible-collections/community.dns/pull/36). + - hosttech_dns_* modules - rename ``zone`` parameter to ``zone_name``. The old + name ``zone`` can still be used as an alias (https://github.com/ansible-collections/community.dns/pull/32). + - hosttech_dns_record_set - ``value`` is no longer required when ``state=absent`` + and ``overwrite=true`` (https://github.com/ansible-collections/community.dns/pull/31). + - hosttech_dns_record_sets - ``records`` has been renamed to ``record_sets``. + The old name ``records`` can still be used as an alias (https://github.com/ansible-collections/community.dns/pull/31). + - hosttech_dns_zone_info - return extra information as ``zone_info`` (https://github.com/ansible-collections/community.dns/pull/38). + release_summary: First alpha release of 2.0.0. + fragments: + - 2.0.0-a1.yml + - 27-hetzner-support.yml + - 27-provider-info.yml + - 31-record-set.yml + - 32-aliases.yml + - 36-http.yml + - 37-module-option-provider.yml + - 38-zone-extra-info.yml + - 39-bulk-changes.yml + modules: + - description: Add or delete a single record in Hetzner DNS service + name: hetzner_dns_record + namespace: '' + - description: Add or delete record sets in Hetzner DNS service + name: hetzner_dns_record_set + namespace: '' + - description: Bulk synchronize DNS record sets in Hetzner DNS service + name: hetzner_dns_record_sets + namespace: '' + - description: Retrieve zone information in Hetzner DNS service + name: hetzner_dns_zone_info + namespace: '' + - description: Add or delete a single record in Hosttech DNS service + name: hosttech_dns_record + namespace: '' + - description: Add or delete record sets in Hosttech DNS service + name: hosttech_dns_record_set + namespace: '' + - description: Bulk synchronize DNS record sets in Hosttech DNS service + name: hosttech_dns_record_sets + namespace: '' + plugins: + inventory: + - description: Create inventory from Hetzner DNS records + name: hetzner_dns_records + namespace: null + - description: Create inventory from Hosttech DNS records + name: hosttech_dns_records + namespace: null + release_date: '2021-07-17' + 2.0.0-a2: + changes: + breaking_changes: + - All Hetzner modules and plugins which handle DNS records now work with unquoted + TXT values by default. The old behavior can be obtained by setting ``txt_transformation=api`` + (https://github.com/ansible-collections/community.dns/issues/48, https://github.com/ansible-collections/community.dns/pull/57, + https://github.com/ansible-collections/community.dns/pull/60). + - The hetzner_dns_record_info and hosttech_dns_record_info modules have been + renamed to hetzner_dns_record_set_info and hosttech_dns_record_set_info, respectively + (https://github.com/ansible-collections/community.dns/pull/54). + - hetzner_dns_record* modules - implement correct handling of default TTL. The + value ``none`` is now accepted and returned in this case (https://github.com/ansible-collections/community.dns/pull/52, + https://github.com/ansible-collections/community.dns/issues/50). + - hetzner_dns_record, hetzner_dns_record_set, hetzner_dns_record_sets - the + default TTL is now 300 and no longer 3600, which equals the default in the + web console (https://github.com/ansible-collections/community.dns/pull/43). + bugfixes: + - Hetzner API - interpret missing TTL as 300, which is what the web console + also does (https://github.com/ansible-collections/community.dns/pull/42). + - Update Public Suffix List. + - hetzner API code - make sure to also handle errors returned by the API if + the HTTP status code indicates success. This sometimes happens for 500 Internal + Server Error (https://github.com/ansible-collections/community.dns/pull/58). + - hosttech_dns_zone_info - make sure that full information is returned both + when requesting a zone by ID or by name (https://github.com/ansible-collections/community.dns/pull/56). + minor_changes: + - Added a ``txt_transformation`` option to all modules and plugins working with + DNS records (https://github.com/ansible-collections/community.dns/issues/48, + https://github.com/ansible-collections/community.dns/pull/57, https://github.com/ansible-collections/community.dns/pull/60). + - hetzner_dns_zone_info - the ``legacy_ns`` return value is now sorted, since + its order is unstable (https://github.com/ansible-collections/community.dns/pull/46). + release_summary: Second alpha release of 2.0.0. + fragments: + - 2.0.0-a2.yml + - 42-hetzner-ttl-300.yml + - 43-hetzner-default-ttl-300.yml + - 45-cleanup.yml + - 46-hetzner-legacy_ns-sorting.yml + - 52-hetzner-default-ttl.yml + - 54-info-rename.yml + - 56-tests-fixes.yml + - 57-60-txt_transformation.yml + - 58-hetzner-api-errors.yml + - update-psl.yml + modules: + - description: Retrieve record sets in Hetzner DNS service + name: hetzner_dns_record_set_info + namespace: '' + release_date: '2021-08-15' + 2.0.0-a3: + changes: + breaking_changes: + - The internal bulk record updating helper (``bulk_apply_changes``) now also + returns the records that were deleted, created or updated (https://github.com/ansible-collections/community.dns/pull/63). + - The internal record API no longer allows to manage comments explicitly (https://github.com/ansible-collections/community.dns/pull/63). + bugfixes: + - Update Public Suffix List. + minor_changes: + - The internal record API allows to manage extra data (https://github.com/ansible-collections/community.dns/pull/63). + - hetzner_dns_record and hosttech_dns_record - when not using check mode, use + actual return data for diff, instead of input data, so that extra data can + be shown (https://github.com/ansible-collections/community.dns/pull/63). + release_summary: Third alpha release of 2.0.0. + fragments: + - 2.0.0-a3.yml + - 63-records-extra-info.yml + - update-psl.yml + modules: + - description: Retrieve records in Hetzner DNS service + name: hetzner_dns_record_info + namespace: '' + - description: Retrieve records in Hosttech DNS service + name: hosttech_dns_record_info + namespace: '' + release_date: '2021-09-11' + 2.0.1: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with Public Suffix List updates. + fragments: + - 2.0.1.yml + - update-psl.yml + release_date: '2021-10-13' + 2.0.2: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Regular maintenance release. + fragments: + - 2.0.2.yml + - update-psl.yml + release_date: '2021-11-14' + 2.0.3: + changes: + minor_changes: + - HTTP API module utils - fix usage of ``fetch_url`` with changes in latest + ansible-core ``devel`` branch (https://github.com/ansible-collections/community.dns/pull/73). + release_summary: Bugfix release. + fragments: + - 2.0.3.yml + - fetch_url-devel.yml + release_date: '2021-11-21' + 2.0.4: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with updated Public Suffix List. + fragments: + - 2.0.4.yml + - update-psl.yml + release_date: '2022-01-08' + 2.0.5: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with updated Public Suffix List. + fragments: + - 2.0.5.yml + - update-psl.yml + release_date: '2022-01-31' + 2.0.6: + changes: + bugfixes: + - Update Public Suffix List. + - wait_for_txt - do not fail if ``NXDOMAIN`` result is returned. Also do not + succeed if no nameserver can be found (https://github.com/ansible-collections/community.dns/issues/81, + https://github.com/ansible-collections/community.dns/pull/82). + release_summary: Bugfix release. + fragments: + - 2.0.6.yml + - 82-wait_for_txt.yml + - update-psl.yml + release_date: '2022-02-01' + 2.0.7: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with updated Public Suffix List. + fragments: + - 2.0.7.yml + - update-psl.yml + release_date: '2022-02-21' + 2.0.8: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with updated Public Suffix List. + fragments: + - 2.0.8.yml + - update-psl.yml + release_date: '2022-03-14' + 2.0.9: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with updated Public Suffix List and added + collection links file. + fragments: + - 2.0.9.yml + - update-psl.yml + release_date: '2022-03-22' + 2.1.0: + changes: + bugfixes: + - Update Public Suffix List. + minor_changes: + - Prepare collection for inclusion in an Execution Environment by declaring + its dependencies (https://github.com/ansible-collections/community.dns/pull/93). + release_summary: Feature and maintenance release with updated PSL. + fragments: + - 2.1.0.yml + - 93-ee.yml + - update-psl.yml + release_date: '2022-04-25' + 2.1.1: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with updated Public Suffix List. + fragments: + - 2.1.1.yml + - update-psl.yml + release_date: '2022-05-16' + 2.2.0: + changes: + bugfixes: + - Update Public Suffix List. + minor_changes: + - hetzner_dns_records and hosttech_dns_records inventory plugins - allow to + template provider-specific credentials and the ``zone_name``, ``zone_id`` + options (https://github.com/ansible-collections/community.dns/pull/106). + - wait_for_txt - improve error messages so that in case of SERVFAILs or other + DNS errors it is clear which record was queried from which DNS server (https://github.com/ansible-collections/community.dns/pull/105). + release_summary: Feature release. + fragments: + - 105-wait_for_txt-improve-error-msg.yml + - 106-inventory-templating.yml + - 2.2.0.yml + - update-psl.yml + release_date: '2022-06-03' + 2.2.1: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with updated Public Suffix List. + fragments: + - 2.2.1.yml + - update-psl.yml + release_date: '2022-07-11' + 2.3.0: + changes: + bugfixes: + - Update Public Suffix List. + minor_changes: + - All software licenses are now in the ``LICENSES/`` directory of the collection + root. Moreover, ``SPDX-License-Identifier:`` is used to declare the applicable + license for every file that is not automatically generated (https://github.com/ansible-collections/community.dns/pull/109). + release_summary: Maintenance release including an updated Public Suffix List. + fragments: + - 109-licenses.yml + - 2.3.0.yml + - update-psl.yml + release_date: '2022-07-31' + 2.3.1: + changes: + bugfixes: + - Update Public Suffix List. + minor_changes: + - The collection repository conforms to the `REUSE specification <https://reuse.software/spec/>`__ + except for the changelog fragments (https://github.com/ansible-collections/community.dns/pull/112). + release_summary: Maintenance release including an updated Public Suffix List. + fragments: + - 2.3.1.yml + - licenses.yml + - update-psl.yml + release_date: '2022-08-21' + 2.3.2: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with updated Public Suffix List. + fragments: + - 2.3.2.yml + - update-psl.yml + release_date: '2022-09-12' + 2.3.3: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release including an updated Public Suffix List. + fragments: + - 2.3.3.yml + - update-psl.yml + release_date: '2022-10-03' + 2.3.4: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with updated Public Suffix List. + fragments: + - 2.3.4.yml + - update-psl.yml + release_date: '2022-10-24' + 2.4.0: + changes: + bugfixes: + - Update Public Suffix List. + minor_changes: + - Added a ``community.dns.hetzner`` module defaults group / action group. Use + with ``group/community.dns.hetzner`` to provide options for all Hetzner DNS + modules (https://github.com/ansible-collections/community.dns/pull/119). + - Added a ``community.dns.hosttech`` module defaults group / action group. Use + with ``group/community.dns.hosttech`` to provide options for all Hosttech + DNS modules (https://github.com/ansible-collections/community.dns/pull/119). + - wait_for_txt - the module now supports check mode. The only practical change + in behavior is that in check mode, the module is now executed instead of skipped. + Since the module does not change anything, it should have been marked as supporting + check mode since it was originally added (https://github.com/ansible-collections/community.dns/pull/119). + release_summary: Feature and maintenance release. + fragments: + - 2.4.0.yml + - action_groups.yml + - update-psl.yml + - wait_for_txt-check_mode.yml + release_date: '2022-11-06' + 2.4.1: + changes: + bugfixes: + - Update Public Suffix List. + - wait_for_txt - also retrieve IPv6 addresses of nameservers. Prevents failures + with IPv6 only nameservers (https://github.com/ansible-collections/community.dns/issues/120, + https://github.com/ansible-collections/community.dns/pull/121). + release_summary: Regular maintenance release. + fragments: + - 121-wait_for_txt-ipv6.yml + - 2.4.1.yml + - update-psl.yml + release_date: '2022-11-14' + 2.4.2: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with updated Public Suffix List. + fragments: + - 2.4.2.yml + - update-psl.yml + release_date: '2022-12-05' + 2.5.0: + changes: + bugfixes: + - Update Public Suffix List. + - inventory plugins - document ``plugin`` option used by the ``ansible.builtin.auto`` + inventory plugin and mention required file ending in the documentation (https://github.com/ansible-collections/community.dns/issues/130, + https://github.com/ansible-collections/community.dns/pull/131). + deprecated_features: + - The default of the newly added option ``txt_character_encoding`` will change + from ``octal`` to ``decimal`` in community.dns 3.0.0. The new default will + be compatible with `RFC 1035 <https://www.ietf.org/rfc/rfc1035.txt>`__ (https://github.com/ansible-collections/community.dns/pull/134). + minor_changes: + - hosttech inventory plugin - allow to configure token, username, and password + with ``ANSIBLE_HOSTTECH_DNS_TOKEN``, ``ANSIBLE_HOSTTECH_API_USERNAME``, and + ``ANSIBLE_HOSTTECH_API_PASSWORD`` environment variables, respectively (https://github.com/ansible-collections/community.dns/pull/131). + - various modules and inventory plugins - add new option ``txt_character_encoding`` + which controls whether numeric escape sequences are interpreted as octals + or decimals when ``txt_transformation=quoted`` (https://github.com/ansible-collections/community.dns/pull/134). + release_summary: Feature and bugfix release with updated PSL. + fragments: + - 131-inventory.yml + - 2.5.0.yml + - txt-quoting.yml + - update-psl.yml + release_date: '2023-01-31' + 2.5.1: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release (updated PSL). + fragments: + - 2.5.1.yml + - update-psl.yml + release_date: '2023-02-25' + 2.5.2: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with improved documentation and updated + PSL. + fragments: + - 2.5.2.yml + - update-psl.yml + release_date: '2023-03-27' + 2.5.3: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with updated PSL. + fragments: + - 2.5.3.yml + - update-psl.yml + release_date: '2023-04-25' + 2.5.4: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with updated PSL. + fragments: + - 2.5.4.yml + - update-psl.yml + release_date: '2023-05-21' + 2.5.5: + changes: + bugfixes: + - Update Public Suffix List. + release_summary: Maintenance release with updated PSL. + fragments: + - 2.5.5.yml + - update-psl.yml + release_date: '2023-06-19' diff --git a/ansible_collections/community/dns/changelogs/changelog.yaml.license b/ansible_collections/community/dns/changelogs/changelog.yaml.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/changelogs/changelog.yaml.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/changelogs/config.yaml b/ansible_collections/community/dns/changelogs/config.yaml new file mode 100644 index 000000000..5f5075391 --- /dev/null +++ b/ansible_collections/community/dns/changelogs/config.yaml @@ -0,0 +1,35 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +changelog_filename_template: ../CHANGELOG.rst +changelog_filename_version_depth: 0 +changes_file: changelog.yaml +changes_format: combined +keep_fragments: false +mention_ancestor: true +new_plugins_after_name: removed_features +notesdir: fragments +prelude_section_name: release_summary +prelude_section_title: Release Summary +sections: +- - major_changes + - Major Changes +- - minor_changes + - Minor Changes +- - breaking_changes + - Breaking Changes / Porting Guide +- - deprecated_features + - Deprecated Features +- - removed_features + - Removed Features (previously deprecated) +- - security_fixes + - Security Fixes +- - bugfixes + - Bugfixes +- - known_issues + - Known Issues +title: Community DNS Collection +trivial_section_name: trivial +use_fqcn: true diff --git a/ansible_collections/community/dns/changelogs/fragments/.keep b/ansible_collections/community/dns/changelogs/fragments/.keep new file mode 100644 index 000000000..e69de29bb --- /dev/null +++ b/ansible_collections/community/dns/changelogs/fragments/.keep diff --git a/ansible_collections/community/dns/codecov.yml b/ansible_collections/community/dns/codecov.yml new file mode 100644 index 000000000..e84c0b92b --- /dev/null +++ b/ansible_collections/community/dns/codecov.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +fixes: + - "ansible_collections/community/dns/::" diff --git a/ansible_collections/community/dns/docs/docsite/extra-docs.yml b/ansible_collections/community/dns/docs/docsite/extra-docs.yml new file mode 100644 index 000000000..616f25706 --- /dev/null +++ b/ansible_collections/community/dns/docs/docsite/extra-docs.yml @@ -0,0 +1,11 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +sections: + - title: Guides + toctree: + - filter_guide + - hetzner_guide + - hosttech_guide diff --git a/ansible_collections/community/dns/docs/docsite/links.yml b/ansible_collections/community/dns/docs/docsite/links.yml new file mode 100644 index 000000000..05a8e8313 --- /dev/null +++ b/ansible_collections/community/dns/docs/docsite/links.yml @@ -0,0 +1,27 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +edit_on_github: + repository: ansible-collections/community.dns + branch: main + path_prefix: '' + +extra_links: + - description: Submit a bug report + url: https://github.com/ansible-collections/community.dns/issues/new?assignees=&labels=&template=bug_report.md + - description: Request a feature + url: https://github.com/ansible-collections/community.dns/issues/new?assignees=&labels=&template=feature_request.md + +communication: + matrix_rooms: + - topic: General usage and support questions + room: '#users:ansible.im' + irc_channels: + - topic: General usage and support questions + network: Libera + channel: '#ansible' + mailing_lists: + - topic: Ansible Project List + url: https://groups.google.com/g/ansible-project diff --git a/ansible_collections/community/dns/docs/docsite/rst/filter_guide.rst b/ansible_collections/community/dns/docs/docsite/rst/filter_guide.rst new file mode 100644 index 000000000..a2169369c --- /dev/null +++ b/ansible_collections/community/dns/docs/docsite/rst/filter_guide.rst @@ -0,0 +1,104 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +.. _ansible_collections.community.dns.docsite.filter_guide: + +Community.Dns Filter Guide +========================== + +.. contents:: Contents + :local: + :depth: 1 + +The :ref:`community.dns collection <plugins_in_community.dns>` offers several filters for working with DNS names: + +- :ref:`community.dns.get_public_suffix <ansible_collections.community.dns.get_public_suffix_filter>`: given a domain name, returns the public suffix; +- :ref:`community.dns.get_registrable_domain <ansible_collections.community.dns.get_registrable_domain_filter>`: given a domain name, returns the registrable domain name; +- :ref:`community.dns.remove_public_suffix <ansible_collections.community.dns.remove_public_suffix_filter>`: given a domain name, returns the part before the public suffix; +- :ref:`community.dns.remove_registrable_domain <ansible_collections.community.dns.remove_registrable_domain_filter>`: given a domain name, returns the part before the registrable domain name. + +These filters allow to work with `public suffixes <https://en.wikipedia.org/wiki/Public_Suffix_List>`_; a *public suffix* is a DNS suffix under which users can (or could) directly register names. They use the `Public Suffix List <https://publicsuffix.org/>`_, a Mozilla initiative maintained as a community resource which tries to list all such public suffixes. Common examples for public suffixes are ``.com``, ``.net``, but also longer suffixes such as ``.co.uk`` or ``.github.io``. + +The label directly before the public suffix together with the suffix is called the *registrable domain name* or *registered domain name*, since these are usually the names that people can register. Examples for registrable domain names are ``example.com`` and ``example.co.uk``, while ``www.example.com`` is not a registrable domain name. A public suffix itself is also not a registrable domain name, as for example ``github.io``. + +Working with public suffixes +---------------------------- + +The :ref:`community.dns.get_public_suffix <ansible_collections.community.dns.get_public_suffix_filter>` and :ref:`community.dns.remove_public_suffix <ansible_collections.community.dns.remove_public_suffix_filter>` filters allow to extract and remove public suffixes from DNS names: + +.. code-block:: yaml+jinja + + - assert: + that: + - >- + "www.ansible.com" | community.dns.get_public_suffix == ".com" + - >- + "some.random.prefixes.ansible.co.uk" | community.dns.get_public_suffix == ".co.uk" + - >- + "www.ansible.com" | community.dns.remove_public_suffix == "www.ansible" + - >- + "some.random.prefixes.ansible.co.uk" | community.dns.remove_public_suffix == "some.random.prefixes.ansible" + +The filters also allow additional options (keyword arguments): + +:keep_unknown_suffix: + + A boolean with default value ``true``. This treats unknown TLDs as valid public suffixes. So for example the public suffix of ``example.tlddoesnotexist`` is ``.tlddoesnotexist`` if this is ``true``. If set to ``false``, it will return an empty string in this case. This option corresponds to whether the global wildcard rule ``*`` in the Public Suffix List is used or not. + +:icann_only: + + A boolean with default value ``false``. This controls whether only entries from the ICANN section of the Public Suffix List are used, or also entries from the Private section. For example, ``.co.uk`` is in the ICANN section, but ``github.io`` is in the Private section. + +:normalize_result: + + (Only for :ref:`community.dns.get_public_suffix <ansible_collections.community.dns.get_public_suffix_filter>`) A boolean with default value ``false``. This controls whether the result is reconstructed from the normalized name used during lookup. During normalization, ulabels are converted to alabels, and every label is converted to lowercase. For example, the ulabel ``ëçãmplê`` is converted to ``xn--mpl-llatwb`` (puny-code), and ``Example.COM`` is converted to ``example.com``. + +:keep_leading_period: + + (Only for :ref:`community.dns.get_public_suffix <ansible_collections.community.dns.get_public_suffix_filter>`) A boolean with default value ``true``. This controls whether the leading period of a public suffix is preserved or not. + +:keep_trailing_period: + + (Only for :ref:`community.dns.remove_public_suffix <ansible_collections.community.dns.remove_public_suffix_filter>`) A boolean with default value ``false``. This controls whether the trailing period of the prefix (that is, the part before the public suffix) is preserved or not. + +Working with registrable domain names +------------------------------------- + +The :ref:`community.dns.get_registrable_domain <ansible_collections.community.dns.get_registrable_domain_filter>` and :ref:`community.dns.remove_registrable_domain <ansible_collections.community.dns.remove_registrable_domain_filter>` filters allow to extract and remove registrable domain names from DNS names: + +.. code-block:: yaml+jinja + + - assert: + that: + - >- + "www.ansible.com" | community.dns.get_registrable_domain == "ansible.com" + - >- + "some.random.prefixes.ansible.co.uk" | community.dns.get_registrable_domain == "ansible.co.uk" + - >- + "www.ansible.com" | community.dns.remove_registrable_domain == "www" + - >- + "some.random.prefixes.ansible.co.uk" | community.dns.remove_registrable_domain == "some.random.prefixes" + +The filters also allow additional options (keyword arguments): + +:keep_unknown_suffix: + + A boolean with default value ``true``. This treats unknown TLDs as valid public suffixes. So for example the public suffix of ``example.tlddoesnotexist`` is ``.tlddoesnotexist`` if this is ``true``, and hence the registrable domain of ``www.example.tlddoesnotexist`` is ``example.tlddoesnotexist``. If set to ``false``, the registrable domain of ``www.example.tlddoesnotexist`` is ``tlddoesnotexist``. This option corresponds to whether the global wildcard rule ``*`` in the Public Suffix List is used or not. + +:icann_only: + + A boolean with default value ``false``. This controls whether only entries from the ICANN section of the Public Suffix List are used, or also entries from the Private section. For example, ``.co.uk`` is in the ICANN section, but ``github.io`` is in the Private section. + +:only_if_registerable: + + A boolean with default value ``true``. This controls the behavior in case there is no label in front of the public suffix. This is the case if the DNS name itself is a public suffix. If set to ``false``, in this case the public suffix is treated as a registrable domain. If set to ``true`` (default), the registrable domain of a public suffix is interpreted as an empty string. + +:normalize_result: + + (Only for :ref:`community.dns.get_registrable_domain <ansible_collections.community.dns.get_registrable_domain_filter>`) A boolean with default value ``false``. This controls whether the result is reconstructed from the normalized name used during lookup. During normalization, ulabels are converted to alabels, and every label is converted to lowercase. For example, the ulabel ``ëçãmplê`` is converted to ``xn--mpl-llatwb`` (puny-code), and ``Example.COM`` is converted to ``example.com``. + +:keep_trailing_period: + + (Only for :ref:`community.dns.remove_registrable_domain <ansible_collections.community.dns.remove_registrable_domain_filter>`) A boolean with default value ``false``. This controls whether the trailing period of the prefix (that is, the part before the registrable domain) is preserved or not. diff --git a/ansible_collections/community/dns/docs/docsite/rst/hetzner_guide.rst b/ansible_collections/community/dns/docs/docsite/rst/hetzner_guide.rst new file mode 100644 index 000000000..cbb029dcf --- /dev/null +++ b/ansible_collections/community/dns/docs/docsite/rst/hetzner_guide.rst @@ -0,0 +1,484 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +.. _ansible_collections.community.dns.docsite.hetzner_guide: + +Hetzner DNS Guide +================= + +.. contents:: Contents + :local: + :depth: 2 + +The :ref:`community.dns collection <plugins_in_community.dns>` offers several modules for working with the `Hetzner DNS service <https://docs.hetzner.com/dns-console/dns/>`_. +The modules use the `JSON REST based API <https://dns.hetzner.com/api-docs/>`_. + +The collection provides six modules for working with Hetzner DNS: + +- :ref:`community.dns.hetzner_dns_record <ansible_collections.community.dns.hetzner_dns_record_module>`: create/update/delete single DNS records +- :ref:`community.dns.hetzner_dns_record_info <ansible_collections.community.dns.hetzner_dns_record_info_module>`: retrieve information on DNS records +- :ref:`community.dns.hetzner_dns_record_set <ansible_collections.community.dns.hetzner_dns_record_set_module>`: create/update/delete DNS record sets +- :ref:`community.dns.hetzner_dns_record_set_info <ansible_collections.community.dns.hetzner_dns_record_set_info_module>`: retrieve information on DNS record sets +- :ref:`community.dns.hetzner_dns_record_sets <ansible_collections.community.dns.hetzner_dns_record_sets_module>`: bulk synchronize DNS record sets +- :ref:`community.dns.hetzner_dns_zone_info <ansible_collections.community.dns.hetzner_dns_zone_info_module>`: retrieve zone information + +If you are interested in migrating from the `markuman.hetzner_dns collection <https://galaxy.ansible.com/markuman/hetzner_dns>`_, please see :ref:`ansible_collections.community.dns.docsite.hetzner_guide.migration_markuman_hetzner_dns`. + +It also provides an inventory plugin: + +- :ref:`community.dns.hetzner_dns_records <ansible_collections.community.dns.hetzner_dns_records_inventory>`: create inventory from DNS records + +Authentication +-------------- + +To use Hetzner's API, you need to create an API token. You can manage API tokens in the "API tokens" menu entry in your user menu in the `DNS Console <https://dns.hetzner.com/>`_. You must provide the token to the ``hetzner_token`` option of the modules, its alias ``api_token``, or pass it on in the ``HETZNER_DNS_TOKEN`` environment variable: + +.. code-block:: yaml+jinja + + - community.dns.hetzner_dns_record: + hetzner_token: '{{ token }}' + ... + +In the examples in this guide, we will leave the authentication options away. Please note that you can set them globally with ``module_defaults`` (see :ref:`module_defaults`) or with an environment variable for the user and machine where the modules are run on. + +Using the ``community.dns.hetzner`` module defaults group +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To avoid having to specify common parameters for all Hetzner DNS modules in every task, you can use the ``community.dns.hetzner`` module defaults group: + +.. code-block:: yaml+jinja + + --- + - name: Hetzner DNS + hosts: localhost + gather_facts: false + module_defaults: + group/community.dns.hetzner + hetzner_token: '{{ token }}' + tasks: + - name: Query zone information + community.dns.hetzner_dns_zone_info: + zone_name: example.com + register: result + + - name: Set A records for www.example.com + community.dns.hetzner_dns_record_set: + state: present + zone_name: example.com + type: A + prefix: www + value: + - 192.168.0.1 + +Here all two tasks will use the options set for the module defaults group. + +Working with DNS zones +---------------------- + +The :ref:`community.dns.hetzner_dns_zone_info module <ansible_collections.community.dns.hetzner_dns_zone_info_module>` allows to query information on a zone. The zone can be identified both by its name and by its ID (which is an integer): + +.. code-block:: yaml+jinja + + - name: Query zone information by name + community.dns.hetzner_dns_zone_info: + zone_name: example.com + register: result + + - name: Query zone information by ID + community.dns.hetzner_dns_zone_info: + zone_id: aBcDeFgHiJlMnOpQrStUvW + register: result + +The module returns both the zone name and zone ID, so this module can be used to convert from zone ID to zone name and vice versa: + +.. code-block:: yaml+jinja + + - ansible.builtin.debug: + msg: | + The zone ID: {{ result.zone_id }} + The zone name: {{ result.zone_name }} + +Working with DNS records +------------------------ + +.. note:: + + By default, TXT record values returned and accepted by the modules and plugins in this collection are unquoted. This means that you do not have to add double quotes (``"``), and escape double quotes (as ``\"``) and backslashes (as ``\\``). All modules and plugins which work with DNS records support the ``txt_transformation`` option which allows to configure this behavior. + +Querying DNS records and record sets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :ref:`community.dns.hetzner_dns_record_set_info module <ansible_collections.community.dns.hetzner_dns_record_set_info_module>` allows to query DNS record sets from the API. It can be used to query a single record set: + +.. code-block:: yaml+jinja + + - name: Query single record + community.dns.hetzner_dns_record_set_info: + zone_name: example.com + type: A # IPv4 addresses + what: single_record # default value + # Either specify a record name: + record: www.example.com + # Or a record prefix ('' is the zone itself): + prefix: www + register: result + + - name: Show IPv4 addresses if record exists + ansible.builtin.debug: + msg: > + IPv4s are {{ result.set.value | join(', ') }}, + TTL is {{ result.set.ttl }} + when: result.set + + - name: Show that record is not set + ansible.builtin.debug: + msg: There is no A record for www.example.com + when: not result.set + +In all examples in this section, you can replace ``zone_name=example.com`` by ``zone_id=aBcDeFgHiJlMnOpQrStUvW`` with the zone's ID string. + +You can also query a list of all record sets for a record name or prefix: + +.. code-block:: yaml+jinja + + - name: Query all records for www.example.com + community.dns.hetzner_dns_record_set_info: + zone_name: example.com + what: all_types_for_record + # Either specify a record name: + record: www.example.com + # Or a record prefix ('' is the zone itself): + prefix: www + register: result + + - name: Show all records for www.example.com + ansible.builtin.debug: + msg: > + {{ item.type }} record with TTL {{ item.ttl }} has + values {{ item.value | join(', ') }} + loop: result.sets + +Finally you can query all record sets for a zone: + +.. code-block:: yaml+jinja + + - name: Query all records for a zone + community.dns.hetzner_dns_record_set_info: + zone_name: example.com + what: all_records + register: result + + - name: Show all records for the example.com zone + ansible.builtin.debug: + msg: > + {{ item.type }} record for {{ item.record }} with + TTL {{ item.ttl }} has values {{ item.value | join(', ') }} + loop: result.sets + +If you are interested in individual DNS records, and not record sets, you should use the :ref:`community.dns.hetzner_dns_record_info module <ansible_collections.community.dns.hetzner_dns_record_info_module>`. It supports the same limiting options as the ``community.dns.hetzner_dns_record_set_info`` module. + +Creating and updating DNS single records +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you do not want to add/remove values, but replace values, you will be interested in modifying a **record set** and not a single record. This is in particular important when working with ``CNAME`` and ``SOA`` records. + +The :ref:`community.dns.hetzner_dns_record module <ansible_collections.community.dns.hetzner_dns_record_module>` allows to set, update and remove single DNS records. Setting and updating can be done as follows. Records will be matched by record name and type, and the TTL value will be updated if necessary: + +.. code-block:: yaml+jinja + + - name: Add an A record with value 1.1.1.1 for www.example.com, resp. make sure the TTL is 300 + community.dns.hetzner_dns_record: + state: present + zone_name: example.com + type: A # IPv4 addresses + # Either specify a record name: + record: www.example.com + # Or a record prefix ('' is the zone itself): + prefix: www + value: 1.1.1.1 + ttl: 300 + +To delete records, simply use ``state=absent``. Records will be matched by record name and type, and the TTL will be ignored: + +.. code-block:: yaml+jinja + + - name: Remove A values for www.example.com + community.dns.hetzner_dns_record: + state: absent + zone_name: example.com + type: A # IPv4 addresses + record: www.example.com + value: 1.1.1.1 + +Records of the same type for the same record name with other values are ignored. + +Creating and updating DNS record sets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :ref:`community.dns.hetzner_dns_record_set module <ansible_collections.community.dns.hetzner_dns_record_set_module>` allows to set, update and remove DNS record sets. Setting and updating can be done as follows: + +.. code-block:: yaml+jinja + + - name: Make sure record is set to the given value + community.dns.hetzner_dns_record_set: + state: present + zone_name: example.com + type: A # IPv4 addresses + # Either specify a record name: + record: www.example.com + # Or a record prefix ('' is the zone itself): + prefix: www + value: + - 1.1.1.1 + - 8.8.8.8 + +If you want to assert that a record has a certain value, set ``on_existing=keep``. Using ``keep_and_warn`` instead will emit a warning if this happens, and ``keep_and_fail`` will make the module fail. + +To delete values, you can either overwrite the values with value ``[]``, or use ``state=absent``: + +.. code-block:: yaml+jinja + + - name: Remove A values for www.example.com + community.dns.hetzner_dns_record_set: + state: present + zone_name: example.com + type: A # IPv4 addresses + record: www.example.com + value: [] + + - name: Remove TXT values for www.example.com + community.dns.hetzner_dns_record_set: + zone_name: example.com + type: TXT + prefix: www + state: absent + + - name: Remove specific AAAA values for www.example.com + community.dns.hetzner_dns_record_set: + zone_name: example.com + type: AAAA # IPv6 addresses + prefix: www + state: absent + on_existing: keep_and_fail + ttl: 300 + value: + - '::1' + +In the third example, ``on_existing=keep_and_fail`` is present and an explicit value and TTL are given. This makes the module remove the current value only if there's a AAAA record for ``www.example.com`` whose current value is ``::1`` and whose TTL is 300. If another value is set, the module will not make any change, but fail. This can be useful to not accidentally remove values you do not want to change. To issue a warning instead of failing, use ``on_existing=keep_and_warn``, and to simply not do a change without any indication of this situation, use ``on_existing=keep``. + +Bulk synchronization of DNS record sets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you want to set/update multiple records at once, or even make sure that the precise set of records you are providing are present and nothing else, you can use the :ref:`community.dns.hetzner_dns_record_sets module <ansible_collections.community.dns.hetzner_dns_record_sets_module>`. + +The following example shows up to set/update multiple records at once: + +.. code-block:: yaml+jinja + + - name: Make sure that multiple records are present + community.dns.hetzner_dns_record_sets: + zone_name: example.com + records: + - prefix: www + type: A + value: + - 1.1.1.1 + - 8.8.8.8 + - prefix: www + type: AAAA + value: + - '::1' + +The next example shows how to make sure that only the given records are available and all other records are deleted. Note that for the ``type=NS`` record we used ``ignore=true``, which allows us to skip the value. It tells the module that it should not touch the ``NS`` record for ``example.com``. + +.. code-block:: yaml+jinja + + - name: Make sure that multiple records are present + community.dns.hetzner_dns_record_sets: + zone_name: example.com + prune: true + records: + - prefix: www + type: A + value: + - 1.1.1.1 + - 8.8.8.8 + - prefix: www + type: AAAA + value: + - '::1' + - prefix: '' + type: NS + ignore: true + +.. _ansible_collections.community.dns.docsite.hetzner_guide.migration_markuman_hetzner_dns: + +Migrating from ``markuman.hetzner_dns`` +--------------------------------------- + +This section describes how to migrate playbooks and roles from using the `markuman.hetzner_dns collection <https://galaxy.ansible.com/markuman/hetzner_dns>`_ to the Hetzner modules and plugins in the ``community.dns`` collection. + +There are three steps for migrating. Two of these steps must be done on migration, the third step can also be done later: + +1. Replace the modules and plugins used by the new ones. +2. Adjust module and plugin options if necessary. +3. Avoid deprecated aliases which ease the transition. + +The `markuman.hetzner_dns collection <https://galaxy.ansible.com/markuman/hetzner_dns>`_ collection provides three modules and one inventory plugin. + +.. note:: + + When working with TXT records, please look at the ``txt_transformation`` option. By default, the modules and plugins in this collection use **unquoted** values (you do not have to add double quotes and escape double quotes and backslashes), while the modules and plugins in ``markuman.hetzner_dns`` use partially quoted values. You can switch behavior of the ``community.dns`` modules by passing ``txt_transformation=api`` or ``txt_transformation=quoted``. + +The markuman.hetzner_dns.record module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``markuman.hetzner_dns.zone_info`` module can be replaced by the :ref:`community.dns.hetzner_dns_record module <ansible_collections.community.dns.hetzner_dns_record_module>` and the :ref:`community.dns.hetzner_dns_record_set module <ansible_collections.community.dns.hetzner_dns_record_set_module>`, depending on what it is used for. + +When creating, updating or removing single records, the :ref:`community.dns.hetzner_dns_record module <ansible_collections.community.dns.hetzner_dns_record_module>` should be used. This is the case when ``purge=false`` is specified (the default value). Note that ``replace``, ``overwrite`` and ``solo`` are aliases of ``purge``. + +.. code-block:: yaml+jinja + + # Creating and updating DNS records + + - name: Creating or updating a single DNS record with markuman.hetzner_dns + markuman.hetzner_dns.record: + zone_name: example.com + name: localhost + type: A + value: 127.0.0.1 + ttl: 60 + # This means the module operates on single DNS entries. If not specified, + # this is the default value: + purge: false + + - name: Creating or updating a single DNS record with community.dns + community.dns.hetzner_dns_record: + zone_name: example.com + # 'state' must always be specified: + state: present + # 'name' is a deprecated alias of 'prefix', so it can be + # kept during a first migration step: + name: localhost + # 'type', 'value' and 'ttl' do not change: + type: A + value: 127.0.0.1 + ttl: 60 + # If type is TXT, you either have to adjust the value you pass, + # or keep the following option: + txt_transformation: api + +When the ``markuman.hetzner_dns.record`` module is in replace mode, it should be replaced by the :ref:`community.dns.hetzner_dns_record_set module <ansible_collections.community.dns.hetzner_dns_record_set_module>`, since then it operates on the *record set* and not just on a single record: + +.. code-block:: yaml+jinja + + # Creating and updating DNS record sets + + - name: Creating or updating a record set with markuman.hetzner_dns + markuman.hetzner_dns.record: + zone_name: example.com + name: localhost + type: A + value: 127.0.0.1 + ttl: 60 + # This means the module operates on the record set: + purge: true + + - name: Creating or updating a record set with community.dns + community.dns.hetzner_dns_record_set: + zone_name: example.com + # 'state' must always be specified: + state: present + # 'name' is a deprecated alias of 'prefix', so it can be + # kept during a first migration step: + name: localhost + # 'type' and 'ttl' do not change: + type: A + ttl: 60 + # 'value' is now a list: + value: + - 127.0.0.1 + # Ansible allows to specify lists as a comma-separated string. + # So for records which do not contain a comma, you can also + # keep the old syntax, in this case: + # + # value: 127.0.0.1 + # + # If type is TXT, you either have to adjust the value you pass, + # or keep the following option: + txt_transformation: api + +When deleting a record, it depends on whether ``value`` is specified or not. If ``value`` is specified, the module is deleting a single DNS record, and the :ref:`community.dns.hetzner_dns_record module <ansible_collections.community.dns.hetzner_dns_record_module>` should be used: + +.. code-block:: yaml+jinja + + # Deleting single DNS records + + - name: Deleting a single DNS record with markuman.hetzner_dns + markuman.hetzner_dns.record: + zone_name: example.com + state: absent + name: localhost + type: A + value: 127.0.0.1 + ttl: 60 + + - name: Deleting a single DNS record with community.dns + community.dns.hetzner_dns_record: + zone_name: example.com + state: absent + # 'name' is a deprecated alias of 'prefix', so it can be + # kept during a first migration step: + name: localhost + # 'type', 'value' and 'ttl' do not change: + type: A + value: 127.0.0.1 + ttl: 60 + # If type is TXT, you either have to adjust the value you pass, + # or keep the following option: + txt_transformation: api + +When ``value`` is not specified, the ``markuman.hetzner_dns.record`` module will delete all records for this prefix and type. In that case, it operates on a record set and the :ref:`community.dns.hetzner_dns_record_set module <ansible_collections.community.dns.hetzner_dns_record_set_module>` should be used: + +.. code-block:: yaml+jinja + + # Deleting multiple DNS records + + - name: Deleting multiple DNS records with markuman.hetzner_dns + markuman.hetzner_dns.record: + zone_name: example.com + state: absent + name: localhost + type: A + + - name: Deleting a single DNS record with community.dns + community.dns.hetzner_dns_record_set: + zone_name: example.com + state: absent + # 'name' is a deprecated alias of 'prefix', so it can be + # kept during a first migration step: + name: localhost + # 'type' does not change: + type: A + +A last step is replacing the deprecated alias ``name`` of ``prefix`` by ``prefix``. This can be done later though, if you do not mind the deprecation warnings. + +The markuman.hetzner_dns.record_info module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``markuman.hetzner_dns.record_info`` module can be replaced by the :ref:`community.dns.hetzner_dns_record_info module <ansible_collections.community.dns.hetzner_dns_record_info_module>`. The main difference is that instead of by the ``filters`` option, the output is controlled by the ``what`` option (choices ``single_record``, ``all_types_for_record``, and ``all_records``), the ``type`` option (needed when ``what=single_record``), and the ``record`` and ``prefix`` options (needed when ``what`` is not ``all_records``). + +The markuman.hetzner_dns.zone_info module +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``markuman.hetzner_dns.zone_info`` module can be replaced by the :ref:`community.dns.hetzner_dns_zone_info module <ansible_collections.community.dns.hetzner_dns_zone_info_module>`. The main differences are: + +1. The parameter ``name`` must be changed to ``zone_name`` or ``zone``. +2. The return value ``zone_info`` no longer has the ``name`` and ``id`` entries. Use the return values ``zone_name`` and ``zone_id`` instead. + +The markuman.hetzner_dns.inventory inventory plugin +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``markuman.hetzner_dns.inventory`` inventory plugin can be replaced by the :ref:`community.dns.hetzner_dns_records inventory plugin <ansible_collections.community.dns.hetzner_dns_records_inventory>`. Besides the plugin name, no change should be necessary. diff --git a/ansible_collections/community/dns/docs/docsite/rst/hosttech_guide.rst b/ansible_collections/community/dns/docs/docsite/rst/hosttech_guide.rst new file mode 100644 index 000000000..8efb8058f --- /dev/null +++ b/ansible_collections/community/dns/docs/docsite/rst/hosttech_guide.rst @@ -0,0 +1,338 @@ +.. + Copyright (c) Ansible Project + GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) + SPDX-License-Identifier: GPL-3.0-or-later + +.. _ansible_collections.community.dns.docsite.hosttech_guide: + +HostTech DNS Guide +================== + +.. contents:: Contents + :local: + :depth: 2 + +The :ref:`community.dns collection <plugins_in_community.dns>` offers several modules for working with the `HostTech DNS service <https://www.hosttech.ch/>`_. +The modules support both the old `WSDL-based API <https://ns1.hosttech.eu/public/api?wsdl>`_ and the new `JSON REST based API <https://api.ns1.hosttech.eu/api/documentation/>`_. + +The collection provides six modules for working with HostTech DNS: + +- :ref:`community.dns.hosttech_dns_record <ansible_collections.community.dns.hosttech_dns_record_module>`: create/update/delete single DNS records +- :ref:`community.dns.hosttech_dns_record_info <ansible_collections.community.dns.hosttech_dns_record_info_module>`: retrieve information on DNS records +- :ref:`community.dns.hosttech_dns_record_set <ansible_collections.community.dns.hosttech_dns_record_set_module>`: create/update/delete DNS record sets +- :ref:`community.dns.hosttech_dns_record_set_info <ansible_collections.community.dns.hosttech_dns_record_set_info_module>`: retrieve information on DNS record sets +- :ref:`community.dns.hosttech_dns_record_sets <ansible_collections.community.dns.hosttech_dns_record_sets_module>`: bulk synchronize DNS record sets +- :ref:`community.dns.hosttech_dns_zone_info <ansible_collections.community.dns.hosttech_dns_zone_info_module>`: retrieve zone information + +It also provides an inventory plugin: + +- :ref:`community.dns.hosttech_dns_records <ansible_collections.community.dns.hosttech_dns_records_inventory>`: create inventory from DNS records + +Authentication, Requirements and APIs +------------------------------------- + +HostTech currently has two APIs for working with DNS records: the old WSDL-based API, and the new JSON-based REST API. We recommend using the new REST API if possible. + +JSON REST API +~~~~~~~~~~~~~ + +To use the JSON REST API, you need to create a API token. You can manage API tokens in the "DNS Editor" in the "API" section. You must provide the token to the ``hosttech_token`` option of the modules: + +.. code-block:: yaml+jinja + + - community.dns.hosttech_dns_record: + hosttech_token: '{{ token }}' + ... + +In the examples in this guide, we will leave the authentication options away. Please note that you can set them globally with ``module_defaults`` (see :ref:`module_defaults`). + +WSDL API +~~~~~~~~ + +To use the WSDL API, you need to set API credentials. These can be found and changed in the "Servercenter" and there in the "Solutions" section under settings for the "DNS Tool". The username is fixed, but the password can be changed. The credentials must be provided to the ``hosttech_username`` and ``hosttech_password`` options of the modules. + +You also need to install the `lxml Python module <https://pypi.org/project/lxml/>`_ to work with the WSDL API. This can be done before using the modules: + +.. code-block:: yaml+jinja + + - name: Make sure lxml is installed + pip: + name: lxml + + - community.dns.hosttech_dns_record: + hosttech_username: '{{ username }}' + hosttech_password: '{{ password }}' + ... + +In the examples in this guide, we will leave the authentication options away. Please note that you can set them globally with ``module_defaults`` (see :ref:`module_defaults`). + +Using the ``community.dns.hosttech`` module defaults group +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +To avoid having to specify common parameters for all Hosttech DNS modules in every task, you can use the ``community.dns.hosttech`` module defaults group: + +.. code-block:: yaml+jinja + + --- + - name: Hosttech DNS + hosts: localhost + gather_facts: false + module_defaults: + group/community.dns.hosttech + hosttech_username: '{{ username }}' + hosttech_password: '{{ password }}' + tasks: + - name: Query zone information + community.dns.hosttech_dns_zone_info: + zone_name: example.com + register: result + + - name: Set A records for www.example.com + community.dns.hosttech_dns_record_set: + state: present + zone_name: example.com + type: A + prefix: www + value: + - 192.168.0.1 + +Here all two tasks will use the options set for the module defaults group. + +Working with DNS zones +---------------------- + +The :ref:`community.dns.hosttech_dns_zone_info module <ansible_collections.community.dns.hosttech_dns_zone_info_module>` allows to query information on a zone. The zone can be identified both by its name and by its ID (which is an integer): + +.. code-block:: yaml+jinja + + - name: Query zone information by name + community.dns.hosttech_dns_zone_info: + zone_name: example.com + register: result + + - name: Query zone information by ID + community.dns.hosttech_dns_zone_info: + zone_id: 42 + register: result + +The module returns both the zone name and zone ID, so this module can be used to convert from zone ID to zone name and vice versa: + +.. code-block:: yaml+jinja + + - ansible.builtin.debug: + msg: | + The zone ID: {{ result.zone_id }} + The zone name: {{ result.zone_name }} + +Working with DNS records +------------------------ + +.. note:: + + By default, TXT record values returned and accepted by the modules and plugins in this collection are unquoted. This means that you do not have to add double quotes (``"``), and escape double quotes (as ``\"``) and backslashes (as ``\\``). All modules and plugins which work with DNS records support the ``txt_transformation`` option which allows to configure this behavior. + +Querying DNS records and record sets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :ref:`community.dns.hosttech_dns_record_set_info module <ansible_collections.community.dns.hosttech_dns_record_set_info_module>` allows to query DNS record sets from the API. It can be used to query a single record set: + +.. code-block:: yaml+jinja + + - name: Query single record + community.dns.hosttech_dns_record_set_info: + zone_name: example.com + type: A # IPv4 addresses + what: single_record # default value + # Either specify a record name: + record: www.example.com + # Or a record prefix ('' is the zone itself): + prefix: www + register: result + + - name: Show IPv4 addresses if record exists + ansible.builtin.debug: + msg: > + IPv4s are {{ result.set.value | join(', ') }}, + TTL is {{ result.set.ttl }} + when: result.set + + - name: Show that record is not set + ansible.builtin.debug: + msg: There is no A record for www.example.com + when: not result.set + +In all examples in this section, you can replace ``zone_name: example.com`` by ``zone_id: 42`` with the zone's integer ID. + +You can also query a list of all record sets for a record name or prefix: + +.. code-block:: yaml+jinja + + - name: Query all records for www.example.com + community.dns.hosttech_dns_record_set_info: + zone_name: example.com + what: all_types_for_record + # Either specify a record name: + record: www.example.com + # Or a record prefix ('' is the zone itself): + prefix: www + register: result + + - name: Show all records for www.example.com + ansible.builtin.debug: + msg: > + {{ item.type }} record with TTL {{ item.ttl }} has + values {{ item.value | join(', ') }} + loop: result.sets + +Finally you can query all record sets for a zone: + +.. code-block:: yaml+jinja + + - name: Query all records for a zone + community.dns.hosttech_dns_record_set_info: + zone_name: example.com + what: all_records + register: result + + - name: Show all records for the example.com zone + ansible.builtin.debug: + msg: > + {{ item.type }} record for {{ item.record }} with + TTL {{ item.ttl }} has values {{ item.value | join(', ') }} + loop: result.sets + +If you are interested in individual DNS records, and not record sets, you should use the :ref:`community.dns.hosttech_dns_record_info module <ansible_collections.community.dns.hosttech_dns_record_info_module>`. It supports the same limiting options as the ``community.dns.hosttech_dns_record_set_info`` module. + +Creating and updating DNS single records +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you do not want to add/remove values, but replace values, you will be interested in modifying a **record set** and not a single record. This is in particular important when working with ``CNAME`` and ``SOA`` records. + +The :ref:`community.dns.hosttech_dns_record module <ansible_collections.community.dns.hosttech_dns_record_module>` allows to set, update and remove single DNS records. Setting and updating can be done as follows. Records will be matched by record name and type, and the TTL value will be updated if necessary: + +.. code-block:: yaml+jinja + + - name: Add an A record with value 1.1.1.1 for www.example.com, resp. make sure the TTL is 300 + community.dns.hosttech_dns_record: + state: present + zone_name: example.com + type: A # IPv4 addresses + # Either specify a record name: + record: www.example.com + # Or a record prefix ('' is the zone itself): + prefix: www + value: 1.1.1.1 + ttl: 300 + +To delete records, simply use ``state: absent``. Records will be matched by record name and type, and the TTL will be ignored: + +.. code-block:: yaml+jinja + + - name: Remove A values for www.example.com + community.dns.hosttech_dns_record: + state: absent + zone_name: example.com + type: A # IPv4 addresses + record: www.example.com + value: 1.1.1.1 + +Records of the same type for the same record name with other values are ignored. + +Creating and updating DNS record sets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The :ref:`community.dns.hosttech_dns_record_set module <ansible_collections.community.dns.hosttech_dns_record_set_module>` allows to set, update and remove DNS record sets. Setting and updating can be done as follows: + +.. code-block:: yaml+jinja + + - name: Make sure record is set to the given value + community.dns.hosttech_dns_record_set: + state: present + zone_name: example.com + type: A # IPv4 addresses + # Either specify a record name: + record: www.example.com + # Or a record prefix ('' is the zone itself): + prefix: www + value: + - 1.1.1.1 + - 8.8.8.8 + +If you want to assert that a record has a certain value, set ``on_existing: keep``. Using ``keep_and_warn`` instead will emit a warning if this happens, and ``keep_and_fail`` will make the module fail. + +To delete values, you can either overwrite the values with value ``[]``, or use ``state: absent``: + +.. code-block:: yaml+jinja + + - name: Remove A values for www.example.com + community.dns.hosttech_dns_record_set: + state: present + zone_name: example.com + type: A # IPv4 addresses + record: www.example.com + value: [] + + - name: Remove TXT values for www.example.com + community.dns.hosttech_dns_record_set: + zone_name: example.com + type: TXT + prefix: www + state: absent + + - name: Remove specific AAAA values for www.example.com + community.dns.hosttech_dns_record_set: + zone_name: example.com + type: AAAA # IPv6 addresses + prefix: www + state: absent + on_existing: keep_and_fail + ttl: 300 + value: + - '::1' + +In the third example, ``on_existing: keep_and_fail`` is present and an explicit value and TTL are given. This makes the module remove the current value only if there's a AAAA record for ``www.example.com`` whose current value is ``::1`` and whose TTL is 300. If another value is set, the module will not make any change, but fail. This can be useful to not accidentally remove values you do not want to change. To issue a warning instead of failing, use ``on_existing: keep_and_warn``, and to simply not do a change without any indication of this situation, use ``on_existing: keep``. + +Bulk synchronization of DNS record sets +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you want to set/update multiple records at once, or even make sure that the precise set of records you are providing are present and nothing else, you can use the :ref:`community.dns.hosttech_dns_record_sets module <ansible_collections.community.dns.hosttech_dns_record_sets_module>`. + +The following example shows up to set/update multiple records at once: + +.. code-block:: yaml+jinja + + - name: Make sure that multiple records are present + community.dns.hosttech_dns_record_sets: + zone_name: example.com + records: + - prefix: www + type: A + value: + - 1.1.1.1 + - 8.8.8.8 + - prefix: www + type: AAAA + value: + - '::1' + +The next example shows how to make sure that only the given records are available and all other records are deleted. Note that for the ``type: NS`` record we used ``ignore: true``, which allows us to skip the value. It tells the module that it should not touch the ``NS`` record for ``example.com``. + +.. code-block:: yaml+jinja + + - name: Make sure that multiple records are present + community.dns.hosttech_dns_record_sets: + zone_name: example.com + prune: true + records: + - prefix: www + type: A + value: + - 1.1.1.1 + - 8.8.8.8 + - prefix: www + type: AAAA + value: + - '::1' + - prefix: '' + type: NS + ignore: true diff --git a/ansible_collections/community/dns/meta/ee-requirements.txt b/ansible_collections/community/dns/meta/ee-requirements.txt new file mode 100644 index 000000000..bb14e11cd --- /dev/null +++ b/ansible_collections/community/dns/meta/ee-requirements.txt @@ -0,0 +1,6 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +dnspython +lxml diff --git a/ansible_collections/community/dns/meta/execution-environment.yml b/ansible_collections/community/dns/meta/execution-environment.yml new file mode 100644 index 000000000..ac7ebac8a --- /dev/null +++ b/ansible_collections/community/dns/meta/execution-environment.yml @@ -0,0 +1,8 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +version: 1 +dependencies: + python: meta/ee-requirements.txt diff --git a/ansible_collections/community/dns/meta/runtime.yml b/ansible_collections/community/dns/meta/runtime.yml new file mode 100644 index 000000000..25f52c7a8 --- /dev/null +++ b/ansible_collections/community/dns/meta/runtime.yml @@ -0,0 +1,29 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +requires_ansible: '>=2.9.10' +action_groups: + hetzner: + - hetzner_dns_record_info + - hetzner_dns_record + - hetzner_dns_record_set_info + - hetzner_dns_record_set + - hetzner_dns_record_sets + - hetzner_dns_zone_info + hosttech: + - hosttech_dns_record_info + - hosttech_dns_record + - hosttech_dns_record_set_info + - hosttech_dns_record_set + - hosttech_dns_record_sets + - hosttech_dns_records # deprecated redirect + - hosttech_dns_zone_info +plugin_routing: + modules: + hosttech_dns_records: + redirect: community.dns.hosttech_dns_record_sets + deprecation: + removal_version: 3.0.0 + warning_text: The hosttech_dns_records module has been renamed to hosttech_dns_record_sets. diff --git a/ansible_collections/community/dns/plugins/doc_fragments/attributes.py b/ansible_collections/community/dns/plugins/doc_fragments/attributes.py new file mode 100644 index 000000000..1fff4ab70 --- /dev/null +++ b/ansible_collections/community/dns/plugins/doc_fragments/attributes.py @@ -0,0 +1,105 @@ +# -*- 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) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard documentation fragment + DOCUMENTATION = r''' +options: {} +attributes: + check_mode: + description: Can run in C(check_mode) and return changed status prediction without modifying target. + diff_mode: + description: Will return details on what has changed (or possibly needs changing in C(check_mode)), when in diff mode. +''' + + # Should be used together with the standard fragment + INFO_MODULE = r''' +options: {} +attributes: + check_mode: + support: full + details: + - This action does not modify state. + diff_mode: + support: N/A + details: + - This action does not modify state. +''' + + ACTIONGROUP_HETZNER = r''' +options: {} +attributes: + action_group: + description: Use C(group/community.dns.hetzner) in C(module_defaults) to set defaults for this module. + support: full + membership: + - community.dns.hetzner +''' + + ACTIONGROUP_HOSTTECH = r''' +options: {} +attributes: + action_group: + description: Use C(group/community.dns.hosttech) in C(module_defaults) to set defaults for this module. + support: full + membership: + - community.dns.hosttech +''' + + CONN = r''' +options: {} +attributes: + become: + description: Is usable alongside C(become) keywords. + connection: + description: Uses the target's configured connection information to execute code on it. + delegation: + description: Can be used in conjunction with C(delegate_to) and related keywords. +''' + + FACTS = r''' +options: {} +attributes: + facts: + description: Action returns an C(ansible_facts) dictionary that will update existing host facts. +''' + + # Should be used together with the standard fragment and the FACTS fragment + FACTS_MODULE = r''' +options: {} +attributes: + check_mode: + support: full + details: + - This action does not modify state. + diff_mode: + support: N/A + details: + - This action does not modify state. + facts: + support: full +''' + + FILES = r''' +options: {} +attributes: + safe_file_operations: + description: Uses Ansible's strict file operation functions to ensure proper permissions and avoid data corruption. +''' + + FLOW = r''' +options: {} +attributes: + action: + description: Indicates this has a corresponding action plugin so some parts of the options can be executed on the controller. + async: + description: Supports being used with the C(async) keyword. +''' diff --git a/ansible_collections/community/dns/plugins/doc_fragments/filters.py b/ansible_collections/community/dns/plugins/doc_fragments/filters.py new file mode 100644 index 000000000..e153d4bf9 --- /dev/null +++ b/ansible_collections/community/dns/plugins/doc_fragments/filters.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2022 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + DOCUMENTATION = r''' +options: + icann_only: + description: + - This controls whether only entries from the ICANN section of the Public Suffix List are used, + or also entries from the Private section. For example, C(.co.uk) is in the ICANN section, + but C(github.io) is in the Private section. + type: boolean + default: false +''' + + PUBLIC_SUFFIX = r''' +options: + keep_unknown_suffix: + description: + - This treats unknown TLDs as valid public suffixes. So for example the public suffix + of C(example.tlddoesnotexist) is C(.tlddoesnotexist) if this is C(true). If set to + C(false), it will return an empty string in this case. + - This option corresponds to whether the global wildcard rule C(*) in the Public + Suffix List is used or not. + type: boolean + default: true +''' + + REGISTERABLE_DOMAIN = r''' +options: + only_if_registerable: + description: + - This controls the behavior in case there is no label in front of the public suffix. + This is the case if the DNS name itself is a public suffix. + - If set to C(false), in this case the public suffix is treated as a registrable domain. + - If set to C(true) (default), the registrable domain of a public suffix is interpreted as an + empty string. + type: boolean + default: true + keep_unknown_suffix: + description: + - This treats unknown TLDs as valid public suffixes. So for example the public suffix of + C(example.tlddoesnotexist) is C(.tlddoesnotexist) if this is C(true), and hence the + registrable domain of C(www.example.tlddoesnotexist) is C(example.tlddoesnotexist). + If set to C(false), the registrable domain of C(www.example.tlddoesnotexist) is + C(tlddoesnotexist). + - This option corresponds to whether the global wildcard rule C(*) in the Public Suffix List + is used or not. + type: boolean + default: true +''' + + GET = r''' +options: + normalize_result: + description: + - This controls whether the result is reconstructed from the normalized name used during lookup. + During normalization, ulabels are converted to alabels, and every label is converted to lowercase. + For example, the ulabel C(ëçãmplê) is converted to C(xn--mpl-llatwb) (puny-code), and + C(Example.COM) is converted to C(example.com). + type: boolean + default: false +''' diff --git a/ansible_collections/community/dns/plugins/doc_fragments/hetzner.py b/ansible_collections/community/dns/plugins/doc_fragments/hetzner.py new file mode 100644 index 000000000..3b79e5da0 --- /dev/null +++ b/ansible_collections/community/dns/plugins/doc_fragments/hetzner.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = r''' +options: + hetzner_token: + description: + - The token for the Hetzner API. + - If not provided, will be read from the environment variable C(HETZNER_DNS_TOKEN). + aliases: + - api_token + type: str + required: true +''' + + # NOTE: This document fragment augments the above standard DOCUMENTATION document fragment + # by providing alternative ways to provide configuration for plugins. (The above + # documentation fragment is tailored for modules.) + PLUGIN = r''' +options: + hetzner_token: + env: + - name: HETZNER_DNS_TOKEN +''' + + # WARNING: This section is automatically generated by update-docs-fragments.py. + # It is used to augment the docs fragments module_record, module_record_set. + # DO NOT EDIT MANUALLY! + RECORD_DEFAULT_TTL = r''' +options: + ttl: + default: null +''' + + # WARNING: This section is automatically generated by update-docs-fragments.py. + # It is used to augment the docs fragments module_record, module_record_info, + # module_record_set, module_record_set_info. + # DO NOT EDIT MANUALLY! + RECORD_TYPE_CHOICES = r''' +options: + type: + choices: + - A + - AAAA + - CAA + - CNAME + - DANE + - DS + - HINFO + - MX + - NS + - RP + - SOA + - SRV + - TLSA + - TXT +''' + + # WARNING: This section is automatically generated by update-docs-fragments.py. + # It is used to augment the docs fragment module_record_sets. + # DO NOT EDIT MANUALLY! + RECORD_TYPE_CHOICES_RECORD_SETS_MODULE = r''' +options: + record_sets: + suboptions: + record: + description: + - The full DNS record to create or delete. + - Exactly one of I(record) and I(prefix) must be specified. + type: str + prefix: + description: + - The prefix of the DNS record. + - This is the part of I(record) before I(zone_name). For example, + if the record to be modified is C(www.example.com) for the zone + C(example.com), the prefix is C(www). If the record in this + example would be C(example.com), the prefix would be C('') (empty + string). + - Exactly one of I(record) and I(prefix) must be specified. + type: str + ttl: + description: + - The TTL to give the new record, in seconds. + type: int + default: null + type: + description: + - The type of DNS record to create or delete. + required: true + type: str + choices: + - A + - AAAA + - CAA + - CNAME + - DANE + - DS + - HINFO + - MX + - NS + - RP + - SOA + - SRV + - TLSA + - TXT + value: + description: + - The new value when creating a DNS record. + - YAML lists or multiple comma-spaced values are allowed. + - When deleting a record all values for the record must be specified + or it will not be deleted. + - Must be specified if I(ignore=false). + type: list + elements: str + ignore: + description: + - If set to C(true), I(value) will be ignored. + - This is useful when I(prune=true), but you do not want certain + entries to be removed without having to know their current value. + type: bool + default: false +''' + + # WARNING: This section is automatically generated by update-docs-fragments.py. + # It is used to augment the docs fragment inventory_records. + # DO NOT EDIT MANUALLY! + RECORD_TYPE_CHOICES_RECORDS_INVENTORY = r''' +options: + filters: + suboptions: + type: + description: + - Record types whose values to use. + type: list + elements: string + default: + - A + - AAAA + - CNAME + choices: + - A + - AAAA + - CAA + - CNAME + - DANE + - DS + - HINFO + - MX + - NS + - RP + - SOA + - SRV + - TLSA + - TXT +''' + + # WARNING: This section is automatically generated by update-docs-fragments.py. + # It is used to augment the docs fragments inventory_records, module_record, + # module_record_info, module_record_set, module_record_set_info, + # module_record_sets, module_zone_info. + # DO NOT EDIT MANUALLY! + ZONE_ID_TYPE = r''' +options: + zone_id: + type: str +''' diff --git a/ansible_collections/community/dns/plugins/doc_fragments/hosttech.py b/ansible_collections/community/dns/plugins/doc_fragments/hosttech.py new file mode 100644 index 000000000..937983f50 --- /dev/null +++ b/ansible_collections/community/dns/plugins/doc_fragments/hosttech.py @@ -0,0 +1,189 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2020 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = r''' +requirements: + - lxml + +options: + hosttech_username: + description: + - The username for the Hosttech API user. + - If provided, I(hosttech_password) must also be provided. + - Mutually exclusive with I(hosttech_token). + type: str + hosttech_password: + description: + - The password for the Hosttech API user. + - If provided, I(hosttech_username) must also be provided. + - Mutually exclusive with I(hosttech_token). + type: str + hosttech_token: + description: + - The password for the Hosttech API user. + - Mutually exclusive with I(hosttech_username) and I(hosttech_password). + - Since community.dns 1.2.0, the alias I(api_token) can be used. + aliases: + - api_token + type: str + version_added: 0.2.0 +''' + + # NOTE: This document fragment augments the above standard DOCUMENTATION document fragment + # by providing alternative ways to provide configuration for plugins. (The above + # documentation fragment is tailored for modules.) + PLUGIN = r''' +options: + hosttech_username: + env: + - name: ANSIBLE_HOSTTECH_API_USERNAME + version_added: 2.5.0 + hosttech_password: + env: + - name: ANSIBLE_HOSTTECH_API_PASSWORD + version_added: 2.5.0 + hosttech_token: + env: + - name: ANSIBLE_HOSTTECH_DNS_TOKEN + version_added: 2.5.0 +''' + + # WARNING: This section is automatically generated by update-docs-fragments.py. + # It is used to augment the docs fragments module_record, module_record_set. + # DO NOT EDIT MANUALLY! + RECORD_DEFAULT_TTL = r''' +options: + ttl: + default: 3600 +''' + + # WARNING: This section is automatically generated by update-docs-fragments.py. + # It is used to augment the docs fragments module_record, module_record_info, + # module_record_set, module_record_set_info. + # DO NOT EDIT MANUALLY! + RECORD_TYPE_CHOICES = r''' +options: + type: + choices: + - A + - AAAA + - CAA + - CNAME + - MX + - NS + - PTR + - SPF + - SRV + - TXT +''' + + # WARNING: This section is automatically generated by update-docs-fragments.py. + # It is used to augment the docs fragment module_record_sets. + # DO NOT EDIT MANUALLY! + RECORD_TYPE_CHOICES_RECORD_SETS_MODULE = r''' +options: + record_sets: + suboptions: + record: + description: + - The full DNS record to create or delete. + - Exactly one of I(record) and I(prefix) must be specified. + type: str + prefix: + description: + - The prefix of the DNS record. + - This is the part of I(record) before I(zone_name). For example, + if the record to be modified is C(www.example.com) for the zone + C(example.com), the prefix is C(www). If the record in this + example would be C(example.com), the prefix would be C('') (empty + string). + - Exactly one of I(record) and I(prefix) must be specified. + type: str + ttl: + description: + - The TTL to give the new record, in seconds. + type: int + default: 3600 + type: + description: + - The type of DNS record to create or delete. + required: true + type: str + choices: + - A + - AAAA + - CAA + - CNAME + - MX + - NS + - PTR + - SPF + - SRV + - TXT + value: + description: + - The new value when creating a DNS record. + - YAML lists or multiple comma-spaced values are allowed. + - When deleting a record all values for the record must be specified + or it will not be deleted. + - Must be specified if I(ignore=false). + type: list + elements: str + ignore: + description: + - If set to C(true), I(value) will be ignored. + - This is useful when I(prune=true), but you do not want certain + entries to be removed without having to know their current value. + type: bool + default: false +''' + + # WARNING: This section is automatically generated by update-docs-fragments.py. + # It is used to augment the docs fragment inventory_records. + # DO NOT EDIT MANUALLY! + RECORD_TYPE_CHOICES_RECORDS_INVENTORY = r''' +options: + filters: + suboptions: + type: + description: + - Record types whose values to use. + type: list + elements: string + default: + - A + - AAAA + - CNAME + choices: + - A + - AAAA + - CAA + - CNAME + - MX + - NS + - PTR + - SPF + - SRV + - TXT +''' + + # WARNING: This section is automatically generated by update-docs-fragments.py. + # It is used to augment the docs fragments inventory_records, module_record, + # module_record_info, module_record_set, module_record_set_info, + # module_record_sets, module_zone_info. + # DO NOT EDIT MANUALLY! + ZONE_ID_TYPE = r''' +options: + zone_id: + type: int +''' diff --git a/ansible_collections/community/dns/plugins/doc_fragments/inventory_records.py b/ansible_collections/community/dns/plugins/doc_fragments/inventory_records.py new file mode 100644 index 000000000..fcdc55acb --- /dev/null +++ b/ansible_collections/community/dns/plugins/doc_fragments/inventory_records.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# Copyright (c) 2020 Markus Bergholz <markuman+spambelongstogoogle@gmail.com> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = r''' +description: + - Records are matched by prefix / record name and value. + +notes: + - The I(zone_name) and I(zone_id) options can be templated. + +options: + zone_name: + description: + - The DNS zone to modify. + - Exactly one of I(zone_name) and I(zone_id) must be specified. + type: str + aliases: + - zone + zone_id: + description: + - The ID of the DNS zone to modify. + - Exactly one of I(zone_name) and I(zone_id) must be specified. + filters: + description: + - A dictionary of filter value pairs. + type: dict + default: {} + suboptions: + # (The following must be kept in sync with the equivalent lines in <provider_name>.py!) + type: + description: + - Record types whose values to use. + type: list + elements: string + default: [A, AAAA, CNAME] +''' diff --git a/ansible_collections/community/dns/plugins/doc_fragments/module_record.py b/ansible_collections/community/dns/plugins/doc_fragments/module_record.py new file mode 100644 index 000000000..4eaa956aa --- /dev/null +++ b/ansible_collections/community/dns/plugins/doc_fragments/module_record.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + + # NOTE: This document fragment needs to be augmented by ZONE_ID_TYPE in a provider document fragment. + # The ZONE_ID_TYPE fragment will provide `choices` for the options.type entry. + DOCUMENTATION = r''' +description: + - Records are matched by prefix / record name and value. + +options: + state: + description: + - Specifies the state of the resource record. + required: true + choices: ['present', 'absent'] + type: str + zone_name: + description: + - The DNS zone to modify. + - Exactly one of I(zone_name) and I(zone_id) must be specified. + type: str + aliases: + - zone + zone_id: + description: + - The ID of the DNS zone to modify. + - Exactly one of I(zone_name) and I(zone_id) must be specified. + record: + description: + - The full DNS record to create or delete. + - Exactly one of I(record) and I(prefix) must be specified. + type: str + prefix: + description: + - The prefix of the DNS record. + - This is the part of I(record) before I(zone_name). For example, if the record to be modified is C(www.example.com) + for the zone C(example.com), the prefix is C(www). If the record in this example would be C(example.com), the + prefix would be C('') (empty string). + - Exactly one of I(record) and I(prefix) must be specified. + type: str + ttl: + description: + - The TTL to give the new record, in seconds. + - This is not used for record deletion. + type: int + type: + description: + - The type of DNS record to create or delete. + required: true + type: str + value: + description: + - The new value when creating a DNS record. + - When deleting a record all values for the record must be specified or it will + not be deleted. + required: true + type: str +''' diff --git a/ansible_collections/community/dns/plugins/doc_fragments/module_record_info.py b/ansible_collections/community/dns/plugins/doc_fragments/module_record_info.py new file mode 100644 index 000000000..e039ec6aa --- /dev/null +++ b/ansible_collections/community/dns/plugins/doc_fragments/module_record_info.py @@ -0,0 +1,56 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + + # NOTE: This document fragment needs to be augmented by ZONE_ID_TYPE in a provider document fragment. + # The ZONE_ID_TYPE fragment will provide `choices` for the options.type entry. + DOCUMENTATION = r''' +options: + what: + description: + - Describes whether to fetch a single record and type combination, all types for a + record, or all records. By default, a single record and type combination is fetched. + - Note that the return value structure depends on this option. + choices: ['single_record', 'all_types_for_record', 'all_records'] + default: single_record + type: str + zone_name: + description: + - The DNS zone to modify. + - Exactly one of I(zone) and I(zone_id) must be specified. + type: str + aliases: + - zone + zone_id: + description: + - The ID of the DNS zone to modify. + - Exactly one of I(zone_name) and I(zone_id) must be specified. + record: + description: + - The full DNS record to retrieve. + - If I(what) is C(single_record) or C(all_types_for_record), exactly one of I(record) and I(prefix) is required. + type: str + prefix: + description: + - The prefix of the DNS record. + - This is the part of I(record) before I(zone_name). For example, if the record to be modified is C(www.example.com) + for the zone C(example.com), the prefix is C(www). If the record in this example would be C(example.com), the + prefix would be C('') (empty string). + - If I(what) is C(single_record) or C(all_types_for_record), exactly one of I(record) and I(prefix) is required. + type: str + type: + description: + - The type of DNS record to retrieve. + - Required if I(what) is C(single_record). + type: str +''' diff --git a/ansible_collections/community/dns/plugins/doc_fragments/module_record_set.py b/ansible_collections/community/dns/plugins/doc_fragments/module_record_set.py new file mode 100644 index 000000000..2b4004422 --- /dev/null +++ b/ansible_collections/community/dns/plugins/doc_fragments/module_record_set.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + + # NOTE: This document fragment needs to be augmented by ZONE_ID_TYPE in a provider document fragment. + # The ZONE_ID_TYPE fragment will provide `choices` for the options.type entry. + DOCUMENTATION = r''' +options: + state: + description: + - Specifies the state of the resource record. + required: true + choices: ['present', 'absent'] + type: str + zone_name: + description: + - The DNS zone to modify. + - Exactly one of I(zone_name) and I(zone_id) must be specified. + type: str + aliases: + - zone + zone_id: + description: + - The ID of the DNS zone to modify. + - Exactly one of I(zone_name) and I(zone_id) must be specified. + version_added: 0.2.0 + record: + description: + - The full DNS record to create or delete. + - Exactly one of I(record) and I(prefix) must be specified. + type: str + prefix: + description: + - The prefix of the DNS record. + - This is the part of I(record) before I(zone_name). For example, if the record to be modified is C(www.example.com) + for the zone C(example.com), the prefix is C(www). If the record in this example would be C(example.com), the + prefix would be C('') (empty string). + - Exactly one of I(record) and I(prefix) must be specified. + type: str + version_added: 0.2.0 + ttl: + description: + - The TTL to give the new record, in seconds. + - Will be ignored if I(state=absent) and I(on_existing=replace). + type: int + type: + description: + - The type of DNS record to create or delete. + required: true + type: str + value: + description: + - The new value when creating a DNS record. + - YAML lists or multiple comma-spaced values are allowed. + - When deleting a record all values for the record must be specified or it will + not be deleted. + - Must be specified if I(state=present) or when I(on_existing) is not C(replace). + - Will be ignored if I(state=absent) and I(on_existing=replace). + type: list + elements: str + on_existing: + description: + - This option defines the behavior if the record set already exists, but differs from the specified record set. + For this comparison, I(value) and I(ttl) are used for all records of type I(type) matching the I(prefix) resp. I(record). + - If set to C(replace), the record will be updated (I(state=present)) or removed (I(state=absent)). + This is the old I(overwrite=true) behavior. + - If set to C(keep_and_fail), the module will fail and not modify the records. + This is the old I(overwrite=false) behavior if I(state=present). + - If set to C(keep_and_warn), the module will warn and not modify the records. + - If set to C(keep), the module will not modify the records. + This is the old I(overwrite=false) behavior if I(state=absent). + - If I(state=absent) and the value is not C(replace), I(value) must be specified. + default: replace + type: str + choices: + - replace + - keep_and_fail + - keep_and_warn + - keep +''' diff --git a/ansible_collections/community/dns/plugins/doc_fragments/module_record_set_info.py b/ansible_collections/community/dns/plugins/doc_fragments/module_record_set_info.py new file mode 100644 index 000000000..857e9036e --- /dev/null +++ b/ansible_collections/community/dns/plugins/doc_fragments/module_record_set_info.py @@ -0,0 +1,58 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + + # NOTE: This document fragment needs to be augmented by ZONE_ID_TYPE in a provider document fragment. + # The ZONE_ID_TYPE fragment will provide `choices` for the options.type entry. + DOCUMENTATION = r''' +options: + what: + description: + - Describes whether to fetch a single record and type combination, all types for a + record, or all records. By default, a single record and type combination is fetched. + - Note that the return value structure depends on this option. + choices: ['single_record', 'all_types_for_record', 'all_records'] + default: single_record + type: str + zone_name: + description: + - The DNS zone to modify. + - Exactly one of I(zone) and I(zone_id) must be specified. + type: str + aliases: + - zone + zone_id: + description: + - The ID of the DNS zone to modify. + - Exactly one of I(zone_name) and I(zone_id) must be specified. + version_added: 0.2.0 + record: + description: + - The full DNS record to retrieve. + - If I(what) is C(single_record) or C(all_types_for_record), exactly one of I(record) and I(prefix) is required. + type: str + prefix: + description: + - The prefix of the DNS record. + - This is the part of I(record) before I(zone_name). For example, if the record to be modified is C(www.example.com) + for the zone C(example.com), the prefix is C(www). If the record in this example would be C(example.com), the + prefix would be C('') (empty string). + - If I(what) is C(single_record) or C(all_types_for_record), exactly one of I(record) and I(prefix) is required. + type: str + version_added: 0.2.0 + type: + description: + - The type of DNS record to retrieve. + - Required if I(what) is C(single_record). + type: str +''' diff --git a/ansible_collections/community/dns/plugins/doc_fragments/module_record_sets.py b/ansible_collections/community/dns/plugins/doc_fragments/module_record_sets.py new file mode 100644 index 000000000..1b393b444 --- /dev/null +++ b/ansible_collections/community/dns/plugins/doc_fragments/module_record_sets.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = r''' +description: + - The module allows to set, modify and delete multiple DNS record sets at once. + - With the I(purge) option, it is also possible to delete existing record sets + that are not mentioned in the module parameters. With this, it is possible + to synchronize the expected state of a DNS zone with the expected state. + - "It is possible to ignore certain record sets by specifying I(ignore: true) for + that record set." + +options: + zone_name: + description: + - The DNS zone to modify. + - Exactly one of I(zone_name) and I(zone_id) must be specified. + type: str + aliases: + - zone + zone_id: + description: + - The ID of the DNS zone to modify. + - Exactly one of I(zone_name) and I(zone_id) must be specified. + prune: + description: + - If set to C(true), will remove all existing records in the zone that are not listed in I(records). + type: bool + default: false + record_sets: + description: + - The records that should be present in the zone. + required: true + type: list + elements: dict + aliases: + - records + suboptions: + # (The following must be kept in sync with the equivalent lines in <provider_name>.py!) + record: + description: + - The full DNS record to create or delete. + - Exactly one of I(record) and I(prefix) must be specified. + type: str + prefix: + description: + - The prefix of the DNS record. + - This is the part of I(record) before I(zone_name). For example, if the record to be modified is C(www.example.com) + for the zone C(example.com), the prefix is C(www). If the record in this example would be C(example.com), the + prefix would be C('') (empty string). + - Exactly one of I(record) and I(prefix) must be specified. + type: str + ttl: + description: + - The TTL to give the new record, in seconds. + type: int + type: + description: + - The type of DNS record to create or delete. + required: true + type: str + value: + description: + - The new value when creating a DNS record. + - YAML lists or multiple comma-spaced values are allowed. + - When deleting a record all values for the record must be specified or it will + not be deleted. + - Must be specified if I(ignore=false). + type: list + elements: str + ignore: + description: + - If set to C(true), I(value) will be ignored. + - This is useful when I(prune=true), but you do not want certain entries to be removed + without having to know their current value. + type: bool + default: false +''' diff --git a/ansible_collections/community/dns/plugins/doc_fragments/module_zone_info.py b/ansible_collections/community/dns/plugins/doc_fragments/module_zone_info.py new file mode 100644 index 000000000..b218051e5 --- /dev/null +++ b/ansible_collections/community/dns/plugins/doc_fragments/module_zone_info.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + # Standard files documentation fragment + DOCUMENTATION = r''' +options: + zone_name: + description: + - The DNS zone to query. + - Exactly one of I(zone_name) and I(zone_id) must be specified. + type: str + aliases: + - zone + zone_id: + description: + - The ID of the DNS zone to query. + - Exactly one of I(zone_name) and I(zone_id) must be specified. + version_added: 0.2.0 +''' diff --git a/ansible_collections/community/dns/plugins/doc_fragments/options.py b/ansible_collections/community/dns/plugins/doc_fragments/options.py new file mode 100644 index 000000000..a9e92e8b5 --- /dev/null +++ b/ansible_collections/community/dns/plugins/doc_fragments/options.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ModuleDocFragment(object): + + BULK_OPERATIONS = r''' +options: + bulk_operation_threshold: + description: + - Determines the threshold from when on bulk operations are used. + - The default value 2 means that if 2 or more operations of a kind are planned, + and the API supports bulk operations for this kind of operation, they will + be used. + type: int + default: 2 +''' + + RECORD_TRANSFORMATION = r''' +options: + txt_transformation: + description: + - Determines how TXT entry values are converted between the API and this module's + input and output. + - The value C(api) means that values are returned from this module as they are returned + from the API, and pushed to the API as they have been passed to this module. For + idempotency checks, the input string will be compared to the strings returned by the + API. The API might automatically transform some values, like splitting long values or + adding quotes, which can cause problems with idempotency. + - The value C(unquoted) automatically transforms values so that you can pass in unquoted + values, and the module will return unquoted values. If you pass in quoted values, they + will be double-quoted. + - The value C(quoted) automatically transforms values so that you must use quoting for values + that contain spaces, characters such as quotation marks and backslashes, and that are + longer than 255 bytes. It also makes sure to return values from the API in a normalized + encoding. + - The default value, C(unquoted), ensures that you can work with values without having + to care about how to correctly quote for DNS. Most users should use one of C(unquoted) + or C(quoted), but not C(api). + - B(Note:) the conversion code assumes UTF-8 encoding for values. If you need another + encoding use I(txt_transformation=api) and handle the encoding yourself. + type: str + choices: + - api + - quoted + - unquoted + default: unquoted + txt_character_encoding: + description: + - Whether to treat numeric escape sequences (C(\xyz)) as octal or decimal numbers. + This is only used when I(txt_transformation=quoted). + - The current default is C(octal) which is deprecated. It will change to C(decimal) in + community.dns 3.0.0. The value C(decimal) is compatible to L(RFC 1035, https://www.ietf.org/rfc/rfc1035.txt). + type: str + choices: + - decimal + - octal + version_added: 2.5.0 +''' diff --git a/ansible_collections/community/dns/plugins/filter/domain_suffix.py b/ansible_collections/community/dns/plugins/filter/domain_suffix.py new file mode 100644 index 000000000..80e86a096 --- /dev/null +++ b/ansible_collections/community/dns/plugins/filter/domain_suffix.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2020-2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +from ansible_collections.community.dns.plugins.plugin_utils.public_suffix import PUBLIC_SUFFIX_LIST + + +def _remove_suffix(dns_name, suffix, keep_trailing_period): + suffix_len = len(suffix) + if suffix_len and suffix_len < len(dns_name) and not keep_trailing_period: + suffix_len += 1 + return dns_name[:-suffix_len] if suffix_len else dns_name + + +def get_registrable_domain(dns_name, + keep_unknown_suffix=True, + only_if_registerable=True, + normalize_result=False, + icann_only=False): + '''Given DNS name, returns the registrable domain.''' + return PUBLIC_SUFFIX_LIST.get_registrable_domain( + dns_name, + keep_unknown_suffix=keep_unknown_suffix, + only_if_registerable=only_if_registerable, + normalize_result=normalize_result, + icann_only=icann_only, + ) + + +def get_public_suffix(dns_name, + keep_leading_period=True, + keep_unknown_suffix=True, + normalize_result=False, + icann_only=False): + '''Given DNS name, returns the public suffix.''' + suffix = PUBLIC_SUFFIX_LIST.get_suffix( + dns_name, + keep_unknown_suffix=keep_unknown_suffix, + normalize_result=normalize_result, + icann_only=icann_only, + ) + if suffix and len(suffix) < len(dns_name) and keep_leading_period: + suffix = '.' + suffix + return suffix + + +def remove_registrable_domain(dns_name, + keep_trailing_period=False, + keep_unknown_suffix=True, + only_if_registerable=True, + icann_only=False): + '''Given DNS name, returns the part before the registrable_domain.''' + suffix = PUBLIC_SUFFIX_LIST.get_registrable_domain( + dns_name, + keep_unknown_suffix=keep_unknown_suffix, + only_if_registerable=only_if_registerable, + normalize_result=False, + icann_only=icann_only, + ) + return _remove_suffix(dns_name, suffix, keep_trailing_period) + + +def remove_public_suffix(dns_name, + keep_trailing_period=False, + keep_unknown_suffix=True, + icann_only=False): + '''Given DNS name, returns the part before the public suffix.''' + suffix = PUBLIC_SUFFIX_LIST.get_suffix( + dns_name, + keep_unknown_suffix=keep_unknown_suffix, + normalize_result=False, + icann_only=icann_only, + ) + return _remove_suffix(dns_name, suffix, keep_trailing_period) + + +class FilterModule(object): + '''Ansible jinja2 filters''' + + def filters(self): + return { + 'get_public_suffix': get_public_suffix, + 'get_registrable_domain': get_registrable_domain, + 'remove_public_suffix': remove_public_suffix, + 'remove_registrable_domain': remove_registrable_domain, + } diff --git a/ansible_collections/community/dns/plugins/filter/get_public_suffix.yml b/ansible_collections/community/dns/plugins/filter/get_public_suffix.yml new file mode 100644 index 000000000..3bdec71af --- /dev/null +++ b/ansible_collections/community/dns/plugins/filter/get_public_suffix.yml @@ -0,0 +1,39 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +DOCUMENTATION: + name: get_public_suffix + short_description: Returns the public suffix of a DNS name + version_added: 0.1.0 + description: + - Returns the public suffix of a DNS name. + options: + _input: + description: + - A DNS name. + type: string + required: true + keep_leading_period: + description: + - This controls whether the leading period of a public suffix is preserved or not. + type: boolean + default: true + extends_documentation_fragment: + - community.dns.filters + - community.dns.filters.public_suffix + - community.dns.filters.get + author: + - Felix Fontein (@felixfontein) + +EXAMPLES: | + - name: Extract the public suffix from a DNS name + ansible.builtin.set_fact: + public_suffix: "{{ 'www.ansible.co.uk' | community.dns.get_public_suffix }}" + # Should result in '.co.uk' + +RETURN: + _value: + description: The public suffix. + type: string diff --git a/ansible_collections/community/dns/plugins/filter/get_registrable_domain.yml b/ansible_collections/community/dns/plugins/filter/get_registrable_domain.yml new file mode 100644 index 000000000..8ce0a08ae --- /dev/null +++ b/ansible_collections/community/dns/plugins/filter/get_registrable_domain.yml @@ -0,0 +1,34 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +DOCUMENTATION: + name: get_registrable_domain + short_description: Returns the registrable domain name of a DNS name + version_added: 0.1.0 + description: + - Returns the registrable domain name of a DNS name. + options: + _input: + description: + - A DNS name. + type: string + required: true + extends_documentation_fragment: + - community.dns.filters + - community.dns.filters.registerable_domain + - community.dns.filters.get + author: + - Felix Fontein (@felixfontein) + +EXAMPLES: | + - name: Extract the registrable domain from a DNS name + ansible.builtin.set_fact: + public_suffix: "{{ 'www.ansible.co.uk' | community.dns.get_registrable_domain }}" + # Should result in 'ansible.co.uk' + +RETURN: + _value: + description: The registrable domain. + type: string diff --git a/ansible_collections/community/dns/plugins/filter/remove_public_suffix.yml b/ansible_collections/community/dns/plugins/filter/remove_public_suffix.yml new file mode 100644 index 000000000..dbb3317f9 --- /dev/null +++ b/ansible_collections/community/dns/plugins/filter/remove_public_suffix.yml @@ -0,0 +1,39 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +DOCUMENTATION: + name: remove_public_suffix + short_description: Removes the public suffix from a DNS name + version_added: 0.1.0 + description: + - Removes the public suffix from a DNS name. + options: + _input: + description: + - A DNS name. + type: string + required: true + keep_trailing_period: + description: + - This controls whether the trailing period of the prefix (that is, the part before the + public suffix) is preserved or not. + type: boolean + default: false + extends_documentation_fragment: + - community.dns.filters + - community.dns.filters.public_suffix + author: + - Felix Fontein (@felixfontein) + +EXAMPLES: | + - name: Remove the public suffix from a DNS name + ansible.builtin.set_fact: + public_suffix: "{{ 'www.ansible.co.uk' | community.dns.remove_public_suffix }}" + # Should result in 'www.ansible' + +RETURN: + _value: + description: The part of the DNS name before the public suffix. + type: string diff --git a/ansible_collections/community/dns/plugins/filter/remove_registrable_domain.yml b/ansible_collections/community/dns/plugins/filter/remove_registrable_domain.yml new file mode 100644 index 000000000..2e8a37118 --- /dev/null +++ b/ansible_collections/community/dns/plugins/filter/remove_registrable_domain.yml @@ -0,0 +1,39 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +DOCUMENTATION: + name: remove_registrable_domain + short_description: Removes the registrable domain name from a DNS name + version_added: 0.1.0 + description: + - Removes the registrable domain name from a DNS name. + options: + _input: + description: + - A DNS name. + type: string + required: true + keep_trailing_period: + description: + - This controls whether the trailing period of the prefix (that is, the part before the + registrable domain) is preserved or not. + type: boolean + default: false + extends_documentation_fragment: + - community.dns.filters + - community.dns.filters.registerable_domain + author: + - Felix Fontein (@felixfontein) + +EXAMPLES: | + - name: Remove the registrable domain from a DNS name + ansible.builtin.set_fact: + public_suffix: "{{ 'www.ansible.co.uk' | community.dns.remove_registrable_domain }}" + # Should result in 'www' + +RETURN: + _value: + description: The part of the DNS name before the registrable domain. + type: string diff --git a/ansible_collections/community/dns/plugins/inventory/hetzner_dns_records.py b/ansible_collections/community/dns/plugins/inventory/hetzner_dns_records.py new file mode 100644 index 000000000..970cd8631 --- /dev/null +++ b/ansible_collections/community/dns/plugins/inventory/hetzner_dns_records.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# Copyright (c) 2020 Markus Bergholz <markuman+spambelongstogoogle@gmail.com> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +name: hetzner_dns_records + +short_description: Create inventory from Hetzner DNS records + +version_added: 2.0.0 + +description: + - This plugin allows to create an inventory from Hetzner DNS records. + - >- + For Ansible to be able to identify a YAML file as an inventory for this plugin, the inventory file must contain + C(plugin: community.dns.hetzner_dns_records) and its filename must end with C(hetzner_dns.yaml) or C(hetzner_dns.yml) + +options: + plugin: + description: The name of this plugin. Should always be set to C(community.dns.hetzner_dns_records) for this plugin to recognize it as its own. + # TODO: add `required: true` in 3.0.0 + # required: true + choices: + - community.dns.hetzner_dns_records + type: str + +extends_documentation_fragment: + - community.dns.hetzner + - community.dns.hetzner.plugin + - community.dns.hetzner.record_type_choices_records_inventory + - community.dns.hetzner.zone_id_type + - community.dns.inventory_records + - community.dns.options.record_transformation + +notes: + - The provider-specific I(hetzner_token) option can be templated. + +author: + - Markus Bergholz (@markuman) <markuman+spambelongstogoogle@gmail.com> + - Felix Fontein (@felixfontein) + +seealso: + - module: community.dns.hetzner_dns_record_set_info + - module: community.dns.hetzner_dns_record_info +''' + +EXAMPLES = ''' +# filename must end with hetzner_dns.yaml or hetzner_dns.yml + +plugin: community.dns.hetzner_dns_records +zone_name: domain.de +filters: + type: + - TXT +txt_transformation: unquoted + +# You can also configure the token by putting secret value into this file, +# but this is discouraged. Use a lookup like below, or leave it away and +# set it with the HETZNER_DNS_TOKEN environment variable. +hetzner_token: >- + {{ (lookup('community.sops.sops', 'keys/hetzner.sops.yml') | from_yaml).hetzner_dns_token }} +''' + +from ansible_collections.community.dns.plugins.module_utils.http import ( + OpenURLHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hetzner.api import ( + create_hetzner_api, + create_hetzner_provider_information, +) + +from ansible_collections.community.dns.plugins.plugin_utils.templated_options import ( + TemplatedOptionProvider, +) + +from ansible_collections.community.dns.plugins.plugin_utils.inventory.records import ( + RecordsInventoryModule, +) + + +class InventoryModule(RecordsInventoryModule): + NAME = 'community.dns.hetzner_dns_records' + VALID_ENDINGS = ('hetzner_dns.yaml', 'hetzner_dns.yml') + + def setup_api(self): + self.provider_information = create_hetzner_provider_information() + self.api = create_hetzner_api(TemplatedOptionProvider(self, self.templar), OpenURLHelper()) diff --git a/ansible_collections/community/dns/plugins/inventory/hosttech_dns_records.py b/ansible_collections/community/dns/plugins/inventory/hosttech_dns_records.py new file mode 100644 index 000000000..aa840510d --- /dev/null +++ b/ansible_collections/community/dns/plugins/inventory/hosttech_dns_records.py @@ -0,0 +1,101 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# Copyright (c) 2020 Markus Bergholz <markuman+spambelongstogoogle@gmail.com> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +DOCUMENTATION = ''' +name: hosttech_dns_records + +short_description: Create inventory from Hosttech DNS records + +version_added: 2.0.0 + +description: + - This plugin allows to create an inventory from Hosttech DNS records. + - >- + For Ansible to be able to identify a YAML file as an inventory for this plugin, the inventory file must contain + C(plugin: community.dns.hosttech_dns_records) and its filename must end with C(hosttech_dns.yaml) or C(hosttech_dns.yml) + +options: + plugin: + description: The name of this plugin. Should always be set to C(community.dns.hosttech_dns_records) for this plugin to recognize it as its own. + # TODO: add `required: true` in 3.0.0 + # required: true + choices: + - community.dns.hosttech_dns_records + type: str + + # We need to overwrite zone_id to be of type string, otherwise templating cannot be passed in + zone_id: + type: raw + # If there wouldn't be ansible-base 2.10, this should be string instead. ansible-base will + # not accept an integer for type=string options, whence type=string breaks backwards + # compatibility with previous type=int... + # type: string + +extends_documentation_fragment: + - community.dns.hosttech + - community.dns.hosttech.plugin + - community.dns.hosttech.record_type_choices_records_inventory + - community.dns.hosttech.zone_id_type + - community.dns.inventory_records + - community.dns.options.record_transformation + +notes: + - The provider-specific I(hosttech_username), I(hosttech_password), and I(hosttech_token) options can be templated. + +author: + - Markus Bergholz (@markuman) <markuman+spambelongstogoogle@gmail.com> + - Felix Fontein (@felixfontein) + +seealso: + - module: community.dns.hosttech_dns_record_set_info + - module: community.dns.hosttech_dns_record_info +''' + +EXAMPLES = ''' +# filename must end with hosttech_dns.yaml or hosttech_dns.yml + +plugin: community.dns.hosttech_dns_records +zone_name: domain.ch +filters: + type: + - AAAA + +# You can also configure the token by putting secret value into this file, +# but this is discouraged. Use a lookup like below, or leave it away and +# set it with the ANSIBLE_HOSTTECH_DNS_TOKEN environment variable. +hosttech_token: >- + {{ (lookup('community.sops.sops', 'keys/hosttech.sops.yml') | from_yaml).hosttech_dns_token }} +''' + +from ansible_collections.community.dns.plugins.module_utils.http import ( + OpenURLHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech.api import ( + create_hosttech_api, + create_hosttech_provider_information, +) + +from ansible_collections.community.dns.plugins.plugin_utils.templated_options import ( + TemplatedOptionProvider, +) + +from ansible_collections.community.dns.plugins.plugin_utils.inventory.records import ( + RecordsInventoryModule, +) + + +class InventoryModule(RecordsInventoryModule): + NAME = 'community.dns.hosttech_dns_records' + VALID_ENDINGS = ('hosttech_dns.yaml', 'hosttech_dns.yml') + + def setup_api(self): + self.provider_information = create_hosttech_provider_information() + self.api = create_hosttech_api(TemplatedOptionProvider(self, self.templar), OpenURLHelper()) diff --git a/ansible_collections/community/dns/plugins/module_utils/argspec.py b/ansible_collections/community/dns/plugins/module_utils/argspec.py new file mode 100644 index 000000000..3b5104595 --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/argspec.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class ArgumentSpec(object): + def __init__(self, argument_spec=None, required_together=None, required_if=None, required_one_of=None, mutually_exclusive=None): + self.argument_spec = {} + self.required_together = [] + self.required_if = [] + self.required_one_of = [] + self.mutually_exclusive = [] + if argument_spec: + self.argument_spec.update(argument_spec) + if required_together: + self.required_together.extend(required_together) + if required_if: + self.required_if.extend(required_if) + if required_one_of: + self.required_one_of.extend(required_one_of) + if mutually_exclusive: + self.mutually_exclusive.extend(mutually_exclusive) + + def merge(self, other): + self.argument_spec.update(other.argument_spec) + self.required_together.extend(other.required_together) + self.required_if.extend(other.required_if) + self.required_one_of.extend(other.required_one_of) + self.mutually_exclusive.extend(other.mutually_exclusive) + return self + + def to_kwargs(self): + return { + 'argument_spec': self.argument_spec, + 'required_together': self.required_together, + 'required_if': self.required_if, + 'required_one_of': self.required_one_of, + 'mutually_exclusive': self.mutually_exclusive, + } + + +class ModuleOptionProvider(object): + def __init__(self, module): + self.module = module + + def get_option(self, option_name): + return self.module.params[option_name] diff --git a/ansible_collections/community/dns/plugins/module_utils/conversion/base.py b/ansible_collections/community/dns/plugins/module_utils/conversion/base.py new file mode 100644 index 000000000..4c2970af1 --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/conversion/base.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class DNSConversionError(Exception): + def __init__(self, message): + super(DNSConversionError, self).__init__(message) + self.error_message = message diff --git a/ansible_collections/community/dns/plugins/module_utils/conversion/converter.py b/ansible_collections/community/dns/plugins/module_utils/conversion/converter.py new file mode 100644 index 000000000..b8a9f4a8d --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/conversion/converter.py @@ -0,0 +1,246 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import warnings + +from ansible.module_utils.common.text.converters import to_text +from ansible.module_utils.six import raise_from + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.base import ( + DNSConversionError, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.txt import ( + decode_txt_value, + encode_txt_value, +) + + +class RecordConverter(object): + def __init__(self, provider_information, option_provider): + """ + Create a record converter. + """ + self._provider_information = provider_information + self._option_provider = option_provider + + # Valid values: 'decoded', 'encoded', 'encoded-no-octal' (deprecated), 'encoded-no-char-encoding' + self._txt_api_handling = self._provider_information.txt_record_handling() + if self._txt_api_handling == 'encoded-no-octal': + warnings.warn('provider_information.txt_record_handling() returned deprecated value "encoded-no-octal"') + self._txt_api_character_encoding = self._provider_information.txt_character_encoding() + # Valid values: 'api', 'quoted', 'unquoted' + self._txt_transformation = self._option_provider.get_option('txt_transformation') + # Valid values: 'decimal', 'octal' + self._txt_character_encoding = self._option_provider.get_option('txt_character_encoding') + self._txt_character_encoding_deprecation = False + if self._txt_character_encoding is None: + # TODO: remove implicit default in community.dns 3.0.0 + self._txt_character_encoding = 'octal' + if self._txt_transformation == 'quoted': + self._txt_character_encoding_deprecation = True + + def emit_deprecations(self, deprecator): + if self._txt_character_encoding_deprecation: + deprecator( + 'The default of the txt_character_encoding option will change from "octal" to "decimal" in community.dns 3.0.0.' + ' This potentially affects you since you use txt_transformation=quoted. You can explicitly set txt_character_encoding' + ' to "octal" to keep the current behavior, or "decimal" to already now switch to the new behavior. We recommend' + ' switching to the new behavior, and using check/diff mode to figure out potential changes', + version='3.0.0', + collection_name='community.dns', + ) + + def _handle_txt_api(self, to_api, record): + """ + Handle TXT records for sending to/from the API. + """ + if self._txt_transformation == 'api': + # Do not touch record values + return + + # We assume that records internally use decoded values + if self._txt_api_handling in ('encoded', 'encoded-no-octal', 'encoded-no-char-encoding'): + if to_api: + record.target = encode_txt_value( + record.target, + use_character_encoding=self._txt_api_handling == 'encoded', + character_encoding=self._txt_api_character_encoding) + else: + record.target = decode_txt_value(record.target, character_encoding=self._txt_api_character_encoding) + + def _handle_txt_user(self, to_user, record): + """ + Handle TXT records for sending to/from the user. + """ + if self._txt_transformation == 'api': + # Do not touch record values + return + + # We assume that records internally use decoded values + if self._txt_transformation == 'quoted': + if to_user: + record.target = encode_txt_value(record.target, character_encoding=self._txt_character_encoding) + else: + record.target = decode_txt_value(record.target, character_encoding=self._txt_character_encoding) + + def process_from_api(self, record): + """ + Process a record object (DNSRecord) after receiving from API. + Modifies the record in-place. + """ + try: + record.target = to_text(record.target) + if record.type == 'TXT': + self._handle_txt_api(False, record) + return record + except DNSConversionError as e: + raise_from(DNSConversionError(u'While processing record from API: {0}'.format(e.error_message)), e) + + def process_to_api(self, record): + """ + Process a record object (DNSRecord) for sending to API. + Modifies the record in-place. + """ + try: + if record.type == 'TXT': + self._handle_txt_api(True, record) + return record + except DNSConversionError as e: + raise_from(DNSConversionError(u'While processing record for the API: {0}'.format(e.error_message)), e) + + def process_from_user(self, record): + """ + Process a record object (DNSRecord) after receiving from the user. + Modifies the record in-place. + """ + try: + record.target = to_text(record.target) + if record.type == 'TXT': + self._handle_txt_user(False, record) + return record + except DNSConversionError as e: + raise_from(DNSConversionError(u'While processing record from the user: {0}'.format(e.error_message)), e) + + def process_to_user(self, record): + """ + Process a record object (DNSRecord) for sending to the user. + Modifies the record in-place. + """ + try: + if record.type == 'TXT': + self._handle_txt_user(True, record) + return record + except DNSConversionError as e: + raise_from(DNSConversionError(u'While processing record for the user: {0}'.format(e.error_message)), e) + + def clone_from_api(self, record): + """ + Process a record object (DNSRecord) after receiving from API. + Return a modified clone of the record; the original will not be modified. + """ + record = record.clone() + self.process_from_api(record) + return record + + def clone_to_api(self, record): + """ + Process a record object (DNSRecord) for sending to API. + Return a modified clone of the record; the original will not be modified. + """ + record = record.clone() + self.process_to_api(record) + return record + + def clone_multiple_from_api(self, records): + """ + Process a list of record object (DNSRecord) after receiving from API. + Return a list of modified clones of the records; the originals will not be modified. + """ + return [self.clone_from_api(record) for record in records] + + def clone_multiple_to_api(self, records): + """ + Process a list of record objects (DNSRecord) for sending to API. + Return a list of modified clones of the records; the originals will not be modified. + """ + return [self.clone_to_api(record) for record in records] + + def process_multiple_from_api(self, records): + """ + Process a list of record object (DNSRecord) after receiving from API. + Modifies the records in-place. + """ + for record in records: + self.process_from_api(record) + return records + + def process_multiple_to_api(self, records): + """ + Process a list of record objects (DNSRecord) for sending to API. + Modifies the records in-place. + """ + for record in records: + self.process_to_api(record) + return records + + def process_multiple_from_user(self, records): + """ + Process a list of record object (DNSRecord) after receiving from the user. + Modifies the records in-place. + """ + for record in records: + self.process_from_user(record) + return records + + def process_multiple_to_user(self, records): + """ + Process a list of record objects (DNSRecord) for sending to the user. + Modifies the records in-place. + """ + for record in records: + self.process_to_user(record) + return records + + def process_value_from_user(self, record_type, value): + """ + Process a record value (string) after receiving from the user. + """ + record = DNSRecord() + record.type = record_type + record.target = value + self.process_from_user(record) + return record.target + + def process_values_from_user(self, record_type, values): + """ + Process a list of record values (strings) after receiving from the user. + """ + return [self.process_value_from_user(record_type, value) for value in values] + + def process_value_to_user(self, record_type, value): + """ + Process a record value (string) for sending to the user. + """ + record = DNSRecord() + record.type = record_type + record.target = value + self.process_to_user(record) + return record.target + + def process_values_to_user(self, record_type, values): + """ + Process a list of record values (strings) for sending to the user. + """ + return [self.process_value_to_user(record_type, value) for value in values] diff --git a/ansible_collections/community/dns/plugins/module_utils/conversion/txt.py b/ansible_collections/community/dns/plugins/module_utils/conversion/txt.py new file mode 100644 index 000000000..a4521c7fa --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/conversion/txt.py @@ -0,0 +1,228 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import sys +import warnings + +from ansible.module_utils.common.text.converters import to_bytes, to_text + +from ansible_collections.community.dns.plugins.module_utils.conversion.base import ( + DNSConversionError, +) + + +_DECIMAL_DIGITS = b'0123456789' + +_STATE_OUTSIDE = 0 +_STATE_QUOTED_STRING = 1 +_STATE_UNQUOTED_STRING = 3 + + +if sys.version_info[0] < 3: + _int_to_byte = chr +else: + def _int_to_byte(value): + return bytes((value, )) + + +def _parse_quoted(value, index, use_octal): + if index == len(value): + raise DNSConversionError(u'Unexpected backslash at end of string') + letter = value[index:index + 1] + index += 1 + if letter in (b'\\', b'"'): + return letter, index + # This must be a decimal sequence + v2 = _DECIMAL_DIGITS.find(letter) + if v2 < 0 or (use_octal and v2 >= 8): + # It is apparently not - error out + raise DNSConversionError( + u'A backslash must not be followed by "{letter}" (index {index})'.format(letter=to_text(letter), index=index)) + if index + 1 >= len(value): + # We need more letters for a three-digit decimal sequence + raise DNSConversionError( + u'The {type} sequence at the end requires {missing} more digit(s)'.format( + type='octal' if use_octal else 'decimal', missing=index + 2 - len(value))) + letter = value[index:index + 1] + index += 1 + v1 = _DECIMAL_DIGITS.find(letter) + if v1 < 0 or (use_octal and v1 >= 8): + raise DNSConversionError( + u'The second letter of the {type} sequence at index {index} is not a {type} digit, but "{letter}"'.format( + type='octal' if use_octal else 'decimal', letter=to_text(letter), index=index)) + letter = value[index:index + 1] + index += 1 + v0 = _DECIMAL_DIGITS.find(letter) + if v0 < 0 or (use_octal and v0 >= 8): + raise DNSConversionError( + u'The third letter of the {type} sequence at index {index} is not a {type} digit, but "{letter}"'.format( + type='octal' if use_octal else 'decimal', letter=to_text(letter), index=index)) + if use_octal: + return _int_to_byte(v2 * 64 + v1 * 8 + v0), index + return _int_to_byte(v2 * 100 + v1 * 10 + v0), index + + +_SENTINEL = object() + + +def decode_txt_value(value, character_encoding=_SENTINEL): + """ + Given an encoded TXT value, decodes it. + + Raises DNSConversionError in case of errors. + """ + if character_encoding is _SENTINEL: + warnings.warn( + 'The default value of the decode_txt_value parameter character_encoding is deprecated.' + ' Set explicitly to "octal" for the old behavior, or set to "decimal" for the new and correct behavior.', + DeprecationWarning, + ) + character_encoding = 'octal' + if character_encoding not in ('octal', 'decimal'): + raise ValueError('character_encoding must be set to "octal" or "decimal"') + value = to_bytes(value) + state = _STATE_OUTSIDE + index = 0 + length = len(value) + result = [] + while index < length: + letter = value[index:index + 1] + index += 1 + if letter == b' ': + if state == _STATE_QUOTED_STRING: + result.append(letter) + else: + state = _STATE_OUTSIDE + elif letter == b'\\': + if state != _STATE_QUOTED_STRING: + state = _STATE_UNQUOTED_STRING + letter, index = _parse_quoted(value, index, character_encoding == 'octal') + result.append(letter) + elif letter == b'"': + if state == _STATE_QUOTED_STRING: + state = _STATE_OUTSIDE + elif state == _STATE_OUTSIDE: + state = _STATE_QUOTED_STRING + else: + raise DNSConversionError( + u'Unexpected double quotation mark inside an unquoted block at position {index}'.format(index=index)) + else: + if state != _STATE_QUOTED_STRING: + state = _STATE_UNQUOTED_STRING + result.append(letter) + + if state == _STATE_QUOTED_STRING: + raise DNSConversionError(u'Missing double quotation mark at the end of value') + + return to_text(b''.join(result)) + + +def _get_utf8_length(first_byte_value): + """ + Given the byte value of a UTF-8 letter, returns the length of the UTF-8 character. + """ + if first_byte_value & 0xE0 == 0xC0: + return 2 + if first_byte_value & 0xF0 == 0xE0: + return 3 + if first_byte_value & 0xF8 == 0xF0: + return 4 + # Shouldn't happen + return 1 + + +def encode_txt_value(value, always_quote=False, use_character_encoding=_SENTINEL, use_octal=_SENTINEL, character_encoding=_SENTINEL): + """ + Given a decoded TXT value, encodes it. + + If always_quote is set to True, always use double quotes for all strings. + If use_character_encoding (default: True) is set to False, do not use octal encoding. + """ + if use_octal is not _SENTINEL: + warnings.warn( + 'The encode_txt_value parameter use_octal is deprecated. Use use_character_encoding instead.', + DeprecationWarning, + ) + if use_character_encoding is not _SENTINEL: + raise ValueError('Cannot use both use_character_encoding and use_octal. Use only use_character_encoding!') + use_character_encoding = use_octal + if use_character_encoding is _SENTINEL: + use_character_encoding = True + if character_encoding is _SENTINEL: + warnings.warn( + 'The default value of the encode_txt_value parameter character_encoding is deprecated.' + ' Set explicitly to "octal" for the old behavior, or set to "decimal" for the new and correct behavior.', + DeprecationWarning, + ) + character_encoding = 'octal' + if character_encoding not in ('octal', 'decimal'): + raise ValueError('character_encoding must be set to "octal" or "decimal"') + + value = to_bytes(value) + buffer = [] + output = [] + + def append(buffer): + value = b''.join(buffer) + if b' ' in value or not value or always_quote: + value = b'"%s"' % value + output.append(value) + + index = 0 + length = len(value) + while index < length: + letter = value[index:index + 1] + index += 1 + + # Add letter + if letter in (b'"', b'\\'): + buffer.append(b'\\') + buffer.append(letter) + elif use_character_encoding and not (0x20 <= ord(letter) < 0x7F): + # Make sure that we don't split up a decimal sequence over multiple TXT strings + if len(buffer) + 4 > 255: + append(buffer[:255]) + buffer = buffer[255:] + letter_value = ord(letter) + buffer.append(b'\\') + if character_encoding == 'octal': + v2 = (letter_value >> 6) & 7 + v1 = (letter_value >> 3) & 7 + v0 = letter_value & 7 + else: + v2 = (letter_value // 100) % 10 + v1 = (letter_value // 10) % 10 + v0 = letter_value % 10 + buffer.append(_DECIMAL_DIGITS[v2:v2 + 1]) + buffer.append(_DECIMAL_DIGITS[v1:v1 + 1]) + buffer.append(_DECIMAL_DIGITS[v0:v0 + 1]) + elif not use_character_encoding and (ord(letter) & 0x80) != 0: + utf8_length = min(_get_utf8_length(ord(letter)), length - index + 1) + # Make sure that we don't split up a UTF-8 letter over multiple TXT strings + if len(buffer) + utf8_length > 255: + append(buffer[:255]) + buffer = buffer[255:] + buffer.append(letter) + while utf8_length > 1: + buffer.append(value[index:index + 1]) + index += 1 + utf8_length -= 1 + else: + buffer.append(letter) + + # Split if too long + if len(buffer) >= 255: + append(buffer[:255]) + buffer = buffer[255:] + + if buffer or not output: + append(buffer) + + return to_text(b' '.join(output)) diff --git a/ansible_collections/community/dns/plugins/module_utils/hetzner/api.py b/ansible_collections/community/dns/plugins/module_utils/hetzner/api.py new file mode 100644 index 000000000..dee1ff13c --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/hetzner/api.py @@ -0,0 +1,419 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# Copyright (c) 2020 Markus Bergholz <markuman+spambelongstogoogle@gmail.com> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +from ansible.module_utils.basic import env_fallback + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ArgumentSpec, +) + +from ansible_collections.community.dns.plugins.module_utils.json_api_helper import ( + JSONAPIHelper, + ERROR_CODES, + UNKNOWN_ERROR, +) + +from ansible_collections.community.dns.plugins.module_utils.provider import ( + ProviderInformation, +) + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, +) + +from ansible_collections.community.dns.plugins.module_utils.zone import ( + DNSZone, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, + NOT_PROVIDED, + ZoneRecordAPI, + filter_records, +) + + +def _create_zone_from_json(source): + zone = DNSZone(source['name']) + zone.id = source['id'] + info = source.copy() + info.pop('name') + info.pop('id') + if 'legacy_ns' in info: + info['legacy_ns'] = sorted(info['legacy_ns']) + zone.info = info + return zone + + +def _create_record_from_json(source, type=None, has_id=True): + source = dict(source) + result = DNSRecord() + if has_id: + result.id = source.pop('id') + result.type = source.pop('type', type) + result.ttl = source.pop('ttl', None) + name = source.pop('name', None) + if name == '@': + name = None + result.prefix = name + result.target = source.pop('value') + source.pop('zone_id', None) + result.extra.update(source) + return result + + +def _record_to_json(record, zone_id): + result = { + 'name': record.prefix or '@', + 'value': record.target, + 'type': record.type, + 'zone_id': zone_id, + } + if record.ttl is not None: + result['ttl'] = record.ttl + return result + + +class HetznerAPI(ZoneRecordAPI, JSONAPIHelper): + def __init__(self, http_helper, token, api='https://dns.hetzner.com/api/', debug=False): + JSONAPIHelper.__init__(self, http_helper, token, api=api, debug=debug) + + def _create_headers(self): + return { + 'Accept': 'application/json', + 'Auth-API-Token': self._token, + } + + def _extract_only_error_message(self, result): + # These errors are not documented, but are what I experienced the API seems to return: + res = '' + if isinstance(result.get('error'), dict): + if 'message' in result['error']: + res = '{0} with error message "{1}"'.format(res, result['error']['message']) + if 'code' in result['error']: + res = '{0} (error code {1})'.format(res, result['error']['code']) + if result.get('message'): + res = '{0} with message "{1}"'.format(res, result['message']) + return res + + def _extract_error_message(self, result): + if result is None: + return '' + if isinstance(result, dict): + res = self._extract_only_error_message(result) + if res: + return res + return ' with data: {0}'.format(result) + + def _validate(self, result=None, info=None, expected=None, method='GET'): + super(HetznerAPI, self)._validate(result=result, info=info, expected=expected, method=method) + if isinstance(result, dict): + error = result.get('error') + if isinstance(error, dict): + status = error.get('code') + if status is None: + return + url = info['url'] + if expected is not None and status in expected: + return + error_code = ERROR_CODES.get(status, UNKNOWN_ERROR) + more = self._extract_error_message(result) + raise DNSAPIError( + '{0} {1} resulted in API error {2} ({3}){4}'.format(method, url, status, error_code, more)) + + def _list_pagination(self, url, data_key, query=None, block_size=100, accept_404=False): + result = [] + page = 1 + while True: + query_ = query.copy() if query else dict() + query_['per_page'] = block_size + query_['page'] = page + res, info = self._get(url, query_, must_have_content=[200], expected=[200, 404] if accept_404 and page == 1 else [200]) + if accept_404 and page == 1 and info['status'] == 404: + return None + result.extend(res[data_key]) + if 'meta' not in res and page == 1: + return result + if page >= res['meta']['pagination']['last_page']: + return result + page += 1 + + def get_zone_by_name(self, name): + """ + Given a zone name, return the zone contents if found. + + @param name: The zone name (string) + @return The zone information (DNSZone), or None if not found + """ + result, info = self._get('v1/zones', expected=[200, 404], query=dict(name=name)) + for zone in result['zones']: + if zone.get('name') == name: + return _create_zone_from_json(zone) + return None + + def get_zone_by_id(self, id): + """ + Given a zone ID, return the zone contents if found. + + @param id: The zone ID + @return The zone information (DNSZone), or None if not found + """ + result, info = self._get('v1/zones/{id}'.format(id=id), expected=[200, 404], must_have_content=[200]) + if info['status'] == 404: + return None + return _create_zone_from_json(result['zone']) + + def get_zone_records(self, zone_id, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): + """ + Given a zone ID, return a list of records, optionally filtered by the provided criteria. + + @param zone_id: The zone ID + @param prefix: The prefix to filter for, if provided. Since None is a valid value, + the special constant NOT_PROVIDED indicates that we are not filtering. + @param record_type: The record type to filter for, if provided + @return A list of DNSrecord objects, or None if zone was not found + """ + result = self._list_pagination('v1/records', data_key='records', query=dict(zone_id=zone_id), accept_404=True) + if result is None: + return None + return filter_records( + [_create_record_from_json(record) for record in result], + prefix=prefix, + record_type=record_type, + ) + + def add_record(self, zone_id, record): + """ + Adds a new record to an existing zone. + + @param zone_id: The zone ID + @param record: The DNS record (DNSRecord) + @return The created DNS record (DNSRecord) + """ + data = _record_to_json(record, zone_id=zone_id) + result, info = self._post('v1/records', data=data, expected=[200, 422]) + if info['status'] == 422: + raise DNSAPIError( + 'The new {type} record with value "{target}" and TTL {ttl} has not been accepted by the server{message}'.format( + type=record.type, + target=record.target, + ttl=record.ttl, + message=self._extract_only_error_message(result), + ) + ) + return _create_record_from_json(result['record']) + + def update_record(self, zone_id, record): + """ + Update a record. + + @param zone_id: The zone ID + @param record: The DNS record (DNSRecord) + @return The DNS record (DNSRecord) + """ + if record.id is None: + raise DNSAPIError('Need record ID to update record!') + data = _record_to_json(record, zone_id=zone_id) + result, info = self._put('v1/records/{id}'.format(id=record.id), data=data, expected=[200, 422]) + if info['status'] == 422: + raise DNSAPIError( + 'The updated {type} record with value "{target}" and TTL {ttl} has not been accepted by the server{message}'.format( + type=record.type, + target=record.target, + ttl=record.ttl, + message=self._extract_only_error_message(result), + ) + ) + return _create_record_from_json(result['record']) + + def delete_record(self, zone_id, record): + """ + Delete a record. + + @param zone_id: The zone ID + @param record: The DNS record (DNSRecord) + @return True in case of success (boolean) + """ + if record.id is None: + raise DNSAPIError('Need record ID to delete record!') + dummy, info = self._delete('v1/records/{id}'.format(id=record.id), must_have_content=False, expected=[200, 404]) + return info['status'] == 200 + + @staticmethod + def _append(results_per_zone_id, zone_id, result): + if zone_id not in results_per_zone_id: + results_per_zone_id[zone_id] = [] + results_per_zone_id[zone_id].append(result) + + def add_records(self, records_per_zone_id, stop_early_on_errors=True): + """ + Add new records to an existing zone. + + @param records_per_zone_id: Maps a zone ID to a list of DNS records (DNSRecord) + @param stop_early_on_errors: If set to ``True``, try to stop changes after the first error happens. + This might only work on some APIs. + @return A dictionary mapping zone IDs to lists of tuples ``(record, created, failed)``. + Here ``created`` indicates whether the record was created (``True``) or not (``False``). + If it was created, ``record`` contains the record ID and ``failed`` is ``None``. + If it was not created, ``failed`` should be a ``DNSAPIError`` instance indicating why + it was not created. It is possible that the API only creates records if all succeed, + in that case ``failed`` can be ``None`` even though ``created`` is ``False``. + """ + json_records = [] + for zone_id, records in records_per_zone_id.items(): + for record in records: + json_records.append(_record_to_json(record, zone_id=zone_id)) + data = {'records': json_records} + # Error 422 means that at least one of the records was not valid + result, info = self._post('v1/records/bulk', data=data, expected=[200, 422]) + results_per_zone_id = {} + # This is the list of invalid records that was detected before accepting the whole set + for json_record in result.get('invalid_records') or []: + record = _create_record_from_json(json_record, has_id=False) + zone_id = json_record['zone_id'] + self._append(results_per_zone_id, zone_id, (record, False, DNSAPIError( + 'Creating {type} record "{target}" with TTL {ttl} for zone {zoneID} failed with unknown reason'.format( + type=record.type, + target=record.target, + ttl=record.ttl, + zoneID=zone_id)))) + # This is the list of valid records that were not processed + for json_record in result.get('valid_records') or []: + record = _create_record_from_json(json_record, has_id=False) + zone_id = json_record['zone_id'] + self._append(results_per_zone_id, zone_id, (record, False, None)) + # This is the list of correctly processed records + for json_record in result.get('records') or []: + record = _create_record_from_json(json_record) + zone_id = json_record['zone_id'] + self._append(results_per_zone_id, zone_id, (record, True, None)) + return results_per_zone_id + + def update_records(self, records_per_zone_id, stop_early_on_errors=True): + """ + Update multiple records. + + @param records_per_zone_id: Maps a zone ID to a list of DNS records (DNSRecord) + @param stop_early_on_errors: If set to ``True``, try to stop changes after the first error happens. + This might only work on some APIs. + @return A dictionary mapping zone IDs to lists of tuples ``(record, updated, failed)``. + Here ``updated`` indicates whether the record was updated (``True``) or not (``False``). + If it was not updated, ``failed`` should be a ``DNSAPIError`` instance. If it was + updated, ``failed`` should be ``None``. It is possible that the API only updates + records if all succeed, in that case ``failed`` can be ``None`` even though + ``updated`` is ``False``. + """ + # Currently Hetzner's bulk update API seems to be broken, it always returns the error message + # "An invalid response was received from the upstream server". That's why for now, we always + # fall back to the default implementation. + if True: # pylint: disable=using-constant-test + return super(HetznerAPI, self).update_records(records_per_zone_id, stop_early_on_errors=stop_early_on_errors) + + json_records = [] + for zone_id, records in records_per_zone_id.items(): + for record in records: + json_records.append(_record_to_json(record, zone_id=zone_id)) + data = {'records': json_records} + result, dummy = self._put('v1/records/bulk', data=data, expected=[200]) + results_per_zone_id = {} + for json_record in result.get('failed_records') or []: + record = _create_record_from_json(json_record) + zone_id = json_record['zone_id'] + self._append(results_per_zone_id, zone_id, (record, False, DNSAPIError( + 'Updating {type} record #{id} "{target}" with TTL {ttl} for zone {zoneID} failed with unknown reason'.format( + type=record.type, + id=record.id, + target=record.target, + ttl=record.ttl, + zoneID=zone_id)))) + for json_record in result.get('records') or []: + record = _create_record_from_json(json_record) + zone_id = json_record['zone_id'] + self._append(results_per_zone_id, zone_id, (record, True, None)) + return results_per_zone_id + + +class HetznerProviderInformation(ProviderInformation): + def get_supported_record_types(self): + """ + Return a list of supported record types. + """ + return ['A', 'AAAA', 'NS', 'MX', 'CNAME', 'RP', 'TXT', 'SOA', 'HINFO', 'SRV', 'DANE', 'TLSA', 'DS', 'CAA'] + + def get_zone_id_type(self): + """ + Return the (short) type for zone IDs, like ``'int'`` or ``'str'``. + """ + return 'str' + + def get_record_id_type(self): + """ + Return the (short) type for record IDs, like ``'int'`` or ``'str'``. + """ + return 'str' + + def get_record_default_ttl(self): + """ + Return the default TTL for records, like 300, 3600 or None. + None means that some other TTL (usually from the zone) will be used. + """ + return None + + def normalize_prefix(self, prefix): + """ + Given a prefix (string or None), return its normalized form. + + The result should always be None for the trivial prefix, and a non-zero length DNS name + for a non-trivial prefix. + + If a provider supports other identifiers for the trivial prefix, such as '@', this + function needs to convert them to None as well. + """ + return None if prefix in ('@', '') else prefix + + def supports_bulk_actions(self): + """ + Return whether the API supports some kind of bulk actions. + """ + return True + + def txt_record_handling(self): + """ + Return how the API handles TXT records. + + Returns one of the following strings: + * 'decoded' - the API works with unencoded values + * 'encoded' - the API works with encoded values + * 'encoded-no-char-encoding' - the API works with encoded values, but without character encoding + """ + return 'encoded-no-char-encoding' + + +def create_hetzner_provider_information(): + return HetznerProviderInformation() + + +def create_hetzner_argument_spec(): + return ArgumentSpec( + argument_spec=dict( + hetzner_token=dict( + type='str', + required=True, + no_log=True, + aliases=['api_token'], + fallback=(env_fallback, ['HETZNER_DNS_TOKEN']), + ), + ), + ) + + +def create_hetzner_api(option_provider, http_helper): + return HetznerAPI(http_helper, option_provider.get_option('hetzner_token')) diff --git a/ansible_collections/community/dns/plugins/module_utils/hosttech/api.py b/ansible_collections/community/dns/plugins/module_utils/hosttech/api.py new file mode 100644 index 000000000..adf2dd15c --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/hosttech/api.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ArgumentSpec, +) + +from ansible_collections.community.dns.plugins.module_utils.provider import ( + ProviderInformation, +) + +from ansible_collections.community.dns.plugins.module_utils.wsdl import ( + HAS_LXML_ETREE, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech.wsdl_api import ( + HostTechWSDLAPI, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech.json_api import ( + HostTechJSONAPI, +) + + +class HosttechProviderInformation(ProviderInformation): + def get_supported_record_types(self): + """ + Return a list of supported record types. + """ + return ['A', 'CNAME', 'MX', 'AAAA', 'TXT', 'PTR', 'SRV', 'SPF', 'NS', 'CAA'] + + def get_zone_id_type(self): + """ + Return the (short) type for zone IDs, like ``'int'`` or ``'str'``. + """ + return 'int' + + def get_record_id_type(self): + """ + Return the (short) type for record IDs, like ``'int'`` or ``'str'``. + """ + return 'int' + + def get_record_default_ttl(self): + """ + Return the default TTL for records, like 300, 3600 or None. + None means that some other TTL (usually from the zone) will be used. + """ + return 3600 + + def normalize_prefix(self, prefix): + """ + Given a prefix (string or None), return its normalized form. + + The result should always be None for the trivial prefix, and a non-zero length DNS name + for a non-trivial prefix. + + If a provider supports other identifiers for the trivial prefix, such as '@', this + function needs to convert them to None as well. + """ + return prefix or None + + def txt_record_handling(self): + """ + Return how the API handles TXT records. + + Returns one of the following strings: + * 'decoded' - the API works with unencoded values + * 'encoded' - the API works with encoded values + * 'encoded-no-char-encoding' - the API works with encoded values, but without character encoding + """ + return 'decoded' + + +def create_hosttech_provider_information(): + return HosttechProviderInformation() + + +def create_hosttech_argument_spec(): + return ArgumentSpec( + argument_spec=dict( + hosttech_username=dict(type='str'), + hosttech_password=dict(type='str', no_log=True), + hosttech_token=dict(type='str', no_log=True, aliases=['api_token']), + ), + required_together=[('hosttech_username', 'hosttech_password')], + mutually_exclusive=[('hosttech_username', 'hosttech_token')], + ) + + +def create_hosttech_api(option_provider, http_helper): + username = option_provider.get_option('hosttech_username') + password = option_provider.get_option('hosttech_password') + if username is not None and password is not None: + if not HAS_LXML_ETREE: + raise DNSAPIError('Needs lxml Python module (pip install lxml)') + + return HostTechWSDLAPI(http_helper, username, password, debug=False) + + token = option_provider.get_option('hosttech_token') + if token is not None: + return HostTechJSONAPI(http_helper, token) + + raise DNSAPIError('One of hosttech_token or both hosttech_username and hosttech_password must be provided!') diff --git a/ansible_collections/community/dns/plugins/module_utils/hosttech/json_api.py b/ansible_collections/community/dns/plugins/module_utils/hosttech/json_api.py new file mode 100644 index 000000000..f5f5ee78d --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/hosttech/json_api.py @@ -0,0 +1,340 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# The API documentation can be found here: https://api.ns1.hosttech.eu/api/documentation/ + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +from ansible_collections.community.dns.plugins.module_utils.json_api_helper import ( + JSONAPIHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, +) + +from ansible_collections.community.dns.plugins.module_utils.zone import ( + DNSZone, + DNSZoneWithRecords, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, + NOT_PROVIDED, + ZoneRecordAPI, + filter_records, +) + + +def _create_record_from_json(source, type=None): + source = dict(source) + result = DNSRecord() + result.id = source.pop('id') + result.type = source.pop('type', type) + ttl = source.pop('ttl') + result.ttl = int(ttl) if ttl is not None else None + result.extra['comment'] = source.pop('comment') + + name = source.pop('name', None) + target = None + if result.type == 'A': + target = source.pop('ipv4') + elif result.type == 'AAAA': + target = source.pop('ipv6') + elif result.type == 'CAA': + target = '{0} {1} "{2}"'.format(source.pop('flag'), source.pop('tag'), source.pop('value')) + elif result.type == 'CNAME': + target = source.pop('cname') + elif result.type == 'MX': + mx_name, name = name, source.pop('ownername') + target = '{0} {1}'.format(source.pop('pref'), mx_name) + elif result.type == 'NS': + name = source.pop('ownername') + target = source.pop('targetname') + elif result.type == 'PTR': + ptr_name, name = name, '' + target = '{0} {1}'.format(source.pop('origin'), ptr_name) + elif result.type == 'SRV': + name = source.pop('service') + target = '{0} {1} {2} {3}'.format(source.pop('priority'), source.pop('weight'), source.pop('port'), source.pop('target')) + elif result.type == 'TXT': + target = source.pop('text') + elif result.type == 'TLSA': + target = source.pop('text') + else: + raise DNSAPIError('Cannot parse unknown record type: {0}'.format(result.type)) + + result.prefix = name or None # API returns '', we want None + result.target = target + result.extra.update(source) + return result + + +def _create_zone_from_json(source): + zone = DNSZone(source['name']) + zone.id = source['id'] + zone.info = dict( + dnssec=source['dnssec'], + dnssec_email=source.get('dnssec_email'), + ds_records=source.get('ds_records'), + email=source.get('email'), + ttl=source['ttl'], + ) + return zone + + +def _create_zone_with_records_from_json(source, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): + return DNSZoneWithRecords( + _create_zone_from_json(source), + filter_records( + [_create_record_from_json(record) for record in source['records']], + prefix=prefix, + record_type=record_type, + ), + ) + + +def _record_to_json(record, include_id=False, include_type=True): + result = { + 'ttl': record.ttl, + 'comment': record.extra.get('comment') or '', + } + if include_type: + result['type'] = record.type + if include_id: + result['id'] = record.id + + if record.type == 'A': + result['name'] = record.prefix or '' + result['ipv4'] = record.target + elif record.type == 'AAAA': + result['name'] = record.prefix or '' + result['ipv6'] = record.target + elif record.type == 'CAA': + result['name'] = record.prefix or '' + try: + flag, tag, value = record.target.split(' ', 2) + if value.startswith('"') and value.endswith('"'): + value = value[1:-1] + result['flag'] = flag + result['tag'] = tag + result['value'] = value + except Exception as e: + raise DNSAPIError( + 'Cannot split {0} record "{1}" into flag, tag and value: {2}'.format( + record.type, record.target, e)) + elif record.type == 'CNAME': + result['name'] = record.prefix or '' + result['cname'] = record.target + elif record.type == 'MX': + result['ownername'] = record.prefix or '' + try: + pref, name = record.target.split(' ', 1) + result['pref'] = int(pref) + result['name'] = name + except Exception as e: + raise DNSAPIError( + 'Cannot split {0} record "{1}" into integer preference and name: {2}'.format( + record.type, record.target, e)) + elif record.type == 'NS': + result['ownername'] = record.prefix or '' + result['targetname'] = record.target + elif record.type == 'PTR': + try: + origin, name = record.target.split(' ', 1) + result['origin'] = origin + result['name'] = name + except Exception as e: + raise DNSAPIError( + 'Cannot split {0} record "{1}" into origin and name: {2}'.format( + record.type, record.target, e)) + elif record.type == 'SRV': + result['service'] = record.prefix or '' + try: + priority, weight, port, target = record.target.split(' ', 3) + result['priority'] = int(priority) + result['weight'] = int(weight) + result['port'] = int(port) + result['target'] = target + except Exception as e: + raise DNSAPIError( + 'Cannot split {0} record "{1}" into integer priority, integer weight, integer port and target: {2}'.format( + record.type, record.target, e)) + elif record.type == 'TXT': + result['name'] = record.prefix or '' + result['text'] = record.target + elif record.type == 'TLSA': + result['name'] = record.prefix or '' + result['text'] = record.target + else: + raise DNSAPIError('Cannot serialize unknown record type: {0}'.format(record.type)) + + return result + + +class HostTechJSONAPI(ZoneRecordAPI, JSONAPIHelper): + def __init__(self, http_helper, token, api='https://api.ns1.hosttech.eu/api/', debug=False): + """ + Create a new HostTech API instance with given API token. + """ + JSONAPIHelper.__init__(self, http_helper, token, api=api, debug=debug) + + def _extract_error_message(self, result): + if result is None: + return '' + if isinstance(result, dict): + res = '' + if result.get('message'): + res = '{0} with message "{1}"'.format(res, result['message']) + if 'errors' in result: + if isinstance(result['errors'], dict): + for k, v in sorted(result['errors'].items()): + if isinstance(v, list): + v = '; '.join(v) + res = '{0} (field "{1}": {2})'.format(res, k, v) + if res: + return res + return ' with data: {0}'.format(result) + + def _create_headers(self): + return dict( + accept='application/json', + authorization='Bearer {token}'.format(token=self._token), + ) + + def _list_pagination(self, url, query=None, block_size=100): + result = [] + offset = 0 + while True: + query_ = query.copy() if query else dict() + query_['limit'] = block_size + query_['offset'] = offset + res, info = self._get(url, query_, must_have_content=True, expected=[200]) + result.extend(res['data']) + if len(res['data']) < block_size: + return result + offset += block_size + + def get_zone_with_records_by_id(self, id, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): + """ + Given a zone ID, return the zone contents with records if found. + + @param id: The zone ID + @param prefix: The prefix to filter for, if provided. Since None is a valid value, + the special constant NOT_PROVIDED indicates that we are not filtering. + @param record_type: The record type to filter for, if provided + @return The zone information with records (DNSZoneWithRecords), or None if not found + """ + result, info = self._get('user/v1/zones/{0}'.format(id), expected=[200, 404], must_have_content=[200]) + if info['status'] == 404: + return None + return _create_zone_with_records_from_json(result['data'], prefix=prefix, record_type=record_type) + + def get_zone_with_records_by_name(self, name, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): + """ + Given a zone name, return the zone contents with records if found. + + @param name: The zone name (string) + @param prefix: The prefix to filter for, if provided. Since None is a valid value, + the special constant NOT_PROVIDED indicates that we are not filtering. + @param record_type: The record type to filter for, if provided + @return The zone information with records (DNSZoneWithRecords), or None if not found + """ + result = self._list_pagination('user/v1/zones', query=dict(query=name)) + for zone in result: + if zone['name'] == name: + result, info = self._get('user/v1/zones/{0}'.format(zone['id']), expected=[200]) + return _create_zone_with_records_from_json(result['data'], prefix=prefix, record_type=record_type) + return None + + def get_zone_records(self, zone_id, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): + """ + Given a zone ID, return a list of records, optionally filtered by the provided criteria. + + @param zone_id: The zone ID + @param prefix: The prefix to filter for, if provided. Since None is a valid value, + the special constant NOT_PROVIDED indicates that we are not filtering. + @param record_type: The record type to filter for, if provided + @return A list of DNSrecord objects, or None if zone was not found + """ + query = dict() + if record_type is not NOT_PROVIDED: + query['type'] = record_type.upper() + result, info = self._get('user/v1/zones/{0}/records'.format(zone_id), query=query, expected=[200, 404], must_have_content=[200]) + if info['status'] == 404: + return None + return filter_records( + [_create_record_from_json(record) for record in result['data']], + prefix=prefix, + record_type=record_type, + ) + + def get_zone_by_name(self, name): + """ + Given a zone name, return the zone contents if found. + + @param name: The zone name (string) + @return The zone information (DNSZone), or None if not found + """ + result = self._list_pagination('user/v1/zones', query=dict(query=name)) + for zone in result: + if zone['name'] == name: + # We cannot simply return `_create_zone_from_json(zone)`, since this contains less information! + return self.get_zone_by_id(zone['id']) + return None + + def get_zone_by_id(self, id): + """ + Given a zone ID, return the zone contents if found. + + @param id: The zone ID + @return The zone information (DNSZone), or None if not found + """ + result, info = self._get('user/v1/zones/{0}'.format(id), expected=[200, 404], must_have_content=[200]) + if info['status'] == 404: + return None + return _create_zone_from_json(result['data']) + + def add_record(self, zone_id, record): + """ + Adds a new record to an existing zone. + + @param zone_id: The zone ID + @param record: The DNS record (DNSRecord) + @return The created DNS record (DNSRecord) + """ + data = _record_to_json(record, include_id=False, include_type=True) + result, dummy = self._post('user/v1/zones/{0}/records'.format(zone_id), data=data, expected=[201]) + return _create_record_from_json(result['data']) + + def update_record(self, zone_id, record): + """ + Update a record. + + @param zone_id: The zone ID + @param record: The DNS record (DNSRecord) + @return The DNS record (DNSRecord) + """ + if record.id is None: + raise DNSAPIError('Need record ID to update record!') + data = _record_to_json(record, include_id=False, include_type=False) + result, dummy = self._put('user/v1/zones/{0}/records/{1}'.format(zone_id, record.id), data=data, expected=[200]) + return _create_record_from_json(result['data']) + + def delete_record(self, zone_id, record): + """ + Delete a record. + + @param zone_id: The zone ID + @param record: The DNS record (DNSRecord) + @return True in case of success (boolean) + """ + if record.id is None: + raise DNSAPIError('Need record ID to delete record!') + dummy, info = self._delete('user/v1/zones/{0}/records/{1}'.format(zone_id, record.id), must_have_content=False, expected=[204, 404]) + return info['status'] == 204 diff --git a/ansible_collections/community/dns/plugins/module_utils/hosttech/wsdl_api.py b/ansible_collections/community/dns/plugins/module_utils/hosttech/wsdl_api.py new file mode 100644 index 000000000..0e36e1ffa --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/hosttech/wsdl_api.py @@ -0,0 +1,279 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +from ansible.module_utils.six import raise_from +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, +) + +from ansible_collections.community.dns.plugins.module_utils.wsdl import ( + WSDLError, + WSDLNetworkError, + Composer, +) + +from ansible_collections.community.dns.plugins.module_utils.zone import ( + DNSZone, + DNSZoneWithRecords, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, + DNSAPIAuthenticationError, + NOT_PROVIDED, + ZoneRecordAPI, + filter_records, +) + + +def _create_record_from_encoding(source, type=None): + source = dict(source) + result = DNSRecord() + result.id = source.pop('id') + result.type = source.pop('type', type) + result.prefix = source.pop('prefix', None) + ttl = source.pop('ttl') + result.ttl = int(ttl) if ttl is not None else None + priority = source.pop('priority') + target = source.pop('target') + if result.type in ('PTR', 'MX'): + result.target = '{0} {1}'.format(priority, target) + else: + result.target = target + source.pop('zone', None) + result.extra['comment'] = source.pop('comment') or '' + result.extra.update(source) + return result + + +def _create_zone_from_encoding(source, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): + zone = DNSZone(source['name']) + zone.id = source['id'] + zone.info = dict( + email=source.get('email'), + ttl=source['ttl'], + ) + return DNSZoneWithRecords( + zone, + filter_records( + [_create_record_from_encoding(record) for record in source['records']], + prefix=prefix, + record_type=record_type, + ), + ) + + +def _encode_record(record, include_id=False): + result = { + 'type': record.type, + 'prefix': record.prefix, + 'target': record.target, + 'ttl': record.ttl, + } + if record.type in ('PTR', 'MX'): + try: + priority, target = record.target.split(' ', 1) + result['priority'] = int(priority) + result['target'] = target + except Exception as e: + raise DNSAPIError( + 'Cannot split {0} record "{1}" into integer priority and target: {2}'.format( + record.type, record.target, e)) + else: + result['priority'] = None + if include_id: + result['id'] = record.id + return result + + +def _encode_zone(zone): + return { + 'id': zone.id, + 'name': zone.name, + # 'email': zone.email, + # 'ttl': zone.ttl, + # 'nameserver': zone.nameserver, + # 'serial': zone.serial, + # 'template': zone.template, + 'records': [_encode_record(record, include_id=True) for record in zone.records], + } + + +class HostTechWSDLAPI(ZoneRecordAPI): + def __init__(self, http_helper, username, password, api='https://ns1.hosttech.eu/public/api', debug=False): + """ + Create a new HostTech API instance with given username and password. + """ + self._http_helper = http_helper + self._api = api + self._namespaces = { + 'ns1': 'https://ns1.hosttech.eu/soap', + } + self._username = username + self._password = password + self._debug = debug + + def _prepare(self): + command = Composer(self._http_helper, self._api, self._namespaces) + command.add_auth(self._username, self._password) + return command + + def _announce(self, msg): + if self._debug: + pass + # q.q('{0} {1} {2}'.format('=' * 4, msg, '=' * 40)) + + def _execute(self, command, result_name, acceptable_types): + if self._debug: + pass + # q.q('Request: {0}'.format(command)) + try: + result = command.execute(debug=self._debug) + except WSDLError as e: + if e.error_code == '998': + raise DNSAPIAuthenticationError('Error on authentication ({0})'.format(e.error_message)) + raise + res = result.get_result(result_name) + if isinstance(res, acceptable_types): + if self._debug: + pass + # q.q('Extracted result: {0} (type {1})'.format(res, type(res))) + return res + if self._debug: + pass + # q.q('Result: {0}; extracted type {1}'.format(result, type(res))) + raise DNSAPIError('Result has unexpected type {0} (expecting {1})!'.format(type(res), acceptable_types)) + + def get_zone_with_records_by_name(self, name, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): + """ + Given a zone name, return the zone contents with records if found. + + @param name: The zone name (string) + @param prefix: The prefix to filter for, if provided. Since None is a valid value, + the special constant NOT_PROVIDED indicates that we are not filtering. + @param record_type: The record type to filter for, if provided + @return The zone information with records (DNSZoneWithRecords), or None if not found + """ + self._announce('get zone') + command = self._prepare() + command.add_simple_command('getZone', sZoneName=name) + try: + return _create_zone_from_encoding(self._execute(command, 'getZoneResponse', dict), prefix=prefix, record_type=record_type) + except WSDLError as exc: + if exc.error_origin == 'server' and exc.error_message == 'zone not found': + return None + raise_from(DNSAPIError('Error while getting zone: {0}'.format(to_native(exc))), exc) + except WSDLNetworkError as exc: + raise_from(DNSAPIError('Network error while getting zone: {0}'.format(to_native(exc))), exc) + + def get_zone_with_records_by_id(self, id, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): + """ + Given a zone ID, return the zone contents with records if found. + + @param id: The zone ID + @param prefix: The prefix to filter for, if provided. Since None is a valid value, + the special constant NOT_PROVIDED indicates that we are not filtering. + @param record_type: The record type to filter for, if provided + @return The zone information with records (DNSZoneWithRecords), or None if not found + """ + return self.get_zone_with_records_by_name(str(id), prefix=prefix, record_type=record_type) + + def get_zone_records(self, zone_id, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): + """ + Given a zone ID, return a list of records, optionally filtered by the provided criteria. + + @param zone_id: The zone ID + @param prefix: The prefix to filter for, if provided. Since None is a valid value, + the special constant NOT_PROVIDED indicates that we are not filtering. + @param record_type: The record type to filter for, if provided + @return A list of DNSrecord objects, or None if zone was not found + """ + result = self.get_zone_with_records_by_id(zone_id, prefix=prefix, record_type=record_type) + return result.records if result is not None else None + + def get_zone_by_name(self, name): + """ + Given a zone name, return the zone contents if found. + + @param name: The zone name (string) + @return The zone information (DNSZone), or None if not found + """ + zone = self.get_zone_with_records_by_name(name) + return zone.zone if zone else None + + def get_zone_by_id(self, id): + """ + Given a zone ID, return the zone contents if found. + + @param id: The zone ID + @return The zone information (DNSZone), or None if not found + """ + zone = self.get_zone_with_records_by_id(id) + return zone.zone if zone else None + + def add_record(self, zone_id, record): + """ + Adds a new record to an existing zone. + + @param zone_id: The zone ID + @param record: The DNS record (DNSRecord) + @return The created DNS record (DNSRecord) + """ + self._announce('add record') + command = self._prepare() + command.add_simple_command('addRecord', search=str(zone_id), recorddata=_encode_record(record, include_id=False)) + try: + return _create_record_from_encoding(self._execute(command, 'addRecordResponse', dict)) + except WSDLError as exc: + raise_from(DNSAPIError('Error while adding record: {0}'.format(to_native(exc))), exc) + except WSDLNetworkError as exc: + raise_from(DNSAPIError('Network error while adding record: {0}'.format(to_native(exc))), exc) + + def update_record(self, zone_id, record): + """ + Update a record. + + @param zone_id: The zone ID + @param record: The DNS record (DNSRecord) + @return The DNS record (DNSRecord) + """ + if record.id is None: + raise DNSAPIError('Need record ID to update record!') + self._announce('update record') + command = self._prepare() + command.add_simple_command('updateRecord', recordId=record.id, recorddata=_encode_record(record, include_id=False)) + try: + return _create_record_from_encoding(self._execute(command, 'updateRecordResponse', dict)) + except WSDLError as exc: + raise_from(DNSAPIError('Error while updating record: {0}'.format(to_native(exc))), exc) + except WSDLNetworkError as exc: + raise_from(DNSAPIError('Network error while updating record: {0}'.format(to_native(exc))), exc) + + def delete_record(self, zone_id, record): + """ + Delete a record. + + @param zone_id: The zone ID + @param record: The DNS record (DNSRecord) + @return True in case of success (boolean) + """ + if record.id is None: + raise DNSAPIError('Need record ID to delete record!') + self._announce('delete record') + command = self._prepare() + command.add_simple_command('deleteRecord', recordId=record.id) + try: + return self._execute(command, 'deleteRecordResponse', bool) + except WSDLError as exc: + raise_from(DNSAPIError('Error while deleting record: {0}'.format(to_native(exc))), exc) + except WSDLNetworkError as exc: + raise_from(DNSAPIError('Network error while deleting record: {0}'.format(to_native(exc))), exc) diff --git a/ansible_collections/community/dns/plugins/module_utils/http.py b/ansible_collections/community/dns/plugins/module_utils/http.py new file mode 100644 index 000000000..fc4e1a590 --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/http.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import abc + +from ansible.module_utils import six +from ansible.module_utils.common.text.converters import to_native +from ansible.module_utils.six import PY3 +from ansible.module_utils.urls import fetch_url, open_url, urllib_error, NoSSLError, ConnectionError + + +class NetworkError(Exception): + pass + + +@six.add_metaclass(abc.ABCMeta) +class HTTPHelper(object): + @abc.abstractmethod + def fetch_url(self, url, method='GET', headers=None, data=None, timeout=None): + """ + Execute a HTTP request and return a tuple (response_content, info). + + In case of errors, either raise NetworkError or terminate the program (for modules only!). + """ + + +class ModuleHTTPHelper(HTTPHelper): + def __init__(self, module): + self.module = module + + def fetch_url(self, url, method='GET', headers=None, data=None, timeout=None): + response, info = fetch_url(self.module, url, method=method, headers=headers, data=data, timeout=timeout) + try: + # In Python 2, reading from a closed response yields a TypeError. + # In Python 3, read() simply returns '' + if PY3 and response.closed: + raise TypeError + content = response.read() + except (AttributeError, TypeError): + content = info.pop('body', None) + return content, info + + +class OpenURLHelper(HTTPHelper): + def fetch_url(self, url, method='GET', headers=None, data=None, timeout=None): + info = {} + try: + req = open_url(url, method=method, headers=headers, data=data, timeout=timeout) + result = req.read() + info.update(dict((k.lower(), v) for k, v in req.info().items())) + info['status'] = req.code + info['url'] = req.geturl() + req.close() + except urllib_error.HTTPError as e: + try: + result = e.read() + except AttributeError: + result = '' + try: + info.update(dict((k.lower(), v) for k, v in e.info().items())) + except Exception: + pass + info['status'] = e.code + except NoSSLError as e: + raise NetworkError('Cannot connect via SSL: {0}'.format(to_native(e))) + except (ConnectionError, ValueError) as e: + raise NetworkError('Connection error: {0}'.format(to_native(e))) + + return result, info diff --git a/ansible_collections/community/dns/plugins/module_utils/json_api_helper.py b/ansible_collections/community/dns/plugins/module_utils/json_api_helper.py new file mode 100644 index 000000000..af5a927b5 --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/json_api_helper.py @@ -0,0 +1,193 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# Copyright (c) 2020 Markus Bergholz <markuman+spambelongstogoogle@gmail.com> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import json +import time + +from ansible.module_utils.six.moves.urllib.parse import urlencode +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, + DNSAPIAuthenticationError, +) + +ERROR_CODES = { + 200: "Successful response", + 401: "Unauthorized", + 403: "Forbidden", + 404: "Not found", + 406: "Not acceptable", + 409: "Conflict", + 422: "Unprocessable entity", + 500: "Internal Server Error", +} +UNKNOWN_ERROR = "Unknown Error" + + +def _get_header_value(info, header_name): + header_name = header_name.lower() + header_value = info.get(header_name) + for k, v in info.items(): + if k.lower() == header_name: + header_value = v + return header_value + + +class JSONAPIHelper(object): + def __init__(self, http_helper, token, api, debug=False): + """ + Create a new JSON API helper instance with given API key. + """ + self._api = api + self._http_helper = http_helper + self._token = token + self._debug = debug + + def _build_url(self, url, query=None): + return '{0}{1}{2}'.format(self._api, url, ('?' + urlencode(query)) if query else '') + + def _extract_error_message(self, result): + if result is None: + return '' + return ' with data: {0}'.format(result) + + def _validate(self, result=None, info=None, expected=None, method='GET'): + if info is None: + raise DNSAPIError('Internal error: info needs to be provided') + status = info['status'] + url = info['url'] + # Check expected status + error_code = ERROR_CODES.get(status, UNKNOWN_ERROR) + if expected is not None: + if status not in expected: + more = self._extract_error_message(result) + raise DNSAPIError( + 'Expected HTTP status {0} for {1} {2}, but got HTTP status {3} ({4}){5}'.format( + ', '.join(['{0}'.format(e) for e in expected]), method, url, status, error_code, more)) + else: + if status < 200 or status >= 300: + more = self._extract_error_message(result) + raise DNSAPIError( + 'Expected successful HTTP status for {0} {1}, but got HTTP status {2} ({3}){4}'.format( + method, url, status, error_code, more)) + + def _process_json_result(self, content, info, must_have_content=True, method='GET', expected=None): + if isinstance(must_have_content, (list, tuple)): + must_have_content = info['status'] in must_have_content + # Check for unauthenticated + if info['status'] == 401: + message = 'Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + try: + body = json.loads(content.decode('utf8')) + if body['message']: + message = '{0}: {1}'.format(message, body['message']) + except Exception: + pass + raise DNSAPIAuthenticationError(message) + if info['status'] == 403: + message = 'Forbidden: you do not have access to this resource (HTTP status 403)' + try: + body = json.loads(content.decode('utf8')) + if body['message']: + message = '{0}: {1}'.format(message, body['message']) + except Exception: + pass + raise DNSAPIAuthenticationError(message) + # Check Content-Type header + content_type = _get_header_value(info, 'content-type') + if content_type != 'application/json' and (content_type is None or not content_type.startswith('application/json;')): + if must_have_content: + raise DNSAPIError( + '{0} {1} did not yield JSON data, but HTTP status code {2} with Content-Type "{3}" and data: {4}'.format( + method, info['url'], info['status'], content_type, to_native(content))) + self._validate(result=content, info=info, expected=expected, method=method) + return None, info + # Decode content as JSON + try: + result = json.loads(content.decode('utf8')) + except Exception: + if must_have_content: + raise DNSAPIError( + '{0} {1} did not yield JSON data, but HTTP status code {2} with data: {3}'.format( + method, info['url'], info['status'], to_native(content))) + self._validate(result=content, info=info, expected=expected, method=method) + return None, info + self._validate(result=result, info=info, expected=expected, method=method) + return result, info + + def _request(self, url, **kwargs): + """Execute a HTTP request and handle common things like rate limiting.""" + number_retries = 10 + countdown = number_retries + 1 + while True: + content, info = self._http_helper.fetch_url(url, **kwargs) + countdown -= 1 + if info['status'] == 429: + if countdown <= 0: + break + try: + retry_after = max(min(float(_get_header_value(info, 'retry-after')), 60), 1) + except (ValueError, TypeError): + retry_after = 10 + time.sleep(retry_after) + continue + return content, info + raise DNSAPIError('Stopping after {0} failed retries with 429 Too Many Attempts'.format(number_retries)) + + def _create_headers(self): + return dict( + accept='application/json', + ) + + def _get(self, url, query=None, must_have_content=True, expected=None): + full_url = self._build_url(url, query) + if self._debug: + pass + # q.q('Request: GET {0}'.format(full_url)) + headers = self._create_headers() + content, info = self._request(full_url, headers=headers, method='GET') + return self._process_json_result(content, info, must_have_content=must_have_content, method='GET', expected=expected) + + def _post(self, url, data=None, query=None, must_have_content=True, expected=None): + full_url = self._build_url(url, query) + if self._debug: + pass + # q.q('Request: POST {0}'.format(full_url)) + headers = self._create_headers() + encoded_data = None + if data is not None: + headers['content-type'] = 'application/json' + encoded_data = json.dumps(data).encode('utf-8') + content, info = self._request(full_url, headers=headers, method='POST', data=encoded_data) + return self._process_json_result(content, info, must_have_content=must_have_content, method='POST', expected=expected) + + def _put(self, url, data=None, query=None, must_have_content=True, expected=None): + full_url = self._build_url(url, query) + if self._debug: + pass + # q.q('Request: PUT {0}'.format(full_url)) + headers = self._create_headers() + encoded_data = None + if data is not None: + headers['content-type'] = 'application/json' + encoded_data = json.dumps(data).encode('utf-8') + content, info = self._request(full_url, headers=headers, method='PUT', data=encoded_data) + return self._process_json_result(content, info, must_have_content=must_have_content, method='PUT', expected=expected) + + def _delete(self, url, query=None, must_have_content=True, expected=None): + full_url = self._build_url(url, query) + if self._debug: + pass + # q.q('Request: DELETE {0}'.format(full_url)) + headers = self._create_headers() + content, info = self._request(full_url, headers=headers, method='DELETE') + return self._process_json_result(content, info, must_have_content=must_have_content, method='DELETE', expected=expected) diff --git a/ansible_collections/community/dns/plugins/module_utils/module/_utils.py b/ansible_collections/community/dns/plugins/module_utils/module/_utils.py new file mode 100644 index 000000000..404e86976 --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/module/_utils.py @@ -0,0 +1,43 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# This module_utils is PRIVATE and should only be used by this collection. Breaking changes can occur any time. + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +from ansible_collections.community.dns.plugins.module_utils.names import ( + split_into_labels, + join_labels, + normalize_label, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, +) + + +def normalize_dns_name(name): + if name is None: + return name + labels, dummy = split_into_labels(name) + return join_labels([normalize_label(label) for label in labels]) + + +def get_prefix(normalized_zone, provider_information, normalized_record=None, prefix=None): + # If normalized_record is not specified, use prefix + if normalized_record is None: + if prefix is not None: + prefix = provider_information.normalize_prefix(normalize_dns_name(prefix)) + return (prefix + '.' + normalized_zone) if prefix else normalized_zone, prefix + # Convert record to prefix + if not normalized_record.endswith('.' + normalized_zone) and normalized_record != normalized_zone: + raise DNSAPIError('Record must be in zone') + if normalized_record == normalized_zone: + return normalized_record, None + else: + return normalized_record, normalized_record[:len(normalized_record) - len(normalized_zone) - 1] diff --git a/ansible_collections/community/dns/plugins/module_utils/module/record.py b/ansible_collections/community/dns/plugins/module_utils/module/record.py new file mode 100644 index 000000000..1ebbfe725 --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/module/record.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# This module_utils is PRIVATE and should only be used by this collection. Breaking changes can occur any time. + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import traceback + +from ansible.module_utils.common.text.converters import to_text + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ArgumentSpec, + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.base import ( + DNSConversionError, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.converter import ( + RecordConverter, +) + +from ansible_collections.community.dns.plugins.module_utils.options import ( + create_record_transformation_argspec, +) + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, + format_record_for_output, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, + DNSAPIAuthenticationError, + NOT_PROVIDED, + filter_records, +) + +from ._utils import ( + normalize_dns_name, + get_prefix, +) + + +def create_module_argument_spec(provider_information): + return ArgumentSpec( + argument_spec=dict( + state=dict(type='str', choices=['present', 'absent'], required=True), + zone_name=dict(type='str', aliases=['zone']), + zone_id=dict(type=provider_information.get_zone_id_type()), + record=dict(type='str'), + prefix=dict(type='str'), + ttl=dict(type='int', default=provider_information.get_record_default_ttl()), + type=dict(choices=provider_information.get_supported_record_types(), required=True), + value=dict(type='str', required=True), + ), + required_one_of=[ + ('zone_name', 'zone_id'), + ('record', 'prefix'), + ], + mutually_exclusive=[ + ('zone_name', 'zone_id'), + ('record', 'prefix'), + ], + ).merge(create_record_transformation_argspec()) + + +def run_module(module, create_api, provider_information): + option_provider = ModuleOptionProvider(module) + record_converter = RecordConverter(provider_information, option_provider) + record_converter.emit_deprecations(module.deprecate) + + record_in = normalize_dns_name(module.params.get('record')) + prefix_in = module.params.get('prefix') + type_in = module.params.get('type') + try: + # Create API + api = create_api() + + # Get zone information + if module.params.get('zone_name') is not None: + zone_in = normalize_dns_name(module.params.get('zone_name')) + record_in, prefix = get_prefix( + normalized_zone=zone_in, normalized_record=record_in, prefix=prefix_in, provider_information=provider_information) + zone = api.get_zone_with_records_by_name(zone_in, prefix=prefix, record_type=type_in) + if zone is None: + module.fail_json(msg='Zone not found') + zone_id = zone.zone.id + records = zone.records + elif record_in is not None: + zone = api.get_zone_with_records_by_id( + module.params.get('zone_id'), + record_type=type_in, + prefix=provider_information.normalize_prefix(prefix_in) if prefix_in is not None else NOT_PROVIDED, + ) + if zone is None: + module.fail_json(msg='Zone not found') + zone_in = normalize_dns_name(zone.zone.name) + record_in, prefix = get_prefix( + normalized_zone=zone_in, normalized_record=record_in, prefix=prefix_in, provider_information=provider_information) + zone_id = zone.zone.id + records = zone.records + else: + zone_id = module.params.get('zone_id') + prefix = provider_information.normalize_prefix(prefix_in) + records = api.get_zone_records( + zone_id, + record_type=type_in, + prefix=prefix, + ) + if records is None: + module.fail_json(msg='Zone not found') + zone_in = None + record_in = None + + # Find matching records + records = filter_records(records, prefix=prefix) + record_converter.process_multiple_from_api(records) + + # Parse records + value_in = module.params.get('value') + value_in = record_converter.process_value_from_user(type_in, value_in) + + # Compare records + existing_record = None + exact_match = False + ttl_in = module.params.get('ttl') + for record in records: + if record.target == value_in: + existing_record = record + exact_match = record.ttl == ttl_in + break + + before = existing_record.clone() if existing_record else None + after = before + changed = False + + if module.params.get('state') == 'present': + if existing_record is None: + # Create record + record = DNSRecord() + record.prefix = prefix + record.type = type_in + record.ttl = ttl_in + record.target = value_in + api_record = record_converter.clone_to_api(record) + if not module.check_mode: + new_api_record = api.add_record(zone_id, api_record) + record = record_converter.clone_from_api(new_api_record) + after = record + changed = True + elif not exact_match: + # Update record + record = existing_record + record.ttl = ttl_in + api_record = record_converter.clone_to_api(record) + if not module.check_mode: + new_api_record = api.update_record(zone_id, api_record) + record = record_converter.clone_from_api(new_api_record) + after = record + changed = True + else: + if existing_record is not None: + # Delete record + api_record = record_converter.clone_to_api(record) + if not module.check_mode: + api.delete_record(zone_id, api_record) + after = None + changed = True + + # Compose result + result = dict( + changed=changed, + zone_id=zone_id, + ) + if module._diff: + result['diff'] = dict( + before=format_record_for_output(before, record_in, prefix, record_converter=record_converter) if before else {}, + after=format_record_for_output(after, record_in, prefix, record_converter=record_converter) if after else {}, + ) + + module.exit_json(**result) + except DNSConversionError as e: + module.fail_json(msg='Error while converting DNS values: {0}'.format(e.error_message), error=e.error_message, exception=traceback.format_exc()) + except DNSAPIAuthenticationError as e: + module.fail_json(msg='Cannot authenticate: {0}'.format(e), error=to_text(e), exception=traceback.format_exc()) + except DNSAPIError as e: + module.fail_json(msg='Error: {0}'.format(e), error=to_text(e), exception=traceback.format_exc()) diff --git a/ansible_collections/community/dns/plugins/module_utils/module/record_info.py b/ansible_collections/community/dns/plugins/module_utils/module/record_info.py new file mode 100644 index 000000000..f59a8afeb --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/module/record_info.py @@ -0,0 +1,149 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# This module_utils is PRIVATE and should only be used by this collection. Breaking changes can occur any time. + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import traceback + +from ansible.module_utils.common.text.converters import to_text + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ArgumentSpec, + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.base import ( + DNSConversionError, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.converter import ( + RecordConverter, +) + +from ansible_collections.community.dns.plugins.module_utils.options import ( + create_record_transformation_argspec, +) + +from ansible_collections.community.dns.plugins.module_utils.record import ( + format_record_for_output, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, + DNSAPIAuthenticationError, + NOT_PROVIDED, +) + +from ._utils import ( + normalize_dns_name, + get_prefix, +) + + +def create_module_argument_spec(provider_information): + return ArgumentSpec( + argument_spec=dict( + what=dict(type='str', choices=['single_record', 'all_types_for_record', 'all_records'], default='single_record'), + zone_name=dict(type='str', aliases=['zone']), + zone_id=dict(type=provider_information.get_zone_id_type()), + record=dict(type='str'), + prefix=dict(type='str'), + type=dict(type='str', choices=provider_information.get_supported_record_types(), default=None), + ), + required_if=[ + ('what', 'single_record', ['type']), + ('what', 'single_record', ['record', 'prefix'], True), + ('what', 'all_types_for_record', ['record', 'prefix'], True), + ], + required_one_of=[ + ('zone_name', 'zone_id'), + ], + mutually_exclusive=[ + ('zone_name', 'zone_id'), + ('record', 'prefix'), + ], + ).merge(create_record_transformation_argspec()) + + +def run_module(module, create_api, provider_information): + option_provider = ModuleOptionProvider(module) + record_converter = RecordConverter(provider_information, option_provider) + record_converter.emit_deprecations(module.deprecate) + + filter_record_type = NOT_PROVIDED + filter_prefix = NOT_PROVIDED + if module.params.get('what') == 'single_record': + filter_record_type = module.params.get('type') + if module.params.get('prefix') is not None: + filter_prefix = provider_information.normalize_prefix(module.params.get('prefix')) + elif module.params.get('what') == 'all_types_for_record': + if module.params.get('prefix') is not None: + filter_prefix = provider_information.normalize_prefix(module.params.get('prefix')) + + try: + # Create API + api = create_api() + + # Get zone information + if module.params.get('zone_name') is not None: + zone_in = normalize_dns_name(module.params.get('zone_name')) + zone = api.get_zone_with_records_by_name(zone_in, prefix=filter_prefix, record_type=filter_record_type) + if zone is None: + module.fail_json(msg='Zone not found') + else: + zone = api.get_zone_with_records_by_id(module.params.get('zone_id'), prefix=filter_prefix, record_type=filter_record_type) + if zone is None: + module.fail_json(msg='Zone not found') + zone_in = normalize_dns_name(zone.zone.name) + + # Retrieve requested information + records = [] + if module.params.get('what') in ('single_record', 'all_types_for_record'): + check_prefix = True + record_in = normalize_dns_name(module.params.get('record')) + prefix_in = module.params.get('prefix') + record_in, prefix = get_prefix( + normalized_zone=zone_in, normalized_record=record_in, prefix=prefix_in, provider_information=provider_information) + else: + check_prefix = False + prefix = None + + # Find matching records + records = [] + for record in zone.records: + if check_prefix: + if record.prefix != prefix: + continue + records.append(( + (record.prefix + '.' + zone_in) if record.prefix else zone_in, + record, + )) + + # Convert records + only_records = [record for record_name, record in records] + record_converter.process_multiple_from_api(only_records) + record_converter.process_multiple_to_user(only_records) + + # Format output + data = [ + format_record_for_output(record, record_name, prefix=record.prefix) + for record_name, record in records + ] + module.exit_json( + changed=False, + records=data, + zone_id=zone.zone.id, + ) + except DNSConversionError as e: + module.fail_json(msg='Error while converting DNS values: {0}'.format(e.error_message), error=e.error_message, exception=traceback.format_exc()) + except DNSAPIAuthenticationError as e: + module.fail_json(msg='Cannot authenticate: {0}'.format(e), error=to_text(e), exception=traceback.format_exc()) + except DNSAPIError as e: + module.fail_json(msg='Error: {0}'.format(e), error=to_text(e), exception=traceback.format_exc()) diff --git a/ansible_collections/community/dns/plugins/module_utils/module/record_set.py b/ansible_collections/community/dns/plugins/module_utils/module/record_set.py new file mode 100644 index 000000000..d60d6f127 --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/module/record_set.py @@ -0,0 +1,272 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# This module_utils is PRIVATE and should only be used by this collection. Breaking changes can occur any time. + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import traceback + +from ansible.module_utils.common.text.converters import to_text + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ArgumentSpec, + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.base import ( + DNSConversionError, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.converter import ( + RecordConverter, +) + +from ansible_collections.community.dns.plugins.module_utils.options import ( + create_bulk_operations_argspec, + create_record_transformation_argspec, +) + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, + format_records_for_output, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, + DNSAPIAuthenticationError, + NOT_PROVIDED, + filter_records, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_helpers import ( + bulk_apply_changes, +) + +from ._utils import ( + normalize_dns_name, + get_prefix, +) + + +def create_module_argument_spec(provider_information): + return ArgumentSpec( + argument_spec=dict( + state=dict(type='str', choices=['present', 'absent'], required=True), + zone_name=dict(type='str', aliases=['zone']), + zone_id=dict(type=provider_information.get_zone_id_type()), + record=dict(type='str'), + prefix=dict(type='str'), + ttl=dict(type='int', default=provider_information.get_record_default_ttl()), + type=dict(choices=provider_information.get_supported_record_types(), required=True), + value=dict(type='list', elements='str'), + on_existing=dict(type='str', default='replace', choices=['replace', 'keep_and_fail', 'keep_and_warn', 'keep']), + ), + required_one_of=[ + ('zone_name', 'zone_id'), + ('record', 'prefix'), + ], + mutually_exclusive=[ + ('zone_name', 'zone_id'), + ('record', 'prefix'), + ], + required_if=[ + ('state', 'present', ['value']), + ('on_existing', 'keep_and_fail', ['value']), + ('on_existing', 'keep_and_warn', ['value']), + ('on_existing', 'keep', ['value']), + ], + ).merge(create_bulk_operations_argspec(provider_information)).merge(create_record_transformation_argspec()) + + +def run_module(module, create_api, provider_information): + option_provider = ModuleOptionProvider(module) + record_converter = RecordConverter(provider_information, option_provider) + record_converter.emit_deprecations(module.deprecate) + + record_in = normalize_dns_name(module.params.get('record')) + prefix_in = module.params.get('prefix') + type_in = module.params.get('type') + try: + # Create API + api = create_api() + + # Get zone information + if module.params.get('zone_name') is not None: + zone_in = normalize_dns_name(module.params.get('zone_name')) + record_in, prefix = get_prefix( + normalized_zone=zone_in, normalized_record=record_in, prefix=prefix_in, provider_information=provider_information) + zone = api.get_zone_with_records_by_name(zone_in, prefix=prefix, record_type=type_in) + if zone is None: + module.fail_json(msg='Zone not found') + zone_id = zone.zone.id + records = zone.records + elif record_in is not None: + zone = api.get_zone_with_records_by_id( + module.params.get('zone_id'), + record_type=type_in, + prefix=provider_information.normalize_prefix(prefix_in) if prefix_in is not None else NOT_PROVIDED, + ) + if zone is None: + module.fail_json(msg='Zone not found') + zone_in = normalize_dns_name(zone.zone.name) + record_in, prefix = get_prefix( + normalized_zone=zone_in, normalized_record=record_in, prefix=prefix_in, provider_information=provider_information) + zone_id = zone.zone.id + records = zone.records + else: + zone_id = module.params.get('zone_id') + prefix = provider_information.normalize_prefix(prefix_in) + records = api.get_zone_records( + zone_id, + record_type=type_in, + prefix=prefix, + ) + if records is None: + module.fail_json(msg='Zone not found') + zone_in = None + record_in = None + + # Find matching records + records = filter_records(records, prefix=prefix) + record_converter.process_multiple_from_api(records) + + # Parse records + values = [] + value_in = module.params.get('value') or [] + value_in = record_converter.process_values_from_user(type_in, value_in) + values = value_in[:] + + # Compare records + ttl_in = module.params.get('ttl') + mismatch = False + mismatch_records = [] + keep_records = [] + for record in records: + if record.ttl != ttl_in: + mismatch = True + mismatch_records.append(record) + continue + val = record.target + if val in values: + values.remove(val) + keep_records.append(record) + else: + mismatch = True + mismatch_records.append(record) + continue + if values: + mismatch = True + + before = [record.clone() for record in records] + after = keep_records[:] + + # Determine what to do + to_create = [] + to_delete = [] + to_change = [] + on_existing = module.params.get('on_existing') + no_mod = False + if module.params.get('state') == 'present': + if records and mismatch: + # Mismatch: user wants to overwrite? + if on_existing == 'replace': + to_delete.extend(mismatch_records) + elif on_existing == 'keep_and_fail': + module.fail_json(msg="Record already exists with different value. Set on_existing=replace to replace it") + elif on_existing == 'keep_and_warn': + module.warn("Record already exists with different value. Set on_existing=replace to replace it") + no_mod = True + else: # on_existing == 'keep' + no_mod = True + if no_mod: + after = before[:] + else: + for target in values: + if to_delete: + # If there's a record to delete, change it to new record + record = to_delete.pop() + to_change.append(record) + else: + # Otherwise create new record + record = DNSRecord() + to_create.append(record) + record.prefix = prefix + record.type = type_in + record.ttl = ttl_in + record.target = target + after.append(record) + if module.params.get('state') == 'absent': + if mismatch: + # Mismatch: user wants to overwrite? + if on_existing == 'replace': + no_mod = False + elif on_existing == 'keep_and_fail': + module.fail_json(msg="Record already exists with different value. Set on_existing=replace to remove it") + elif on_existing == 'keep_and_warn': + module.warn("Record already exists with different value. Set on_existing=replace to remove it") + no_mod = True + else: # on_existing == 'keep' + no_mod = True + if no_mod: + after = before[:] + else: + to_delete.extend(records) + after = [] + + # Compose result + result = dict( + changed=False, + zone_id=zone_id, + ) + + # Determine whether there's something to do + if to_create or to_delete or to_change: + # Actually do something + records_to_delete = record_converter.clone_multiple_to_api(to_delete) + records_to_change = record_converter.clone_multiple_to_api(to_change) + records_to_create = record_converter.clone_multiple_to_api(to_create) + result['changed'] = True + if not module.check_mode: + dummy, errors, success = bulk_apply_changes( + api, + zone_id=zone_id, + records_to_delete=records_to_delete, + records_to_change=records_to_change, + records_to_create=records_to_create, + provider_information=provider_information, + options=option_provider, + ) + if errors: + if len(errors) == 1: + raise errors[0] + module.fail_json( + msg='Errors: {0}'.format('; '.join([str(e) for e in errors])), + errors=[str(e) for e in errors], + ) + + # Include diff information + if module._diff: + result['diff'] = dict( + before=( + format_records_for_output(sorted(before, key=lambda record: record.target), record_in, prefix, record_converter=record_converter) + if before else dict() + ), + after=( + format_records_for_output(sorted(after, key=lambda record: record.target), record_in, prefix, record_converter=record_converter) + if after else dict() + ), + ) + + module.exit_json(**result) + except DNSConversionError as e: + module.fail_json(msg='Error while converting DNS values: {0}'.format(e.error_message), error=e.error_message, exception=traceback.format_exc()) + except DNSAPIAuthenticationError as e: + module.fail_json(msg='Cannot authenticate: {0}'.format(e), error=to_text(e), exception=traceback.format_exc()) + except DNSAPIError as e: + module.fail_json(msg='Error: {0}'.format(e), error=to_text(e), exception=traceback.format_exc()) diff --git a/ansible_collections/community/dns/plugins/module_utils/module/record_set_info.py b/ansible_collections/community/dns/plugins/module_utils/module/record_set_info.py new file mode 100644 index 000000000..4c4961bc9 --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/module/record_set_info.py @@ -0,0 +1,175 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# This module_utils is PRIVATE and should only be used by this collection. Breaking changes can occur any time. + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import traceback + +from ansible.module_utils.common.text.converters import to_text + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ArgumentSpec, + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.base import ( + DNSConversionError, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.converter import ( + RecordConverter, +) + +from ansible_collections.community.dns.plugins.module_utils.options import ( + create_record_transformation_argspec, +) + +from ansible_collections.community.dns.plugins.module_utils.record import ( + format_records_for_output, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, + DNSAPIAuthenticationError, + NOT_PROVIDED, +) + +from ._utils import ( + normalize_dns_name, + get_prefix, +) + + +def create_module_argument_spec(provider_information): + return ArgumentSpec( + argument_spec=dict( + what=dict(type='str', choices=['single_record', 'all_types_for_record', 'all_records'], default='single_record'), + zone_name=dict(type='str', aliases=['zone']), + zone_id=dict(type=provider_information.get_zone_id_type()), + record=dict(type='str'), + prefix=dict(type='str'), + type=dict(type='str', choices=provider_information.get_supported_record_types(), default=None), + ), + required_if=[ + ('what', 'single_record', ['type']), + ('what', 'single_record', ['record', 'prefix'], True), + ('what', 'all_types_for_record', ['record', 'prefix'], True), + ], + required_one_of=[ + ('zone_name', 'zone_id'), + ], + mutually_exclusive=[ + ('zone_name', 'zone_id'), + ('record', 'prefix'), + ], + ).merge(create_record_transformation_argspec()) + + +def run_module(module, create_api, provider_information): + option_provider = ModuleOptionProvider(module) + record_converter = RecordConverter(provider_information, option_provider) + record_converter.emit_deprecations(module.deprecate) + + filter_record_type = NOT_PROVIDED + filter_prefix = NOT_PROVIDED + if module.params.get('what') == 'single_record': + filter_record_type = module.params.get('type') + if module.params.get('prefix') is not None: + filter_prefix = provider_information.normalize_prefix(module.params.get('prefix')) + elif module.params.get('what') == 'all_types_for_record': + if module.params.get('prefix') is not None: + filter_prefix = provider_information.normalize_prefix(module.params.get('prefix')) + + try: + # Create API + api = create_api() + + # Get zone information + if module.params.get('zone_name') is not None: + zone_in = normalize_dns_name(module.params.get('zone_name')) + zone = api.get_zone_with_records_by_name(zone_in, prefix=filter_prefix, record_type=filter_record_type) + if zone is None: + module.fail_json(msg='Zone not found') + else: + zone = api.get_zone_with_records_by_id(module.params.get('zone_id'), prefix=filter_prefix, record_type=filter_record_type) + if zone is None: + module.fail_json(msg='Zone not found') + zone_in = normalize_dns_name(zone.zone.name) + + # Retrieve requested information + if module.params.get('what') == 'single_record': + # Extract prefix + record_in = normalize_dns_name(module.params.get('record')) + prefix_in = module.params.get('prefix') + record_in, prefix = get_prefix( + normalized_zone=zone_in, normalized_record=record_in, prefix=prefix_in, provider_information=provider_information) + + # Find matching records + records = [] + for record in zone.records: + if record.prefix == prefix: + records.append(record) + + # Convert records + record_converter.process_multiple_from_api(records) + record_converter.process_multiple_to_user(records) + + # Format output + data = format_records_for_output(records, record_in, prefix) if records else {} + module.exit_json( + changed=False, + set=data, + zone_id=zone.zone.id, + ) + else: + # Extract prefix if necessary + if module.params.get('what') == 'all_types_for_record': + check_prefix = True + record_in = normalize_dns_name(module.params.get('record')) + prefix_in = module.params.get('prefix') + record_in, prefix = get_prefix( + normalized_zone=zone_in, normalized_record=record_in, prefix=prefix_in, provider_information=provider_information) + else: + check_prefix = False + prefix = None + + # Find matching records + records = {} + for record in zone.records: + if check_prefix: + if record.prefix != prefix: + continue + key = ((record.prefix + '.' + zone_in) if record.prefix else zone_in, record.type) + record_list = records.get(key) + if record_list is None: + record_list = records[key] = [] + record_list.append(record) + + # Convert records + for record_list in records.values(): + record_converter.process_multiple_from_api(record_list) + record_converter.process_multiple_to_user(record_list) + + # Format output + data = [ + format_records_for_output(record_list, record_name, record_list[0].prefix) + for (record_name, dummy), record_list in sorted(records.items()) + ] + module.exit_json( + changed=False, + sets=data, + zone_id=zone.zone.id, + ) + except DNSConversionError as e: + module.fail_json(msg='Error while converting DNS values: {0}'.format(e.error_message), error=e.error_message, exception=traceback.format_exc()) + except DNSAPIAuthenticationError as e: + module.fail_json(msg='Cannot authenticate: {0}'.format(e), error=to_text(e), exception=traceback.format_exc()) + except DNSAPIError as e: + module.fail_json(msg='Error: {0}'.format(e), error=to_text(e), exception=traceback.format_exc()) diff --git a/ansible_collections/community/dns/plugins/module_utils/module/record_sets.py b/ansible_collections/community/dns/plugins/module_utils/module/record_sets.py new file mode 100644 index 000000000..bc93e32ee --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/module/record_sets.py @@ -0,0 +1,264 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# This module_utils is PRIVATE and should only be used by this collection. Breaking changes can occur any time. + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import traceback + +from ansible.module_utils.common.text.converters import to_text + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ArgumentSpec, + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.base import ( + DNSConversionError, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.converter import ( + RecordConverter, +) + +from ansible_collections.community.dns.plugins.module_utils.options import ( + create_bulk_operations_argspec, + create_record_transformation_argspec, +) + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, + format_records_for_output, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, + DNSAPIAuthenticationError, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_helpers import ( + bulk_apply_changes, +) + +from ._utils import ( + normalize_dns_name, + get_prefix, +) + + +def create_module_argument_spec(provider_information): + return ArgumentSpec( + argument_spec=dict( + zone_name=dict(type='str', aliases=['zone']), + zone_id=dict(type=provider_information.get_zone_id_type()), + prune=dict(type='bool', default=False), + record_sets=dict( + type='list', + elements='dict', + required=True, + aliases=['records'], + options=dict( + record=dict(type='str'), + prefix=dict(type='str'), + ttl=dict(type='int', default=provider_information.get_record_default_ttl()), + type=dict(choices=provider_information.get_supported_record_types(), required=True), + value=dict(type='list', elements='str'), + ignore=dict(type='bool', default=False), + ), + required_if=[('ignore', False, ['value'])], + required_one_of=[('record', 'prefix')], + mutually_exclusive=[('record', 'prefix')], + ), + ), + required_one_of=[ + ('zone_name', 'zone_id'), + ], + mutually_exclusive=[ + ('zone_name', 'zone_id'), + ], + ).merge(create_bulk_operations_argspec(provider_information)).merge(create_record_transformation_argspec()) + + +def run_module(module, create_api, provider_information): + option_provider = ModuleOptionProvider(module) + record_converter = RecordConverter(provider_information, option_provider) + record_converter.emit_deprecations(module.deprecate) + + try: + # Create API + api = create_api() + + # Get zone information + if module.params['zone_name'] is not None: + zone_in = normalize_dns_name(module.params['zone_name']) + zone = api.get_zone_with_records_by_name(zone_in) + if zone is None: + module.fail_json(msg='Zone not found') + zone_id = zone.zone.id + zone_records = zone.records + else: + zone = api.get_zone_with_records_by_id(module.params['zone_id']) + if zone is None: + module.fail_json(msg='Zone not found') + zone_in = normalize_dns_name(zone.zone.name) + zone_id = zone.zone.id + zone_records = zone.records + + record_converter.process_multiple_from_api(zone_records) + + # Process parameters + prune = module.params['prune'] + record_sets = module.params['record_sets'] + record_sets_dict = dict() + for index, record_set in enumerate(record_sets): + record_set = record_set.copy() + record_name = record_set['record'] + prefix = record_set['prefix'] + record_name, prefix = get_prefix( + normalized_zone=zone_in, normalized_record=record_name, prefix=prefix, provider_information=provider_information) + record_set['record'] = record_name + record_set['prefix'] = prefix + if record_set['value']: + record_set['value'] = record_converter.process_values_from_user(record_set['type'], record_set['value']) + key = (prefix, record_set['type']) + if key in record_sets_dict: + module.fail_json(msg='Found multiple sets for record {record} and type {type}: index #{i1} and #{i2}'.format( + record=record_name, + type=record_set['type'], + i1=record_sets_dict[key][0], + i2=index, + )) + record_sets_dict[key] = (index, record_set) + + # Group existing record sets + existing_record_sets = dict() + for record in zone_records: + key = (record.prefix, record.type) + if key not in existing_record_sets: + existing_record_sets[key] = [] + existing_record_sets[key].append(record) + + # Data required for diff + old_record_sets = dict([(k, [r.clone() for r in v]) for k, v in existing_record_sets.items()]) + new_record_sets = dict([(k, list(v)) for k, v in existing_record_sets.items()]) + + # Create action lists + to_create = [] + to_delete = [] + to_change = [] + for (prefix, record_type), (dummy, record_set) in record_sets_dict.items(): + key = (prefix, record_type) + if key not in new_record_sets: + new_record_sets[key] = [] + existing_recs = existing_record_sets.get(key, []) + existing_record_sets[key] = [] + new_recs = new_record_sets[key] + + if record_set['ignore']: + continue + + mismatch_recs = [] + keep_record_sets = [] + values = list(record_set['value']) + for record in existing_recs: + if record.ttl != record_set['ttl']: + mismatch_recs.append(record) + new_recs.remove(record) + continue + if record.target in values: + values.remove(record.target) + keep_record_sets.append(record) + else: + mismatch_recs.append(record) + new_recs.remove(record) + + for target in values: + if mismatch_recs: + record = mismatch_recs.pop() + to_change.append(record) + else: + # Otherwise create new record + record = DNSRecord() + to_create.append(record) + record.prefix = prefix + record.type = record_type + record.ttl = record_set['ttl'] + record.target = target + new_recs.append(record) + + to_delete.extend(mismatch_recs) + + # If pruning, remove superfluous record sets + if prune: + for key, record_set in existing_record_sets.items(): + to_delete.extend(record_set) + for record in record_set: + new_record_sets[key].remove(record) + + # Compose result + result = dict( + changed=False, + zone_id=zone_id, + ) + + # Apply changes + if to_create or to_delete or to_change: + records_to_delete = record_converter.clone_multiple_to_api(to_delete) + records_to_change = record_converter.clone_multiple_to_api(to_change) + records_to_create = record_converter.clone_multiple_to_api(to_create) + result['changed'] = True + if not module.check_mode: + dummy, errors, success = bulk_apply_changes( + api, + zone_id=zone_id, + records_to_delete=records_to_delete, + records_to_change=records_to_change, + records_to_create=records_to_create, + provider_information=provider_information, + options=option_provider, + ) + if errors: + if len(errors) == 1: + raise errors[0] + module.fail_json( + msg='Errors: {0}'.format('; '.join([str(e) for e in errors])), + errors=[str(e) for e in errors], + ) + + # Include diff information + if module._diff: + def sort_items(dictionary): + items = [ + (zone_in if prefix is None else (prefix + '.' + zone_in), type, prefix, record_set) + for (prefix, type), record_set in dictionary.items() if len(record_set) > 0 + ] + return sorted(items) + + result['diff'] = dict( + before=dict( + record_sets=[ + format_records_for_output(record_set, record_name, prefix, record_converter=record_converter) + for record_name, type, prefix, record_set in sort_items(old_record_sets) + ], + ), + after=dict( + record_sets=[ + format_records_for_output(record_set, record_name, prefix, record_converter=record_converter) + for record_name, type, prefix, record_set in sort_items(new_record_sets) + ], + ), + ) + + module.exit_json(**result) + except DNSConversionError as e: + module.fail_json(msg='Error while converting DNS values: {0}'.format(e.error_message), error=e.error_message, exception=traceback.format_exc()) + except DNSAPIAuthenticationError as e: + module.fail_json(msg='Cannot authenticate: {0}'.format(e), error=to_text(e), exception=traceback.format_exc()) + except DNSAPIError as e: + module.fail_json(msg='Error: {0}'.format(e), error=to_text(e), exception=traceback.format_exc()) diff --git a/ansible_collections/community/dns/plugins/module_utils/module/zone_info.py b/ansible_collections/community/dns/plugins/module_utils/module/zone_info.py new file mode 100644 index 000000000..00ea2af8a --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/module/zone_info.py @@ -0,0 +1,71 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# This module_utils is PRIVATE and should only be used by this collection. Breaking changes can occur any time. + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import traceback + +from ansible.module_utils.common.text.converters import to_text + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ArgumentSpec, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, + DNSAPIAuthenticationError, +) + +from ._utils import ( + normalize_dns_name, +) + + +def create_module_argument_spec(provider_information): + return ArgumentSpec( + argument_spec=dict( + zone_name=dict(type='str', aliases=['zone']), + zone_id=dict(type=provider_information.get_zone_id_type()), + ), + required_one_of=[ + ('zone_name', 'zone_id'), + ], + mutually_exclusive=[ + ('zone_name', 'zone_id'), + ], + ) + + +def run_module(module, create_api, provider_information): + try: + # Create API + api = create_api() + + # Get zone information + if module.params.get('zone_name') is not None: + zone_id = normalize_dns_name(module.params.get('zone_name')) + zone = api.get_zone_by_name(zone_id) + if zone is None: + module.fail_json(msg='Zone not found') + else: + zone = api.get_zone_by_id(module.params.get('zone_id')) + if zone is None: + module.fail_json(msg='Zone not found') + + module.exit_json( + changed=False, + zone_name=zone.name, + zone_id=zone.id, + zone_info=zone.info, + ) + except DNSAPIAuthenticationError as e: + module.fail_json(msg='Cannot authenticate: {0}'.format(e), error=to_text(e), exception=traceback.format_exc()) + except DNSAPIError as e: + module.fail_json(msg='Error: {0}'.format(e), error=to_text(e), exception=traceback.format_exc()) diff --git a/ansible_collections/community/dns/plugins/module_utils/names.py b/ansible_collections/community/dns/plugins/module_utils/names.py new file mode 100644 index 000000000..1c65630f6 --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/names.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import re + +from ansible.module_utils.common.text.converters import to_text + + +_ASCII_PRINTABLE_MATCHER = re.compile(r'^[\x20-\x7e]*$') + + +def is_ascii_label(domain): + ''' + Check whether domain name has only ASCII labels. + ''' + return _ASCII_PRINTABLE_MATCHER.match(domain) is not None + + +class InvalidDomainName(Exception): + ''' + The provided domain name is not valid. + ''' + pass + + +def split_into_labels(domain): + ''' + Split domain name to a list of labels. Start with the top-most label. + + Returns a list of labels and a tail, which is either ``''`` or ``'.'``. + Raises ``InvalidDomainName`` if the domain name is not valid. + ''' + result = [] + index = len(domain) + tail = '' + if domain.endswith('.'): + index -= 1 + tail = '.' + if index > 0: + while index >= 0: + next_index = domain.rfind('.', 0, index) + label = domain[next_index + 1:index] + if label == '' or label[0] == '-' or label[-1] == '-' or len(label) > 63: + raise InvalidDomainName(domain) + result.append(label) + index = next_index + return result, tail + + +def join_labels(labels, tail=''): + ''' + Combines the result of split_into_labels() back into a domain name. + ''' + return '.'.join(reversed(labels)) + tail + + +def normalize_label(label): + ''' + Normalize a domain label. Returns a lower-case ASCII label. + + If a ulabel is provided, it is converted to an alabel. + ''' + if not is_ascii_label(label): + # Convert ulabel to alabel + label = to_text(b'xn--' + to_text(label).encode('punycode')) + # Always convert to lower-case + return label.lower() diff --git a/ansible_collections/community/dns/plugins/module_utils/options.py b/ansible_collections/community/dns/plugins/module_utils/options.py new file mode 100644 index 000000000..3906b4b6c --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/options.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ArgumentSpec, +) + + +def create_bulk_operations_argspec(provider_information): + """ + If the provider supports bulk operations, return an ArgumentSpec object with appropriate + options. Otherwise return an empty one. + """ + if not provider_information.supports_bulk_actions(): + return ArgumentSpec() + + return ArgumentSpec( + argument_spec=dict( + bulk_operation_threshold=dict(type='int', default=2), + ), + ) + + +def create_record_transformation_argspec(): + return ArgumentSpec( + argument_spec=dict( + txt_transformation=dict(type='str', default='unquoted', choices=['api', 'quoted', 'unquoted']), + txt_character_encoding=dict(type='str', choices=['decimal', 'octal']), + ), + ) diff --git a/ansible_collections/community/dns/plugins/module_utils/provider.py b/ansible_collections/community/dns/plugins/module_utils/provider.py new file mode 100644 index 000000000..56282d9c9 --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/provider.py @@ -0,0 +1,109 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import abc + +from ansible.module_utils import six + +from ansible.module_utils.common.validation import ( + check_type_str, + check_type_list, + check_type_dict, + check_type_bool, + check_type_int, + check_type_float, +) + + +def ensure_type(value, type_name): + if type_name == 'str': + return check_type_str(value) + if type_name == 'list': + return check_type_list(value) + if type_name == 'dict': + return check_type_dict(value) + if type_name == 'bool': + return check_type_bool(value) + if type_name == 'int': + return check_type_int(value) + if type_name == 'float': + return check_type_float(value) + return value + + +@six.add_metaclass(abc.ABCMeta) +class ProviderInformation(object): + @abc.abstractmethod + def get_zone_id_type(self): + """ + Return the (short) type for zone IDs, like ``'int'`` or ``'str'``. + """ + + @abc.abstractmethod + def get_record_id_type(self): + """ + Return the (short) type for record IDs, like ``'int'`` or ``'str'``. + """ + + @abc.abstractmethod + def get_record_default_ttl(self): + """ + Return the default TTL for records, like 300, 3600 or None. + None means that some other TTL (usually from the zone) will be used. + """ + + @abc.abstractmethod + def get_supported_record_types(self): + """ + Return a list of supported record types. + """ + + def normalize_prefix(self, prefix): + """ + Given a prefix (string or None), return its normalized form. + + The result should always be None for the trivial prefix, and a non-zero length DNS name + for a non-trivial prefix. + + If a provider supports other identifiers for the trivial prefix, such as '@', this + function needs to convert them to None as well. + """ + return prefix or None + + def supports_bulk_actions(self): + """ + Return whether the API supports some kind of bulk actions. + """ + return False + + @abc.abstractmethod + def txt_record_handling(self): + """ + Return how the API handles TXT records. + + Returns one of the following strings: + * 'decoded' - the API works with unencoded values + * 'encoded' - the API works with encoded values + * 'encoded-no-char-encoding' - the API works with encoded values, but without character encoding + """ + + def txt_character_encoding(self): + """ + Return how the API handles escape sequences in TXT records. + + Returns one of the following strings: + * 'octal' - the API works with octal escape sequences + * 'decimal' - the API works with decimal escape sequences + + This return value is only used if txt_record_handling returns 'encoded'. + + WARNING: the default return value will change to 'decimal' for community.dns 3.0.0! + """ + return 'octal' diff --git a/ansible_collections/community/dns/plugins/module_utils/record.py b/ansible_collections/community/dns/plugins/module_utils/record.py new file mode 100644 index 000000000..bb1f9ce77 --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/record.py @@ -0,0 +1,100 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +def format_ttl(ttl): + if ttl is None: + return 'default' + sec = ttl % 60 + ttl //= 60 + min = ttl % 60 + ttl //= 60 + h = ttl + result = [] + if h: + result.append('{0}h'.format(h)) + if min: + result.append('{0}m'.format(min)) + if sec: + result.append('{0}s'.format(sec)) + return ' '.join(result) + + +class DNSRecord(object): + def __init__(self): + self.id = None + self.type = None + self.prefix = None + self.target = None + self.ttl = 86400 # 24 * 60 * 60 + self.extra = {} + + def clone(self): + result = DNSRecord() + result.id = self.id + result.type = self.type + result.prefix = self.prefix + result.target = self.target + result.ttl = self.ttl + result.extra = dict(self.extra) + return result + + def __str__(self): + data = [] + if self.id: + data.append('id: {0}'.format(self.id)) + data.append('type: {0}'.format(self.type)) + if self.prefix: + data.append('prefix: "{0}"'.format(self.prefix)) + else: + data.append('prefix: (none)') + data.append('target: "{0}"'.format(self.target)) + data.append('ttl: {0}'.format(format_ttl(self.ttl))) + if self.extra: + data.append('extra: {0}'.format(self.extra)) + return 'DNSRecord(' + ', '.join(data) + ')' + + def __repr__(self): + return self.__str__() + + +def sorted_ttls(ttls): + return sorted(ttls, key=lambda ttl: 0 if ttl is None else ttl) + + +def format_records_for_output(records, record_name, prefix=None, record_converter=None): + ttls = sorted_ttls(set([record.ttl for record in records])) + entry = { + 'prefix': prefix or '', + 'type': min([record.type for record in records]) if records else None, + 'ttl': ttls[0] if len(ttls) > 0 else None, + 'value': [record.target for record in records], + } + if record_converter: + entry['value'] = record_converter.process_values_to_user(entry['type'], entry['value']) + if record_name is not None: + entry['record'] = record_name + if len(ttls) > 1: + entry['ttls'] = ttls + return entry + + +def format_record_for_output(record, record_name, prefix=None, record_converter=None): + entry = { + 'prefix': prefix or '', + 'type': record.type, + 'ttl': record.ttl, + 'value': record.target, + 'extra': record.extra, + } + if record_converter: + entry['value'] = record_converter.process_value_to_user(entry['type'], entry['value']) + if record_name is not None: + entry['record'] = record_name + return entry diff --git a/ansible_collections/community/dns/plugins/module_utils/resolver.py b/ansible_collections/community/dns/plugins/module_utils/resolver.py new file mode 100644 index 000000000..98f1034e0 --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/resolver.py @@ -0,0 +1,213 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import traceback + +from ansible.module_utils.basic import missing_required_lib +from ansible.module_utils.common.text.converters import to_text + +try: + import dns + import dns.exception + import dns.name + import dns.message + import dns.query + import dns.rcode + import dns.rdatatype + import dns.resolver +except ImportError: + DNSPYTHON_IMPORTERROR = traceback.format_exc() +else: + DNSPYTHON_IMPORTERROR = None + + +class ResolverError(Exception): + pass + + +class ResolveDirectlyFromNameServers(object): + def __init__(self, timeout=10, timeout_retries=3, always_ask_default_resolver=True): + self.cache = {} + self.timeout = timeout + self.timeout_retries = timeout_retries + self.default_resolver = dns.resolver.get_default_resolver() + self.default_nameservers = self.default_resolver.nameservers + self.always_ask_default_resolver = always_ask_default_resolver + + def _handle_reponse_errors(self, target, response, nameserver=None, query=None): + rcode = response.rcode() + if rcode == dns.rcode.NOERROR: + return True + if rcode == dns.rcode.NXDOMAIN: + raise dns.resolver.NXDOMAIN(qnames=[target], responses={target: response}) + msg = 'Error %s' % dns.rcode.to_text(rcode) + if nameserver: + msg = '%s while querying %s' % (msg, nameserver) + if query: + msg = '%s with query %s' % (msg, query) + raise ResolverError(msg) + + def _handle_timeout(self, function, *args, **kwargs): + retry = 0 + while True: + try: + return function(*args, **kwargs) + except dns.exception.Timeout as exc: + if retry >= self.timeout_retries: + raise exc + retry += 1 + + def _lookup_ns_names(self, target, nameservers=None, nameserver_ips=None): + if self.always_ask_default_resolver: + nameservers = None + nameserver_ips = self.default_nameservers + if nameservers is None and nameserver_ips is None: + nameserver_ips = self.default_nameservers + if not nameserver_ips and nameservers: + nameserver_ips = self._lookup_address(nameservers[0]) + if not nameserver_ips: + raise ResolverError('Have neither nameservers nor nameserver IPs') + + query = dns.message.make_query(target, dns.rdatatype.NS) + response = self._handle_timeout(dns.query.udp, query, nameserver_ips[0], timeout=self.timeout) + self._handle_reponse_errors(target, response, nameserver=nameserver_ips[0], query='get NS for "%s"' % target) + + cname = None + for rrset in response.answer: + if rrset.rdtype == dns.rdatatype.CNAME: + cname = dns.name.from_text(to_text(rrset[0])) + + new_nameservers = [] + rrsets = list(response.authority) + rrsets.extend(response.answer) + for rrset in rrsets: + if rrset.rdtype == dns.rdatatype.SOA: + # We keep the current nameservers + return None, cname + if rrset.rdtype == dns.rdatatype.NS: + new_nameservers.extend(str(ns_record.target) for ns_record in rrset) + return sorted(set(new_nameservers)) if new_nameservers else None, cname + + def _lookup_address_impl(self, target, rdtype): + try: + try: + answer = self._handle_timeout(self.default_resolver.resolve, target, rdtype=rdtype, lifetime=self.timeout) + except AttributeError: + # For dnspython < 2.0.0 + self.default_resolver.search = False + try: + answer = self._handle_timeout(self.default_resolver.query, target, rdtype=rdtype, lifetime=self.timeout) + except TypeError: + # For dnspython < 1.6.0 + self.default_resolver.lifetime = self.timeout + answer = self._handle_timeout(self.default_resolver.query, target, rdtype=rdtype) + return [str(res) for res in answer.rrset] + except dns.resolver.NoAnswer: + return [] + + def _lookup_address(self, target): + result = self.cache.get((target, 'addr')) + if not result: + result = self._lookup_address_impl(target, dns.rdatatype.A) + result.extend(self._lookup_address_impl(target, dns.rdatatype.AAAA)) + self.cache[(target, 'addr')] = result + return result + + def _do_lookup_ns(self, target): + nameserver_ips = self.default_nameservers + nameservers = None + for i in range(2, len(target.labels) + 1): + target_part = target.split(i)[1] + _nameservers = self.cache.get((str(target_part), 'ns')) + if _nameservers is None: + nameserver_names, cname = self._lookup_ns_names(target_part, nameservers=nameservers, nameserver_ips=nameserver_ips) + if nameserver_names is not None: + nameservers = nameserver_names + + self.cache[(str(target_part), 'ns')] = nameservers + self.cache[(str(target_part), 'cname')] = cname + else: + nameservers = _nameservers + nameserver_ips = None + + return nameservers + + def _lookup_ns(self, target): + result = self.cache.get((str(target), 'ns')) + if not result: + result = self._do_lookup_ns(target) + self.cache[(str(target), 'ns')] = result + return result + + def _get_resolver(self, dnsname, nameservers): + cache_index = ('|'.join([str(dnsname)] + sorted(nameservers)), 'resolver') + resolver = self.cache.get(cache_index) + if resolver is None: + resolver = dns.resolver.Resolver(configure=False) + resolver.timeout = self.timeout + nameserver_ips = set() + for nameserver in nameservers: + nameserver_ips.update(self._lookup_address(nameserver)) + resolver.nameservers = sorted(nameserver_ips) + self.cache[cache_index] = resolver + return resolver + + def resolve_nameservers(self, target, resolve_addresses=False): + nameservers = self._lookup_ns(dns.name.from_unicode(to_text(target))) + if resolve_addresses: + nameserver_ips = set() + for nameserver in nameservers: + nameserver_ips.update(self._lookup_address(nameserver)) + nameservers = list(nameserver_ips) + return sorted(nameservers) + + def resolve(self, target, nxdomain_is_empty=True, **kwargs): + dnsname = dns.name.from_unicode(to_text(target)) + loop_catcher = set() + while True: + try: + nameservers = self._lookup_ns(dnsname) + except dns.resolver.NXDOMAIN: + if nxdomain_is_empty: + return {} + raise + cname = self.cache.get((str(dnsname), 'cname')) + if cname is None: + break + dnsname = cname + if dnsname in loop_catcher: + raise ResolverError('Found CNAME loop starting at {0}'.format(target)) + loop_catcher.add(dnsname) + + results = {} + for nameserver in nameservers: + results[nameserver] = None + resolver = self._get_resolver(dnsname, [nameserver]) + try: + try: + response = self._handle_timeout(resolver.resolve, dnsname, lifetime=self.timeout, **kwargs) + except AttributeError: + # For dnspython < 2.0.0 + resolver.search = False + try: + response = self._handle_timeout(resolver.query, dnsname, lifetime=self.timeout, **kwargs) + except TypeError: + # For dnspython < 1.6.0 + resolver.lifetime = self.timeout + response = self._handle_timeout(resolver.query, dnsname, **kwargs) + if response.rrset: + results[nameserver] = response.rrset + except dns.resolver.NoAnswer: + pass + return results + + +def assert_requirements_present(module): + if DNSPYTHON_IMPORTERROR is not None: + module.fail_json(msg=missing_required_lib('dnspython'), exception=DNSPYTHON_IMPORTERROR) diff --git a/ansible_collections/community/dns/plugins/module_utils/wsdl.py b/ansible_collections/community/dns/plugins/module_utils/wsdl.py new file mode 100644 index 000000000..713803e5b --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/wsdl.py @@ -0,0 +1,308 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2020 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +from ansible.module_utils.common.text.converters import to_native +from ansible.module_utils.six import string_types + +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + +from ansible_collections.community.dns.plugins.module_utils.http import ( + NetworkError, +) + + +class WSDLException(Exception): + pass + + +class WSDLNetworkError(WSDLException): + pass + + +class WSDLError(WSDLException): + def __init__(self, origin, error_code, message): + super(WSDLError, self).__init__('{0} ({1}): {2}'.format(origin, error_code, message)) + self.error_origin = origin + self.error_code = error_code + self.error_message = message + + +class WSDLCodingException(WSDLException): + pass + + +def _split_text_namespace(node, text): + i = text.find(':') + if i < 0: + return text, None + ns = node.nsmap.get(text[:i]) + text = text[i + 1:] + return text, ns + + +_NAMESPACE_ENVELOPE = 'http://schemas.xmlsoap.org/soap/envelope/' +_NAMESPACE_XSI = 'http://www.w3.org/2001/XMLSchema-instance' +_NAMESPACE_XSD = 'http://www.w3.org/2001/XMLSchema' +_NAMESPACE_XML_SOAP = 'http://xml.apache.org/xml-soap' +_NAMESPACE_XML_SOAP_ENCODING = 'http://schemas.xmlsoap.org/soap/encoding/' + + +def _set_type(node, type_value, namespace=None): + if namespace is not None: + type_value = lxml.etree.QName(namespace, type_value) + node.set(lxml.etree.QName(_NAMESPACE_XSI, 'type').text, type_value) + + +def encode_wsdl(node, value): + if value is None: + node.set(lxml.etree.QName(_NAMESPACE_XSI, 'nil').text, 'true') + elif isinstance(value, string_types): + _set_type(node, 'xsd:string') + node.text = value + elif isinstance(value, int): + _set_type(node, 'xsd:int') + node.text = str(value) + elif isinstance(value, bool): + _set_type(node, 'xsd:boolean') + node.text = ('true' if value else 'false') + elif isinstance(value, dict): + _set_type(node, 'Map', _NAMESPACE_XML_SOAP) + for key, val in sorted(value.items()): + child = lxml.etree.Element('item') + ke = lxml.etree.Element('key') + encode_wsdl(ke, key) + child.append(ke) + ve = lxml.etree.Element('value') + encode_wsdl(ve, val) + child.append(ve) + node.append(child) + elif isinstance(value, list): + _set_type(node, 'SOAP-ENC:Array') + for elt in value: + child = lxml.etree.Element('item') + encode_wsdl(child, elt) + node.append(child) + else: + raise WSDLCodingException('Do not know how to encode {0}!'.format(type(value))) + + +def _decode_wsdl_array(result, node, root_ns, ids): + for item in node: + if item.tag != 'item': + raise WSDLCodingException('Invalid child tag "{0}" in map!'.format(item.tag)) + result.append(decode_wsdl(item, root_ns, ids)) + + +def decode_wsdl(node, root_ns, ids): + href = node.get('href') + nil = node.get(lxml.etree.QName(_NAMESPACE_XSI, 'nil')) + id = node.get('id') + if href is not None: + if not href.startswith('#'): + raise WSDLCodingException('Global reference "{0}" not supported!'.format(href)) + href = href[1:] + if href not in ids: + raise WSDLCodingException('ID "{0}" not yet defined!'.format(href)) + result = ids[href] + elif nil == 'true': + result = None + else: + type_with_ns = node.get(lxml.etree.QName(_NAMESPACE_XSI, 'type')) + if type_with_ns is None: + raise WSDLCodingException('Element "{0}" has no "xsi:type" tag!'.format(node)) + type, ns = _split_text_namespace(node, type_with_ns) + if ns is None: + raise WSDLCodingException('Cannot find namespace for "{0}"!'.format(type_with_ns)) + if ns == _NAMESPACE_XSD: + if type == 'boolean': + if node.text == 'true': + result = True + elif node.text == 'false': + result = False + else: + raise WSDLCodingException('Invalid value for boolean: "{0}"'.format(node.text)) + elif type == 'int': + result = int(node.text) + elif type == 'string': + result = node.text + else: + raise WSDLCodingException('Unknown XSD type "{0}"!'.format(type)) + elif ns == _NAMESPACE_XML_SOAP: + if type == 'Map': + result = dict() + if id is not None: + ids[id] = result + for item in node: + if item.tag != 'item': + raise WSDLCodingException('Invalid child tag "{0}" in map!'.format(item.tag)) + key = item.find('key') + if key is None: + raise WSDLCodingException('Cannot find key for "{0}"!'.format(item)) + key = decode_wsdl(key, root_ns, ids) + value = item.find('value') + if value is None: + raise WSDLCodingException('Cannot find value for "{0}"!'.format(item)) + value = decode_wsdl(value, root_ns, ids) + result[key] = value + return result + else: + raise WSDLCodingException('Unknown XSD type "{0}"!'.format(type)) + elif ns == _NAMESPACE_XML_SOAP_ENCODING: + if type == 'Array': + result = [] + if id is not None: + ids[id] = result + _decode_wsdl_array(result, node, root_ns, ids) + else: + raise WSDLCodingException('Unknown XSD type "{0}"!'.format(type)) + elif ns == root_ns: + array_type = node.get(lxml.etree.QName(_NAMESPACE_XML_SOAP_ENCODING, 'arrayType')) + if array_type is not None: + result = [] + if id is not None: + ids[id] = result + _decode_wsdl_array(result, node, root_ns, ids) + else: + result = dict() + if id is not None: + ids[id] = result + for item in node: + result[item.tag] = decode_wsdl(item, root_ns, ids) + else: + raise WSDLCodingException('Unknown type namespace "{0}" (with type "{1}")!'.format(ns, type)) + if id is not None: + ids[id] = result + return result + + +class Parser(object): + def _parse(self, result, node, where): + for child in node: + tag = lxml.etree.QName(child.tag) + if tag.namespace != self._api: + raise WSDLCodingException('Cannot interpret {0} item of type "{1}"!'.format(where, tag)) + for res in child.iter('return'): + result[tag.localname] = decode_wsdl(res, self._api, {}) + + def __init__(self, api, root): + self._main_ns = _NAMESPACE_ENVELOPE + self._api = api + self._root = root + for fault in self._root.iter(lxml.etree.QName(self._main_ns, 'Fault').text): + fault_code = fault.find('faultcode') + fault_code_val = None + fault_string = fault.find('faultstring') + origin = 'server' + if fault_code is not None and fault_code.text: + code, code_ns = _split_text_namespace(fault, fault_code.text) + fault_code_val = code + if code_ns == self._main_ns: + origin = code.lower() + if fault_string is not None and fault_string.text: + raise WSDLError(origin, fault_code_val, fault_string.text) + raise WSDLError(origin, fault_code_val, lxml.etree.tostring(fault).decode('utf-8')) + self._header = dict() + self._body = dict() + for header in self._root.iter(lxml.etree.QName(self._main_ns, 'Header').text): + self._parse(self._header, header, 'header') + for body in self._root.iter(lxml.etree.QName(self._main_ns, 'Body').text): + self._parse(self._body, body, 'body') + + def get_header(self, header): + return self._header[header] + + def get_result(self, body): + return self._body[body] + + def __str__(self): + return 'header={0}, body={1}'.format(self._header, self._body) + + def __repr__(self): + return '''<?xml version='1.0' encoding='utf-8'?>''' + '\n' + lxml.etree.tostring(self._root, pretty_print=True).decode('utf-8') + + +class Composer(object): + @staticmethod + def _create(tag, namespace=None, **kwarg): + if namespace: + return lxml.etree.Element(lxml.etree.QName(namespace, tag), **kwarg) + else: + return lxml.etree.Element(tag, **kwarg) + + def __str__(self): + return '''<?xml version='1.0' encoding='utf-8'?>''' + '\n' + lxml.etree.tostring(self._root, pretty_print=True).decode('utf-8') + + def _create_envelope(self, tag, **kwarg): + return self._create(tag, self._main_ns, **kwarg) + + def __init__(self, http_helper, api, namespaces=None): + self._http_helper = http_helper + self._main_ns = _NAMESPACE_ENVELOPE + self._api = api + # Compose basic document + all_namespaces = { + 'SOAP-ENV': _NAMESPACE_ENVELOPE, + 'xsd': _NAMESPACE_XSD, + 'xsi': _NAMESPACE_XSI, + 'ns2': 'auth', + 'SOAP-ENC': _NAMESPACE_XML_SOAP_ENCODING, + } + if namespaces is not None: + all_namespaces.update(namespaces) + self._root = self._create_envelope('Envelope', nsmap=all_namespaces) + self._root.set(lxml.etree.QName(self._main_ns, 'encodingStyle').text, _NAMESPACE_XML_SOAP_ENCODING) + self._header = self._create_envelope('Header') + self._root.append(self._header) + self._body = self._create_envelope('Body') + self._root.append(self._body) + self._command = None + + def add_auth(self, username, password): + auth = self._create('authenticate', 'auth') + user = self._create('UserName') + user.text = username + auth.append(user) + pw = self._create('Password') + pw.text = password + auth.append(pw) + self._header.append(auth) + + def add_simple_command(self, command, **args): + self._command = command + command = self._create(command, self._api) + for arg, value in args.items(): + arg = self._create(arg) + encode_wsdl(arg, value) + command.append(arg) + self._body.append(command) + + def execute(self, debug=False): + payload = b'''<?xml version='1.0' encoding='utf-8'?>''' + b'\n' + lxml.etree.tostring(self._root) + b'\n' + try: + headers = { + 'Content-Type': 'text/xml; charset=utf-8', + 'Content-Length': str(len(payload)), + } + if self._command: + headers['SOAPAction'] = '"{0}#{1}"'.format(self._api, self._command) + result, info = self._http_helper.fetch_url(self._api, data=payload, method='POST', timeout=300, headers=headers) + code = info['status'] + except NetworkError as e: + raise WSDLNetworkError(to_native(e)) + # if debug: + # q.q('Result: {0}, content: {1}'.format(code, result.decode('utf-8'))) + if code < 200 or code >= 300: + Parser(self._api, lxml.etree.fromstring(result)) + raise WSDLError('server', 'Error {0} while executing WSDL command:\n{1}'.format(code, result.decode('utf-8'))) + return Parser(self._api, lxml.etree.fromstring(result)) diff --git a/ansible_collections/community/dns/plugins/module_utils/zone.py b/ansible_collections/community/dns/plugins/module_utils/zone.py new file mode 100644 index 000000000..d71d3ecdf --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/zone.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class DNSZone(object): + def __init__(self, name, info=None): + self.id = None + self.name = name + self.info = info or dict() + + def __str__(self): + data = [] + if self.id is not None: + data.append('id: {0}'.format(self.id)) + data.append('name: {0}'.format(self.name)) + data.append('info: {0}'.format(self.info)) + return 'DNSZone(' + ', '.join(data) + ')' + + def __repr__(self): + return self.__str__() + + +class DNSZoneWithRecords(object): + def __init__(self, zone, records): + self.zone = zone + self.records = records + + def __str__(self): + return '({0}, {1})'.format(self.zone, self.records) + + def __repr__(self): + return 'DNSZoneWithRecords({0!r}, {1!r})'.format(self.zone, self.records) diff --git a/ansible_collections/community/dns/plugins/module_utils/zone_record_api.py b/ansible_collections/community/dns/plugins/module_utils/zone_record_api.py new file mode 100644 index 000000000..c42a5de3b --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/zone_record_api.py @@ -0,0 +1,221 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import abc + +from ansible.module_utils import six + +from ansible_collections.community.dns.plugins.module_utils.zone import ( + DNSZoneWithRecords, +) + + +class DNSAPIError(Exception): + pass + + +class DNSAPIAuthenticationError(DNSAPIError): + pass + + +class NotProvidedType(object): + pass + + +NOT_PROVIDED = NotProvidedType() + + +@six.add_metaclass(abc.ABCMeta) +class ZoneRecordAPI(object): + @abc.abstractmethod + def get_zone_by_name(self, name): + """ + Given a zone name, return the zone contents if found. + + @param name: The zone name (string) + @return The zone information (DNSZone), or None if not found + """ + + @abc.abstractmethod + def get_zone_by_id(self, id): + """ + Given a zone ID, return the zone contents if found. + + @param id: The zone ID + @return The zone information (DNSZone), or None if not found + """ + + def get_zone_with_records_by_name(self, name, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): + """ + Given a zone name, return the zone contents with records if found. + + @param name: The zone name (string) + @param prefix: The prefix to filter for, if provided. Since None is a valid value, + the special constant NOT_PROVIDED indicates that we are not filtering. + @param record_type: The record type to filter for, if provided + @return The zone information with records (DNSZoneWithRecords), or None if not found + """ + zone = self.get_zone_by_name(name) + if zone is None: + return None + return DNSZoneWithRecords(zone, self.get_zone_records(zone.id, prefix=prefix, record_type=record_type)) + + def get_zone_with_records_by_id(self, id, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): + """ + Given a zone ID, return the zone contents with records if found. + + @param id: The zone ID + @param prefix: The prefix to filter for, if provided. Since None is a valid value, + the special constant NOT_PROVIDED indicates that we are not filtering. + @param record_type: The record type to filter for, if provided + @return The zone information with records (DNSZoneWithRecords), or None if not found + """ + zone = self.get_zone_by_id(id) + if zone is None: + return None + return DNSZoneWithRecords(zone, self.get_zone_records(zone.id, prefix=prefix, record_type=record_type)) + + @abc.abstractmethod + def get_zone_records(self, zone_id, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): + """ + Given a zone ID, return a list of records, optionally filtered by the provided criteria. + + @param zone_id: The zone ID + @param prefix: The prefix to filter for, if provided. Since None is a valid value, + the special constant NOT_PROVIDED indicates that we are not filtering. + @param record_type: The record type to filter for, if provided + @return A list of DNSrecord objects, or None if zone was not found + """ + + @abc.abstractmethod + def add_record(self, zone_id, record): + """ + Adds a new record to an existing zone. + + @param zone_id: The zone ID + @param record: The DNS record (DNSRecord) + @return The created DNS record (DNSRecord) + """ + + @abc.abstractmethod + def update_record(self, zone_id, record): + """ + Update a record. + + @param zone_id: The zone ID + @param record: The DNS record (DNSRecord) + @return The DNS record (DNSRecord) + """ + + @abc.abstractmethod + def delete_record(self, zone_id, record): + """ + Delete a record. + + @param zone_id: The zone ID + @param record: The DNS record (DNSRecord) + @return True in case of success (boolean) + """ + + def add_records(self, records_per_zone_id, stop_early_on_errors=True): + """ + Add new records to an existing zone. + + @param records_per_zone_id: Maps a zone ID to a list of DNS records (DNSRecord) + @param stop_early_on_errors: If set to ``True``, try to stop changes after the first error happens. + This might only work on some APIs. + @return A dictionary mapping zone IDs to lists of tuples ``(record, created, failed)``. + Here ``created`` indicates whether the record was created (``True``) or not (``False``). + If it was created, ``record`` contains the record ID and ``failed`` is ``None``. + If it was not created, ``failed`` should be a ``DNSAPIError`` instance indicating why + it was not created. It is possible that the API only creates records if all succeed, + in that case ``failed`` can be ``None`` even though ``created`` is ``False``. + """ + results_per_zone_id = {} + for zone_id, records in records_per_zone_id.items(): + result = [] + results_per_zone_id[zone_id] = result + for record in records: + try: + result.append((self.add_record(zone_id, record), True, None)) + except DNSAPIError as e: + result.append((record, False, e)) + if stop_early_on_errors: + return results_per_zone_id + return results_per_zone_id + + def update_records(self, records_per_zone_id, stop_early_on_errors=True): + """ + Update multiple records. + + @param records_per_zone_id: Maps a zone ID to a list of DNS records (DNSRecord) + @param stop_early_on_errors: If set to ``True``, try to stop changes after the first error happens. + This might only work on some APIs. + @return A dictionary mapping zone IDs to lists of tuples ``(record, updated, failed)``. + Here ``updated`` indicates whether the record was updated (``True``) or not (``False``). + If it was not updated, ``failed`` should be a ``DNSAPIError`` instance. If it was + updated, ``failed`` should be ``None``. It is possible that the API only updates + records if all succeed, in that case ``failed`` can be ``None`` even though + ``updated`` is ``False``. + """ + results_per_zone_id = {} + for zone_id, records in records_per_zone_id.items(): + result = [] + results_per_zone_id[zone_id] = result + for record in records: + try: + result.append((self.update_record(zone_id, record), True, None)) + except DNSAPIError as e: + result.append((record, False, e)) + if stop_early_on_errors: + return results_per_zone_id + return results_per_zone_id + + def delete_records(self, records_per_zone_id, stop_early_on_errors=True): + """ + Delete multiple records. + + @param records_per_zone_id: Maps a zone ID to a list of DNS records (DNSRecord) + @param stop_early_on_errors: If set to ``True``, try to stop changes after the first error happens. + This might only work on some APIs. + @return A dictionary mapping zone IDs to lists of tuples ``(record, deleted, failed)``. + In case ``record`` was deleted or not deleted, ``deleted`` is ``True`` + respectively ``False``, and ``failed`` is ``None``. In case an error happened + while deleting, ``deleted`` is ``False`` and ``failed`` is a ``DNSAPIError`` + instance hopefully providing information on the error. + """ + results_per_zone_id = {} + for zone_id, records in records_per_zone_id.items(): + result = [] + results_per_zone_id[zone_id] = result + for record in records: + try: + result.append((record, self.delete_record(zone_id, record), None)) + except DNSAPIError as e: + result.append((record, False, e)) + if stop_early_on_errors: + return results_per_zone_id + return results_per_zone_id + + +def filter_records(records, prefix=NOT_PROVIDED, record_type=NOT_PROVIDED): + """ + Given a list of records, returns a filtered subset. + + @param prefix: The prefix to filter for, if provided. Since None is a valid value, + the special constant NOT_PROVIDED indicates that we are not filtering. + @param record_type: The record type to filter for, if provided + @return The list of records matching the provided filters. + """ + if prefix is not NOT_PROVIDED: + records = [record for record in records if record.prefix == prefix] + if record_type is not NOT_PROVIDED: + records = [record for record in records if record.type == record_type] + return records diff --git a/ansible_collections/community/dns/plugins/module_utils/zone_record_helpers.py b/ansible_collections/community/dns/plugins/module_utils/zone_record_helpers.py new file mode 100644 index 000000000..57277e29b --- /dev/null +++ b/ansible_collections/community/dns/plugins/module_utils/zone_record_helpers.py @@ -0,0 +1,132 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, +) + + +def bulk_apply_changes(api, + provider_information, + options, + zone_id, + records_to_delete=None, + records_to_change=None, + records_to_create=None, + stop_early_on_errors=True, + ): + """ + Update multiple records. If an operation failed, raise a DNSAPIException. + + @param api: A ZoneRecordAPI instance + @param provider_information: A ProviderInformation object. + @param options: A object compatible with ModuleOptionProvider that gives access to the module/plugin + options. + @param zone_id: Zone ID to apply changes to + @param records_to_delete: Optional list of DNS records to delete (DNSRecord) + @param records_to_change: Optional list of DNS records to change (DNSRecord) + @param records_to_create: Optional list of DNS records to create (DNSRecord) + @param bulk_threshold: Minimum number of changes for using the bulk API instead of the regular API + @param stop_early_on_errors: If set to ``True``, try to stop changes after the first error happens. + This might only work on some APIs. + @return A tuple (changed, errors, success) where ``changed`` is a boolean which indicates whether a + change was made, ``errors`` is a list of ``DNSAPIError`` instances for the errors occured, + and ``success`` is a dictionary with three lists ``success['deleted']``, + ``success['changed']`` and ``success['created']``, which list all records that were deleted, + changed and created, respectively. + """ + records_to_delete = records_to_delete or [] + records_to_change = records_to_change or [] + records_to_create = records_to_create or [] + + has_change = False + errors = [] + + bulk_threshold = 2 + if provider_information.supports_bulk_actions(): + bulk_threshold = options.get_option('bulk_operation_threshold') + + success = { + 'deleted': [], + 'changed': [], + 'created': [], + } + + # Delete records + if len(records_to_delete) >= bulk_threshold: + results = api.delete_records({zone_id: records_to_delete}, stop_early_on_errors=stop_early_on_errors) + result = results.get(zone_id) or [] + for record, deleted, failed in result: + has_change |= deleted + if failed is not None: + errors.append(failed) + if deleted: + success['deleted'].append(record) + if errors and stop_early_on_errors: + return has_change, errors, success + else: + for record in records_to_delete: + try: + deleted = api.delete_record(zone_id, record) + has_change |= deleted + if deleted: + success['deleted'].append(record) + except DNSAPIError as e: + errors.append(e) + if stop_early_on_errors: + return has_change, errors, success + + # Change records + if len(records_to_change) >= bulk_threshold: + results = api.update_records({zone_id: records_to_change}, stop_early_on_errors=stop_early_on_errors) + result = results.get(zone_id) or [] + for record, changed, failed in result: + has_change |= changed + if failed is not None: + errors.append(failed) + if changed: + success['changed'].append(record) + if errors and stop_early_on_errors: + return has_change, errors, success + else: + for record in records_to_change: + try: + record = api.update_record(zone_id, record) + has_change = True + success['changed'].append(record) + except DNSAPIError as e: + errors.append(e) + if stop_early_on_errors: + return has_change, errors, success + + # Create records + if len(records_to_create) >= bulk_threshold: + results = api.add_records({zone_id: records_to_create}, stop_early_on_errors=stop_early_on_errors) + result = results.get(zone_id) or [] + for record, created, failed in result: + has_change |= created + if failed is not None: + errors.append(failed) + if created: + success['created'].append(record) + if errors and stop_early_on_errors: + return has_change, errors, success + else: + for record in records_to_create: + try: + record = api.add_record(zone_id, record) + has_change = True + success['created'].append(record) + except DNSAPIError as e: + errors.append(e) + if stop_early_on_errors: + return has_change, errors, success + + return has_change, errors, success diff --git a/ansible_collections/community/dns/plugins/modules/hetzner_dns_record.py b/ansible_collections/community/dns/plugins/modules/hetzner_dns_record.py new file mode 100644 index 000000000..b17e0842c --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/hetzner_dns_record.py @@ -0,0 +1,118 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: hetzner_dns_record + +short_description: Add or delete a single record in Hetzner DNS service + +version_added: 2.0.0 + +description: + - "Creates and deletes single DNS records in Hetzner DNS service." + - If you do not want to add/remove values, but replace values, you will be interested in + modifying a B(record set) and not a single record. This is in particular important + when working with C(CNAME) and C(SOA) records. + Use the M(community.dns.hetzner_dns_record_set) module for working with record sets. + +extends_documentation_fragment: + - community.dns.hetzner + - community.dns.hetzner.record_default_ttl + - community.dns.hetzner.record_type_choices + - community.dns.hetzner.zone_id_type + - community.dns.module_record + - community.dns.options.record_transformation + - community.dns.attributes + - community.dns.attributes.actiongroup_hetzner + +attributes: + action_group: + version_added: 2.4.0 + check_mode: + support: full + diff_mode: + support: full + +options: + prefix: + aliases: + - name + +author: + - Markus Bergholz (@markuman) <markuman+spambelongstogoogle@gmail.com> + - Felix Fontein (@felixfontein) +''' + +EXAMPLES = ''' +- name: Add a new.foo.com A record + community.dns.hetzner_dns_record: + state: present + zone: foo.com + record: new.foo.com + type: A + ttl: 7200 + value: 1.1.1.1 + hetzner_token: access_token + +- name: Remove a new.foo.com A record + community.dns.hetzner_dns_record: + state: absent + zone_name: foo.com + record: new.foo.com + type: A + ttl: 7200 + value: 2.2.2.2 + hetzner_token: access_token +''' + +RETURN = ''' +zone_id: + description: The ID of the zone. + type: str + returned: success + sample: 23 +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.http import ( + ModuleHTTPHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hetzner.api import ( + create_hetzner_argument_spec, + create_hetzner_api, + create_hetzner_provider_information, +) + +from ansible_collections.community.dns.plugins.module_utils.module.record import ( + create_module_argument_spec, + run_module, +) + + +def main(): + provider_information = create_hetzner_provider_information() + argument_spec = create_hetzner_argument_spec() + argument_spec.merge(create_module_argument_spec(provider_information=provider_information)) + argument_spec.argument_spec['prefix']['aliases'] = ['name'] + argument_spec.argument_spec['prefix']['deprecated_aliases'] = [dict(name='name', version='3.0.0', collection_name='community.dns')] + module = AnsibleModule(supports_check_mode=True, **argument_spec.to_kwargs()) + run_module(module, lambda: create_hetzner_api(ModuleOptionProvider(module), ModuleHTTPHelper(module)), provider_information=provider_information) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/plugins/modules/hetzner_dns_record_info.py b/ansible_collections/community/dns/plugins/modules/hetzner_dns_record_info.py new file mode 100644 index 000000000..9d77a38d5 --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/hetzner_dns_record_info.py @@ -0,0 +1,136 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: hetzner_dns_record_info + +short_description: Retrieve records in Hetzner DNS service + +version_added: 2.0.0 + +description: + - "Retrieves DNS records in Hetzner DNS service." + +extends_documentation_fragment: + - community.dns.hetzner + - community.dns.hetzner.record_type_choices + - community.dns.hetzner.zone_id_type + - community.dns.module_record_info + - community.dns.options.record_transformation + - community.dns.attributes + - community.dns.attributes.actiongroup_hetzner + - community.dns.attributes.info_module + +attributes: + action_group: + version_added: 2.4.0 + +author: + - Markus Bergholz (@markuman) <markuman+spambelongstogoogle@gmail.com> + - Felix Fontein (@felixfontein) +''' + +EXAMPLES = ''' +- name: Retrieve the details for the A records of new.foo.com + community.dns.hetzner_dns_record_info: + zone: foo.com + record: new.foo.com + type: A + hetzner_token: access_token + register: rec + +- name: Print the A records + ansible.builtin.debug: + msg: "{{ rec.records }}" +''' + +RETURN = ''' +records: + description: The list of fetched records. + type: list + elements: dict + returned: success and I(what) is not C(single_record) + contains: + record: + description: The record name. + type: str + sample: sample.example.com + prefix: + description: The record prefix. + type: str + sample: sample + type: + description: The DNS record type. + type: str + sample: A + ttl: + description: + - The TTL. + - Will return C(none) if the zone's default TTL is used. + type: int + sample: 3600 + value: + description: The DNS record's value. + type: str + sample: 1.2.3.4 + extra: + description: Extra information on records. + type: dict + sample: + created: '2021-07-09T11:18:37Z' + modified: '2021-07-09T11:18:37Z' + sample: + - record: sample.example.com + type: A + ttl: 3600 + value: 1.2.3.4 + extra: {} + +zone_id: + description: The ID of the zone. + type: str + returned: success + sample: 23 +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.http import ( + ModuleHTTPHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hetzner.api import ( + create_hetzner_argument_spec, + create_hetzner_api, + create_hetzner_provider_information, +) + +from ansible_collections.community.dns.plugins.module_utils.module.record_info import ( + run_module, + create_module_argument_spec, +) + + +def main(): + provider_information = create_hetzner_provider_information() + argument_spec = create_hetzner_argument_spec() + argument_spec.merge(create_module_argument_spec(provider_information=provider_information)) + module = AnsibleModule(supports_check_mode=True, **argument_spec.to_kwargs()) + run_module(module, lambda: create_hetzner_api(ModuleOptionProvider(module), ModuleHTTPHelper(module)), provider_information=provider_information) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/plugins/modules/hetzner_dns_record_set.py b/ansible_collections/community/dns/plugins/modules/hetzner_dns_record_set.py new file mode 100644 index 000000000..0766e6465 --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/hetzner_dns_record_set.py @@ -0,0 +1,234 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: hetzner_dns_record_set + +short_description: Add or delete record sets in Hetzner DNS service + +version_added: 2.0.0 + +description: + - "Creates and deletes DNS record sets in Hetzner DNS service." + +extends_documentation_fragment: + - community.dns.hetzner + - community.dns.hetzner.record_default_ttl + - community.dns.hetzner.record_type_choices + - community.dns.hetzner.zone_id_type + - community.dns.module_record_set + - community.dns.options.bulk_operations + - community.dns.options.record_transformation + - community.dns.attributes + - community.dns.attributes.actiongroup_hetzner + +attributes: + action_group: + version_added: 2.4.0 + check_mode: + support: full + diff_mode: + support: full + +options: + prefix: + aliases: + - name + +author: + - Markus Bergholz (@markuman) <markuman+spambelongstogoogle@gmail.com> + - Felix Fontein (@felixfontein) +''' + +EXAMPLES = ''' +- name: Add new.foo.com as an A record with 3 IPs + community.dns.hetzner_dns_record_set: + state: present + zone: foo.com + record: new.foo.com + type: A + ttl: 7200 + value: 1.1.1.1,2.2.2.2,3.3.3.3 + hetzner_token: access_token + +- name: Update new.foo.com as an A record with a list of 3 IPs + community.dns.hetzner_dns_record_set: + state: present + zone: foo.com + record: new.foo.com + type: A + ttl: 7200 + value: + - 1.1.1.1 + - 2.2.2.2 + - 3.3.3.3 + hetzner_token: access_token + +- name: Retrieve the details for new.foo.com + community.dns.hetzner_dns_record_set_info: + zone: foo.com + record: new.foo.com + type: A + hetzner_token: access_token + register: rec + +- name: Delete new.foo.com A record using the results from the facts retrieval command + community.dns.hetzner_dns_record_set: + state: absent + zone: foo.com + record: "{{ rec.set.record }}" + ttl: "{{ rec.set.ttl }}" + type: "{{ rec.set.type }}" + value: "{{ rec.set.value }}" + hetzner_token: access_token + +- name: Add an AAAA record + # Note that because there are colons in the value that the IPv6 address must be quoted! + community.dns.hetzner_dns_record_set: + state: present + zone: foo.com + record: localhost.foo.com + type: AAAA + ttl: 7200 + value: "::1" + hetzner_token: access_token + +- name: Add a TXT record + community.dns.hetzner_dns_record_set: + state: present + zone: foo.com + record: localhost.foo.com + type: TXT + ttl: 7200 + value: 'bar' + hetzner_token: access_token + +- name: Remove the TXT record + community.dns.hetzner_dns_record_set: + state: absent + zone: foo.com + record: localhost.foo.com + type: TXT + ttl: 7200 + value: 'bar' + hetzner_token: access_token + +- name: Add a CAA record + community.dns.hetzner_dns_record_set: + state: present + zone: foo.com + record: foo.com + type: CAA + ttl: 3600 + value: + - "128 issue letsencrypt.org" + - "128 iodef mailto:webmaster@foo.com" + hetzner_token: access_token + +- name: Add an MX record + community.dns.hetzner_dns_record_set: + state: present + zone: foo.com + record: foo.com + type: MX + ttl: 3600 + value: + - "10 mail.foo.com" + hetzner_token: access_token + +- name: Add a CNAME record + community.dns.hetzner_dns_record_set: + state: present + zone: bla.foo.com + record: foo.com + type: CNAME + ttl: 3600 + value: + - foo.foo.com + hetzner_token: access_token + +- name: Add a PTR record + community.dns.hetzner_dns_record_set: + state: present + zone: foo.foo.com + record: foo.com + type: PTR + ttl: 3600 + value: + - foo.foo.com + hetzner_token: access_token + +- name: Add an SPF record + community.dns.hetzner_dns_record_set: + state: present + zone: foo.com + record: foo.com + type: SPF + ttl: 3600 + value: + - "v=spf1 a mx ~all" + hetzner_token: access_token + +- name: Add a PTR record + community.dns.hetzner_dns_record_set: + state: present + zone: foo.com + record: foo.com + type: PTR + ttl: 3600 + value: + - "10 100 3333 service.foo.com" + hetzner_token: access_token +''' + +RETURN = ''' +zone_id: + description: The ID of the zone. + type: str + returned: success + sample: 23 +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.http import ( + ModuleHTTPHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hetzner.api import ( + create_hetzner_argument_spec, + create_hetzner_api, + create_hetzner_provider_information, +) + +from ansible_collections.community.dns.plugins.module_utils.module.record_set import ( + create_module_argument_spec, + run_module, +) + + +def main(): + provider_information = create_hetzner_provider_information() + argument_spec = create_hetzner_argument_spec() + argument_spec.merge(create_module_argument_spec(provider_information=provider_information)) + argument_spec.argument_spec['prefix']['aliases'] = ['name'] + argument_spec.argument_spec['prefix']['deprecated_aliases'] = [dict(name='name', version='3.0.0', collection_name='community.dns')] + module = AnsibleModule(supports_check_mode=True, **argument_spec.to_kwargs()) + run_module(module, lambda: create_hetzner_api(ModuleOptionProvider(module), ModuleHTTPHelper(module)), provider_information=provider_information) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/plugins/modules/hetzner_dns_record_set_info.py b/ansible_collections/community/dns/plugins/modules/hetzner_dns_record_set_info.py new file mode 100644 index 000000000..5c70d1fb0 --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/hetzner_dns_record_set_info.py @@ -0,0 +1,199 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: hetzner_dns_record_set_info + +short_description: Retrieve record sets in Hetzner DNS service + +version_added: 2.0.0 + +description: + - "Retrieves DNS record sets in Hetzner DNS service." + +extends_documentation_fragment: + - community.dns.hetzner + - community.dns.hetzner.record_type_choices + - community.dns.hetzner.zone_id_type + - community.dns.module_record_set_info + - community.dns.options.record_transformation + - community.dns.attributes + - community.dns.attributes.actiongroup_hetzner + - community.dns.attributes.info_module + +attributes: + action_group: + version_added: 2.4.0 + +author: + - Markus Bergholz (@markuman) <markuman+spambelongstogoogle@gmail.com> + - Felix Fontein (@felixfontein) +''' + +EXAMPLES = ''' +- name: Retrieve the details for the A records of new.foo.com + community.dns.hetzner_dns_record_set_info: + zone: foo.com + record: new.foo.com + type: A + hetzner_token: access_token + register: rec + +- name: Print the A record set + ansible.builtin.debug: + msg: "{{ rec.set }}" +''' + +RETURN = ''' +set: + description: The fetched record set. Is empty if record set does not exist. + type: dict + returned: success and I(what) is C(single_record) + contains: + record: + description: The record name. + type: str + sample: sample.example.com + prefix: + description: The record prefix. + type: str + sample: sample + version_added: 0.2.0 + type: + description: The DNS record type. + type: str + sample: A + ttl: + description: + - The TTL. + - If there are records in this set with different TTLs, the minimum of the TTLs will be presented here. + - Will return C(none) if the zone's default TTL is used. + type: int + sample: 3600 + ttls: + description: + - If there are records with different TTL values in this set, this will be the list of TTLs appearing + in the records. + - Every distinct TTL will appear once, and the TTLs are in ascending order. + returned: When there is more than one distinct TTL + type: list + elements: int + sample: + - 300 + - 3600 + value: + description: The DNS record set's value. + type: list + elements: str + sample: + - 1.2.3.4 + - 1.2.3.5 + sample: + record: sample.example.com + type: A + ttl: 3600 + value: + - 1.2.3.4 + - 1.2.3.5 + +sets: + description: The list of fetched record sets. + type: list + elements: dict + returned: success and I(what) is not C(single_record) + contains: + record: + description: The record name. + type: str + sample: sample.example.com + prefix: + description: The record prefix. + type: str + sample: sample + version_added: 0.2.0 + type: + description: The DNS record type. + type: str + sample: A + ttl: + description: + - The TTL. + - If there are records in this set with different TTLs, the minimum of the TTLs will be presented here. + - Will return C(none) if the zone's default TTL is used. + type: int + sample: 3600 + ttls: + description: + - If there are records with different TTL values in this set, this will be the list of TTLs appearing + in the records. + - Every distinct TTL will appear once, and the TTLs are in ascending order. + returned: When there is more than one distinct TTL + type: list + elements: int + sample: + - 300 + - 3600 + value: + description: The DNS record set's value. + type: list + elements: str + sample: + - 1.2.3.4 + - 1.2.3.5 + sample: + - record: sample.example.com + type: A + ttl: 3600 + value: + - 1.2.3.4 + - 1.2.3.5 + +zone_id: + description: The ID of the zone. + type: str + returned: success + sample: 23 + version_added: 0.2.0 +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.http import ( + ModuleHTTPHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hetzner.api import ( + create_hetzner_argument_spec, + create_hetzner_api, + create_hetzner_provider_information, +) + +from ansible_collections.community.dns.plugins.module_utils.module.record_set_info import ( + run_module, + create_module_argument_spec, +) + + +def main(): + provider_information = create_hetzner_provider_information() + argument_spec = create_hetzner_argument_spec() + argument_spec.merge(create_module_argument_spec(provider_information=provider_information)) + module = AnsibleModule(supports_check_mode=True, **argument_spec.to_kwargs()) + run_module(module, lambda: create_hetzner_api(ModuleOptionProvider(module), ModuleHTTPHelper(module)), provider_information=provider_information) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/plugins/modules/hetzner_dns_record_sets.py b/ansible_collections/community/dns/plugins/modules/hetzner_dns_record_sets.py new file mode 100644 index 000000000..52857186c --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/hetzner_dns_record_sets.py @@ -0,0 +1,129 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: hetzner_dns_record_sets + +short_description: Bulk synchronize DNS record sets in Hetzner DNS service + +version_added: 2.0.0 + +description: + - Bulk synchronize DNS record sets in Hetzner DNS service. + +extends_documentation_fragment: + - community.dns.hetzner + - community.dns.hetzner.record_type_choices_record_sets_module + - community.dns.hetzner.zone_id_type + - community.dns.module_record_sets + - community.dns.options.bulk_operations + - community.dns.options.record_transformation + - community.dns.attributes + - community.dns.attributes.actiongroup_hetzner + +attributes: + action_group: + version_added: 2.4.0 + check_mode: + support: full + diff_mode: + support: full + +author: + - Markus Bergholz (@markuman) <markuman+spambelongstogoogle@gmail.com> + - Felix Fontein (@felixfontein) + +''' + +EXAMPLES = ''' +- name: Make sure some records exist and have the expected values + community.dns.hetzner_dns_record_sets: + zone: foo.com + records: + - prefix: new + type: A + ttl: 7200 + value: + - 1.1.1.1 + - 2.2.2.2 + - prefix: new + type: AAAA + ttl: 7200 + value: + - "::1" + - record: foo.com + type: TXT + value: + - test + hetzner_token: access_token + +- name: Synchronize DNS zone with a fixed set of records + # If a record exists that is not mentioned here, it will be deleted + community.dns.hetzner_dns_record_sets: + zone_id: 23 + purge: true + records: + - prefix: '' + type: A + value: 127.0.0.1 + - prefix: '' + type: AAAA + value: "::1" + - prefix: '' + type: NS + value: + - ns-1.hoster.com + - ns-2.hoster.com + - ns-3.hoster.com + hetzner_token: access_token +''' + +RETURN = ''' +zone_id: + description: The ID of the zone. + type: str + returned: success + sample: 23 +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.http import ( + ModuleHTTPHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hetzner.api import ( + create_hetzner_argument_spec, + create_hetzner_api, + create_hetzner_provider_information, +) + +from ansible_collections.community.dns.plugins.module_utils.module.record_sets import ( + create_module_argument_spec, + run_module, +) + + +def main(): + provider_information = create_hetzner_provider_information() + argument_spec = create_hetzner_argument_spec() + argument_spec.merge(create_module_argument_spec(provider_information=provider_information)) + module = AnsibleModule(supports_check_mode=True, **argument_spec.to_kwargs()) + run_module(module, lambda: create_hetzner_api(ModuleOptionProvider(module), ModuleHTTPHelper(module)), provider_information=provider_information) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/plugins/modules/hetzner_dns_zone_info.py b/ansible_collections/community/dns/plugins/modules/hetzner_dns_zone_info.py new file mode 100644 index 000000000..44cc88af5 --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/hetzner_dns_zone_info.py @@ -0,0 +1,199 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: hetzner_dns_zone_info + +short_description: Retrieve zone information in Hetzner DNS service + +version_added: 2.0.0 + +description: + - "Retrieves zone information in Hetzner DNS service." + +extends_documentation_fragment: + - community.dns.hetzner + - community.dns.hetzner.zone_id_type + - community.dns.module_zone_info + - community.dns.attributes + - community.dns.attributes.actiongroup_hetzner + - community.dns.attributes.info_module + +attributes: + action_group: + version_added: 2.4.0 + +author: + - Markus Bergholz (@markuman) <markuman+spambelongstogoogle@gmail.com> + - Felix Fontein (@felixfontein) +''' + +EXAMPLES = ''' +- name: Retrieve details for foo.com zone + community.dns.hetzner_dns_zone_info: + zone: foo.com + hetzner_token: access_token + register: rec + +- name: Retrieve details for zone 23 + community.dns.hetzner_dns_zone_info: + zone_id: 23 + hetzner_token: access_token +''' + +RETURN = ''' +zone_name: + description: The name of the zone. + type: int + returned: success + sample: example.com + +zone_id: + description: The ID of the zone. + type: str + returned: success + sample: 23 + +zone_info: + description: + - Extra information returned by the API. + type: dict + returned: success + contains: + created: + description: + - The time when the zone was created. + type: str + sample: "2021-07-15T19:23:58Z" + modified: + description: + - The time the zone was last modified. + type: str + sample: "2021-07-15T19:23:58Z" + legacy_dns_host: + description: + # TODO + - Unknown. + type: str + legacy_ns: + description: + - List of nameservers during import. + type: list + elements: str + ns: + description: + - List of nameservers the zone should have for using Hetzner's DNS. + type: list + elements: str + owner: + description: + - Owner of the zone. + type: str + paused: + description: + # TODO + - Unknown. + type: bool + sample: true + permission: + description: + - Zone's permissions. + type: str + project: + description: + # TODO + - Unknown. + type: str + registrar: + description: + # TODO + - Unknown. + type: str + status: + description: + - Status of the zone. + - Can be one of C(verified), C(failed) and C(pending). + type: str + sample: verified + # choices: + # - verified + # - failed + # - pending + ttl: + description: + - TTL of zone. + type: int + sample: 0 + verified: + description: + - Time when zone was verified. + type: str + sample: "2021-07-15T19:23:58Z" + records_count: + description: + - Number of records associated to this zone. + type: int + sample: 0 + is_secondary_dns: + description: + - Indicates whether the zone is a secondary DNS zone. + type: bool + sample: true + txt_verification: + description: + - Shape of the TXT record that has to be set to verify a zone. + - If name and token are empty, no TXT record needs to be set. + type: dict + sample: {'name': '', 'token': ''} + contains: + name: + description: + - The TXT record's name. + type: str + token: + description: + - The TXT record's content. + type: str +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.http import ( + ModuleHTTPHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hetzner.api import ( + create_hetzner_argument_spec, + create_hetzner_api, + create_hetzner_provider_information, +) + +from ansible_collections.community.dns.plugins.module_utils.module.zone_info import ( + run_module, + create_module_argument_spec, +) + + +def main(): + provider_information = create_hetzner_provider_information() + argument_spec = create_hetzner_argument_spec() + argument_spec.merge(create_module_argument_spec(provider_information=provider_information)) + module = AnsibleModule(supports_check_mode=True, **argument_spec.to_kwargs()) + run_module(module, lambda: create_hetzner_api(ModuleOptionProvider(module), ModuleHTTPHelper(module)), provider_information=provider_information) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/plugins/modules/hosttech_dns_record.py b/ansible_collections/community/dns/plugins/modules/hosttech_dns_record.py new file mode 100644 index 000000000..0756b6a4c --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/hosttech_dns_record.py @@ -0,0 +1,111 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: hosttech_dns_record + +short_description: Add or delete a single record in Hosttech DNS service + +version_added: 2.0.0 + +description: + - "Creates and deletes single DNS records in Hosttech DNS service." + - This module replaces C(hosttech_dns_record) from community.dns before 2.0.0. + - If you do not want to add/remove values, but replace values, you will be interested in + modifying a B(record set) and not a single record. This is in particular important + when working with C(CNAME) and C(SOA) records. + Use the M(community.dns.hosttech_dns_record_set) module for working with record sets. + +extends_documentation_fragment: + - community.dns.hosttech + - community.dns.hosttech.record_default_ttl + - community.dns.hosttech.record_type_choices + - community.dns.hosttech.zone_id_type + - community.dns.module_record + - community.dns.options.record_transformation + - community.dns.attributes + - community.dns.attributes.actiongroup_hosttech + +attributes: + action_group: + version_added: 2.4.0 + check_mode: + support: full + diff_mode: + support: full + +author: + - Felix Fontein (@felixfontein) +''' + +EXAMPLES = ''' +- name: Add a new.foo.com A record + community.dns.hosttech_dns_record: + state: present + zone: foo.com + record: new.foo.com + type: A + ttl: 7200 + value: 1.1.1.1 + hosttech_token: access_token + +- name: Remove a new.foo.com A record + community.dns.hosttech_dns_record: + state: absent + zone_name: foo.com + record: new.foo.com + type: A + ttl: 7200 + value: 2.2.2.2 + hosttech_token: access_token +''' + +RETURN = ''' +zone_id: + description: The ID of the zone. + type: int + returned: success + sample: 23 +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.http import ( + ModuleHTTPHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech.api import ( + create_hosttech_argument_spec, + create_hosttech_api, + create_hosttech_provider_information, +) + +from ansible_collections.community.dns.plugins.module_utils.module.record import ( + create_module_argument_spec, + run_module, +) + + +def main(): + provider_information = create_hosttech_provider_information() + argument_spec = create_hosttech_argument_spec() + argument_spec.merge(create_module_argument_spec(provider_information=provider_information)) + module = AnsibleModule(supports_check_mode=True, **argument_spec.to_kwargs()) + run_module(module, lambda: create_hosttech_api(ModuleOptionProvider(module), ModuleHTTPHelper(module)), provider_information=provider_information) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/plugins/modules/hosttech_dns_record_info.py b/ansible_collections/community/dns/plugins/modules/hosttech_dns_record_info.py new file mode 100644 index 000000000..84ee8e3b2 --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/hosttech_dns_record_info.py @@ -0,0 +1,133 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: hosttech_dns_record_info + +short_description: Retrieve records in Hosttech DNS service + +version_added: 2.0.0 + +description: + - "Retrieves DNS records in Hosttech DNS service." + +extends_documentation_fragment: + - community.dns.hosttech + - community.dns.hosttech.record_type_choices + - community.dns.hosttech.zone_id_type + - community.dns.module_record_info + - community.dns.options.record_transformation + - community.dns.attributes + - community.dns.attributes.actiongroup_hosttech + - community.dns.attributes.info_module + +attributes: + action_group: + version_added: 2.4.0 + +author: + - Felix Fontein (@felixfontein) +''' + +EXAMPLES = ''' +- name: Retrieve the details for the A records of new.foo.com + community.dns.hosttech_dns_record_info: + zone_name: foo.com + record: new.foo.com + type: A + hosttech_token: access_token + register: rec + +- name: Print the A records + ansible.builtin.debug: + msg: "{{ rec.records }}" +''' + +RETURN = ''' +records: + description: The list of fetched records. + type: list + elements: dict + returned: success and I(what) is not C(single_record) + contains: + record: + description: The record name. + type: str + sample: sample.example.com + prefix: + description: The record prefix. + type: str + sample: sample + type: + description: The DNS record type. + type: str + sample: A + ttl: + description: + - The TTL. + type: int + sample: 3600 + value: + description: The DNS record's value. + type: str + sample: 1.2.3.4 + extra: + description: Extra information on records. + type: dict + sample: + comment: '' + sample: + - record: sample.example.com + type: A + ttl: 3600 + value: 1.2.3.4 + extra: {} + +zone_id: + description: The ID of the zone. + type: int + returned: success + sample: 23 +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.http import ( + ModuleHTTPHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech.api import ( + create_hosttech_argument_spec, + create_hosttech_api, + create_hosttech_provider_information, +) + +from ansible_collections.community.dns.plugins.module_utils.module.record_info import ( + run_module, + create_module_argument_spec, +) + + +def main(): + provider_information = create_hosttech_provider_information() + argument_spec = create_hosttech_argument_spec() + argument_spec.merge(create_module_argument_spec(provider_information=provider_information)) + module = AnsibleModule(supports_check_mode=True, **argument_spec.to_kwargs()) + run_module(module, lambda: create_hosttech_api(ModuleOptionProvider(module), ModuleHTTPHelper(module)), provider_information=provider_information) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/plugins/modules/hosttech_dns_record_set.py b/ansible_collections/community/dns/plugins/modules/hosttech_dns_record_set.py new file mode 100644 index 000000000..d16c82ad7 --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/hosttech_dns_record_set.py @@ -0,0 +1,233 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: hosttech_dns_record_set + +short_description: Add or delete record sets in Hosttech DNS service + +version_added: 2.0.0 + +description: + - "Creates and deletes DNS record sets in Hosttech DNS service." + - This module replaces C(hosttech_dns_record) from community.dns before 2.0.0. + +extends_documentation_fragment: + - community.dns.hosttech + - community.dns.hosttech.record_default_ttl + - community.dns.hosttech.record_type_choices + - community.dns.hosttech.zone_id_type + - community.dns.module_record_set + - community.dns.options.record_transformation + - community.dns.attributes + - community.dns.attributes.actiongroup_hosttech + +attributes: + action_group: + version_added: 2.4.0 + check_mode: + support: full + diff_mode: + support: full + +author: + - Felix Fontein (@felixfontein) +''' + +EXAMPLES = ''' +- name: Add new.foo.com as an A record with 3 IPs + community.dns.hosttech_dns_record_set: + state: present + zone_name: foo.com + record: new.foo.com + type: A + ttl: 7200 + value: 1.1.1.1,2.2.2.2,3.3.3.3 + hosttech_token: access_token + +- name: Update new.foo.com as an A record with a list of 3 IPs + community.dns.hosttech_dns_record_set: + state: present + zone_name: foo.com + record: new.foo.com + type: A + ttl: 7200 + value: + - 1.1.1.1 + - 2.2.2.2 + - 3.3.3.3 + hosttech_token: access_token + +- name: Retrieve the details for new.foo.com + community.dns.hosttech_dns_record_set_info: + zone_name: foo.com + record: new.foo.com + type: A + hosttech_username: foo + hosttech_password: bar + register: rec + +- name: Delete new.foo.com A record using the results from the facts retrieval command + community.dns.hosttech_dns_record_set: + state: absent + zone_name: foo.com + record: "{{ rec.set.record }}" + ttl: "{{ rec.set.ttl }}" + type: "{{ rec.set.type }}" + value: "{{ rec.set.value }}" + hosttech_username: foo + hosttech_password: bar + +- name: Add an AAAA record + # Note that because there are colons in the value that the IPv6 address must be quoted! + community.dns.hosttech_dns_record_set: + state: present + zone_name: foo.com + record: localhost.foo.com + type: AAAA + ttl: 7200 + value: "::1" + hosttech_token: access_token + +- name: Add a TXT record + community.dns.hosttech_dns_record_set: + state: present + zone_name: foo.com + record: localhost.foo.com + type: TXT + ttl: 7200 + value: 'bar' + hosttech_username: foo + hosttech_password: bar + +- name: Remove the TXT record + community.dns.hosttech_dns_record_set: + state: absent + zone_name: foo.com + record: localhost.foo.com + type: TXT + ttl: 7200 + value: 'bar' + hosttech_username: foo + hosttech_password: bar + +- name: Add a CAA record + community.dns.hosttech_dns_record_set: + state: present + zone_name: foo.com + record: foo.com + type: CAA + ttl: 3600 + value: + - "128 issue letsencrypt.org" + - "128 iodef mailto:webmaster@foo.com" + hosttech_token: access_token + +- name: Add an MX record + community.dns.hosttech_dns_record_set: + state: present + zone_name: foo.com + record: foo.com + type: MX + ttl: 3600 + value: + - "10 mail.foo.com" + hosttech_token: access_token + +- name: Add a CNAME record + community.dns.hosttech_dns_record_set: + state: present + zone_name: bla.foo.com + record: foo.com + type: CNAME + ttl: 3600 + value: + - foo.foo.com + hosttech_username: foo + hosttech_password: bar + +- name: Add a PTR record + community.dns.hosttech_dns_record_set: + state: present + zone_name: foo.foo.com + record: foo.com + type: PTR + ttl: 3600 + value: + - foo.foo.com + hosttech_token: access_token + +- name: Add an SPF record + community.dns.hosttech_dns_record_set: + state: present + zone_name: foo.com + record: foo.com + type: SPF + ttl: 3600 + value: + - "v=spf1 a mx ~all" + hosttech_username: foo + hosttech_password: bar + +- name: Add a PTR record + community.dns.hosttech_dns_record_set: + state: present + zone_name: foo.com + record: foo.com + type: PTR + ttl: 3600 + value: + - "10 100 3333 service.foo.com" + hosttech_token: access_token +''' + +RETURN = ''' +zone_id: + description: The ID of the zone. + type: int + returned: success + sample: 23 + version_added: 0.2.0 +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.http import ( + ModuleHTTPHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech.api import ( + create_hosttech_argument_spec, + create_hosttech_api, + create_hosttech_provider_information, +) + +from ansible_collections.community.dns.plugins.module_utils.module.record_set import ( + create_module_argument_spec, + run_module, +) + + +def main(): + provider_information = create_hosttech_provider_information() + argument_spec = create_hosttech_argument_spec() + argument_spec.merge(create_module_argument_spec(provider_information=provider_information)) + module = AnsibleModule(supports_check_mode=True, **argument_spec.to_kwargs()) + run_module(module, lambda: create_hosttech_api(ModuleOptionProvider(module), ModuleHTTPHelper(module)), provider_information=provider_information) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/plugins/modules/hosttech_dns_record_set_info.py b/ansible_collections/community/dns/plugins/modules/hosttech_dns_record_set_info.py new file mode 100644 index 000000000..5b7c576b8 --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/hosttech_dns_record_set_info.py @@ -0,0 +1,198 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: hosttech_dns_record_set_info + +short_description: Retrieve record sets in Hosttech DNS service + +version_added: 0.1.0 + +description: + - "Retrieves DNS record sets in Hosttech DNS service." + - This module was renamed from C(community.dns.hosttech_dns_record_info) to C(community.dns.hosttech_dns_record_set_info) + in community.dns 2.0.0. + +extends_documentation_fragment: + - community.dns.hosttech + - community.dns.hosttech.record_type_choices + - community.dns.hosttech.zone_id_type + - community.dns.module_record_set_info + - community.dns.options.record_transformation + - community.dns.attributes + - community.dns.attributes.actiongroup_hosttech + - community.dns.attributes.info_module + +attributes: + action_group: + version_added: 2.4.0 + +author: + - Felix Fontein (@felixfontein) +''' + +EXAMPLES = ''' +- name: Retrieve the details for the A records of new.foo.com + community.dns.hosttech_dns_record_set_info: + zone_name: foo.com + record: new.foo.com + type: A + hosttech_token: access_token + register: rec + +- name: Print the A record set + ansible.builtin.debug: + msg: "{{ rec.set }}" +''' + +RETURN = ''' +set: + description: The fetched record set. Is empty if record set does not exist. + type: dict + returned: success and I(what) is C(single_record) + contains: + record: + description: The record name. + type: str + sample: sample.example.com + prefix: + description: The record prefix. + type: str + sample: sample + version_added: 0.2.0 + type: + description: The DNS record type. + type: str + sample: A + ttl: + description: + - The TTL. + - If there are records in this set with different TTLs, the minimum of the TTLs will be presented here. + type: int + sample: 3600 + ttls: + description: + - If there are records with different TTL values in this set, this will be the list of TTLs appearing + in the records. + - Every distinct TTL will appear once, and the TTLs are in ascending order. + returned: When there is more than one distinct TTL + type: list + elements: int + sample: + - 300 + - 3600 + value: + description: The DNS record set's value. + type: list + elements: str + sample: + - 1.2.3.4 + - 1.2.3.5 + sample: + record: sample.example.com + type: A + ttl: 3600 + value: + - 1.2.3.4 + - 1.2.3.5 + +sets: + description: The list of fetched record sets. + type: list + elements: dict + returned: success and I(what) is not C(single_record) + contains: + record: + description: The record name. + type: str + sample: sample.example.com + prefix: + description: The record prefix. + type: str + sample: sample + version_added: 0.2.0 + type: + description: The DNS record type. + type: str + sample: A + ttl: + description: + - The TTL. + - If there are records in this set with different TTLs, the minimum of the TTLs will be presented here. + type: int + sample: 3600 + ttls: + description: + - If there are records with different TTL values in this set, this will be the list of TTLs appearing + in the records. + - Every distinct TTL will appear once, and the TTLs are in ascending order. + returned: When there is more than one distinct TTL + type: list + elements: int + sample: + - 300 + - 3600 + value: + description: The DNS record set's value. + type: list + elements: str + sample: + - 1.2.3.4 + - 1.2.3.5 + sample: + - record: sample.example.com + type: A + ttl: 3600 + value: + - 1.2.3.4 + - 1.2.3.5 + +zone_id: + description: The ID of the zone. + type: int + returned: success + sample: 23 + version_added: 0.2.0 +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.http import ( + ModuleHTTPHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech.api import ( + create_hosttech_argument_spec, + create_hosttech_api, + create_hosttech_provider_information, +) + +from ansible_collections.community.dns.plugins.module_utils.module.record_set_info import ( + run_module, + create_module_argument_spec, +) + + +def main(): + provider_information = create_hosttech_provider_information() + argument_spec = create_hosttech_argument_spec() + argument_spec.merge(create_module_argument_spec(provider_information=provider_information)) + module = AnsibleModule(supports_check_mode=True, **argument_spec.to_kwargs()) + run_module(module, lambda: create_hosttech_api(ModuleOptionProvider(module), ModuleHTTPHelper(module)), provider_information=provider_information) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/plugins/modules/hosttech_dns_record_sets.py b/ansible_collections/community/dns/plugins/modules/hosttech_dns_record_sets.py new file mode 100644 index 000000000..c985779ca --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/hosttech_dns_record_sets.py @@ -0,0 +1,128 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: hosttech_dns_record_sets + +short_description: Bulk synchronize DNS record sets in Hosttech DNS service + +version_added: 2.0.0 + +description: + - Bulk synchronize DNS record sets in Hosttech DNS service. + - This module replaces C(hosttech_dns_records) from community.dns before 2.0.0. + +extends_documentation_fragment: + - community.dns.hosttech + - community.dns.hosttech.record_type_choices_record_sets_module + - community.dns.hosttech.zone_id_type + - community.dns.module_record_sets + - community.dns.options.record_transformation + - community.dns.attributes + - community.dns.attributes.actiongroup_hosttech + +attributes: + action_group: + version_added: 2.4.0 + check_mode: + support: full + diff_mode: + support: full + +author: + - Felix Fontein (@felixfontein) + +''' + +EXAMPLES = ''' +- name: Make sure some records exist and have the expected values + community.dns.hosttech_dns_record_sets: + zone_name: foo.com + records: + - prefix: new + type: A + ttl: 7200 + value: + - 1.1.1.1 + - 2.2.2.2 + - prefix: new + type: AAAA + ttl: 7200 + value: + - "::1" + - record: foo.com + type: TXT + value: + - test + hosttech_token: access_token + +- name: Synchronize DNS zone with a fixed set of records + # If a record exists that is not mentioned here, it will be deleted + community.dns.hosttech_dns_record_sets: + zone_id: 23 + purge: true + records: + - prefix: '' + type: A + value: 127.0.0.1 + - prefix: '' + type: AAAA + value: "::1" + - prefix: '' + type: NS + value: + - ns-1.hoster.com + - ns-2.hoster.com + - ns-3.hoster.com + hosttech_token: access_token +''' + +RETURN = ''' +zone_id: + description: The ID of the zone. + type: int + returned: success + sample: 23 +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.http import ( + ModuleHTTPHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech.api import ( + create_hosttech_argument_spec, + create_hosttech_api, + create_hosttech_provider_information, +) + +from ansible_collections.community.dns.plugins.module_utils.module.record_sets import ( + create_module_argument_spec, + run_module, +) + + +def main(): + provider_information = create_hosttech_provider_information() + argument_spec = create_hosttech_argument_spec() + argument_spec.merge(create_module_argument_spec(provider_information=provider_information)) + module = AnsibleModule(supports_check_mode=True, **argument_spec.to_kwargs()) + run_module(module, lambda: create_hosttech_api(ModuleOptionProvider(module), ModuleHTTPHelper(module)), provider_information=provider_information) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/plugins/modules/hosttech_dns_records.py b/ansible_collections/community/dns/plugins/modules/hosttech_dns_records.py new file mode 100644 index 000000000..c985779ca --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/hosttech_dns_records.py @@ -0,0 +1,128 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: hosttech_dns_record_sets + +short_description: Bulk synchronize DNS record sets in Hosttech DNS service + +version_added: 2.0.0 + +description: + - Bulk synchronize DNS record sets in Hosttech DNS service. + - This module replaces C(hosttech_dns_records) from community.dns before 2.0.0. + +extends_documentation_fragment: + - community.dns.hosttech + - community.dns.hosttech.record_type_choices_record_sets_module + - community.dns.hosttech.zone_id_type + - community.dns.module_record_sets + - community.dns.options.record_transformation + - community.dns.attributes + - community.dns.attributes.actiongroup_hosttech + +attributes: + action_group: + version_added: 2.4.0 + check_mode: + support: full + diff_mode: + support: full + +author: + - Felix Fontein (@felixfontein) + +''' + +EXAMPLES = ''' +- name: Make sure some records exist and have the expected values + community.dns.hosttech_dns_record_sets: + zone_name: foo.com + records: + - prefix: new + type: A + ttl: 7200 + value: + - 1.1.1.1 + - 2.2.2.2 + - prefix: new + type: AAAA + ttl: 7200 + value: + - "::1" + - record: foo.com + type: TXT + value: + - test + hosttech_token: access_token + +- name: Synchronize DNS zone with a fixed set of records + # If a record exists that is not mentioned here, it will be deleted + community.dns.hosttech_dns_record_sets: + zone_id: 23 + purge: true + records: + - prefix: '' + type: A + value: 127.0.0.1 + - prefix: '' + type: AAAA + value: "::1" + - prefix: '' + type: NS + value: + - ns-1.hoster.com + - ns-2.hoster.com + - ns-3.hoster.com + hosttech_token: access_token +''' + +RETURN = ''' +zone_id: + description: The ID of the zone. + type: int + returned: success + sample: 23 +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.http import ( + ModuleHTTPHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech.api import ( + create_hosttech_argument_spec, + create_hosttech_api, + create_hosttech_provider_information, +) + +from ansible_collections.community.dns.plugins.module_utils.module.record_sets import ( + create_module_argument_spec, + run_module, +) + + +def main(): + provider_information = create_hosttech_provider_information() + argument_spec = create_hosttech_argument_spec() + argument_spec.merge(create_module_argument_spec(provider_information=provider_information)) + module = AnsibleModule(supports_check_mode=True, **argument_spec.to_kwargs()) + run_module(module, lambda: create_hosttech_api(ModuleOptionProvider(module), ModuleHTTPHelper(module)), provider_information=provider_information) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/plugins/modules/hosttech_dns_zone_info.py b/ansible_collections/community/dns/plugins/modules/hosttech_dns_zone_info.py new file mode 100644 index 000000000..6621893a7 --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/hosttech_dns_zone_info.py @@ -0,0 +1,185 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +DOCUMENTATION = ''' +--- +module: hosttech_dns_zone_info + +short_description: Retrieve zone information in Hosttech DNS service + +version_added: 0.2.0 + +description: + - "Retrieves zone information in Hosttech DNS service." + +extends_documentation_fragment: + - community.dns.hosttech + - community.dns.hosttech.zone_id_type + - community.dns.module_zone_info + - community.dns.attributes + - community.dns.attributes.actiongroup_hosttech + - community.dns.attributes.info_module + +attributes: + action_group: + version_added: 2.4.0 + +author: + - Felix Fontein (@felixfontein) +''' + +EXAMPLES = ''' +- name: Retrieve details for foo.com zone + community.dns.hosttech_dns_zone_info: + zone_name: foo.com + hosttech_username: foo + hosttech_password: bar + register: rec + +- name: Retrieve details for zone 23 + community.dns.hosttech_dns_zone_info: + zone_id: 23 + hosttech_token: access_token +''' + +RETURN = ''' +zone_name: + description: The name of the zone. + type: int + returned: success + sample: example.com + +zone_id: + description: The ID of the zone. + type: int + returned: success + sample: 23 + +zone_info: + description: + - Extra information returned by the API. + type: dict + returned: success + version_added: 2.0.0 + sample: + dnssec: true + dnssec_email: test@example.com + ds_records: [] + email: test@example.com + ttl: 3600 + contains: + dnssec: + description: + - Whether DNSSEC is enabled for the zone or not. + type: bool + returned: When I(hosttech_token) has been specified. + dnssec_email: + description: + - The email address contacted when the DNSSEC key is changed. + - Is C(none) if DNSSEC is not enabled. + type: str + returned: When I(hosttech_token) has been specified. + ds_records: + description: + - The DS records. + - See L(Section 5 of RFC 4034,https://datatracker.ietf.org/doc/html/rfc4034#section-5) and + L(Section 2.1 of RFC 4034,https://datatracker.ietf.org/doc/html/rfc4034#section-2.1) for details. + - Is C(none) if DNSSEC is not enabled. + type: list + elements: dict + returned: When I(hosttech_token) has been specified. + contains: + algorithm: + description: + - This value is the algorithm number of the DNSKEY RR referred to by the DS record. + - A list of values can be found in L(Appendix A.1 of RFC 4034,https://datatracker.ietf.org/doc/html/rfc4034#appendix-A.1). + type: int + sample: 8 + digest: + description: + - A digest of the DNSKEY RR record this DS record refers to. + type: str + sample: 012356789ABCDEF0123456789ABCDEF012345678 + digest_type: + description: + - This value identifies the algorithm used to construct the digest. + - A list of values can be found in L(Appendix A.2 of RFC 4034,https://datatracker.ietf.org/doc/html/rfc4034#appendix-A.2). + type: int + sample: 1 + flags: + description: + - The Zone Key flag. See L(Section 2.1.1 of RFC 4034,https://datatracker.ietf.org/doc/html/rfc4034#section-2.1.1) for details. + type: int + sample: 257 + key_tag: + description: + - The Key Tag field lists the key tag of the DNSKEY RR referred to by the DS record. + type: int + sample: 12345 + protocol: + description: + - Must be 3 according to RFC 4034. + type: int + sample: 3 + public_key: + description: + - The public key material. + type: str + sample: >- + MuhdzsQdqEGShwjtJDKZZjdKqUSGluFzTTinpuEeIRzLLcgkwgAPKWFa + eQntNlmcNDeCziGwpdvhJnvKXEMbFcZwsaDIJuWqERxAQNGABWfPlCLh + HQPnbpRPNKipSdBaUhuOubvFvjBpFAwiwSAapRDVsAgKvjXucfXpFfYb + pCundbAXBWhbpHVbqgmGoixXzFSwUsGVYLPpBCiDlLJwzjRKYYaoVYge + kMtKFYUVnWIKbectWkDFdVqXwkKigCUDiuTTJxOBRJRNzGiDNMWBjYSm + bBCAHMaMYaghLbYTwyKXltdHTHwBwtswGNfpnEdSpKFzZJonBZArQfHD + lfceKgmKwEF= + email: + description: + - The zone's DNS contact mail in the SOA record. + type: str + ttl: + description: + - The zone's TTL. + type: int +''' + +from ansible.module_utils.basic import AnsibleModule + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ModuleOptionProvider, +) + +from ansible_collections.community.dns.plugins.module_utils.http import ( + ModuleHTTPHelper, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech.api import ( + create_hosttech_argument_spec, + create_hosttech_api, + create_hosttech_provider_information, +) + +from ansible_collections.community.dns.plugins.module_utils.module.zone_info import ( + run_module, + create_module_argument_spec, +) + + +def main(): + provider_information = create_hosttech_provider_information() + argument_spec = create_hosttech_argument_spec() + argument_spec.merge(create_module_argument_spec(provider_information=provider_information)) + module = AnsibleModule(supports_check_mode=True, **argument_spec.to_kwargs()) + run_module(module, lambda: create_hosttech_api(ModuleOptionProvider(module), ModuleHTTPHelper(module)), provider_information=provider_information) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/plugins/modules/wait_for_txt.py b/ansible_collections/community/dns/plugins/modules/wait_for_txt.py new file mode 100644 index 000000000..1a09c5ed6 --- /dev/null +++ b/ansible_collections/community/dns/plugins/modules/wait_for_txt.py @@ -0,0 +1,335 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + + +DOCUMENTATION = r''' +--- +module: wait_for_txt +short_description: Wait for TXT entries to be available on all authoritative nameservers +version_added: 0.1.0 +description: + - Wait for TXT entries with specific values to show up on B(all) authoritative nameservers for the DNS name. +extends_documentation_fragment: + - community.dns.attributes +attributes: + check_mode: + support: full + details: + - This action does not modify state. + version_added: 2.4.0 + diff_mode: + support: N/A + details: + - This action does not modify state. +author: + - Felix Fontein (@felixfontein) +options: + records: + description: + - A list of DNS names with TXT entries to look out for. + required: true + type: list + elements: dict + suboptions: + name: + description: + - A DNS name, like C(www.example.com). + type: str + required: true + values: + description: + - The TXT values to look for. + type: list + elements: str + required: true + mode: + description: + - Comparison modes for the values in I(values). + - If C(subset), I(values) should be a (not necessarily proper) subset of the TXT values set for + the DNS name. + - If C(superset), I(values) should be a (not necessarily proper) superset of the TXT values set + for the DNS name. + This includes the case that no TXT entries are set. + - If C(superset_not_empty), I(values) should be a (not necessarily proper) superset of the TXT + values set for the DNS name, assuming at least one TXT record is present. + - If C(equals), I(values) should be the same set of strings as the TXT values for the DNS name + (up to order). + - If C(equals_ordered), I(values) should be the same ordered list of strings as the TXT values + for the DNS name. + type: str + default: subset + choices: + - subset + - superset + - superset_not_empty + - equals + - equals_ordered + query_retry: + description: + - Number of retries for DNS query timeouts. + type: int + default: 3 + query_timeout: + description: + - Timeout per DNS query in seconds. + type: float + default: 10 + timeout: + description: + - Global timeout for waiting for all records in seconds. + - If not set, will wait indefinitely. + type: float + max_sleep: + description: + - Maximal amount of seconds to sleep between two rounds of probing the TXT records. + type: float + default: 10 + always_ask_default_resolver: + description: + - When set to C(true) (default), will use the default resolver to find the authoritative nameservers + of a subzone. + - When set to C(false), will use the authoritative nameservers of the parent zone to find the + authoritative nameservers of a subzone. This only makes sense when the nameservers were recently + changed and haven't propagated. + type: bool + default: true +requirements: + - dnspython >= 1.15.0 (maybe older versions also work) +''' + +EXAMPLES = r''' +- name: Wait for a TXT entry to appear + community.dns.wait_for_txt: + records: + # We want that www.example.com has a single TXT record with value 'Hello world!'. + # There should not be any other TXT record for www.example.com. + - name: www.example.com + values: "Hello world!" + mode: equals + # We want that example.com has a specific SPF record set. + # We do not care about other TXT records. + - name: www.example.com + values: "v=spf1 a mx -all" + mode: subset +''' + +RETURN = r''' +records: + description: + - Results on the TXT records queried. + - The entries are in a 1:1 correspondence to the entries of the I(records) parameter, + in exactly the same order. + returned: always + type: list + elements: dict + contains: + name: + description: + - The DNS name this check is for. + returned: always + type: str + sample: example.com + done: + description: + - Whether the check completed. + returned: always + type: bool + sample: false + values: + description: + - For every authoritative nameserver for the DNS name, lists the TXT records retrieved during the last lookup made. + - Once the check completed for all TXT records retrieved, the TXT records for this DNS name are no longer checked. + - If these are multiple TXT entries for a nameserver, the order is as it was received from that nameserver. This + might not be the same order provided in the check. + returned: lookup was done at least once + type: dict + elements: list + sample: + ns1.example.com: + - TXT value 1 + - TXT value 2 + ns2.example.com: + - TXT value 2 + check_count: + description: + - How often the TXT records for this DNS name were checked. + returned: always + type: int + sample: 3 + sample: + - name: example.com + done: true + values: [a, b, c] + check_count: 1 + - name: foo.example.org + done: false + check_count: 0 +completed: + description: + - How many of the checks were completed. + returned: always + type: int + sample: 3 +''' + +import time +import traceback + +try: + from time import monotonic +except ImportError: + from time import clock as monotonic + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.common.text.converters import to_native, to_text + +from ansible_collections.community.dns.plugins.module_utils.resolver import ( + ResolveDirectlyFromNameServers, + ResolverError, + assert_requirements_present, +) + +try: + import dns.exception + import dns.rdatatype +except ImportError: + pass # handled in assert_requirements_present() + + +def lookup(resolver, name): + result = {} + txts = resolver.resolve(name, rdtype=dns.rdatatype.TXT) + for key, txt in txts.items(): + res = [] + if txt is not None: + for data in txt: + line = [] + for str in data.strings: + line.append(to_text(str)) + res.append(u''.join(line)) + result[key] = res + txts[key] = [] + return result + + +def validate_check(record_values, expected_values, comparison_mode): + if comparison_mode == 'subset': + return set(expected_values) <= set(record_values) + + if comparison_mode == 'superset': + return set(expected_values) >= set(record_values) + + if comparison_mode == 'superset_not_empty': + return bool(record_values) and set(expected_values) >= set(record_values) + + if comparison_mode == 'equals': + return sorted(record_values) == sorted(expected_values) + + if comparison_mode == 'equals_ordered': + return record_values == expected_values + + raise Exception('Internal error!') + + +def main(): + module = AnsibleModule( + argument_spec=dict( + records=dict(required=True, type='list', elements='dict', options=dict( + name=dict(required=True, type='str'), + values=dict(required=True, type='list', elements='str'), + mode=dict(type='str', default='subset', choices=['subset', 'superset', 'superset_not_empty', 'equals', 'equals_ordered']), + )), + query_retry=dict(type='int', default=3), + query_timeout=dict(type='float', default=10), + timeout=dict(type='float'), + max_sleep=dict(type='float', default=10), + always_ask_default_resolver=dict(type='bool', default=True), + ), + supports_check_mode=True, + ) + assert_requirements_present(module) + + resolver = ResolveDirectlyFromNameServers( + timeout=module.params['query_timeout'], + timeout_retries=module.params['query_retry'], + always_ask_default_resolver=module.params['always_ask_default_resolver'], + ) + records = module.params['records'] + timeout = module.params['timeout'] + max_sleep = module.params['max_sleep'] + + results = [None] * len(records) + for index in range(len(records)): + results[index] = { + 'name': records[index]['name'], + 'done': False, + 'check_count': 0, + } + finished_checks = 0 + + start_time = monotonic() + try: + step = 0 + while True: + has_timeout = False + if timeout is not None: + expired = monotonic() - start_time + has_timeout = expired > timeout + + done = True + for index, record in enumerate(records): + if results[index]['done']: + continue + txts = lookup(resolver, record['name']) + results[index]['values'] = txts + results[index]['check_count'] += 1 + if txts and all(validate_check(txt, record['values'], record['mode']) for txt in txts.values()): + results[index]['done'] = True + finished_checks += 1 + else: + done = False + + if done: + module.exit_json( + msg='All checks passed', + records=results, + completed=finished_checks) + + if has_timeout: + module.fail_json( + msg='Timeout ({0} out of {1} check(s) passed).'.format(finished_checks, len(records)), + records=results, + completed=finished_checks) + + # Simple quadratic sleep with maximum wait of max_sleep seconds + wait = min(2 + step * 0.5, max_sleep) + if timeout is not None: + # Make sure we do not exceed the timeout by much by waiting + expired = monotonic() - start_time + wait = max(min(wait, timeout - expired + 0.1), 0.1) + + time.sleep(wait) + step += 1 + except ResolverError as e: + module.fail_json( + msg='Unexpected resolving error: {0}'.format(to_native(e)), + records=results, + completed=finished_checks, + exception=traceback.format_exc()) + except dns.exception.DNSException as e: + module.fail_json( + msg='Unexpected DNS error: {0}'.format(to_native(e)), + records=results, + completed=finished_checks, + exception=traceback.format_exc()) + + +if __name__ == "__main__": + main() diff --git a/ansible_collections/community/dns/plugins/plugin_utils/inventory/records.py b/ansible_collections/community/dns/plugins/plugin_utils/inventory/records.py new file mode 100644 index 000000000..461a92b35 --- /dev/null +++ b/ansible_collections/community/dns/plugins/plugin_utils/inventory/records.py @@ -0,0 +1,124 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2021 Felix Fontein +# Copyright (c) 2020 Markus Bergholz <markuman+spambelongstogoogle@gmail.com> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import abc + +from ansible.errors import AnsibleError +from ansible.module_utils import six +from ansible.module_utils.common._collections_compat import Sequence +from ansible.plugins.inventory import BaseInventoryPlugin +from ansible.utils.display import Display +from ansible.template import Templar + +from ansible_collections.community.dns.plugins.module_utils.provider import ( + ensure_type, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, + DNSAPIAuthenticationError, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.base import ( + DNSConversionError, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.converter import ( + RecordConverter, +) + +display = Display() + + +@six.add_metaclass(abc.ABCMeta) +class RecordsInventoryModule(BaseInventoryPlugin): + VALID_ENDINGS = ('dns.yaml', 'dns.yml') + + def __init__(self): + super(RecordsInventoryModule, self).__init__() + + @abc.abstractmethod + def setup_api(self): + """ + This function needs to set up self.provider_information and self.api. + It can indicate errors by raising DNSAPIError. + """ + + def verify_file(self, path): + if super(RecordsInventoryModule, self).verify_file(path): + if path.endswith(self.VALID_ENDINGS): + return True + else: + display.debug("{name} inventory filename must end with {endings}".format( + name=self.NAME, + endings=' or '.join(["'{0}'".format(ending) for ending in self.VALID_ENDINGS]) + )) + return False + + def parse(self, inventory, loader, path, cache=False): + super(RecordsInventoryModule, self).parse(inventory, loader, path, cache) + + self._read_config_data(path) + + self.templar = Templar(loader=loader) + + try: + self.setup_api() + self.record_converter = RecordConverter(self.provider_information, self) + self.record_converter.emit_deprecations(display.deprecated) + + zone_name = self.get_option('zone_name') + if self.templar.is_template(zone_name): + zone_name = self.templar.template(variable=zone_name, disable_lookups=False) + zone_id = self.get_option('zone_id') + if zone_id is not None: + if self.templar.is_template(zone_id): + zone_id = self.templar.template(variable=zone_id, disable_lookups=False) + # For templating, we need to make the zone_id type 'string' or 'raw'. + # This converts the value to its proper type expected by the API. + zone_id_type = self.provider_information.get_record_id_type() + try: + zone_id = ensure_type(zone_id, zone_id_type) + except TypeError as exc: + raise AnsibleError(u'Error while ensuring that zone_id is of type {0}: {1}'.format(zone_id_type, exc)) + + if zone_name is not None: + zone_with_records = self.api.get_zone_with_records_by_name(zone_name) + elif zone_id is not None: + zone_with_records = self.api.get_zone_with_records_by_id(zone_id) + else: + raise AnsibleError('One of zone_name and zone_id must be specified!') + + if zone_with_records is None: + raise AnsibleError('Zone does not exist') + + self.record_converter.process_multiple_from_api(zone_with_records.records) + self.record_converter.process_multiple_to_user(zone_with_records.records) + + except DNSConversionError as e: + raise AnsibleError(u'Error while converting DNS values: {0}'.format(e.error_message)) + except DNSAPIAuthenticationError as e: + raise AnsibleError('Cannot authenticate: %s' % e) + except DNSAPIError as e: + raise AnsibleError('Error: %s' % e) + + filters = self.get_option('filters') + + filter_types = filters.get('type') or ['A', 'AAAA', 'CNAME'] + if not isinstance(filter_types, Sequence) or isinstance(filter_types, six.string_types): + filter_types = [filter_types] + + for record in zone_with_records.records: + if record.type in filter_types: + name = zone_with_records.zone.name + if record.prefix: + name = '%s.%s' % (record.prefix, name) + self.inventory.add_host(name) + self.inventory.set_variable(name, 'ansible_host', record.target) diff --git a/ansible_collections/community/dns/plugins/plugin_utils/public_suffix.py b/ansible_collections/community/dns/plugins/plugin_utils/public_suffix.py new file mode 100644 index 000000000..1388bbd36 --- /dev/null +++ b/ansible_collections/community/dns/plugins/plugin_utils/public_suffix.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import absolute_import, division, print_function +__metaclass__ = type + +import os.path +import re + +from ansible_collections.community.dns.plugins.module_utils.names import InvalidDomainName, split_into_labels, normalize_label + + +_BEGIN_SUBSET_MATCHER = re.compile(r'===BEGIN ([^=]*) DOMAINS===') +_END_SUBSET_MATCHER = re.compile(r'===END ([^=]*) DOMAINS===') + + +class PublicSuffixEntry(object): + ''' + Contains a Public Suffix List entry with metadata. + ''' + + def __init__(self, labels, exception_rule=False, part=None): + self.labels = labels + self.exception_rule = exception_rule + self.part = part + + def matches(self, normalized_labels): + ''' + Match PSL entry with a given normalized list of labels. + ''' + if len(normalized_labels) < len(self.labels): + return False + for i, label in enumerate(self.labels): + normalized_label = normalized_labels[i] + if label not in (normalized_label, '*'): + return False + return True + + +def select_prevailing_rule(rules): + ''' + Given a non-empty set of rules matching a domain name, finds the prevailing rule. + + It uses the algorithm specified on https://publicsuffix.org/list/. + ''' + max_length_rule = rules[0] + max_length = len(max_length_rule.labels) + for rule in rules: + if rule.exception_rule: + return rule + if len(rule.labels) > max_length: + max_length = len(rule.labels) + max_length_rule = rule + return max_length_rule + + +class PublicSuffixList(object): + ''' + Contains the Public Suffix List. + ''' + + def __init__(self, rules): + self._generic_rule = PublicSuffixEntry(('*', )) + self._rules = sorted(rules, key=lambda entry: entry.labels) + + @classmethod + def load(cls, filename): + ''' + Load Public Suffix List from the given filename. + ''' + rules = [] + part = None + with open(filename, 'rb') as content_file: + content = content_file.read().decode('utf-8') + for line in content.splitlines(): + line = line.strip() + if line.startswith('//') or not line: + m = _BEGIN_SUBSET_MATCHER.search(line) + if m: + part = m.group(1).lower() + m = _END_SUBSET_MATCHER.search(line) + if m: + part = None + continue + if part is None: + raise Exception('Internal error: found PSL entry with no part!') + exception_rule = False + if line.startswith('!'): + exception_rule = True + line = line[1:] + if line.startswith('.'): + line = line[1:] + labels = tuple(normalize_label(label) for label in split_into_labels(line)[0]) + rules.append(PublicSuffixEntry(labels, exception_rule=exception_rule, part=part)) + return cls(rules) + + def get_suffix_length_and_rule(self, normalized_labels, icann_only=False): + ''' + Given a list of normalized labels, searches for a matching rule. + + Returns the tuple ``(suffix_length, rule)``. The ``rule`` is never ``None`` + except if ``normalized_labels`` is empty, in which case ``(0, None)`` is returned. + + If ``icann_only`` is set to ``True``, only official ICANN rules are used. If + ``icann_only`` is ``False`` (default), also private rules are used. + ''' + if not normalized_labels: + return 0, None + + # Find matching rules + rules = [] + for rule in self._rules: + if icann_only and rule.part != 'icann': + continue + if rule.matches(normalized_labels): + rules.append(rule) + if not rules: + rules.append(self._generic_rule) + + # Select prevailing rule + rule = select_prevailing_rule(rules) + + # Determine suffix + suffix_length = len(rule.labels) + if rule.exception_rule: + suffix_length -= 1 + + # Return result + return suffix_length, rule + + def get_suffix(self, domain, keep_unknown_suffix=True, normalize_result=False, + icann_only=False): + ''' + Given a domain name, extracts the public suffix. + + If ``keep_unknown_suffix`` is set to ``False``, only suffixes matching explicit + entries from the PSL are returned. If ``keep_unknown_suffix`` is ``True`` (default), + the implicit ``*`` rule is used if no other rule matches. + + If ``normalize_result`` is set to ``True``, the result is re-combined form the + normalized labels. In that case, the result is lower-case ASCII. If + ``normalize_result`` is ``False`` (default), the result ``result`` always satisfies + ``domain.endswith(result)``. + + If ``icann_only`` is set to ``True``, only official ICANN rules are used. If + ``icann_only`` is ``False`` (default), also private rules are used. + ''' + # Split into labels and normalize + try: + labels, tail = split_into_labels(domain) + normalized_labels = [normalize_label(label) for label in labels] + except InvalidDomainName: + return '' + if normalize_result: + labels = normalized_labels + + # Get suffix length + suffix_length, rule = self.get_suffix_length_and_rule(normalized_labels, icann_only=icann_only) + if rule is None: + return '' + if not keep_unknown_suffix and rule is self._generic_rule: + return '' + return '.'.join(reversed(labels[:suffix_length])) + tail + + def get_registrable_domain(self, domain, keep_unknown_suffix=True, only_if_registerable=True, + normalize_result=False, icann_only=False): + ''' + Given a domain name, extracts the registrable domain. This is the public suffix + including the last label before the suffix. + + If ``keep_unknown_suffix`` is set to ``False``, only suffixes matching explicit + entries from the PSL are returned. If no suffix can be found, ``''`` is returned. + If ``keep_unknown_suffix`` is ``True`` (default), the implicit ``*`` rule is used + if no other rule matches. + + If ``only_if_registerable`` is set to ``False``, the public suffix is returned + if there is no label before the suffix. If ``only_if_registerable`` is ``True`` + (default), ``''`` is returned in that case. + + If ``normalize_result`` is set to ``True``, the result is re-combined form the + normalized labels. In that case, the result is lower-case ASCII. If + ``normalize_result`` is ``False`` (default), the result ``result`` always satisfies + ``domain.endswith(result)``. + + If ``icann_only`` is set to ``True``, only official ICANN rules are used. If + ``icann_only`` is ``False`` (default), also private rules are used. + ''' + # Split into labels and normalize + try: + labels, tail = split_into_labels(domain) + normalized_labels = [normalize_label(label) for label in labels] + except InvalidDomainName: + return '' + if normalize_result: + labels = normalized_labels + + # Get suffix length + suffix_length, rule = self.get_suffix_length_and_rule(normalized_labels, icann_only=icann_only) + if rule is None: + return '' + if not keep_unknown_suffix and rule is self._generic_rule: + return '' + if suffix_length < len(labels): + suffix_length += 1 + elif only_if_registerable: + return '' + return '.'.join(reversed(labels[:suffix_length])) + tail + + +# The official Public Suffix List +PUBLIC_SUFFIX_LIST = PublicSuffixList.load(os.path.join(os.path.dirname(__file__), '..', 'public_suffix_list.dat')) diff --git a/ansible_collections/community/dns/plugins/plugin_utils/templated_options.py b/ansible_collections/community/dns/plugins/plugin_utils/templated_options.py new file mode 100644 index 000000000..86bb5c282 --- /dev/null +++ b/ansible_collections/community/dns/plugins/plugin_utils/templated_options.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2022 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +class TemplatedOptionProvider(object): + def __init__(self, plugin, templar): + self.plugin = plugin + self.templar = templar + + def get_option(self, option_name): + value = self.plugin.get_option(option_name) + if self.templar.is_template(value): + value = self.templar.template(variable=value, disable_lookups=False) + return value diff --git a/ansible_collections/community/dns/plugins/public_suffix_list.dat b/ansible_collections/community/dns/plugins/public_suffix_list.dat new file mode 100644 index 000000000..b63242437 --- /dev/null +++ b/ansible_collections/community/dns/plugins/public_suffix_list.dat @@ -0,0 +1,13806 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Please pull this list from, and only from https://publicsuffix.org/list/public_suffix_list.dat, +// rather than any other VCS sites. Pulling from any other URL is not guaranteed to be supported. + +// Instructions on pulling and using this list can be found at https://publicsuffix.org/list/. + +// ===BEGIN ICANN DOMAINS=== + +// ac : http://nic.ac/rules.htm +ac +com.ac +edu.ac +gov.ac +net.ac +mil.ac +org.ac + +// ad : https://en.wikipedia.org/wiki/.ad +ad +nom.ad + +// ae : https://tdra.gov.ae/en/aeda/ae-policies +ae +co.ae +net.ae +org.ae +sch.ae +ac.ae +gov.ae +mil.ae + +// aero : see https://www.information.aero/index.php?id=66 +aero +accident-investigation.aero +accident-prevention.aero +aerobatic.aero +aeroclub.aero +aerodrome.aero +agents.aero +aircraft.aero +airline.aero +airport.aero +air-surveillance.aero +airtraffic.aero +air-traffic-control.aero +ambulance.aero +amusement.aero +association.aero +author.aero +ballooning.aero +broker.aero +caa.aero +cargo.aero +catering.aero +certification.aero +championship.aero +charter.aero +civilaviation.aero +club.aero +conference.aero +consultant.aero +consulting.aero +control.aero +council.aero +crew.aero +design.aero +dgca.aero +educator.aero +emergency.aero +engine.aero +engineer.aero +entertainment.aero +equipment.aero +exchange.aero +express.aero +federation.aero +flight.aero +fuel.aero +gliding.aero +government.aero +groundhandling.aero +group.aero +hanggliding.aero +homebuilt.aero +insurance.aero +journal.aero +journalist.aero +leasing.aero +logistics.aero +magazine.aero +maintenance.aero +media.aero +microlight.aero +modelling.aero +navigation.aero +parachuting.aero +paragliding.aero +passenger-association.aero +pilot.aero +press.aero +production.aero +recreation.aero +repbody.aero +res.aero +research.aero +rotorcraft.aero +safety.aero +scientist.aero +services.aero +show.aero +skydiving.aero +software.aero +student.aero +trader.aero +trading.aero +trainer.aero +union.aero +workinggroup.aero +works.aero + +// af : http://www.nic.af/help.jsp +af +gov.af +com.af +org.af +net.af +edu.af + +// ag : http://www.nic.ag/prices.htm +ag +com.ag +org.ag +net.ag +co.ag +nom.ag + +// ai : http://nic.com.ai/ +ai +off.ai +com.ai +net.ai +org.ai + +// al : http://www.ert.gov.al/ert_alb/faq_det.html?Id=31 +al +com.al +edu.al +gov.al +mil.al +net.al +org.al + +// am : https://www.amnic.net/policy/en/Policy_EN.pdf +am +co.am +com.am +commune.am +net.am +org.am + +// ao : https://en.wikipedia.org/wiki/.ao +// http://www.dns.ao/REGISTR.DOC +ao +ed.ao +gv.ao +og.ao +co.ao +pb.ao +it.ao + +// aq : https://en.wikipedia.org/wiki/.aq +aq + +// ar : https://nic.ar/es/nic-argentina/normativa +ar +bet.ar +com.ar +coop.ar +edu.ar +gob.ar +gov.ar +int.ar +mil.ar +musica.ar +mutual.ar +net.ar +org.ar +senasa.ar +tur.ar + +// arpa : https://en.wikipedia.org/wiki/.arpa +// Confirmed by registry <iana-questions@icann.org> 2008-06-18 +arpa +e164.arpa +in-addr.arpa +ip6.arpa +iris.arpa +uri.arpa +urn.arpa + +// as : https://en.wikipedia.org/wiki/.as +as +gov.as + +// asia : https://en.wikipedia.org/wiki/.asia +asia + +// at : https://en.wikipedia.org/wiki/.at +// Confirmed by registry <it@nic.at> 2008-06-17 +at +ac.at +co.at +gv.at +or.at +sth.ac.at + +// au : https://en.wikipedia.org/wiki/.au +// http://www.auda.org.au/ +au +// 2LDs +com.au +net.au +org.au +edu.au +gov.au +asn.au +id.au +// Historic 2LDs (closed to new registration, but sites still exist) +info.au +conf.au +oz.au +// CGDNs - http://www.cgdn.org.au/ +act.au +nsw.au +nt.au +qld.au +sa.au +tas.au +vic.au +wa.au +// 3LDs +act.edu.au +catholic.edu.au +// eq.edu.au - Removed at the request of the Queensland Department of Education +nsw.edu.au +nt.edu.au +qld.edu.au +sa.edu.au +tas.edu.au +vic.edu.au +wa.edu.au +// act.gov.au Bug 984824 - Removed at request of Greg Tankard +// nsw.gov.au Bug 547985 - Removed at request of <Shae.Donelan@services.nsw.gov.au> +// nt.gov.au Bug 940478 - Removed at request of Greg Connors <Greg.Connors@nt.gov.au> +qld.gov.au +sa.gov.au +tas.gov.au +vic.gov.au +wa.gov.au +// 4LDs +// education.tas.edu.au - Removed at the request of the Department of Education Tasmania +schools.nsw.edu.au + +// aw : https://en.wikipedia.org/wiki/.aw +aw +com.aw + +// ax : https://en.wikipedia.org/wiki/.ax +ax + +// az : https://en.wikipedia.org/wiki/.az +az +com.az +net.az +int.az +gov.az +org.az +edu.az +info.az +pp.az +mil.az +name.az +pro.az +biz.az + +// ba : http://nic.ba/users_data/files/pravilnik_o_registraciji.pdf +ba +com.ba +edu.ba +gov.ba +mil.ba +net.ba +org.ba + +// bb : https://en.wikipedia.org/wiki/.bb +bb +biz.bb +co.bb +com.bb +edu.bb +gov.bb +info.bb +net.bb +org.bb +store.bb +tv.bb + +// bd : https://en.wikipedia.org/wiki/.bd +*.bd + +// be : https://en.wikipedia.org/wiki/.be +// Confirmed by registry <tech@dns.be> 2008-06-08 +be +ac.be + +// bf : https://en.wikipedia.org/wiki/.bf +bf +gov.bf + +// bg : https://en.wikipedia.org/wiki/.bg +// https://www.register.bg/user/static/rules/en/index.html +bg +a.bg +b.bg +c.bg +d.bg +e.bg +f.bg +g.bg +h.bg +i.bg +j.bg +k.bg +l.bg +m.bg +n.bg +o.bg +p.bg +q.bg +r.bg +s.bg +t.bg +u.bg +v.bg +w.bg +x.bg +y.bg +z.bg +0.bg +1.bg +2.bg +3.bg +4.bg +5.bg +6.bg +7.bg +8.bg +9.bg + +// bh : https://en.wikipedia.org/wiki/.bh +bh +com.bh +edu.bh +net.bh +org.bh +gov.bh + +// bi : https://en.wikipedia.org/wiki/.bi +// http://whois.nic.bi/ +bi +co.bi +com.bi +edu.bi +or.bi +org.bi + +// biz : https://en.wikipedia.org/wiki/.biz +biz + +// bj : https://nic.bj/bj-suffixes.txt +// submitted by registry <contact@nic.bj> +bj +africa.bj +agro.bj +architectes.bj +assur.bj +avocats.bj +co.bj +com.bj +eco.bj +econo.bj +edu.bj +info.bj +loisirs.bj +money.bj +net.bj +org.bj +ote.bj +resto.bj +restaurant.bj +tourism.bj +univ.bj + +// bm : http://www.bermudanic.bm/dnr-text.txt +bm +com.bm +edu.bm +gov.bm +net.bm +org.bm + +// bn : http://www.bnnic.bn/faqs +bn +com.bn +edu.bn +gov.bn +net.bn +org.bn + +// bo : https://nic.bo/delegacion2015.php#h-1.10 +bo +com.bo +edu.bo +gob.bo +int.bo +org.bo +net.bo +mil.bo +tv.bo +web.bo +// Social Domains +academia.bo +agro.bo +arte.bo +blog.bo +bolivia.bo +ciencia.bo +cooperativa.bo +democracia.bo +deporte.bo +ecologia.bo +economia.bo +empresa.bo +indigena.bo +industria.bo +info.bo +medicina.bo +movimiento.bo +musica.bo +natural.bo +nombre.bo +noticias.bo +patria.bo +politica.bo +profesional.bo +plurinacional.bo +pueblo.bo +revista.bo +salud.bo +tecnologia.bo +tksat.bo +transporte.bo +wiki.bo + +// br : http://registro.br/dominio/categoria.html +// Submitted by registry <fneves@registro.br> +br +9guacu.br +abc.br +adm.br +adv.br +agr.br +aju.br +am.br +anani.br +aparecida.br +app.br +arq.br +art.br +ato.br +b.br +barueri.br +belem.br +bhz.br +bib.br +bio.br +blog.br +bmd.br +boavista.br +bsb.br +campinagrande.br +campinas.br +caxias.br +cim.br +cng.br +cnt.br +com.br +contagem.br +coop.br +coz.br +cri.br +cuiaba.br +curitiba.br +def.br +des.br +det.br +dev.br +ecn.br +eco.br +edu.br +emp.br +enf.br +eng.br +esp.br +etc.br +eti.br +far.br +feira.br +flog.br +floripa.br +fm.br +fnd.br +fortal.br +fot.br +foz.br +fst.br +g12.br +geo.br +ggf.br +goiania.br +gov.br +// gov.br 26 states + df https://en.wikipedia.org/wiki/States_of_Brazil +ac.gov.br +al.gov.br +am.gov.br +ap.gov.br +ba.gov.br +ce.gov.br +df.gov.br +es.gov.br +go.gov.br +ma.gov.br +mg.gov.br +ms.gov.br +mt.gov.br +pa.gov.br +pb.gov.br +pe.gov.br +pi.gov.br +pr.gov.br +rj.gov.br +rn.gov.br +ro.gov.br +rr.gov.br +rs.gov.br +sc.gov.br +se.gov.br +sp.gov.br +to.gov.br +gru.br +imb.br +ind.br +inf.br +jab.br +jampa.br +jdf.br +joinville.br +jor.br +jus.br +leg.br +lel.br +log.br +londrina.br +macapa.br +maceio.br +manaus.br +maringa.br +mat.br +med.br +mil.br +morena.br +mp.br +mus.br +natal.br +net.br +niteroi.br +*.nom.br +not.br +ntr.br +odo.br +ong.br +org.br +osasco.br +palmas.br +poa.br +ppg.br +pro.br +psc.br +psi.br +pvh.br +qsl.br +radio.br +rec.br +recife.br +rep.br +ribeirao.br +rio.br +riobranco.br +riopreto.br +salvador.br +sampa.br +santamaria.br +santoandre.br +saobernardo.br +saogonca.br +seg.br +sjc.br +slg.br +slz.br +sorocaba.br +srv.br +taxi.br +tc.br +tec.br +teo.br +the.br +tmp.br +trd.br +tur.br +tv.br +udi.br +vet.br +vix.br +vlog.br +wiki.br +zlg.br + +// bs : http://www.nic.bs/rules.html +bs +com.bs +net.bs +org.bs +edu.bs +gov.bs + +// bt : https://en.wikipedia.org/wiki/.bt +bt +com.bt +edu.bt +gov.bt +net.bt +org.bt + +// bv : No registrations at this time. +// Submitted by registry <jarle@uninett.no> +bv + +// bw : https://en.wikipedia.org/wiki/.bw +// http://www.gobin.info/domainname/bw.doc +// list of other 2nd level tlds ? +bw +co.bw +org.bw + +// by : https://en.wikipedia.org/wiki/.by +// http://tld.by/rules_2006_en.html +// list of other 2nd level tlds ? +by +gov.by +mil.by +// Official information does not indicate that com.by is a reserved +// second-level domain, but it's being used as one (see www.google.com.by and +// www.yahoo.com.by, for example), so we list it here for safety's sake. +com.by + +// http://hoster.by/ +of.by + +// bz : https://en.wikipedia.org/wiki/.bz +// http://www.belizenic.bz/ +bz +com.bz +net.bz +org.bz +edu.bz +gov.bz + +// ca : https://en.wikipedia.org/wiki/.ca +ca +// ca geographical names +ab.ca +bc.ca +mb.ca +nb.ca +nf.ca +nl.ca +ns.ca +nt.ca +nu.ca +on.ca +pe.ca +qc.ca +sk.ca +yk.ca +// gc.ca: https://en.wikipedia.org/wiki/.gc.ca +// see also: http://registry.gc.ca/en/SubdomainFAQ +gc.ca + +// cat : https://en.wikipedia.org/wiki/.cat +cat + +// cc : https://en.wikipedia.org/wiki/.cc +cc + +// cd : https://en.wikipedia.org/wiki/.cd +// see also: https://www.nic.cd/domain/insertDomain_2.jsp?act=1 +cd +gov.cd + +// cf : https://en.wikipedia.org/wiki/.cf +cf + +// cg : https://en.wikipedia.org/wiki/.cg +cg + +// ch : https://en.wikipedia.org/wiki/.ch +ch + +// ci : https://en.wikipedia.org/wiki/.ci +// http://www.nic.ci/index.php?page=charte +ci +org.ci +or.ci +com.ci +co.ci +edu.ci +ed.ci +ac.ci +net.ci +go.ci +asso.ci +aéroport.ci +int.ci +presse.ci +md.ci +gouv.ci + +// ck : https://en.wikipedia.org/wiki/.ck +*.ck +!www.ck + +// cl : https://www.nic.cl +// Confirmed by .CL registry <hsalgado@nic.cl> +cl +co.cl +gob.cl +gov.cl +mil.cl + +// cm : https://en.wikipedia.org/wiki/.cm plus bug 981927 +cm +co.cm +com.cm +gov.cm +net.cm + +// cn : https://en.wikipedia.org/wiki/.cn +// Submitted by registry <tanyaling@cnnic.cn> +cn +ac.cn +com.cn +edu.cn +gov.cn +net.cn +org.cn +mil.cn +公司.cn +网络.cn +網絡.cn +// cn geographic names +ah.cn +bj.cn +cq.cn +fj.cn +gd.cn +gs.cn +gz.cn +gx.cn +ha.cn +hb.cn +he.cn +hi.cn +hl.cn +hn.cn +jl.cn +js.cn +jx.cn +ln.cn +nm.cn +nx.cn +qh.cn +sc.cn +sd.cn +sh.cn +sn.cn +sx.cn +tj.cn +xj.cn +xz.cn +yn.cn +zj.cn +hk.cn +mo.cn +tw.cn + +// co : https://en.wikipedia.org/wiki/.co +// Submitted by registry <tecnico@uniandes.edu.co> +co +arts.co +com.co +edu.co +firm.co +gov.co +info.co +int.co +mil.co +net.co +nom.co +org.co +rec.co +web.co + +// com : https://en.wikipedia.org/wiki/.com +com + +// coop : https://en.wikipedia.org/wiki/.coop +coop + +// cr : http://www.nic.cr/niccr_publico/showRegistroDominiosScreen.do +cr +ac.cr +co.cr +ed.cr +fi.cr +go.cr +or.cr +sa.cr + +// cu : https://en.wikipedia.org/wiki/.cu +cu +com.cu +edu.cu +org.cu +net.cu +gov.cu +inf.cu + +// cv : https://en.wikipedia.org/wiki/.cv +// cv : http://www.dns.cv/tldcv_portal/do?com=DS;5446457100;111;+PAGE(4000018)+K-CAT-CODIGO(RDOM)+RCNT(100); <- registration rules +cv +com.cv +edu.cv +int.cv +nome.cv +org.cv + +// cw : http://www.una.cw/cw_registry/ +// Confirmed by registry <registry@una.net> 2013-03-26 +cw +com.cw +edu.cw +net.cw +org.cw + +// cx : https://en.wikipedia.org/wiki/.cx +// list of other 2nd level tlds ? +cx +gov.cx + +// cy : http://www.nic.cy/ +// Submitted by registry Panayiotou Fotia <cydns@ucy.ac.cy> +// namespace policies URL https://www.nic.cy/portal//sites/default/files/symfonia_gia_eggrafi.pdf +cy +ac.cy +biz.cy +com.cy +ekloges.cy +gov.cy +ltd.cy +mil.cy +net.cy +org.cy +press.cy +pro.cy +tm.cy + +// cz : https://en.wikipedia.org/wiki/.cz +cz + +// de : https://en.wikipedia.org/wiki/.de +// Confirmed by registry <ops@denic.de> (with technical +// reservations) 2008-07-01 +de + +// dj : https://en.wikipedia.org/wiki/.dj +dj + +// dk : https://en.wikipedia.org/wiki/.dk +// Confirmed by registry <robert@dk-hostmaster.dk> 2008-06-17 +dk + +// dm : https://en.wikipedia.org/wiki/.dm +dm +com.dm +net.dm +org.dm +edu.dm +gov.dm + +// do : https://en.wikipedia.org/wiki/.do +do +art.do +com.do +edu.do +gob.do +gov.do +mil.do +net.do +org.do +sld.do +web.do + +// dz : http://www.nic.dz/images/pdf_nic/charte.pdf +dz +art.dz +asso.dz +com.dz +edu.dz +gov.dz +org.dz +net.dz +pol.dz +soc.dz +tm.dz + +// ec : http://www.nic.ec/reg/paso1.asp +// Submitted by registry <vabboud@nic.ec> +ec +com.ec +info.ec +net.ec +fin.ec +k12.ec +med.ec +pro.ec +org.ec +edu.ec +gov.ec +gob.ec +mil.ec + +// edu : https://en.wikipedia.org/wiki/.edu +edu + +// ee : http://www.eenet.ee/EENet/dom_reeglid.html#lisa_B +ee +edu.ee +gov.ee +riik.ee +lib.ee +med.ee +com.ee +pri.ee +aip.ee +org.ee +fie.ee + +// eg : https://en.wikipedia.org/wiki/.eg +eg +com.eg +edu.eg +eun.eg +gov.eg +mil.eg +name.eg +net.eg +org.eg +sci.eg + +// er : https://en.wikipedia.org/wiki/.er +*.er + +// es : https://www.nic.es/site_ingles/ingles/dominios/index.html +es +com.es +nom.es +org.es +gob.es +edu.es + +// et : https://en.wikipedia.org/wiki/.et +et +com.et +gov.et +org.et +edu.et +biz.et +name.et +info.et +net.et + +// eu : https://en.wikipedia.org/wiki/.eu +eu + +// fi : https://en.wikipedia.org/wiki/.fi +fi +// aland.fi : https://en.wikipedia.org/wiki/.ax +// This domain is being phased out in favor of .ax. As there are still many +// domains under aland.fi, we still keep it on the list until aland.fi is +// completely removed. +// TODO: Check for updates (expected to be phased out around Q1/2009) +aland.fi + +// fj : http://domains.fj/ +// Submitted by registry <garth.miller@cocca.org.nz> 2020-02-11 +fj +ac.fj +biz.fj +com.fj +gov.fj +info.fj +mil.fj +name.fj +net.fj +org.fj +pro.fj + +// fk : https://en.wikipedia.org/wiki/.fk +*.fk + +// fm : https://en.wikipedia.org/wiki/.fm +com.fm +edu.fm +net.fm +org.fm +fm + +// fo : https://en.wikipedia.org/wiki/.fo +fo + +// fr : https://www.afnic.fr/ https://www.afnic.fr/wp-media/uploads/2022/12/afnic-naming-policy-2023-01-01.pdf +fr +asso.fr +com.fr +gouv.fr +nom.fr +prd.fr +tm.fr +// Former "domaines sectoriels", still registration suffixes +aeroport.fr +avocat.fr +avoues.fr +cci.fr +chambagri.fr +chirurgiens-dentistes.fr +experts-comptables.fr +geometre-expert.fr +greta.fr +huissier-justice.fr +medecin.fr +notaires.fr +pharmacien.fr +port.fr +veterinaire.fr + +// ga : https://en.wikipedia.org/wiki/.ga +ga + +// gb : This registry is effectively dormant +// Submitted by registry <Damien.Shaw@ja.net> +gb + +// gd : https://en.wikipedia.org/wiki/.gd +edu.gd +gov.gd +gd + +// ge : http://www.nic.net.ge/policy_en.pdf +ge +com.ge +edu.ge +gov.ge +org.ge +mil.ge +net.ge +pvt.ge + +// gf : https://en.wikipedia.org/wiki/.gf +gf + +// gg : http://www.channelisles.net/register-domains/ +// Confirmed by registry <nigel@channelisles.net> 2013-11-28 +gg +co.gg +net.gg +org.gg + +// gh : https://en.wikipedia.org/wiki/.gh +// see also: http://www.nic.gh/reg_now.php +// Although domains directly at second level are not possible at the moment, +// they have been possible for some time and may come back. +gh +com.gh +edu.gh +gov.gh +org.gh +mil.gh + +// gi : http://www.nic.gi/rules.html +gi +com.gi +ltd.gi +gov.gi +mod.gi +edu.gi +org.gi + +// gl : https://en.wikipedia.org/wiki/.gl +// http://nic.gl +gl +co.gl +com.gl +edu.gl +net.gl +org.gl + +// gm : http://www.nic.gm/htmlpages%5Cgm-policy.htm +gm + +// gn : http://psg.com/dns/gn/gn.txt +// Submitted by registry <randy@psg.com> +gn +ac.gn +com.gn +edu.gn +gov.gn +org.gn +net.gn + +// gov : https://en.wikipedia.org/wiki/.gov +gov + +// gp : http://www.nic.gp/index.php?lang=en +gp +com.gp +net.gp +mobi.gp +edu.gp +org.gp +asso.gp + +// gq : https://en.wikipedia.org/wiki/.gq +gq + +// gr : https://grweb.ics.forth.gr/english/1617-B-2005.html +// Submitted by registry <segred@ics.forth.gr> +gr +com.gr +edu.gr +net.gr +org.gr +gov.gr + +// gs : https://en.wikipedia.org/wiki/.gs +gs + +// gt : https://www.gt/sitio/registration_policy.php?lang=en +gt +com.gt +edu.gt +gob.gt +ind.gt +mil.gt +net.gt +org.gt + +// gu : http://gadao.gov.gu/register.html +// University of Guam : https://www.uog.edu +// Submitted by uognoc@triton.uog.edu +gu +com.gu +edu.gu +gov.gu +guam.gu +info.gu +net.gu +org.gu +web.gu + +// gw : https://en.wikipedia.org/wiki/.gw +// gw : https://nic.gw/regras/ +gw + +// gy : https://en.wikipedia.org/wiki/.gy +// http://registry.gy/ +gy +co.gy +com.gy +edu.gy +gov.gy +net.gy +org.gy + +// hk : https://www.hkirc.hk +// Submitted by registry <hk.tech@hkirc.hk> +hk +com.hk +edu.hk +gov.hk +idv.hk +net.hk +org.hk +公司.hk +教育.hk +敎育.hk +政府.hk +個人.hk +个人.hk +箇人.hk +網络.hk +网络.hk +组織.hk +網絡.hk +网絡.hk +组织.hk +組織.hk +組织.hk + +// hm : https://en.wikipedia.org/wiki/.hm +hm + +// hn : http://www.nic.hn/politicas/ps02,,05.html +hn +com.hn +edu.hn +org.hn +net.hn +mil.hn +gob.hn + +// hr : http://www.dns.hr/documents/pdf/HRTLD-regulations.pdf +hr +iz.hr +from.hr +name.hr +com.hr + +// ht : http://www.nic.ht/info/charte.cfm +ht +com.ht +shop.ht +firm.ht +info.ht +adult.ht +net.ht +pro.ht +org.ht +med.ht +art.ht +coop.ht +pol.ht +asso.ht +edu.ht +rel.ht +gouv.ht +perso.ht + +// hu : http://www.domain.hu/domain/English/sld.html +// Confirmed by registry <pasztor@iszt.hu> 2008-06-12 +hu +co.hu +info.hu +org.hu +priv.hu +sport.hu +tm.hu +2000.hu +agrar.hu +bolt.hu +casino.hu +city.hu +erotica.hu +erotika.hu +film.hu +forum.hu +games.hu +hotel.hu +ingatlan.hu +jogasz.hu +konyvelo.hu +lakas.hu +media.hu +news.hu +reklam.hu +sex.hu +shop.hu +suli.hu +szex.hu +tozsde.hu +utazas.hu +video.hu + +// id : https://pandi.id/en/domain/registration-requirements/ +id +ac.id +biz.id +co.id +desa.id +go.id +mil.id +my.id +net.id +or.id +ponpes.id +sch.id +web.id + +// ie : https://en.wikipedia.org/wiki/.ie +ie +gov.ie + +// il : http://www.isoc.org.il/domains/ +// see also: https://en.isoc.org.il/il-cctld/registration-rules +// ISOC-IL (operated by .il Registry) +il +ac.il +co.il +gov.il +idf.il +k12.il +muni.il +net.il +org.il +// xn--4dbrk0ce ("Israel", Hebrew) : IL +ישראל +// xn--4dbgdty6c.xn--4dbrk0ce. +אקדמיה.ישראל +// xn--5dbhl8d.xn--4dbrk0ce. +ישוב.ישראל +// xn--8dbq2a.xn--4dbrk0ce. +צהל.ישראל +// xn--hebda8b.xn--4dbrk0ce. +ממשל.ישראל + +// im : https://www.nic.im/ +// Submitted by registry <info@nic.im> +im +ac.im +co.im +com.im +ltd.co.im +net.im +org.im +plc.co.im +tt.im +tv.im + +// in : https://en.wikipedia.org/wiki/.in +// see also: https://registry.in/policies +// Please note, that nic.in is not an official eTLD, but used by most +// government institutions. +in +5g.in +6g.in +ac.in +ai.in +am.in +bihar.in +biz.in +business.in +ca.in +cn.in +co.in +com.in +coop.in +cs.in +delhi.in +dr.in +edu.in +er.in +firm.in +gen.in +gov.in +gujarat.in +ind.in +info.in +int.in +internet.in +io.in +me.in +mil.in +net.in +nic.in +org.in +pg.in +post.in +pro.in +res.in +travel.in +tv.in +uk.in +up.in +us.in + +// info : https://en.wikipedia.org/wiki/.info +info + +// int : https://en.wikipedia.org/wiki/.int +// Confirmed by registry <iana-questions@icann.org> 2008-06-18 +int +eu.int + +// io : http://www.nic.io/rules.htm +// list of other 2nd level tlds ? +io +com.io + +// iq : http://www.cmc.iq/english/iq/iqregister1.htm +iq +gov.iq +edu.iq +mil.iq +com.iq +org.iq +net.iq + +// ir : http://www.nic.ir/Terms_and_Conditions_ir,_Appendix_1_Domain_Rules +// Also see http://www.nic.ir/Internationalized_Domain_Names +// Two <iran>.ir entries added at request of <tech-team@nic.ir>, 2010-04-16 +ir +ac.ir +co.ir +gov.ir +id.ir +net.ir +org.ir +sch.ir +// xn--mgba3a4f16a.ir (<iran>.ir, Persian YEH) +ایران.ir +// xn--mgba3a4fra.ir (<iran>.ir, Arabic YEH) +ايران.ir + +// is : http://www.isnic.is/domain/rules.php +// Confirmed by registry <marius@isgate.is> 2008-12-06 +is +net.is +com.is +edu.is +gov.is +org.is +int.is + +// it : https://en.wikipedia.org/wiki/.it +it +gov.it +edu.it +// Reserved geo-names (regions and provinces): +// https://www.nic.it/sites/default/files/archivio/docs/Regulation_assignation_v7.1.pdf +// Regions +abr.it +abruzzo.it +aosta-valley.it +aostavalley.it +bas.it +basilicata.it +cal.it +calabria.it +cam.it +campania.it +emilia-romagna.it +emiliaromagna.it +emr.it +friuli-v-giulia.it +friuli-ve-giulia.it +friuli-vegiulia.it +friuli-venezia-giulia.it +friuli-veneziagiulia.it +friuli-vgiulia.it +friuliv-giulia.it +friulive-giulia.it +friulivegiulia.it +friulivenezia-giulia.it +friuliveneziagiulia.it +friulivgiulia.it +fvg.it +laz.it +lazio.it +lig.it +liguria.it +lom.it +lombardia.it +lombardy.it +lucania.it +mar.it +marche.it +mol.it +molise.it +piedmont.it +piemonte.it +pmn.it +pug.it +puglia.it +sar.it +sardegna.it +sardinia.it +sic.it +sicilia.it +sicily.it +taa.it +tos.it +toscana.it +trentin-sud-tirol.it +trentin-süd-tirol.it +trentin-sudtirol.it +trentin-südtirol.it +trentin-sued-tirol.it +trentin-suedtirol.it +trentino-a-adige.it +trentino-aadige.it +trentino-alto-adige.it +trentino-altoadige.it +trentino-s-tirol.it +trentino-stirol.it +trentino-sud-tirol.it +trentino-süd-tirol.it +trentino-sudtirol.it +trentino-südtirol.it +trentino-sued-tirol.it +trentino-suedtirol.it +trentino.it +trentinoa-adige.it +trentinoaadige.it +trentinoalto-adige.it +trentinoaltoadige.it +trentinos-tirol.it +trentinostirol.it +trentinosud-tirol.it +trentinosüd-tirol.it +trentinosudtirol.it +trentinosüdtirol.it +trentinosued-tirol.it +trentinosuedtirol.it +trentinsud-tirol.it +trentinsüd-tirol.it +trentinsudtirol.it +trentinsüdtirol.it +trentinsued-tirol.it +trentinsuedtirol.it +tuscany.it +umb.it +umbria.it +val-d-aosta.it +val-daosta.it +vald-aosta.it +valdaosta.it +valle-aosta.it +valle-d-aosta.it +valle-daosta.it +valleaosta.it +valled-aosta.it +valledaosta.it +vallee-aoste.it +vallée-aoste.it +vallee-d-aoste.it +vallée-d-aoste.it +valleeaoste.it +valléeaoste.it +valleedaoste.it +valléedaoste.it +vao.it +vda.it +ven.it +veneto.it +// Provinces +ag.it +agrigento.it +al.it +alessandria.it +alto-adige.it +altoadige.it +an.it +ancona.it +andria-barletta-trani.it +andria-trani-barletta.it +andriabarlettatrani.it +andriatranibarletta.it +ao.it +aosta.it +aoste.it +ap.it +aq.it +aquila.it +ar.it +arezzo.it +ascoli-piceno.it +ascolipiceno.it +asti.it +at.it +av.it +avellino.it +ba.it +balsan-sudtirol.it +balsan-südtirol.it +balsan-suedtirol.it +balsan.it +bari.it +barletta-trani-andria.it +barlettatraniandria.it +belluno.it +benevento.it +bergamo.it +bg.it +bi.it +biella.it +bl.it +bn.it +bo.it +bologna.it +bolzano-altoadige.it +bolzano.it +bozen-sudtirol.it +bozen-südtirol.it +bozen-suedtirol.it +bozen.it +br.it +brescia.it +brindisi.it +bs.it +bt.it +bulsan-sudtirol.it +bulsan-südtirol.it +bulsan-suedtirol.it +bulsan.it +bz.it +ca.it +cagliari.it +caltanissetta.it +campidano-medio.it +campidanomedio.it +campobasso.it +carbonia-iglesias.it +carboniaiglesias.it +carrara-massa.it +carraramassa.it +caserta.it +catania.it +catanzaro.it +cb.it +ce.it +cesena-forli.it +cesena-forlì.it +cesenaforli.it +cesenaforlì.it +ch.it +chieti.it +ci.it +cl.it +cn.it +co.it +como.it +cosenza.it +cr.it +cremona.it +crotone.it +cs.it +ct.it +cuneo.it +cz.it +dell-ogliastra.it +dellogliastra.it +en.it +enna.it +fc.it +fe.it +fermo.it +ferrara.it +fg.it +fi.it +firenze.it +florence.it +fm.it +foggia.it +forli-cesena.it +forlì-cesena.it +forlicesena.it +forlìcesena.it +fr.it +frosinone.it +ge.it +genoa.it +genova.it +go.it +gorizia.it +gr.it +grosseto.it +iglesias-carbonia.it +iglesiascarbonia.it +im.it +imperia.it +is.it +isernia.it +kr.it +la-spezia.it +laquila.it +laspezia.it +latina.it +lc.it +le.it +lecce.it +lecco.it +li.it +livorno.it +lo.it +lodi.it +lt.it +lu.it +lucca.it +macerata.it +mantova.it +massa-carrara.it +massacarrara.it +matera.it +mb.it +mc.it +me.it +medio-campidano.it +mediocampidano.it +messina.it +mi.it +milan.it +milano.it +mn.it +mo.it +modena.it +monza-brianza.it +monza-e-della-brianza.it +monza.it +monzabrianza.it +monzaebrianza.it +monzaedellabrianza.it +ms.it +mt.it +na.it +naples.it +napoli.it +no.it +novara.it +nu.it +nuoro.it +og.it +ogliastra.it +olbia-tempio.it +olbiatempio.it +or.it +oristano.it +ot.it +pa.it +padova.it +padua.it +palermo.it +parma.it +pavia.it +pc.it +pd.it +pe.it +perugia.it +pesaro-urbino.it +pesarourbino.it +pescara.it +pg.it +pi.it +piacenza.it +pisa.it +pistoia.it +pn.it +po.it +pordenone.it +potenza.it +pr.it +prato.it +pt.it +pu.it +pv.it +pz.it +ra.it +ragusa.it +ravenna.it +rc.it +re.it +reggio-calabria.it +reggio-emilia.it +reggiocalabria.it +reggioemilia.it +rg.it +ri.it +rieti.it +rimini.it +rm.it +rn.it +ro.it +roma.it +rome.it +rovigo.it +sa.it +salerno.it +sassari.it +savona.it +si.it +siena.it +siracusa.it +so.it +sondrio.it +sp.it +sr.it +ss.it +suedtirol.it +südtirol.it +sv.it +ta.it +taranto.it +te.it +tempio-olbia.it +tempioolbia.it +teramo.it +terni.it +tn.it +to.it +torino.it +tp.it +tr.it +trani-andria-barletta.it +trani-barletta-andria.it +traniandriabarletta.it +tranibarlettaandria.it +trapani.it +trento.it +treviso.it +trieste.it +ts.it +turin.it +tv.it +ud.it +udine.it +urbino-pesaro.it +urbinopesaro.it +va.it +varese.it +vb.it +vc.it +ve.it +venezia.it +venice.it +verbania.it +vercelli.it +verona.it +vi.it +vibo-valentia.it +vibovalentia.it +vicenza.it +viterbo.it +vr.it +vs.it +vt.it +vv.it + +// je : http://www.channelisles.net/register-domains/ +// Confirmed by registry <nigel@channelisles.net> 2013-11-28 +je +co.je +net.je +org.je + +// jm : http://www.com.jm/register.html +*.jm + +// jo : http://www.dns.jo/Registration_policy.aspx +jo +com.jo +org.jo +net.jo +edu.jo +sch.jo +gov.jo +mil.jo +name.jo + +// jobs : https://en.wikipedia.org/wiki/.jobs +jobs + +// jp : https://en.wikipedia.org/wiki/.jp +// http://jprs.co.jp/en/jpdomain.html +// Submitted by registry <info@jprs.jp> +jp +// jp organizational type names +ac.jp +ad.jp +co.jp +ed.jp +go.jp +gr.jp +lg.jp +ne.jp +or.jp +// jp prefecture type names +aichi.jp +akita.jp +aomori.jp +chiba.jp +ehime.jp +fukui.jp +fukuoka.jp +fukushima.jp +gifu.jp +gunma.jp +hiroshima.jp +hokkaido.jp +hyogo.jp +ibaraki.jp +ishikawa.jp +iwate.jp +kagawa.jp +kagoshima.jp +kanagawa.jp +kochi.jp +kumamoto.jp +kyoto.jp +mie.jp +miyagi.jp +miyazaki.jp +nagano.jp +nagasaki.jp +nara.jp +niigata.jp +oita.jp +okayama.jp +okinawa.jp +osaka.jp +saga.jp +saitama.jp +shiga.jp +shimane.jp +shizuoka.jp +tochigi.jp +tokushima.jp +tokyo.jp +tottori.jp +toyama.jp +wakayama.jp +yamagata.jp +yamaguchi.jp +yamanashi.jp +栃木.jp +愛知.jp +愛媛.jp +兵庫.jp +熊本.jp +茨城.jp +北海道.jp +千葉.jp +和歌山.jp +長崎.jp +長野.jp +新潟.jp +青森.jp +静岡.jp +東京.jp +石川.jp +埼玉.jp +三重.jp +京都.jp +佐賀.jp +大分.jp +大阪.jp +奈良.jp +宮城.jp +宮崎.jp +富山.jp +山口.jp +山形.jp +山梨.jp +岩手.jp +岐阜.jp +岡山.jp +島根.jp +広島.jp +徳島.jp +沖縄.jp +滋賀.jp +神奈川.jp +福井.jp +福岡.jp +福島.jp +秋田.jp +群馬.jp +香川.jp +高知.jp +鳥取.jp +鹿児島.jp +// jp geographic type names +// http://jprs.jp/doc/rule/saisoku-1.html +*.kawasaki.jp +*.kitakyushu.jp +*.kobe.jp +*.nagoya.jp +*.sapporo.jp +*.sendai.jp +*.yokohama.jp +!city.kawasaki.jp +!city.kitakyushu.jp +!city.kobe.jp +!city.nagoya.jp +!city.sapporo.jp +!city.sendai.jp +!city.yokohama.jp +// 4th level registration +aisai.aichi.jp +ama.aichi.jp +anjo.aichi.jp +asuke.aichi.jp +chiryu.aichi.jp +chita.aichi.jp +fuso.aichi.jp +gamagori.aichi.jp +handa.aichi.jp +hazu.aichi.jp +hekinan.aichi.jp +higashiura.aichi.jp +ichinomiya.aichi.jp +inazawa.aichi.jp +inuyama.aichi.jp +isshiki.aichi.jp +iwakura.aichi.jp +kanie.aichi.jp +kariya.aichi.jp +kasugai.aichi.jp +kira.aichi.jp +kiyosu.aichi.jp +komaki.aichi.jp +konan.aichi.jp +kota.aichi.jp +mihama.aichi.jp +miyoshi.aichi.jp +nishio.aichi.jp +nisshin.aichi.jp +obu.aichi.jp +oguchi.aichi.jp +oharu.aichi.jp +okazaki.aichi.jp +owariasahi.aichi.jp +seto.aichi.jp +shikatsu.aichi.jp +shinshiro.aichi.jp +shitara.aichi.jp +tahara.aichi.jp +takahama.aichi.jp +tobishima.aichi.jp +toei.aichi.jp +togo.aichi.jp +tokai.aichi.jp +tokoname.aichi.jp +toyoake.aichi.jp +toyohashi.aichi.jp +toyokawa.aichi.jp +toyone.aichi.jp +toyota.aichi.jp +tsushima.aichi.jp +yatomi.aichi.jp +akita.akita.jp +daisen.akita.jp +fujisato.akita.jp +gojome.akita.jp +hachirogata.akita.jp +happou.akita.jp +higashinaruse.akita.jp +honjo.akita.jp +honjyo.akita.jp +ikawa.akita.jp +kamikoani.akita.jp +kamioka.akita.jp +katagami.akita.jp +kazuno.akita.jp +kitaakita.akita.jp +kosaka.akita.jp +kyowa.akita.jp +misato.akita.jp +mitane.akita.jp +moriyoshi.akita.jp +nikaho.akita.jp +noshiro.akita.jp +odate.akita.jp +oga.akita.jp +ogata.akita.jp +semboku.akita.jp +yokote.akita.jp +yurihonjo.akita.jp +aomori.aomori.jp +gonohe.aomori.jp +hachinohe.aomori.jp +hashikami.aomori.jp +hiranai.aomori.jp +hirosaki.aomori.jp +itayanagi.aomori.jp +kuroishi.aomori.jp +misawa.aomori.jp +mutsu.aomori.jp +nakadomari.aomori.jp +noheji.aomori.jp +oirase.aomori.jp +owani.aomori.jp +rokunohe.aomori.jp +sannohe.aomori.jp +shichinohe.aomori.jp +shingo.aomori.jp +takko.aomori.jp +towada.aomori.jp +tsugaru.aomori.jp +tsuruta.aomori.jp +abiko.chiba.jp +asahi.chiba.jp +chonan.chiba.jp +chosei.chiba.jp +choshi.chiba.jp +chuo.chiba.jp +funabashi.chiba.jp +futtsu.chiba.jp +hanamigawa.chiba.jp +ichihara.chiba.jp +ichikawa.chiba.jp +ichinomiya.chiba.jp +inzai.chiba.jp +isumi.chiba.jp +kamagaya.chiba.jp +kamogawa.chiba.jp +kashiwa.chiba.jp +katori.chiba.jp +katsuura.chiba.jp +kimitsu.chiba.jp +kisarazu.chiba.jp +kozaki.chiba.jp +kujukuri.chiba.jp +kyonan.chiba.jp +matsudo.chiba.jp +midori.chiba.jp +mihama.chiba.jp +minamiboso.chiba.jp +mobara.chiba.jp +mutsuzawa.chiba.jp +nagara.chiba.jp +nagareyama.chiba.jp +narashino.chiba.jp +narita.chiba.jp +noda.chiba.jp +oamishirasato.chiba.jp +omigawa.chiba.jp +onjuku.chiba.jp +otaki.chiba.jp +sakae.chiba.jp +sakura.chiba.jp +shimofusa.chiba.jp +shirako.chiba.jp +shiroi.chiba.jp +shisui.chiba.jp +sodegaura.chiba.jp +sosa.chiba.jp +tako.chiba.jp +tateyama.chiba.jp +togane.chiba.jp +tohnosho.chiba.jp +tomisato.chiba.jp +urayasu.chiba.jp +yachimata.chiba.jp +yachiyo.chiba.jp +yokaichiba.chiba.jp +yokoshibahikari.chiba.jp +yotsukaido.chiba.jp +ainan.ehime.jp +honai.ehime.jp +ikata.ehime.jp +imabari.ehime.jp +iyo.ehime.jp +kamijima.ehime.jp +kihoku.ehime.jp +kumakogen.ehime.jp +masaki.ehime.jp +matsuno.ehime.jp +matsuyama.ehime.jp +namikata.ehime.jp +niihama.ehime.jp +ozu.ehime.jp +saijo.ehime.jp +seiyo.ehime.jp +shikokuchuo.ehime.jp +tobe.ehime.jp +toon.ehime.jp +uchiko.ehime.jp +uwajima.ehime.jp +yawatahama.ehime.jp +echizen.fukui.jp +eiheiji.fukui.jp +fukui.fukui.jp +ikeda.fukui.jp +katsuyama.fukui.jp +mihama.fukui.jp +minamiechizen.fukui.jp +obama.fukui.jp +ohi.fukui.jp +ono.fukui.jp +sabae.fukui.jp +sakai.fukui.jp +takahama.fukui.jp +tsuruga.fukui.jp +wakasa.fukui.jp +ashiya.fukuoka.jp +buzen.fukuoka.jp +chikugo.fukuoka.jp +chikuho.fukuoka.jp +chikujo.fukuoka.jp +chikushino.fukuoka.jp +chikuzen.fukuoka.jp +chuo.fukuoka.jp +dazaifu.fukuoka.jp +fukuchi.fukuoka.jp +hakata.fukuoka.jp +higashi.fukuoka.jp +hirokawa.fukuoka.jp +hisayama.fukuoka.jp +iizuka.fukuoka.jp +inatsuki.fukuoka.jp +kaho.fukuoka.jp +kasuga.fukuoka.jp +kasuya.fukuoka.jp +kawara.fukuoka.jp +keisen.fukuoka.jp +koga.fukuoka.jp +kurate.fukuoka.jp +kurogi.fukuoka.jp +kurume.fukuoka.jp +minami.fukuoka.jp +miyako.fukuoka.jp +miyama.fukuoka.jp +miyawaka.fukuoka.jp +mizumaki.fukuoka.jp +munakata.fukuoka.jp +nakagawa.fukuoka.jp +nakama.fukuoka.jp +nishi.fukuoka.jp +nogata.fukuoka.jp +ogori.fukuoka.jp +okagaki.fukuoka.jp +okawa.fukuoka.jp +oki.fukuoka.jp +omuta.fukuoka.jp +onga.fukuoka.jp +onojo.fukuoka.jp +oto.fukuoka.jp +saigawa.fukuoka.jp +sasaguri.fukuoka.jp +shingu.fukuoka.jp +shinyoshitomi.fukuoka.jp +shonai.fukuoka.jp +soeda.fukuoka.jp +sue.fukuoka.jp +tachiarai.fukuoka.jp +tagawa.fukuoka.jp +takata.fukuoka.jp +toho.fukuoka.jp +toyotsu.fukuoka.jp +tsuiki.fukuoka.jp +ukiha.fukuoka.jp +umi.fukuoka.jp +usui.fukuoka.jp +yamada.fukuoka.jp +yame.fukuoka.jp +yanagawa.fukuoka.jp +yukuhashi.fukuoka.jp +aizubange.fukushima.jp +aizumisato.fukushima.jp +aizuwakamatsu.fukushima.jp +asakawa.fukushima.jp +bandai.fukushima.jp +date.fukushima.jp +fukushima.fukushima.jp +furudono.fukushima.jp +futaba.fukushima.jp +hanawa.fukushima.jp +higashi.fukushima.jp +hirata.fukushima.jp +hirono.fukushima.jp +iitate.fukushima.jp +inawashiro.fukushima.jp +ishikawa.fukushima.jp +iwaki.fukushima.jp +izumizaki.fukushima.jp +kagamiishi.fukushima.jp +kaneyama.fukushima.jp +kawamata.fukushima.jp +kitakata.fukushima.jp +kitashiobara.fukushima.jp +koori.fukushima.jp +koriyama.fukushima.jp +kunimi.fukushima.jp +miharu.fukushima.jp +mishima.fukushima.jp +namie.fukushima.jp +nango.fukushima.jp +nishiaizu.fukushima.jp +nishigo.fukushima.jp +okuma.fukushima.jp +omotego.fukushima.jp +ono.fukushima.jp +otama.fukushima.jp +samegawa.fukushima.jp +shimogo.fukushima.jp +shirakawa.fukushima.jp +showa.fukushima.jp +soma.fukushima.jp +sukagawa.fukushima.jp +taishin.fukushima.jp +tamakawa.fukushima.jp +tanagura.fukushima.jp +tenei.fukushima.jp +yabuki.fukushima.jp +yamato.fukushima.jp +yamatsuri.fukushima.jp +yanaizu.fukushima.jp +yugawa.fukushima.jp +anpachi.gifu.jp +ena.gifu.jp +gifu.gifu.jp +ginan.gifu.jp +godo.gifu.jp +gujo.gifu.jp +hashima.gifu.jp +hichiso.gifu.jp +hida.gifu.jp +higashishirakawa.gifu.jp +ibigawa.gifu.jp +ikeda.gifu.jp +kakamigahara.gifu.jp +kani.gifu.jp +kasahara.gifu.jp +kasamatsu.gifu.jp +kawaue.gifu.jp +kitagata.gifu.jp +mino.gifu.jp +minokamo.gifu.jp +mitake.gifu.jp +mizunami.gifu.jp +motosu.gifu.jp +nakatsugawa.gifu.jp +ogaki.gifu.jp +sakahogi.gifu.jp +seki.gifu.jp +sekigahara.gifu.jp +shirakawa.gifu.jp +tajimi.gifu.jp +takayama.gifu.jp +tarui.gifu.jp +toki.gifu.jp +tomika.gifu.jp +wanouchi.gifu.jp +yamagata.gifu.jp +yaotsu.gifu.jp +yoro.gifu.jp +annaka.gunma.jp +chiyoda.gunma.jp +fujioka.gunma.jp +higashiagatsuma.gunma.jp +isesaki.gunma.jp +itakura.gunma.jp +kanna.gunma.jp +kanra.gunma.jp +katashina.gunma.jp +kawaba.gunma.jp +kiryu.gunma.jp +kusatsu.gunma.jp +maebashi.gunma.jp +meiwa.gunma.jp +midori.gunma.jp +minakami.gunma.jp +naganohara.gunma.jp +nakanojo.gunma.jp +nanmoku.gunma.jp +numata.gunma.jp +oizumi.gunma.jp +ora.gunma.jp +ota.gunma.jp +shibukawa.gunma.jp +shimonita.gunma.jp +shinto.gunma.jp +showa.gunma.jp +takasaki.gunma.jp +takayama.gunma.jp +tamamura.gunma.jp +tatebayashi.gunma.jp +tomioka.gunma.jp +tsukiyono.gunma.jp +tsumagoi.gunma.jp +ueno.gunma.jp +yoshioka.gunma.jp +asaminami.hiroshima.jp +daiwa.hiroshima.jp +etajima.hiroshima.jp +fuchu.hiroshima.jp +fukuyama.hiroshima.jp +hatsukaichi.hiroshima.jp +higashihiroshima.hiroshima.jp +hongo.hiroshima.jp +jinsekikogen.hiroshima.jp +kaita.hiroshima.jp +kui.hiroshima.jp +kumano.hiroshima.jp +kure.hiroshima.jp +mihara.hiroshima.jp +miyoshi.hiroshima.jp +naka.hiroshima.jp +onomichi.hiroshima.jp +osakikamijima.hiroshima.jp +otake.hiroshima.jp +saka.hiroshima.jp +sera.hiroshima.jp +seranishi.hiroshima.jp +shinichi.hiroshima.jp +shobara.hiroshima.jp +takehara.hiroshima.jp +abashiri.hokkaido.jp +abira.hokkaido.jp +aibetsu.hokkaido.jp +akabira.hokkaido.jp +akkeshi.hokkaido.jp +asahikawa.hokkaido.jp +ashibetsu.hokkaido.jp +ashoro.hokkaido.jp +assabu.hokkaido.jp +atsuma.hokkaido.jp +bibai.hokkaido.jp +biei.hokkaido.jp +bifuka.hokkaido.jp +bihoro.hokkaido.jp +biratori.hokkaido.jp +chippubetsu.hokkaido.jp +chitose.hokkaido.jp +date.hokkaido.jp +ebetsu.hokkaido.jp +embetsu.hokkaido.jp +eniwa.hokkaido.jp +erimo.hokkaido.jp +esan.hokkaido.jp +esashi.hokkaido.jp +fukagawa.hokkaido.jp +fukushima.hokkaido.jp +furano.hokkaido.jp +furubira.hokkaido.jp +haboro.hokkaido.jp +hakodate.hokkaido.jp +hamatonbetsu.hokkaido.jp +hidaka.hokkaido.jp +higashikagura.hokkaido.jp +higashikawa.hokkaido.jp +hiroo.hokkaido.jp +hokuryu.hokkaido.jp +hokuto.hokkaido.jp +honbetsu.hokkaido.jp +horokanai.hokkaido.jp +horonobe.hokkaido.jp +ikeda.hokkaido.jp +imakane.hokkaido.jp +ishikari.hokkaido.jp +iwamizawa.hokkaido.jp +iwanai.hokkaido.jp +kamifurano.hokkaido.jp +kamikawa.hokkaido.jp +kamishihoro.hokkaido.jp +kamisunagawa.hokkaido.jp +kamoenai.hokkaido.jp +kayabe.hokkaido.jp +kembuchi.hokkaido.jp +kikonai.hokkaido.jp +kimobetsu.hokkaido.jp +kitahiroshima.hokkaido.jp +kitami.hokkaido.jp +kiyosato.hokkaido.jp +koshimizu.hokkaido.jp +kunneppu.hokkaido.jp +kuriyama.hokkaido.jp +kuromatsunai.hokkaido.jp +kushiro.hokkaido.jp +kutchan.hokkaido.jp +kyowa.hokkaido.jp +mashike.hokkaido.jp +matsumae.hokkaido.jp +mikasa.hokkaido.jp +minamifurano.hokkaido.jp +mombetsu.hokkaido.jp +moseushi.hokkaido.jp +mukawa.hokkaido.jp +muroran.hokkaido.jp +naie.hokkaido.jp +nakagawa.hokkaido.jp +nakasatsunai.hokkaido.jp +nakatombetsu.hokkaido.jp +nanae.hokkaido.jp +nanporo.hokkaido.jp +nayoro.hokkaido.jp +nemuro.hokkaido.jp +niikappu.hokkaido.jp +niki.hokkaido.jp +nishiokoppe.hokkaido.jp +noboribetsu.hokkaido.jp +numata.hokkaido.jp +obihiro.hokkaido.jp +obira.hokkaido.jp +oketo.hokkaido.jp +okoppe.hokkaido.jp +otaru.hokkaido.jp +otobe.hokkaido.jp +otofuke.hokkaido.jp +otoineppu.hokkaido.jp +oumu.hokkaido.jp +ozora.hokkaido.jp +pippu.hokkaido.jp +rankoshi.hokkaido.jp +rebun.hokkaido.jp +rikubetsu.hokkaido.jp +rishiri.hokkaido.jp +rishirifuji.hokkaido.jp +saroma.hokkaido.jp +sarufutsu.hokkaido.jp +shakotan.hokkaido.jp +shari.hokkaido.jp +shibecha.hokkaido.jp +shibetsu.hokkaido.jp +shikabe.hokkaido.jp +shikaoi.hokkaido.jp +shimamaki.hokkaido.jp +shimizu.hokkaido.jp +shimokawa.hokkaido.jp +shinshinotsu.hokkaido.jp +shintoku.hokkaido.jp +shiranuka.hokkaido.jp +shiraoi.hokkaido.jp +shiriuchi.hokkaido.jp +sobetsu.hokkaido.jp +sunagawa.hokkaido.jp +taiki.hokkaido.jp +takasu.hokkaido.jp +takikawa.hokkaido.jp +takinoue.hokkaido.jp +teshikaga.hokkaido.jp +tobetsu.hokkaido.jp +tohma.hokkaido.jp +tomakomai.hokkaido.jp +tomari.hokkaido.jp +toya.hokkaido.jp +toyako.hokkaido.jp +toyotomi.hokkaido.jp +toyoura.hokkaido.jp +tsubetsu.hokkaido.jp +tsukigata.hokkaido.jp +urakawa.hokkaido.jp +urausu.hokkaido.jp +uryu.hokkaido.jp +utashinai.hokkaido.jp +wakkanai.hokkaido.jp +wassamu.hokkaido.jp +yakumo.hokkaido.jp +yoichi.hokkaido.jp +aioi.hyogo.jp +akashi.hyogo.jp +ako.hyogo.jp +amagasaki.hyogo.jp +aogaki.hyogo.jp +asago.hyogo.jp +ashiya.hyogo.jp +awaji.hyogo.jp +fukusaki.hyogo.jp +goshiki.hyogo.jp +harima.hyogo.jp +himeji.hyogo.jp +ichikawa.hyogo.jp +inagawa.hyogo.jp +itami.hyogo.jp +kakogawa.hyogo.jp +kamigori.hyogo.jp +kamikawa.hyogo.jp +kasai.hyogo.jp +kasuga.hyogo.jp +kawanishi.hyogo.jp +miki.hyogo.jp +minamiawaji.hyogo.jp +nishinomiya.hyogo.jp +nishiwaki.hyogo.jp +ono.hyogo.jp +sanda.hyogo.jp +sannan.hyogo.jp +sasayama.hyogo.jp +sayo.hyogo.jp +shingu.hyogo.jp +shinonsen.hyogo.jp +shiso.hyogo.jp +sumoto.hyogo.jp +taishi.hyogo.jp +taka.hyogo.jp +takarazuka.hyogo.jp +takasago.hyogo.jp +takino.hyogo.jp +tamba.hyogo.jp +tatsuno.hyogo.jp +toyooka.hyogo.jp +yabu.hyogo.jp +yashiro.hyogo.jp +yoka.hyogo.jp +yokawa.hyogo.jp +ami.ibaraki.jp +asahi.ibaraki.jp +bando.ibaraki.jp +chikusei.ibaraki.jp +daigo.ibaraki.jp +fujishiro.ibaraki.jp +hitachi.ibaraki.jp +hitachinaka.ibaraki.jp +hitachiomiya.ibaraki.jp +hitachiota.ibaraki.jp +ibaraki.ibaraki.jp +ina.ibaraki.jp +inashiki.ibaraki.jp +itako.ibaraki.jp +iwama.ibaraki.jp +joso.ibaraki.jp +kamisu.ibaraki.jp +kasama.ibaraki.jp +kashima.ibaraki.jp +kasumigaura.ibaraki.jp +koga.ibaraki.jp +miho.ibaraki.jp +mito.ibaraki.jp +moriya.ibaraki.jp +naka.ibaraki.jp +namegata.ibaraki.jp +oarai.ibaraki.jp +ogawa.ibaraki.jp +omitama.ibaraki.jp +ryugasaki.ibaraki.jp +sakai.ibaraki.jp +sakuragawa.ibaraki.jp +shimodate.ibaraki.jp +shimotsuma.ibaraki.jp +shirosato.ibaraki.jp +sowa.ibaraki.jp +suifu.ibaraki.jp +takahagi.ibaraki.jp +tamatsukuri.ibaraki.jp +tokai.ibaraki.jp +tomobe.ibaraki.jp +tone.ibaraki.jp +toride.ibaraki.jp +tsuchiura.ibaraki.jp +tsukuba.ibaraki.jp +uchihara.ibaraki.jp +ushiku.ibaraki.jp +yachiyo.ibaraki.jp +yamagata.ibaraki.jp +yawara.ibaraki.jp +yuki.ibaraki.jp +anamizu.ishikawa.jp +hakui.ishikawa.jp +hakusan.ishikawa.jp +kaga.ishikawa.jp +kahoku.ishikawa.jp +kanazawa.ishikawa.jp +kawakita.ishikawa.jp +komatsu.ishikawa.jp +nakanoto.ishikawa.jp +nanao.ishikawa.jp +nomi.ishikawa.jp +nonoichi.ishikawa.jp +noto.ishikawa.jp +shika.ishikawa.jp +suzu.ishikawa.jp +tsubata.ishikawa.jp +tsurugi.ishikawa.jp +uchinada.ishikawa.jp +wajima.ishikawa.jp +fudai.iwate.jp +fujisawa.iwate.jp +hanamaki.iwate.jp +hiraizumi.iwate.jp +hirono.iwate.jp +ichinohe.iwate.jp +ichinoseki.iwate.jp +iwaizumi.iwate.jp +iwate.iwate.jp +joboji.iwate.jp +kamaishi.iwate.jp +kanegasaki.iwate.jp +karumai.iwate.jp +kawai.iwate.jp +kitakami.iwate.jp +kuji.iwate.jp +kunohe.iwate.jp +kuzumaki.iwate.jp +miyako.iwate.jp +mizusawa.iwate.jp +morioka.iwate.jp +ninohe.iwate.jp +noda.iwate.jp +ofunato.iwate.jp +oshu.iwate.jp +otsuchi.iwate.jp +rikuzentakata.iwate.jp +shiwa.iwate.jp +shizukuishi.iwate.jp +sumita.iwate.jp +tanohata.iwate.jp +tono.iwate.jp +yahaba.iwate.jp +yamada.iwate.jp +ayagawa.kagawa.jp +higashikagawa.kagawa.jp +kanonji.kagawa.jp +kotohira.kagawa.jp +manno.kagawa.jp +marugame.kagawa.jp +mitoyo.kagawa.jp +naoshima.kagawa.jp +sanuki.kagawa.jp +tadotsu.kagawa.jp +takamatsu.kagawa.jp +tonosho.kagawa.jp +uchinomi.kagawa.jp +utazu.kagawa.jp +zentsuji.kagawa.jp +akune.kagoshima.jp +amami.kagoshima.jp +hioki.kagoshima.jp +isa.kagoshima.jp +isen.kagoshima.jp +izumi.kagoshima.jp +kagoshima.kagoshima.jp +kanoya.kagoshima.jp +kawanabe.kagoshima.jp +kinko.kagoshima.jp +kouyama.kagoshima.jp +makurazaki.kagoshima.jp +matsumoto.kagoshima.jp +minamitane.kagoshima.jp +nakatane.kagoshima.jp +nishinoomote.kagoshima.jp +satsumasendai.kagoshima.jp +soo.kagoshima.jp +tarumizu.kagoshima.jp +yusui.kagoshima.jp +aikawa.kanagawa.jp +atsugi.kanagawa.jp +ayase.kanagawa.jp +chigasaki.kanagawa.jp +ebina.kanagawa.jp +fujisawa.kanagawa.jp +hadano.kanagawa.jp +hakone.kanagawa.jp +hiratsuka.kanagawa.jp +isehara.kanagawa.jp +kaisei.kanagawa.jp +kamakura.kanagawa.jp +kiyokawa.kanagawa.jp +matsuda.kanagawa.jp +minamiashigara.kanagawa.jp +miura.kanagawa.jp +nakai.kanagawa.jp +ninomiya.kanagawa.jp +odawara.kanagawa.jp +oi.kanagawa.jp +oiso.kanagawa.jp +sagamihara.kanagawa.jp +samukawa.kanagawa.jp +tsukui.kanagawa.jp +yamakita.kanagawa.jp +yamato.kanagawa.jp +yokosuka.kanagawa.jp +yugawara.kanagawa.jp +zama.kanagawa.jp +zushi.kanagawa.jp +aki.kochi.jp +geisei.kochi.jp +hidaka.kochi.jp +higashitsuno.kochi.jp +ino.kochi.jp +kagami.kochi.jp +kami.kochi.jp +kitagawa.kochi.jp +kochi.kochi.jp +mihara.kochi.jp +motoyama.kochi.jp +muroto.kochi.jp +nahari.kochi.jp +nakamura.kochi.jp +nankoku.kochi.jp +nishitosa.kochi.jp +niyodogawa.kochi.jp +ochi.kochi.jp +okawa.kochi.jp +otoyo.kochi.jp +otsuki.kochi.jp +sakawa.kochi.jp +sukumo.kochi.jp +susaki.kochi.jp +tosa.kochi.jp +tosashimizu.kochi.jp +toyo.kochi.jp +tsuno.kochi.jp +umaji.kochi.jp +yasuda.kochi.jp +yusuhara.kochi.jp +amakusa.kumamoto.jp +arao.kumamoto.jp +aso.kumamoto.jp +choyo.kumamoto.jp +gyokuto.kumamoto.jp +kamiamakusa.kumamoto.jp +kikuchi.kumamoto.jp +kumamoto.kumamoto.jp +mashiki.kumamoto.jp +mifune.kumamoto.jp +minamata.kumamoto.jp +minamioguni.kumamoto.jp +nagasu.kumamoto.jp +nishihara.kumamoto.jp +oguni.kumamoto.jp +ozu.kumamoto.jp +sumoto.kumamoto.jp +takamori.kumamoto.jp +uki.kumamoto.jp +uto.kumamoto.jp +yamaga.kumamoto.jp +yamato.kumamoto.jp +yatsushiro.kumamoto.jp +ayabe.kyoto.jp +fukuchiyama.kyoto.jp +higashiyama.kyoto.jp +ide.kyoto.jp +ine.kyoto.jp +joyo.kyoto.jp +kameoka.kyoto.jp +kamo.kyoto.jp +kita.kyoto.jp +kizu.kyoto.jp +kumiyama.kyoto.jp +kyotamba.kyoto.jp +kyotanabe.kyoto.jp +kyotango.kyoto.jp +maizuru.kyoto.jp +minami.kyoto.jp +minamiyamashiro.kyoto.jp +miyazu.kyoto.jp +muko.kyoto.jp +nagaokakyo.kyoto.jp +nakagyo.kyoto.jp +nantan.kyoto.jp +oyamazaki.kyoto.jp +sakyo.kyoto.jp +seika.kyoto.jp +tanabe.kyoto.jp +uji.kyoto.jp +ujitawara.kyoto.jp +wazuka.kyoto.jp +yamashina.kyoto.jp +yawata.kyoto.jp +asahi.mie.jp +inabe.mie.jp +ise.mie.jp +kameyama.mie.jp +kawagoe.mie.jp +kiho.mie.jp +kisosaki.mie.jp +kiwa.mie.jp +komono.mie.jp +kumano.mie.jp +kuwana.mie.jp +matsusaka.mie.jp +meiwa.mie.jp +mihama.mie.jp +minamiise.mie.jp +misugi.mie.jp +miyama.mie.jp +nabari.mie.jp +shima.mie.jp +suzuka.mie.jp +tado.mie.jp +taiki.mie.jp +taki.mie.jp +tamaki.mie.jp +toba.mie.jp +tsu.mie.jp +udono.mie.jp +ureshino.mie.jp +watarai.mie.jp +yokkaichi.mie.jp +furukawa.miyagi.jp +higashimatsushima.miyagi.jp +ishinomaki.miyagi.jp +iwanuma.miyagi.jp +kakuda.miyagi.jp +kami.miyagi.jp +kawasaki.miyagi.jp +marumori.miyagi.jp +matsushima.miyagi.jp +minamisanriku.miyagi.jp +misato.miyagi.jp +murata.miyagi.jp +natori.miyagi.jp +ogawara.miyagi.jp +ohira.miyagi.jp +onagawa.miyagi.jp +osaki.miyagi.jp +rifu.miyagi.jp +semine.miyagi.jp +shibata.miyagi.jp +shichikashuku.miyagi.jp +shikama.miyagi.jp +shiogama.miyagi.jp +shiroishi.miyagi.jp +tagajo.miyagi.jp +taiwa.miyagi.jp +tome.miyagi.jp +tomiya.miyagi.jp +wakuya.miyagi.jp +watari.miyagi.jp +yamamoto.miyagi.jp +zao.miyagi.jp +aya.miyazaki.jp +ebino.miyazaki.jp +gokase.miyazaki.jp +hyuga.miyazaki.jp +kadogawa.miyazaki.jp +kawaminami.miyazaki.jp +kijo.miyazaki.jp +kitagawa.miyazaki.jp +kitakata.miyazaki.jp +kitaura.miyazaki.jp +kobayashi.miyazaki.jp +kunitomi.miyazaki.jp +kushima.miyazaki.jp +mimata.miyazaki.jp +miyakonojo.miyazaki.jp +miyazaki.miyazaki.jp +morotsuka.miyazaki.jp +nichinan.miyazaki.jp +nishimera.miyazaki.jp +nobeoka.miyazaki.jp +saito.miyazaki.jp +shiiba.miyazaki.jp +shintomi.miyazaki.jp +takaharu.miyazaki.jp +takanabe.miyazaki.jp +takazaki.miyazaki.jp +tsuno.miyazaki.jp +achi.nagano.jp +agematsu.nagano.jp +anan.nagano.jp +aoki.nagano.jp +asahi.nagano.jp +azumino.nagano.jp +chikuhoku.nagano.jp +chikuma.nagano.jp +chino.nagano.jp +fujimi.nagano.jp +hakuba.nagano.jp +hara.nagano.jp +hiraya.nagano.jp +iida.nagano.jp +iijima.nagano.jp +iiyama.nagano.jp +iizuna.nagano.jp +ikeda.nagano.jp +ikusaka.nagano.jp +ina.nagano.jp +karuizawa.nagano.jp +kawakami.nagano.jp +kiso.nagano.jp +kisofukushima.nagano.jp +kitaaiki.nagano.jp +komagane.nagano.jp +komoro.nagano.jp +matsukawa.nagano.jp +matsumoto.nagano.jp +miasa.nagano.jp +minamiaiki.nagano.jp +minamimaki.nagano.jp +minamiminowa.nagano.jp +minowa.nagano.jp +miyada.nagano.jp +miyota.nagano.jp +mochizuki.nagano.jp +nagano.nagano.jp +nagawa.nagano.jp +nagiso.nagano.jp +nakagawa.nagano.jp +nakano.nagano.jp +nozawaonsen.nagano.jp +obuse.nagano.jp +ogawa.nagano.jp +okaya.nagano.jp +omachi.nagano.jp +omi.nagano.jp +ookuwa.nagano.jp +ooshika.nagano.jp +otaki.nagano.jp +otari.nagano.jp +sakae.nagano.jp +sakaki.nagano.jp +saku.nagano.jp +sakuho.nagano.jp +shimosuwa.nagano.jp +shinanomachi.nagano.jp +shiojiri.nagano.jp +suwa.nagano.jp +suzaka.nagano.jp +takagi.nagano.jp +takamori.nagano.jp +takayama.nagano.jp +tateshina.nagano.jp +tatsuno.nagano.jp +togakushi.nagano.jp +togura.nagano.jp +tomi.nagano.jp +ueda.nagano.jp +wada.nagano.jp +yamagata.nagano.jp +yamanouchi.nagano.jp +yasaka.nagano.jp +yasuoka.nagano.jp +chijiwa.nagasaki.jp +futsu.nagasaki.jp +goto.nagasaki.jp +hasami.nagasaki.jp +hirado.nagasaki.jp +iki.nagasaki.jp +isahaya.nagasaki.jp +kawatana.nagasaki.jp +kuchinotsu.nagasaki.jp +matsuura.nagasaki.jp +nagasaki.nagasaki.jp +obama.nagasaki.jp +omura.nagasaki.jp +oseto.nagasaki.jp +saikai.nagasaki.jp +sasebo.nagasaki.jp +seihi.nagasaki.jp +shimabara.nagasaki.jp +shinkamigoto.nagasaki.jp +togitsu.nagasaki.jp +tsushima.nagasaki.jp +unzen.nagasaki.jp +ando.nara.jp +gose.nara.jp +heguri.nara.jp +higashiyoshino.nara.jp +ikaruga.nara.jp +ikoma.nara.jp +kamikitayama.nara.jp +kanmaki.nara.jp +kashiba.nara.jp +kashihara.nara.jp +katsuragi.nara.jp +kawai.nara.jp +kawakami.nara.jp +kawanishi.nara.jp +koryo.nara.jp +kurotaki.nara.jp +mitsue.nara.jp +miyake.nara.jp +nara.nara.jp +nosegawa.nara.jp +oji.nara.jp +ouda.nara.jp +oyodo.nara.jp +sakurai.nara.jp +sango.nara.jp +shimoichi.nara.jp +shimokitayama.nara.jp +shinjo.nara.jp +soni.nara.jp +takatori.nara.jp +tawaramoto.nara.jp +tenkawa.nara.jp +tenri.nara.jp +uda.nara.jp +yamatokoriyama.nara.jp +yamatotakada.nara.jp +yamazoe.nara.jp +yoshino.nara.jp +aga.niigata.jp +agano.niigata.jp +gosen.niigata.jp +itoigawa.niigata.jp +izumozaki.niigata.jp +joetsu.niigata.jp +kamo.niigata.jp +kariwa.niigata.jp +kashiwazaki.niigata.jp +minamiuonuma.niigata.jp +mitsuke.niigata.jp +muika.niigata.jp +murakami.niigata.jp +myoko.niigata.jp +nagaoka.niigata.jp +niigata.niigata.jp +ojiya.niigata.jp +omi.niigata.jp +sado.niigata.jp +sanjo.niigata.jp +seiro.niigata.jp +seirou.niigata.jp +sekikawa.niigata.jp +shibata.niigata.jp +tagami.niigata.jp +tainai.niigata.jp +tochio.niigata.jp +tokamachi.niigata.jp +tsubame.niigata.jp +tsunan.niigata.jp +uonuma.niigata.jp +yahiko.niigata.jp +yoita.niigata.jp +yuzawa.niigata.jp +beppu.oita.jp +bungoono.oita.jp +bungotakada.oita.jp +hasama.oita.jp +hiji.oita.jp +himeshima.oita.jp +hita.oita.jp +kamitsue.oita.jp +kokonoe.oita.jp +kuju.oita.jp +kunisaki.oita.jp +kusu.oita.jp +oita.oita.jp +saiki.oita.jp +taketa.oita.jp +tsukumi.oita.jp +usa.oita.jp +usuki.oita.jp +yufu.oita.jp +akaiwa.okayama.jp +asakuchi.okayama.jp +bizen.okayama.jp +hayashima.okayama.jp +ibara.okayama.jp +kagamino.okayama.jp +kasaoka.okayama.jp +kibichuo.okayama.jp +kumenan.okayama.jp +kurashiki.okayama.jp +maniwa.okayama.jp +misaki.okayama.jp +nagi.okayama.jp +niimi.okayama.jp +nishiawakura.okayama.jp +okayama.okayama.jp +satosho.okayama.jp +setouchi.okayama.jp +shinjo.okayama.jp +shoo.okayama.jp +soja.okayama.jp +takahashi.okayama.jp +tamano.okayama.jp +tsuyama.okayama.jp +wake.okayama.jp +yakage.okayama.jp +aguni.okinawa.jp +ginowan.okinawa.jp +ginoza.okinawa.jp +gushikami.okinawa.jp +haebaru.okinawa.jp +higashi.okinawa.jp +hirara.okinawa.jp +iheya.okinawa.jp +ishigaki.okinawa.jp +ishikawa.okinawa.jp +itoman.okinawa.jp +izena.okinawa.jp +kadena.okinawa.jp +kin.okinawa.jp +kitadaito.okinawa.jp +kitanakagusuku.okinawa.jp +kumejima.okinawa.jp +kunigami.okinawa.jp +minamidaito.okinawa.jp +motobu.okinawa.jp +nago.okinawa.jp +naha.okinawa.jp +nakagusuku.okinawa.jp +nakijin.okinawa.jp +nanjo.okinawa.jp +nishihara.okinawa.jp +ogimi.okinawa.jp +okinawa.okinawa.jp +onna.okinawa.jp +shimoji.okinawa.jp +taketomi.okinawa.jp +tarama.okinawa.jp +tokashiki.okinawa.jp +tomigusuku.okinawa.jp +tonaki.okinawa.jp +urasoe.okinawa.jp +uruma.okinawa.jp +yaese.okinawa.jp +yomitan.okinawa.jp +yonabaru.okinawa.jp +yonaguni.okinawa.jp +zamami.okinawa.jp +abeno.osaka.jp +chihayaakasaka.osaka.jp +chuo.osaka.jp +daito.osaka.jp +fujiidera.osaka.jp +habikino.osaka.jp +hannan.osaka.jp +higashiosaka.osaka.jp +higashisumiyoshi.osaka.jp +higashiyodogawa.osaka.jp +hirakata.osaka.jp +ibaraki.osaka.jp +ikeda.osaka.jp +izumi.osaka.jp +izumiotsu.osaka.jp +izumisano.osaka.jp +kadoma.osaka.jp +kaizuka.osaka.jp +kanan.osaka.jp +kashiwara.osaka.jp +katano.osaka.jp +kawachinagano.osaka.jp +kishiwada.osaka.jp +kita.osaka.jp +kumatori.osaka.jp +matsubara.osaka.jp +minato.osaka.jp +minoh.osaka.jp +misaki.osaka.jp +moriguchi.osaka.jp +neyagawa.osaka.jp +nishi.osaka.jp +nose.osaka.jp +osakasayama.osaka.jp +sakai.osaka.jp +sayama.osaka.jp +sennan.osaka.jp +settsu.osaka.jp +shijonawate.osaka.jp +shimamoto.osaka.jp +suita.osaka.jp +tadaoka.osaka.jp +taishi.osaka.jp +tajiri.osaka.jp +takaishi.osaka.jp +takatsuki.osaka.jp +tondabayashi.osaka.jp +toyonaka.osaka.jp +toyono.osaka.jp +yao.osaka.jp +ariake.saga.jp +arita.saga.jp +fukudomi.saga.jp +genkai.saga.jp +hamatama.saga.jp +hizen.saga.jp +imari.saga.jp +kamimine.saga.jp +kanzaki.saga.jp +karatsu.saga.jp +kashima.saga.jp +kitagata.saga.jp +kitahata.saga.jp +kiyama.saga.jp +kouhoku.saga.jp +kyuragi.saga.jp +nishiarita.saga.jp +ogi.saga.jp +omachi.saga.jp +ouchi.saga.jp +saga.saga.jp +shiroishi.saga.jp +taku.saga.jp +tara.saga.jp +tosu.saga.jp +yoshinogari.saga.jp +arakawa.saitama.jp +asaka.saitama.jp +chichibu.saitama.jp +fujimi.saitama.jp +fujimino.saitama.jp +fukaya.saitama.jp +hanno.saitama.jp +hanyu.saitama.jp +hasuda.saitama.jp +hatogaya.saitama.jp +hatoyama.saitama.jp +hidaka.saitama.jp +higashichichibu.saitama.jp +higashimatsuyama.saitama.jp +honjo.saitama.jp +ina.saitama.jp +iruma.saitama.jp +iwatsuki.saitama.jp +kamiizumi.saitama.jp +kamikawa.saitama.jp +kamisato.saitama.jp +kasukabe.saitama.jp +kawagoe.saitama.jp +kawaguchi.saitama.jp +kawajima.saitama.jp +kazo.saitama.jp +kitamoto.saitama.jp +koshigaya.saitama.jp +kounosu.saitama.jp +kuki.saitama.jp +kumagaya.saitama.jp +matsubushi.saitama.jp +minano.saitama.jp +misato.saitama.jp +miyashiro.saitama.jp +miyoshi.saitama.jp +moroyama.saitama.jp +nagatoro.saitama.jp +namegawa.saitama.jp +niiza.saitama.jp +ogano.saitama.jp +ogawa.saitama.jp +ogose.saitama.jp +okegawa.saitama.jp +omiya.saitama.jp +otaki.saitama.jp +ranzan.saitama.jp +ryokami.saitama.jp +saitama.saitama.jp +sakado.saitama.jp +satte.saitama.jp +sayama.saitama.jp +shiki.saitama.jp +shiraoka.saitama.jp +soka.saitama.jp +sugito.saitama.jp +toda.saitama.jp +tokigawa.saitama.jp +tokorozawa.saitama.jp +tsurugashima.saitama.jp +urawa.saitama.jp +warabi.saitama.jp +yashio.saitama.jp +yokoze.saitama.jp +yono.saitama.jp +yorii.saitama.jp +yoshida.saitama.jp +yoshikawa.saitama.jp +yoshimi.saitama.jp +aisho.shiga.jp +gamo.shiga.jp +higashiomi.shiga.jp +hikone.shiga.jp +koka.shiga.jp +konan.shiga.jp +kosei.shiga.jp +koto.shiga.jp +kusatsu.shiga.jp +maibara.shiga.jp +moriyama.shiga.jp +nagahama.shiga.jp +nishiazai.shiga.jp +notogawa.shiga.jp +omihachiman.shiga.jp +otsu.shiga.jp +ritto.shiga.jp +ryuoh.shiga.jp +takashima.shiga.jp +takatsuki.shiga.jp +torahime.shiga.jp +toyosato.shiga.jp +yasu.shiga.jp +akagi.shimane.jp +ama.shimane.jp +gotsu.shimane.jp +hamada.shimane.jp +higashiizumo.shimane.jp +hikawa.shimane.jp +hikimi.shimane.jp +izumo.shimane.jp +kakinoki.shimane.jp +masuda.shimane.jp +matsue.shimane.jp +misato.shimane.jp +nishinoshima.shimane.jp +ohda.shimane.jp +okinoshima.shimane.jp +okuizumo.shimane.jp +shimane.shimane.jp +tamayu.shimane.jp +tsuwano.shimane.jp +unnan.shimane.jp +yakumo.shimane.jp +yasugi.shimane.jp +yatsuka.shimane.jp +arai.shizuoka.jp +atami.shizuoka.jp +fuji.shizuoka.jp +fujieda.shizuoka.jp +fujikawa.shizuoka.jp +fujinomiya.shizuoka.jp +fukuroi.shizuoka.jp +gotemba.shizuoka.jp +haibara.shizuoka.jp +hamamatsu.shizuoka.jp +higashiizu.shizuoka.jp +ito.shizuoka.jp +iwata.shizuoka.jp +izu.shizuoka.jp +izunokuni.shizuoka.jp +kakegawa.shizuoka.jp +kannami.shizuoka.jp +kawanehon.shizuoka.jp +kawazu.shizuoka.jp +kikugawa.shizuoka.jp +kosai.shizuoka.jp +makinohara.shizuoka.jp +matsuzaki.shizuoka.jp +minamiizu.shizuoka.jp +mishima.shizuoka.jp +morimachi.shizuoka.jp +nishiizu.shizuoka.jp +numazu.shizuoka.jp +omaezaki.shizuoka.jp +shimada.shizuoka.jp +shimizu.shizuoka.jp +shimoda.shizuoka.jp +shizuoka.shizuoka.jp +susono.shizuoka.jp +yaizu.shizuoka.jp +yoshida.shizuoka.jp +ashikaga.tochigi.jp +bato.tochigi.jp +haga.tochigi.jp +ichikai.tochigi.jp +iwafune.tochigi.jp +kaminokawa.tochigi.jp +kanuma.tochigi.jp +karasuyama.tochigi.jp +kuroiso.tochigi.jp +mashiko.tochigi.jp +mibu.tochigi.jp +moka.tochigi.jp +motegi.tochigi.jp +nasu.tochigi.jp +nasushiobara.tochigi.jp +nikko.tochigi.jp +nishikata.tochigi.jp +nogi.tochigi.jp +ohira.tochigi.jp +ohtawara.tochigi.jp +oyama.tochigi.jp +sakura.tochigi.jp +sano.tochigi.jp +shimotsuke.tochigi.jp +shioya.tochigi.jp +takanezawa.tochigi.jp +tochigi.tochigi.jp +tsuga.tochigi.jp +ujiie.tochigi.jp +utsunomiya.tochigi.jp +yaita.tochigi.jp +aizumi.tokushima.jp +anan.tokushima.jp +ichiba.tokushima.jp +itano.tokushima.jp +kainan.tokushima.jp +komatsushima.tokushima.jp +matsushige.tokushima.jp +mima.tokushima.jp +minami.tokushima.jp +miyoshi.tokushima.jp +mugi.tokushima.jp +nakagawa.tokushima.jp +naruto.tokushima.jp +sanagochi.tokushima.jp +shishikui.tokushima.jp +tokushima.tokushima.jp +wajiki.tokushima.jp +adachi.tokyo.jp +akiruno.tokyo.jp +akishima.tokyo.jp +aogashima.tokyo.jp +arakawa.tokyo.jp +bunkyo.tokyo.jp +chiyoda.tokyo.jp +chofu.tokyo.jp +chuo.tokyo.jp +edogawa.tokyo.jp +fuchu.tokyo.jp +fussa.tokyo.jp +hachijo.tokyo.jp +hachioji.tokyo.jp +hamura.tokyo.jp +higashikurume.tokyo.jp +higashimurayama.tokyo.jp +higashiyamato.tokyo.jp +hino.tokyo.jp +hinode.tokyo.jp +hinohara.tokyo.jp +inagi.tokyo.jp +itabashi.tokyo.jp +katsushika.tokyo.jp +kita.tokyo.jp +kiyose.tokyo.jp +kodaira.tokyo.jp +koganei.tokyo.jp +kokubunji.tokyo.jp +komae.tokyo.jp +koto.tokyo.jp +kouzushima.tokyo.jp +kunitachi.tokyo.jp +machida.tokyo.jp +meguro.tokyo.jp +minato.tokyo.jp +mitaka.tokyo.jp +mizuho.tokyo.jp +musashimurayama.tokyo.jp +musashino.tokyo.jp +nakano.tokyo.jp +nerima.tokyo.jp +ogasawara.tokyo.jp +okutama.tokyo.jp +ome.tokyo.jp +oshima.tokyo.jp +ota.tokyo.jp +setagaya.tokyo.jp +shibuya.tokyo.jp +shinagawa.tokyo.jp +shinjuku.tokyo.jp +suginami.tokyo.jp +sumida.tokyo.jp +tachikawa.tokyo.jp +taito.tokyo.jp +tama.tokyo.jp +toshima.tokyo.jp +chizu.tottori.jp +hino.tottori.jp +kawahara.tottori.jp +koge.tottori.jp +kotoura.tottori.jp +misasa.tottori.jp +nanbu.tottori.jp +nichinan.tottori.jp +sakaiminato.tottori.jp +tottori.tottori.jp +wakasa.tottori.jp +yazu.tottori.jp +yonago.tottori.jp +asahi.toyama.jp +fuchu.toyama.jp +fukumitsu.toyama.jp +funahashi.toyama.jp +himi.toyama.jp +imizu.toyama.jp +inami.toyama.jp +johana.toyama.jp +kamiichi.toyama.jp +kurobe.toyama.jp +nakaniikawa.toyama.jp +namerikawa.toyama.jp +nanto.toyama.jp +nyuzen.toyama.jp +oyabe.toyama.jp +taira.toyama.jp +takaoka.toyama.jp +tateyama.toyama.jp +toga.toyama.jp +tonami.toyama.jp +toyama.toyama.jp +unazuki.toyama.jp +uozu.toyama.jp +yamada.toyama.jp +arida.wakayama.jp +aridagawa.wakayama.jp +gobo.wakayama.jp +hashimoto.wakayama.jp +hidaka.wakayama.jp +hirogawa.wakayama.jp +inami.wakayama.jp +iwade.wakayama.jp +kainan.wakayama.jp +kamitonda.wakayama.jp +katsuragi.wakayama.jp +kimino.wakayama.jp +kinokawa.wakayama.jp +kitayama.wakayama.jp +koya.wakayama.jp +koza.wakayama.jp +kozagawa.wakayama.jp +kudoyama.wakayama.jp +kushimoto.wakayama.jp +mihama.wakayama.jp +misato.wakayama.jp +nachikatsuura.wakayama.jp +shingu.wakayama.jp +shirahama.wakayama.jp +taiji.wakayama.jp +tanabe.wakayama.jp +wakayama.wakayama.jp +yuasa.wakayama.jp +yura.wakayama.jp +asahi.yamagata.jp +funagata.yamagata.jp +higashine.yamagata.jp +iide.yamagata.jp +kahoku.yamagata.jp +kaminoyama.yamagata.jp +kaneyama.yamagata.jp +kawanishi.yamagata.jp +mamurogawa.yamagata.jp +mikawa.yamagata.jp +murayama.yamagata.jp +nagai.yamagata.jp +nakayama.yamagata.jp +nanyo.yamagata.jp +nishikawa.yamagata.jp +obanazawa.yamagata.jp +oe.yamagata.jp +oguni.yamagata.jp +ohkura.yamagata.jp +oishida.yamagata.jp +sagae.yamagata.jp +sakata.yamagata.jp +sakegawa.yamagata.jp +shinjo.yamagata.jp +shirataka.yamagata.jp +shonai.yamagata.jp +takahata.yamagata.jp +tendo.yamagata.jp +tozawa.yamagata.jp +tsuruoka.yamagata.jp +yamagata.yamagata.jp +yamanobe.yamagata.jp +yonezawa.yamagata.jp +yuza.yamagata.jp +abu.yamaguchi.jp +hagi.yamaguchi.jp +hikari.yamaguchi.jp +hofu.yamaguchi.jp +iwakuni.yamaguchi.jp +kudamatsu.yamaguchi.jp +mitou.yamaguchi.jp +nagato.yamaguchi.jp +oshima.yamaguchi.jp +shimonoseki.yamaguchi.jp +shunan.yamaguchi.jp +tabuse.yamaguchi.jp +tokuyama.yamaguchi.jp +toyota.yamaguchi.jp +ube.yamaguchi.jp +yuu.yamaguchi.jp +chuo.yamanashi.jp +doshi.yamanashi.jp +fuefuki.yamanashi.jp +fujikawa.yamanashi.jp +fujikawaguchiko.yamanashi.jp +fujiyoshida.yamanashi.jp +hayakawa.yamanashi.jp +hokuto.yamanashi.jp +ichikawamisato.yamanashi.jp +kai.yamanashi.jp +kofu.yamanashi.jp +koshu.yamanashi.jp +kosuge.yamanashi.jp +minami-alps.yamanashi.jp +minobu.yamanashi.jp +nakamichi.yamanashi.jp +nanbu.yamanashi.jp +narusawa.yamanashi.jp +nirasaki.yamanashi.jp +nishikatsura.yamanashi.jp +oshino.yamanashi.jp +otsuki.yamanashi.jp +showa.yamanashi.jp +tabayama.yamanashi.jp +tsuru.yamanashi.jp +uenohara.yamanashi.jp +yamanakako.yamanashi.jp +yamanashi.yamanashi.jp + +// ke : http://www.kenic.or.ke/index.php/en/ke-domains/ke-domains +ke +ac.ke +co.ke +go.ke +info.ke +me.ke +mobi.ke +ne.ke +or.ke +sc.ke + +// kg : http://www.domain.kg/dmn_n.html +kg +org.kg +net.kg +com.kg +edu.kg +gov.kg +mil.kg + +// kh : http://www.mptc.gov.kh/dns_registration.htm +*.kh + +// ki : http://www.ki/dns/index.html +ki +edu.ki +biz.ki +net.ki +org.ki +gov.ki +info.ki +com.ki + +// km : https://en.wikipedia.org/wiki/.km +// http://www.domaine.km/documents/charte.doc +km +org.km +nom.km +gov.km +prd.km +tm.km +edu.km +mil.km +ass.km +com.km +// These are only mentioned as proposed suggestions at domaine.km, but +// https://en.wikipedia.org/wiki/.km says they're available for registration: +coop.km +asso.km +presse.km +medecin.km +notaires.km +pharmaciens.km +veterinaire.km +gouv.km + +// kn : https://en.wikipedia.org/wiki/.kn +// http://www.dot.kn/domainRules.html +kn +net.kn +org.kn +edu.kn +gov.kn + +// kp : http://www.kcce.kp/en_index.php +kp +com.kp +edu.kp +gov.kp +org.kp +rep.kp +tra.kp + +// kr : https://en.wikipedia.org/wiki/.kr +// see also: http://domain.nida.or.kr/eng/registration.jsp +kr +ac.kr +co.kr +es.kr +go.kr +hs.kr +kg.kr +mil.kr +ms.kr +ne.kr +or.kr +pe.kr +re.kr +sc.kr +// kr geographical names +busan.kr +chungbuk.kr +chungnam.kr +daegu.kr +daejeon.kr +gangwon.kr +gwangju.kr +gyeongbuk.kr +gyeonggi.kr +gyeongnam.kr +incheon.kr +jeju.kr +jeonbuk.kr +jeonnam.kr +seoul.kr +ulsan.kr + +// kw : https://www.nic.kw/policies/ +// Confirmed by registry <nic.tech@citra.gov.kw> +kw +com.kw +edu.kw +emb.kw +gov.kw +ind.kw +net.kw +org.kw + +// ky : http://www.icta.ky/da_ky_reg_dom.php +// Confirmed by registry <kysupport@perimeterusa.com> 2008-06-17 +ky +com.ky +edu.ky +net.ky +org.ky + +// kz : https://en.wikipedia.org/wiki/.kz +// see also: http://www.nic.kz/rules/index.jsp +kz +org.kz +edu.kz +net.kz +gov.kz +mil.kz +com.kz + +// la : https://en.wikipedia.org/wiki/.la +// Submitted by registry <gavin.brown@nic.la> +la +int.la +net.la +info.la +edu.la +gov.la +per.la +com.la +org.la + +// lb : https://en.wikipedia.org/wiki/.lb +// Submitted by registry <randy@psg.com> +lb +com.lb +edu.lb +gov.lb +net.lb +org.lb + +// lc : https://en.wikipedia.org/wiki/.lc +// see also: http://www.nic.lc/rules.htm +lc +com.lc +net.lc +co.lc +org.lc +edu.lc +gov.lc + +// li : https://en.wikipedia.org/wiki/.li +li + +// lk : https://www.nic.lk/index.php/domain-registration/lk-domain-naming-structure +lk +gov.lk +sch.lk +net.lk +int.lk +com.lk +org.lk +edu.lk +ngo.lk +soc.lk +web.lk +ltd.lk +assn.lk +grp.lk +hotel.lk +ac.lk + +// lr : http://psg.com/dns/lr/lr.txt +// Submitted by registry <randy@psg.com> +lr +com.lr +edu.lr +gov.lr +org.lr +net.lr + +// ls : http://www.nic.ls/ +// Confirmed by registry <lsadmin@nic.ls> +ls +ac.ls +biz.ls +co.ls +edu.ls +gov.ls +info.ls +net.ls +org.ls +sc.ls + +// lt : https://en.wikipedia.org/wiki/.lt +lt +// gov.lt : http://www.gov.lt/index_en.php +gov.lt + +// lu : http://www.dns.lu/en/ +lu + +// lv : http://www.nic.lv/DNS/En/generic.php +lv +com.lv +edu.lv +gov.lv +org.lv +mil.lv +id.lv +net.lv +asn.lv +conf.lv + +// ly : http://www.nic.ly/regulations.php +ly +com.ly +net.ly +gov.ly +plc.ly +edu.ly +sch.ly +med.ly +org.ly +id.ly + +// ma : https://en.wikipedia.org/wiki/.ma +// http://www.anrt.ma/fr/admin/download/upload/file_fr782.pdf +ma +co.ma +net.ma +gov.ma +org.ma +ac.ma +press.ma + +// mc : http://www.nic.mc/ +mc +tm.mc +asso.mc + +// md : https://en.wikipedia.org/wiki/.md +md + +// me : https://en.wikipedia.org/wiki/.me +me +co.me +net.me +org.me +edu.me +ac.me +gov.me +its.me +priv.me + +// mg : http://nic.mg/nicmg/?page_id=39 +mg +org.mg +nom.mg +gov.mg +prd.mg +tm.mg +edu.mg +mil.mg +com.mg +co.mg + +// mh : https://en.wikipedia.org/wiki/.mh +mh + +// mil : https://en.wikipedia.org/wiki/.mil +mil + +// mk : https://en.wikipedia.org/wiki/.mk +// see also: http://dns.marnet.net.mk/postapka.php +mk +com.mk +org.mk +net.mk +edu.mk +gov.mk +inf.mk +name.mk + +// ml : http://www.gobin.info/domainname/ml-template.doc +// see also: https://en.wikipedia.org/wiki/.ml +ml +com.ml +edu.ml +gouv.ml +gov.ml +net.ml +org.ml +presse.ml + +// mm : https://en.wikipedia.org/wiki/.mm +*.mm + +// mn : https://en.wikipedia.org/wiki/.mn +mn +gov.mn +edu.mn +org.mn + +// mo : http://www.monic.net.mo/ +mo +com.mo +net.mo +org.mo +edu.mo +gov.mo + +// mobi : https://en.wikipedia.org/wiki/.mobi +mobi + +// mp : http://www.dot.mp/ +// Confirmed by registry <dcamacho@saipan.com> 2008-06-17 +mp + +// mq : https://en.wikipedia.org/wiki/.mq +mq + +// mr : https://en.wikipedia.org/wiki/.mr +mr +gov.mr + +// ms : http://www.nic.ms/pdf/MS_Domain_Name_Rules.pdf +ms +com.ms +edu.ms +gov.ms +net.ms +org.ms + +// mt : https://www.nic.org.mt/go/policy +// Submitted by registry <help@nic.org.mt> +mt +com.mt +edu.mt +net.mt +org.mt + +// mu : https://en.wikipedia.org/wiki/.mu +mu +com.mu +net.mu +org.mu +gov.mu +ac.mu +co.mu +or.mu + +// museum : https://welcome.museum/wp-content/uploads/2018/05/20180525-Registration-Policy-MUSEUM-EN_VF-2.pdf https://welcome.museum/buy-your-dot-museum-2/ +museum + +// mv : https://en.wikipedia.org/wiki/.mv +// "mv" included because, contra Wikipedia, google.mv exists. +mv +aero.mv +biz.mv +com.mv +coop.mv +edu.mv +gov.mv +info.mv +int.mv +mil.mv +museum.mv +name.mv +net.mv +org.mv +pro.mv + +// mw : http://www.registrar.mw/ +mw +ac.mw +biz.mw +co.mw +com.mw +coop.mw +edu.mw +gov.mw +int.mw +museum.mw +net.mw +org.mw + +// mx : http://www.nic.mx/ +// Submitted by registry <farias@nic.mx> +mx +com.mx +org.mx +gob.mx +edu.mx +net.mx + +// my : http://www.mynic.my/ +// Available strings: https://mynic.my/resources/domains/buying-a-domain/ +my +biz.my +com.my +edu.my +gov.my +mil.my +name.my +net.my +org.my + +// mz : http://www.uem.mz/ +// Submitted by registry <antonio@uem.mz> +mz +ac.mz +adv.mz +co.mz +edu.mz +gov.mz +mil.mz +net.mz +org.mz + +// na : http://www.na-nic.com.na/ +// http://www.info.na/domain/ +na +info.na +pro.na +name.na +school.na +or.na +dr.na +us.na +mx.na +ca.na +in.na +cc.na +tv.na +ws.na +mobi.na +co.na +com.na +org.na + +// name : has 2nd-level tlds, but there's no list of them +name + +// nc : http://www.cctld.nc/ +nc +asso.nc +nom.nc + +// ne : https://en.wikipedia.org/wiki/.ne +ne + +// net : https://en.wikipedia.org/wiki/.net +net + +// nf : https://en.wikipedia.org/wiki/.nf +nf +com.nf +net.nf +per.nf +rec.nf +web.nf +arts.nf +firm.nf +info.nf +other.nf +store.nf + +// ng : http://www.nira.org.ng/index.php/join-us/register-ng-domain/189-nira-slds +ng +com.ng +edu.ng +gov.ng +i.ng +mil.ng +mobi.ng +name.ng +net.ng +org.ng +sch.ng + +// ni : http://www.nic.ni/ +ni +ac.ni +biz.ni +co.ni +com.ni +edu.ni +gob.ni +in.ni +info.ni +int.ni +mil.ni +net.ni +nom.ni +org.ni +web.ni + +// nl : https://en.wikipedia.org/wiki/.nl +// https://www.sidn.nl/ +// ccTLD for the Netherlands +nl + +// no : https://www.norid.no/en/om-domenenavn/regelverk-for-no/ +// Norid geographical second level domains : https://www.norid.no/en/om-domenenavn/regelverk-for-no/vedlegg-b/ +// Norid category second level domains : https://www.norid.no/en/om-domenenavn/regelverk-for-no/vedlegg-c/ +// Norid category second-level domains managed by parties other than Norid : https://www.norid.no/en/om-domenenavn/regelverk-for-no/vedlegg-d/ +// RSS feed: https://teknisk.norid.no/en/feed/ +no +// Norid category second level domains : https://www.norid.no/en/om-domenenavn/regelverk-for-no/vedlegg-c/ +fhs.no +vgs.no +fylkesbibl.no +folkebibl.no +museum.no +idrett.no +priv.no +// Norid category second-level domains managed by parties other than Norid : https://www.norid.no/en/om-domenenavn/regelverk-for-no/vedlegg-d/ +mil.no +stat.no +dep.no +kommune.no +herad.no +// Norid geographical second level domains : https://www.norid.no/en/om-domenenavn/regelverk-for-no/vedlegg-b/ +// counties +aa.no +ah.no +bu.no +fm.no +hl.no +hm.no +jan-mayen.no +mr.no +nl.no +nt.no +of.no +ol.no +oslo.no +rl.no +sf.no +st.no +svalbard.no +tm.no +tr.no +va.no +vf.no +// primary and lower secondary schools per county +gs.aa.no +gs.ah.no +gs.bu.no +gs.fm.no +gs.hl.no +gs.hm.no +gs.jan-mayen.no +gs.mr.no +gs.nl.no +gs.nt.no +gs.of.no +gs.ol.no +gs.oslo.no +gs.rl.no +gs.sf.no +gs.st.no +gs.svalbard.no +gs.tm.no +gs.tr.no +gs.va.no +gs.vf.no +// cities +akrehamn.no +åkrehamn.no +algard.no +ålgård.no +arna.no +brumunddal.no +bryne.no +bronnoysund.no +brønnøysund.no +drobak.no +drøbak.no +egersund.no +fetsund.no +floro.no +florø.no +fredrikstad.no +hokksund.no +honefoss.no +hønefoss.no +jessheim.no +jorpeland.no +jørpeland.no +kirkenes.no +kopervik.no +krokstadelva.no +langevag.no +langevåg.no +leirvik.no +mjondalen.no +mjøndalen.no +mo-i-rana.no +mosjoen.no +mosjøen.no +nesoddtangen.no +orkanger.no +osoyro.no +osøyro.no +raholt.no +råholt.no +sandnessjoen.no +sandnessjøen.no +skedsmokorset.no +slattum.no +spjelkavik.no +stathelle.no +stavern.no +stjordalshalsen.no +stjørdalshalsen.no +tananger.no +tranby.no +vossevangen.no +// communities +afjord.no +åfjord.no +agdenes.no +al.no +ål.no +alesund.no +ålesund.no +alstahaug.no +alta.no +áltá.no +alaheadju.no +álaheadju.no +alvdal.no +amli.no +åmli.no +amot.no +åmot.no +andebu.no +andoy.no +andøy.no +andasuolo.no +ardal.no +årdal.no +aremark.no +arendal.no +ås.no +aseral.no +åseral.no +asker.no +askim.no +askvoll.no +askoy.no +askøy.no +asnes.no +åsnes.no +audnedaln.no +aukra.no +aure.no +aurland.no +aurskog-holand.no +aurskog-høland.no +austevoll.no +austrheim.no +averoy.no +averøy.no +balestrand.no +ballangen.no +balat.no +bálát.no +balsfjord.no +bahccavuotna.no +báhccavuotna.no +bamble.no +bardu.no +beardu.no +beiarn.no +bajddar.no +bájddar.no +baidar.no +báidár.no +berg.no +bergen.no +berlevag.no +berlevåg.no +bearalvahki.no +bearalváhki.no +bindal.no +birkenes.no +bjarkoy.no +bjarkøy.no +bjerkreim.no +bjugn.no +bodo.no +bodø.no +badaddja.no +bådåddjå.no +budejju.no +bokn.no +bremanger.no +bronnoy.no +brønnøy.no +bygland.no +bykle.no +barum.no +bærum.no +bo.telemark.no +bø.telemark.no +bo.nordland.no +bø.nordland.no +bievat.no +bievát.no +bomlo.no +bømlo.no +batsfjord.no +båtsfjord.no +bahcavuotna.no +báhcavuotna.no +dovre.no +drammen.no +drangedal.no +dyroy.no +dyrøy.no +donna.no +dønna.no +eid.no +eidfjord.no +eidsberg.no +eidskog.no +eidsvoll.no +eigersund.no +elverum.no +enebakk.no +engerdal.no +etne.no +etnedal.no +evenes.no +evenassi.no +evenášši.no +evje-og-hornnes.no +farsund.no +fauske.no +fuossko.no +fuoisku.no +fedje.no +fet.no +finnoy.no +finnøy.no +fitjar.no +fjaler.no +fjell.no +flakstad.no +flatanger.no +flekkefjord.no +flesberg.no +flora.no +fla.no +flå.no +folldal.no +forsand.no +fosnes.no +frei.no +frogn.no +froland.no +frosta.no +frana.no +fræna.no +froya.no +frøya.no +fusa.no +fyresdal.no +forde.no +førde.no +gamvik.no +gangaviika.no +gáŋgaviika.no +gaular.no +gausdal.no +gildeskal.no +gildeskål.no +giske.no +gjemnes.no +gjerdrum.no +gjerstad.no +gjesdal.no +gjovik.no +gjøvik.no +gloppen.no +gol.no +gran.no +grane.no +granvin.no +gratangen.no +grimstad.no +grong.no +kraanghke.no +kråanghke.no +grue.no +gulen.no +hadsel.no +halden.no +halsa.no +hamar.no +hamaroy.no +habmer.no +hábmer.no +hapmir.no +hápmir.no +hammerfest.no +hammarfeasta.no +hámmárfeasta.no +haram.no +hareid.no +harstad.no +hasvik.no +aknoluokta.no +ákŋoluokta.no +hattfjelldal.no +aarborte.no +haugesund.no +hemne.no +hemnes.no +hemsedal.no +heroy.more-og-romsdal.no +herøy.møre-og-romsdal.no +heroy.nordland.no +herøy.nordland.no +hitra.no +hjartdal.no +hjelmeland.no +hobol.no +hobøl.no +hof.no +hol.no +hole.no +holmestrand.no +holtalen.no +holtålen.no +hornindal.no +horten.no +hurdal.no +hurum.no +hvaler.no +hyllestad.no +hagebostad.no +hægebostad.no +hoyanger.no +høyanger.no +hoylandet.no +høylandet.no +ha.no +hå.no +ibestad.no +inderoy.no +inderøy.no +iveland.no +jevnaker.no +jondal.no +jolster.no +jølster.no +karasjok.no +karasjohka.no +kárášjohka.no +karlsoy.no +galsa.no +gálsá.no +karmoy.no +karmøy.no +kautokeino.no +guovdageaidnu.no +klepp.no +klabu.no +klæbu.no +kongsberg.no +kongsvinger.no +kragero.no +kragerø.no +kristiansand.no +kristiansund.no +krodsherad.no +krødsherad.no +kvalsund.no +rahkkeravju.no +ráhkkerávju.no +kvam.no +kvinesdal.no +kvinnherad.no +kviteseid.no +kvitsoy.no +kvitsøy.no +kvafjord.no +kvæfjord.no +giehtavuoatna.no +kvanangen.no +kvænangen.no +navuotna.no +návuotna.no +kafjord.no +kåfjord.no +gaivuotna.no +gáivuotna.no +larvik.no +lavangen.no +lavagis.no +loabat.no +loabát.no +lebesby.no +davvesiida.no +leikanger.no +leirfjord.no +leka.no +leksvik.no +lenvik.no +leangaviika.no +leaŋgaviika.no +lesja.no +levanger.no +lier.no +lierne.no +lillehammer.no +lillesand.no +lindesnes.no +lindas.no +lindås.no +lom.no +loppa.no +lahppi.no +láhppi.no +lund.no +lunner.no +luroy.no +lurøy.no +luster.no +lyngdal.no +lyngen.no +ivgu.no +lardal.no +lerdal.no +lærdal.no +lodingen.no +lødingen.no +lorenskog.no +lørenskog.no +loten.no +løten.no +malvik.no +masoy.no +måsøy.no +muosat.no +muosát.no +mandal.no +marker.no +marnardal.no +masfjorden.no +meland.no +meldal.no +melhus.no +meloy.no +meløy.no +meraker.no +meråker.no +moareke.no +moåreke.no +midsund.no +midtre-gauldal.no +modalen.no +modum.no +molde.no +moskenes.no +moss.no +mosvik.no +malselv.no +målselv.no +malatvuopmi.no +málatvuopmi.no +namdalseid.no +aejrie.no +namsos.no +namsskogan.no +naamesjevuemie.no +nååmesjevuemie.no +laakesvuemie.no +nannestad.no +narvik.no +narviika.no +naustdal.no +nedre-eiker.no +nes.akershus.no +nes.buskerud.no +nesna.no +nesodden.no +nesseby.no +unjarga.no +unjárga.no +nesset.no +nissedal.no +nittedal.no +nord-aurdal.no +nord-fron.no +nord-odal.no +norddal.no +nordkapp.no +davvenjarga.no +davvenjárga.no +nordre-land.no +nordreisa.no +raisa.no +ráisa.no +nore-og-uvdal.no +notodden.no +naroy.no +nærøy.no +notteroy.no +nøtterøy.no +odda.no +oksnes.no +øksnes.no +oppdal.no +oppegard.no +oppegård.no +orkdal.no +orland.no +ørland.no +orskog.no +ørskog.no +orsta.no +ørsta.no +os.hedmark.no +os.hordaland.no +osen.no +osteroy.no +osterøy.no +ostre-toten.no +østre-toten.no +overhalla.no +ovre-eiker.no +øvre-eiker.no +oyer.no +øyer.no +oygarden.no +øygarden.no +oystre-slidre.no +øystre-slidre.no +porsanger.no +porsangu.no +porsáŋgu.no +porsgrunn.no +radoy.no +radøy.no +rakkestad.no +rana.no +ruovat.no +randaberg.no +rauma.no +rendalen.no +rennebu.no +rennesoy.no +rennesøy.no +rindal.no +ringebu.no +ringerike.no +ringsaker.no +rissa.no +risor.no +risør.no +roan.no +rollag.no +rygge.no +ralingen.no +rælingen.no +rodoy.no +rødøy.no +romskog.no +rømskog.no +roros.no +røros.no +rost.no +røst.no +royken.no +røyken.no +royrvik.no +røyrvik.no +rade.no +råde.no +salangen.no +siellak.no +saltdal.no +salat.no +sálát.no +sálat.no +samnanger.no +sande.more-og-romsdal.no +sande.møre-og-romsdal.no +sande.vestfold.no +sandefjord.no +sandnes.no +sandoy.no +sandøy.no +sarpsborg.no +sauda.no +sauherad.no +sel.no +selbu.no +selje.no +seljord.no +sigdal.no +siljan.no +sirdal.no +skaun.no +skedsmo.no +ski.no +skien.no +skiptvet.no +skjervoy.no +skjervøy.no +skierva.no +skiervá.no +skjak.no +skjåk.no +skodje.no +skanland.no +skånland.no +skanit.no +skánit.no +smola.no +smøla.no +snillfjord.no +snasa.no +snåsa.no +snoasa.no +snaase.no +snåase.no +sogndal.no +sokndal.no +sola.no +solund.no +songdalen.no +sortland.no +spydeberg.no +stange.no +stavanger.no +steigen.no +steinkjer.no +stjordal.no +stjørdal.no +stokke.no +stor-elvdal.no +stord.no +stordal.no +storfjord.no +omasvuotna.no +strand.no +stranda.no +stryn.no +sula.no +suldal.no +sund.no +sunndal.no +surnadal.no +sveio.no +svelvik.no +sykkylven.no +sogne.no +søgne.no +somna.no +sømna.no +sondre-land.no +søndre-land.no +sor-aurdal.no +sør-aurdal.no +sor-fron.no +sør-fron.no +sor-odal.no +sør-odal.no +sor-varanger.no +sør-varanger.no +matta-varjjat.no +mátta-várjjat.no +sorfold.no +sørfold.no +sorreisa.no +sørreisa.no +sorum.no +sørum.no +tana.no +deatnu.no +time.no +tingvoll.no +tinn.no +tjeldsund.no +dielddanuorri.no +tjome.no +tjøme.no +tokke.no +tolga.no +torsken.no +tranoy.no +tranøy.no +tromso.no +tromsø.no +tromsa.no +romsa.no +trondheim.no +troandin.no +trysil.no +trana.no +træna.no +trogstad.no +trøgstad.no +tvedestrand.no +tydal.no +tynset.no +tysfjord.no +divtasvuodna.no +divttasvuotna.no +tysnes.no +tysvar.no +tysvær.no +tonsberg.no +tønsberg.no +ullensaker.no +ullensvang.no +ulvik.no +utsira.no +vadso.no +vadsø.no +cahcesuolo.no +čáhcesuolo.no +vaksdal.no +valle.no +vang.no +vanylven.no +vardo.no +vardø.no +varggat.no +várggát.no +vefsn.no +vaapste.no +vega.no +vegarshei.no +vegårshei.no +vennesla.no +verdal.no +verran.no +vestby.no +vestnes.no +vestre-slidre.no +vestre-toten.no +vestvagoy.no +vestvågøy.no +vevelstad.no +vik.no +vikna.no +vindafjord.no +volda.no +voss.no +varoy.no +værøy.no +vagan.no +vågan.no +voagat.no +vagsoy.no +vågsøy.no +vaga.no +vågå.no +valer.ostfold.no +våler.østfold.no +valer.hedmark.no +våler.hedmark.no + +// np : http://www.mos.com.np/register.html +*.np + +// nr : http://cenpac.net.nr/dns/index.html +// Submitted by registry <technician@cenpac.net.nr> +nr +biz.nr +info.nr +gov.nr +edu.nr +org.nr +net.nr +com.nr + +// nu : https://en.wikipedia.org/wiki/.nu +nu + +// nz : https://en.wikipedia.org/wiki/.nz +// Submitted by registry <jay@nzrs.net.nz> +nz +ac.nz +co.nz +cri.nz +geek.nz +gen.nz +govt.nz +health.nz +iwi.nz +kiwi.nz +maori.nz +mil.nz +māori.nz +net.nz +org.nz +parliament.nz +school.nz + +// om : https://en.wikipedia.org/wiki/.om +om +co.om +com.om +edu.om +gov.om +med.om +museum.om +net.om +org.om +pro.om + +// onion : https://tools.ietf.org/html/rfc7686 +onion + +// org : https://en.wikipedia.org/wiki/.org +org + +// pa : http://www.nic.pa/ +// Some additional second level "domains" resolve directly as hostnames, such as +// pannet.pa, so we add a rule for "pa". +pa +ac.pa +gob.pa +com.pa +org.pa +sld.pa +edu.pa +net.pa +ing.pa +abo.pa +med.pa +nom.pa + +// pe : https://www.nic.pe/InformeFinalComision.pdf +pe +edu.pe +gob.pe +nom.pe +mil.pe +org.pe +com.pe +net.pe + +// pf : http://www.gobin.info/domainname/formulaire-pf.pdf +pf +com.pf +org.pf +edu.pf + +// pg : https://en.wikipedia.org/wiki/.pg +*.pg + +// ph : http://www.domains.ph/FAQ2.asp +// Submitted by registry <jed@email.com.ph> +ph +com.ph +net.ph +org.ph +gov.ph +edu.ph +ngo.ph +mil.ph +i.ph + +// pk : http://pk5.pknic.net.pk/pk5/msgNamepk.PK +pk +com.pk +net.pk +edu.pk +org.pk +fam.pk +biz.pk +web.pk +gov.pk +gob.pk +gok.pk +gon.pk +gop.pk +gos.pk +info.pk + +// pl http://www.dns.pl/english/index.html +// Submitted by registry +pl +com.pl +net.pl +org.pl +// pl functional domains (http://www.dns.pl/english/index.html) +aid.pl +agro.pl +atm.pl +auto.pl +biz.pl +edu.pl +gmina.pl +gsm.pl +info.pl +mail.pl +miasta.pl +media.pl +mil.pl +nieruchomosci.pl +nom.pl +pc.pl +powiat.pl +priv.pl +realestate.pl +rel.pl +sex.pl +shop.pl +sklep.pl +sos.pl +szkola.pl +targi.pl +tm.pl +tourism.pl +travel.pl +turystyka.pl +// Government domains +gov.pl +ap.gov.pl +griw.gov.pl +ic.gov.pl +is.gov.pl +kmpsp.gov.pl +konsulat.gov.pl +kppsp.gov.pl +kwp.gov.pl +kwpsp.gov.pl +mup.gov.pl +mw.gov.pl +oia.gov.pl +oirm.gov.pl +oke.gov.pl +oow.gov.pl +oschr.gov.pl +oum.gov.pl +pa.gov.pl +pinb.gov.pl +piw.gov.pl +po.gov.pl +pr.gov.pl +psp.gov.pl +psse.gov.pl +pup.gov.pl +rzgw.gov.pl +sa.gov.pl +sdn.gov.pl +sko.gov.pl +so.gov.pl +sr.gov.pl +starostwo.gov.pl +ug.gov.pl +ugim.gov.pl +um.gov.pl +umig.gov.pl +upow.gov.pl +uppo.gov.pl +us.gov.pl +uw.gov.pl +uzs.gov.pl +wif.gov.pl +wiih.gov.pl +winb.gov.pl +wios.gov.pl +witd.gov.pl +wiw.gov.pl +wkz.gov.pl +wsa.gov.pl +wskr.gov.pl +wsse.gov.pl +wuoz.gov.pl +wzmiuw.gov.pl +zp.gov.pl +zpisdn.gov.pl +// pl regional domains (http://www.dns.pl/english/index.html) +augustow.pl +babia-gora.pl +bedzin.pl +beskidy.pl +bialowieza.pl +bialystok.pl +bielawa.pl +bieszczady.pl +boleslawiec.pl +bydgoszcz.pl +bytom.pl +cieszyn.pl +czeladz.pl +czest.pl +dlugoleka.pl +elblag.pl +elk.pl +glogow.pl +gniezno.pl +gorlice.pl +grajewo.pl +ilawa.pl +jaworzno.pl +jelenia-gora.pl +jgora.pl +kalisz.pl +kazimierz-dolny.pl +karpacz.pl +kartuzy.pl +kaszuby.pl +katowice.pl +kepno.pl +ketrzyn.pl +klodzko.pl +kobierzyce.pl +kolobrzeg.pl +konin.pl +konskowola.pl +kutno.pl +lapy.pl +lebork.pl +legnica.pl +lezajsk.pl +limanowa.pl +lomza.pl +lowicz.pl +lubin.pl +lukow.pl +malbork.pl +malopolska.pl +mazowsze.pl +mazury.pl +mielec.pl +mielno.pl +mragowo.pl +naklo.pl +nowaruda.pl +nysa.pl +olawa.pl +olecko.pl +olkusz.pl +olsztyn.pl +opoczno.pl +opole.pl +ostroda.pl +ostroleka.pl +ostrowiec.pl +ostrowwlkp.pl +pila.pl +pisz.pl +podhale.pl +podlasie.pl +polkowice.pl +pomorze.pl +pomorskie.pl +prochowice.pl +pruszkow.pl +przeworsk.pl +pulawy.pl +radom.pl +rawa-maz.pl +rybnik.pl +rzeszow.pl +sanok.pl +sejny.pl +slask.pl +slupsk.pl +sosnowiec.pl +stalowa-wola.pl +skoczow.pl +starachowice.pl +stargard.pl +suwalki.pl +swidnica.pl +swiebodzin.pl +swinoujscie.pl +szczecin.pl +szczytno.pl +tarnobrzeg.pl +tgory.pl +turek.pl +tychy.pl +ustka.pl +walbrzych.pl +warmia.pl +warszawa.pl +waw.pl +wegrow.pl +wielun.pl +wlocl.pl +wloclawek.pl +wodzislaw.pl +wolomin.pl +wroclaw.pl +zachpomor.pl +zagan.pl +zarow.pl +zgora.pl +zgorzelec.pl + +// pm : https://www.afnic.fr/wp-media/uploads/2022/12/afnic-naming-policy-2023-01-01.pdf +pm + +// pn : http://www.government.pn/PnRegistry/policies.htm +pn +gov.pn +co.pn +org.pn +edu.pn +net.pn + +// post : https://en.wikipedia.org/wiki/.post +post + +// pr : http://www.nic.pr/index.asp?f=1 +pr +com.pr +net.pr +org.pr +gov.pr +edu.pr +isla.pr +pro.pr +biz.pr +info.pr +name.pr +// these aren't mentioned on nic.pr, but on https://en.wikipedia.org/wiki/.pr +est.pr +prof.pr +ac.pr + +// pro : http://registry.pro/get-pro +pro +aaa.pro +aca.pro +acct.pro +avocat.pro +bar.pro +cpa.pro +eng.pro +jur.pro +law.pro +med.pro +recht.pro + +// ps : https://en.wikipedia.org/wiki/.ps +// http://www.nic.ps/registration/policy.html#reg +ps +edu.ps +gov.ps +sec.ps +plo.ps +com.ps +org.ps +net.ps + +// pt : https://www.dns.pt/en/domain/pt-terms-and-conditions-registration-rules/ +pt +net.pt +gov.pt +org.pt +edu.pt +int.pt +publ.pt +com.pt +nome.pt + +// pw : https://en.wikipedia.org/wiki/.pw +pw +co.pw +ne.pw +or.pw +ed.pw +go.pw +belau.pw + +// py : http://www.nic.py/pautas.html#seccion_9 +// Submitted by registry +py +com.py +coop.py +edu.py +gov.py +mil.py +net.py +org.py + +// qa : http://domains.qa/en/ +qa +com.qa +edu.qa +gov.qa +mil.qa +name.qa +net.qa +org.qa +sch.qa + +// re : https://www.afnic.fr/wp-media/uploads/2022/12/afnic-naming-policy-2023-01-01.pdf +re +asso.re +com.re +nom.re + +// ro : http://www.rotld.ro/ +ro +arts.ro +com.ro +firm.ro +info.ro +nom.ro +nt.ro +org.ro +rec.ro +store.ro +tm.ro +www.ro + +// rs : https://www.rnids.rs/en/domains/national-domains +rs +ac.rs +co.rs +edu.rs +gov.rs +in.rs +org.rs + +// ru : https://cctld.ru/files/pdf/docs/en/rules_ru-rf.pdf +// Submitted by George Georgievsky <gug@cctld.ru> +ru + +// rw : https://www.ricta.org.rw/sites/default/files/resources/registry_registrar_contract_0.pdf +rw +ac.rw +co.rw +coop.rw +gov.rw +mil.rw +net.rw +org.rw + +// sa : http://www.nic.net.sa/ +sa +com.sa +net.sa +org.sa +gov.sa +med.sa +pub.sa +edu.sa +sch.sa + +// sb : http://www.sbnic.net.sb/ +// Submitted by registry <lee.humphries@telekom.com.sb> +sb +com.sb +edu.sb +gov.sb +net.sb +org.sb + +// sc : http://www.nic.sc/ +sc +com.sc +gov.sc +net.sc +org.sc +edu.sc + +// sd : http://www.isoc.sd/sudanic.isoc.sd/billing_pricing.htm +// Submitted by registry <admin@isoc.sd> +sd +com.sd +net.sd +org.sd +edu.sd +med.sd +tv.sd +gov.sd +info.sd + +// se : https://en.wikipedia.org/wiki/.se +// Submitted by registry <patrik.wallstrom@iis.se> +se +a.se +ac.se +b.se +bd.se +brand.se +c.se +d.se +e.se +f.se +fh.se +fhsk.se +fhv.se +g.se +h.se +i.se +k.se +komforb.se +kommunalforbund.se +komvux.se +l.se +lanbib.se +m.se +n.se +naturbruksgymn.se +o.se +org.se +p.se +parti.se +pp.se +press.se +r.se +s.se +t.se +tm.se +u.se +w.se +x.se +y.se +z.se + +// sg : http://www.nic.net.sg/page/registration-policies-procedures-and-guidelines +sg +com.sg +net.sg +org.sg +gov.sg +edu.sg +per.sg + +// sh : http://nic.sh/rules.htm +sh +com.sh +net.sh +gov.sh +org.sh +mil.sh + +// si : https://en.wikipedia.org/wiki/.si +si + +// sj : No registrations at this time. +// Submitted by registry <jarle@uninett.no> +sj + +// sk : https://en.wikipedia.org/wiki/.sk +// list of 2nd level domains ? +sk + +// sl : http://www.nic.sl +// Submitted by registry <adam@neoip.com> +sl +com.sl +net.sl +edu.sl +gov.sl +org.sl + +// sm : https://en.wikipedia.org/wiki/.sm +sm + +// sn : https://en.wikipedia.org/wiki/.sn +sn +art.sn +com.sn +edu.sn +gouv.sn +org.sn +perso.sn +univ.sn + +// so : http://sonic.so/policies/ +so +com.so +edu.so +gov.so +me.so +net.so +org.so + +// sr : https://en.wikipedia.org/wiki/.sr +sr + +// ss : https://registry.nic.ss/ +// Submitted by registry <technical@nic.ss> +ss +biz.ss +com.ss +edu.ss +gov.ss +me.ss +net.ss +org.ss +sch.ss + +// st : http://www.nic.st/html/policyrules/ +st +co.st +com.st +consulado.st +edu.st +embaixada.st +mil.st +net.st +org.st +principe.st +saotome.st +store.st + +// su : https://en.wikipedia.org/wiki/.su +su + +// sv : http://www.svnet.org.sv/niveldos.pdf +sv +com.sv +edu.sv +gob.sv +org.sv +red.sv + +// sx : https://en.wikipedia.org/wiki/.sx +// Submitted by registry <jcvignes@openregistry.com> +sx +gov.sx + +// sy : https://en.wikipedia.org/wiki/.sy +// see also: http://www.gobin.info/domainname/sy.doc +sy +edu.sy +gov.sy +net.sy +mil.sy +com.sy +org.sy + +// sz : https://en.wikipedia.org/wiki/.sz +// http://www.sispa.org.sz/ +sz +co.sz +ac.sz +org.sz + +// tc : https://en.wikipedia.org/wiki/.tc +tc + +// td : https://en.wikipedia.org/wiki/.td +td + +// tel: https://en.wikipedia.org/wiki/.tel +// http://www.telnic.org/ +tel + +// tf : https://www.afnic.fr/wp-media/uploads/2022/12/afnic-naming-policy-2023-01-01.pdf +tf + +// tg : https://en.wikipedia.org/wiki/.tg +// http://www.nic.tg/ +tg + +// th : https://en.wikipedia.org/wiki/.th +// Submitted by registry <krit@thains.co.th> +th +ac.th +co.th +go.th +in.th +mi.th +net.th +or.th + +// tj : http://www.nic.tj/policy.html +tj +ac.tj +biz.tj +co.tj +com.tj +edu.tj +go.tj +gov.tj +int.tj +mil.tj +name.tj +net.tj +nic.tj +org.tj +test.tj +web.tj + +// tk : https://en.wikipedia.org/wiki/.tk +tk + +// tl : https://en.wikipedia.org/wiki/.tl +tl +gov.tl + +// tm : http://www.nic.tm/local.html +tm +com.tm +co.tm +org.tm +net.tm +nom.tm +gov.tm +mil.tm +edu.tm + +// tn : http://www.registre.tn/fr/ +// https://whois.ati.tn/ +tn +com.tn +ens.tn +fin.tn +gov.tn +ind.tn +info.tn +intl.tn +mincom.tn +nat.tn +net.tn +org.tn +perso.tn +tourism.tn + +// to : https://en.wikipedia.org/wiki/.to +// Submitted by registry <egullich@colo.to> +to +com.to +gov.to +net.to +org.to +edu.to +mil.to + +// tr : https://nic.tr/ +// https://nic.tr/forms/eng/policies.pdf +// https://nic.tr/index.php?USRACTN=PRICELST +tr +av.tr +bbs.tr +bel.tr +biz.tr +com.tr +dr.tr +edu.tr +gen.tr +gov.tr +info.tr +mil.tr +k12.tr +kep.tr +name.tr +net.tr +org.tr +pol.tr +tel.tr +tsk.tr +tv.tr +web.tr +// Used by Northern Cyprus +nc.tr +// Used by government agencies of Northern Cyprus +gov.nc.tr + +// tt : http://www.nic.tt/ +tt +co.tt +com.tt +org.tt +net.tt +biz.tt +info.tt +pro.tt +int.tt +coop.tt +jobs.tt +mobi.tt +travel.tt +museum.tt +aero.tt +name.tt +gov.tt +edu.tt + +// tv : https://en.wikipedia.org/wiki/.tv +// Not listing any 2LDs as reserved since none seem to exist in practice, +// Wikipedia notwithstanding. +tv + +// tw : https://en.wikipedia.org/wiki/.tw +tw +edu.tw +gov.tw +mil.tw +com.tw +net.tw +org.tw +idv.tw +game.tw +ebiz.tw +club.tw +網路.tw +組織.tw +商業.tw + +// tz : http://www.tznic.or.tz/index.php/domains +// Submitted by registry <manager@tznic.or.tz> +tz +ac.tz +co.tz +go.tz +hotel.tz +info.tz +me.tz +mil.tz +mobi.tz +ne.tz +or.tz +sc.tz +tv.tz + +// ua : https://hostmaster.ua/policy/?ua +// Submitted by registry <dk@cctld.ua> +ua +// ua 2LD +com.ua +edu.ua +gov.ua +in.ua +net.ua +org.ua +// ua geographic names +// https://hostmaster.ua/2ld/ +cherkassy.ua +cherkasy.ua +chernigov.ua +chernihiv.ua +chernivtsi.ua +chernovtsy.ua +ck.ua +cn.ua +cr.ua +crimea.ua +cv.ua +dn.ua +dnepropetrovsk.ua +dnipropetrovsk.ua +donetsk.ua +dp.ua +if.ua +ivano-frankivsk.ua +kh.ua +kharkiv.ua +kharkov.ua +kherson.ua +khmelnitskiy.ua +khmelnytskyi.ua +kiev.ua +kirovograd.ua +km.ua +kr.ua +kropyvnytskyi.ua +krym.ua +ks.ua +kv.ua +kyiv.ua +lg.ua +lt.ua +lugansk.ua +lutsk.ua +lv.ua +lviv.ua +mk.ua +mykolaiv.ua +nikolaev.ua +od.ua +odesa.ua +odessa.ua +pl.ua +poltava.ua +rivne.ua +rovno.ua +rv.ua +sb.ua +sebastopol.ua +sevastopol.ua +sm.ua +sumy.ua +te.ua +ternopil.ua +uz.ua +uzhgorod.ua +vinnica.ua +vinnytsia.ua +vn.ua +volyn.ua +yalta.ua +zaporizhzhe.ua +zaporizhzhia.ua +zhitomir.ua +zhytomyr.ua +zp.ua +zt.ua + +// ug : https://www.registry.co.ug/ +ug +co.ug +or.ug +ac.ug +sc.ug +go.ug +ne.ug +com.ug +org.ug + +// uk : https://en.wikipedia.org/wiki/.uk +// Submitted by registry <Michael.Daly@nominet.org.uk> +uk +ac.uk +co.uk +gov.uk +ltd.uk +me.uk +net.uk +nhs.uk +org.uk +plc.uk +police.uk +*.sch.uk + +// us : https://en.wikipedia.org/wiki/.us +us +dni.us +fed.us +isa.us +kids.us +nsn.us +// us geographic names +ak.us +al.us +ar.us +as.us +az.us +ca.us +co.us +ct.us +dc.us +de.us +fl.us +ga.us +gu.us +hi.us +ia.us +id.us +il.us +in.us +ks.us +ky.us +la.us +ma.us +md.us +me.us +mi.us +mn.us +mo.us +ms.us +mt.us +nc.us +nd.us +ne.us +nh.us +nj.us +nm.us +nv.us +ny.us +oh.us +ok.us +or.us +pa.us +pr.us +ri.us +sc.us +sd.us +tn.us +tx.us +ut.us +vi.us +vt.us +va.us +wa.us +wi.us +wv.us +wy.us +// The registrar notes several more specific domains available in each state, +// such as state.*.us, dst.*.us, etc., but resolution of these is somewhat +// haphazard; in some states these domains resolve as addresses, while in others +// only subdomains are available, or even nothing at all. We include the +// most common ones where it's clear that different sites are different +// entities. +k12.ak.us +k12.al.us +k12.ar.us +k12.as.us +k12.az.us +k12.ca.us +k12.co.us +k12.ct.us +k12.dc.us +k12.de.us +k12.fl.us +k12.ga.us +k12.gu.us +// k12.hi.us Bug 614565 - Hawaii has a state-wide DOE login +k12.ia.us +k12.id.us +k12.il.us +k12.in.us +k12.ks.us +k12.ky.us +k12.la.us +k12.ma.us +k12.md.us +k12.me.us +k12.mi.us +k12.mn.us +k12.mo.us +k12.ms.us +k12.mt.us +k12.nc.us +// k12.nd.us Bug 1028347 - Removed at request of Travis Rosso <trossow@nd.gov> +k12.ne.us +k12.nh.us +k12.nj.us +k12.nm.us +k12.nv.us +k12.ny.us +k12.oh.us +k12.ok.us +k12.or.us +k12.pa.us +k12.pr.us +// k12.ri.us Removed at request of Kim Cournoyer <netsupport@staff.ri.net> +k12.sc.us +// k12.sd.us Bug 934131 - Removed at request of James Booze <James.Booze@k12.sd.us> +k12.tn.us +k12.tx.us +k12.ut.us +k12.vi.us +k12.vt.us +k12.va.us +k12.wa.us +k12.wi.us +// k12.wv.us Bug 947705 - Removed at request of Verne Britton <verne@wvnet.edu> +k12.wy.us +cc.ak.us +cc.al.us +cc.ar.us +cc.as.us +cc.az.us +cc.ca.us +cc.co.us +cc.ct.us +cc.dc.us +cc.de.us +cc.fl.us +cc.ga.us +cc.gu.us +cc.hi.us +cc.ia.us +cc.id.us +cc.il.us +cc.in.us +cc.ks.us +cc.ky.us +cc.la.us +cc.ma.us +cc.md.us +cc.me.us +cc.mi.us +cc.mn.us +cc.mo.us +cc.ms.us +cc.mt.us +cc.nc.us +cc.nd.us +cc.ne.us +cc.nh.us +cc.nj.us +cc.nm.us +cc.nv.us +cc.ny.us +cc.oh.us +cc.ok.us +cc.or.us +cc.pa.us +cc.pr.us +cc.ri.us +cc.sc.us +cc.sd.us +cc.tn.us +cc.tx.us +cc.ut.us +cc.vi.us +cc.vt.us +cc.va.us +cc.wa.us +cc.wi.us +cc.wv.us +cc.wy.us +lib.ak.us +lib.al.us +lib.ar.us +lib.as.us +lib.az.us +lib.ca.us +lib.co.us +lib.ct.us +lib.dc.us +// lib.de.us Issue #243 - Moved to Private section at request of Ed Moore <Ed.Moore@lib.de.us> +lib.fl.us +lib.ga.us +lib.gu.us +lib.hi.us +lib.ia.us +lib.id.us +lib.il.us +lib.in.us +lib.ks.us +lib.ky.us +lib.la.us +lib.ma.us +lib.md.us +lib.me.us +lib.mi.us +lib.mn.us +lib.mo.us +lib.ms.us +lib.mt.us +lib.nc.us +lib.nd.us +lib.ne.us +lib.nh.us +lib.nj.us +lib.nm.us +lib.nv.us +lib.ny.us +lib.oh.us +lib.ok.us +lib.or.us +lib.pa.us +lib.pr.us +lib.ri.us +lib.sc.us +lib.sd.us +lib.tn.us +lib.tx.us +lib.ut.us +lib.vi.us +lib.vt.us +lib.va.us +lib.wa.us +lib.wi.us +// lib.wv.us Bug 941670 - Removed at request of Larry W Arnold <arnold@wvlc.lib.wv.us> +lib.wy.us +// k12.ma.us contains school districts in Massachusetts. The 4LDs are +// managed independently except for private (PVT), charter (CHTR) and +// parochial (PAROCH) schools. Those are delegated directly to the +// 5LD operators. <k12-ma-hostmaster _ at _ rsuc.gweep.net> +pvt.k12.ma.us +chtr.k12.ma.us +paroch.k12.ma.us +// Merit Network, Inc. maintains the registry for =~ /(k12|cc|lib).mi.us/ and the following +// see also: http://domreg.merit.edu +// see also: whois -h whois.domreg.merit.edu help +ann-arbor.mi.us +cog.mi.us +dst.mi.us +eaton.mi.us +gen.mi.us +mus.mi.us +tec.mi.us +washtenaw.mi.us + +// uy : http://www.nic.org.uy/ +uy +com.uy +edu.uy +gub.uy +mil.uy +net.uy +org.uy + +// uz : http://www.reg.uz/ +uz +co.uz +com.uz +net.uz +org.uz + +// va : https://en.wikipedia.org/wiki/.va +va + +// vc : https://en.wikipedia.org/wiki/.vc +// Submitted by registry <kshah@ca.afilias.info> +vc +com.vc +net.vc +org.vc +gov.vc +mil.vc +edu.vc + +// ve : https://registro.nic.ve/ +// Submitted by registry nic@nic.ve and nicve@conatel.gob.ve +ve +arts.ve +bib.ve +co.ve +com.ve +e12.ve +edu.ve +firm.ve +gob.ve +gov.ve +info.ve +int.ve +mil.ve +net.ve +nom.ve +org.ve +rar.ve +rec.ve +store.ve +tec.ve +web.ve + +// vg : https://en.wikipedia.org/wiki/.vg +vg + +// vi : http://www.nic.vi/newdomainform.htm +// http://www.nic.vi/Domain_Rules/body_domain_rules.html indicates some other +// TLDs are "reserved", such as edu.vi and gov.vi, but doesn't actually say they +// are available for registration (which they do not seem to be). +vi +co.vi +com.vi +k12.vi +net.vi +org.vi + +// vn : https://www.vnnic.vn/en/domain/cctld-vn +// https://vnnic.vn/sites/default/files/tailieu/vn.cctld.domains.txt +vn +ac.vn +ai.vn +biz.vn +com.vn +edu.vn +gov.vn +health.vn +id.vn +info.vn +int.vn +io.vn +name.vn +net.vn +org.vn +pro.vn + +// vn geographical names +angiang.vn +bacgiang.vn +backan.vn +baclieu.vn +bacninh.vn +baria-vungtau.vn +bentre.vn +binhdinh.vn +binhduong.vn +binhphuoc.vn +binhthuan.vn +camau.vn +cantho.vn +caobang.vn +daklak.vn +daknong.vn +danang.vn +dienbien.vn +dongnai.vn +dongthap.vn +gialai.vn +hagiang.vn +haiduong.vn +haiphong.vn +hanam.vn +hanoi.vn +hatinh.vn +haugiang.vn +hoabinh.vn +hungyen.vn +khanhhoa.vn +kiengiang.vn +kontum.vn +laichau.vn +lamdong.vn +langson.vn +laocai.vn +longan.vn +namdinh.vn +nghean.vn +ninhbinh.vn +ninhthuan.vn +phutho.vn +phuyen.vn +quangbinh.vn +quangnam.vn +quangngai.vn +quangninh.vn +quangtri.vn +soctrang.vn +sonla.vn +tayninh.vn +thaibinh.vn +thainguyen.vn +thanhhoa.vn +thanhphohochiminh.vn +thuathienhue.vn +tiengiang.vn +travinh.vn +tuyenquang.vn +vinhlong.vn +vinhphuc.vn +yenbai.vn + +// vu : https://en.wikipedia.org/wiki/.vu +// http://www.vunic.vu/ +vu +com.vu +edu.vu +net.vu +org.vu + +// wf : https://www.afnic.fr/wp-media/uploads/2022/12/afnic-naming-policy-2023-01-01.pdf +wf + +// ws : https://en.wikipedia.org/wiki/.ws +// http://samoanic.ws/index.dhtml +ws +com.ws +net.ws +org.ws +gov.ws +edu.ws + +// yt : https://www.afnic.fr/wp-media/uploads/2022/12/afnic-naming-policy-2023-01-01.pdf +yt + +// IDN ccTLDs +// When submitting patches, please maintain a sort by ISO 3166 ccTLD, then +// U-label, and follow this format: +// // A-Label ("<Latin renderings>", <language name>[, variant info]) : <ISO 3166 ccTLD> +// // [sponsoring org] +// U-Label + +// xn--mgbaam7a8h ("Emerat", Arabic) : AE +// http://nic.ae/english/arabicdomain/rules.jsp +امارات + +// xn--y9a3aq ("hye", Armenian) : AM +// ISOC AM (operated by .am Registry) +հայ + +// xn--54b7fta0cc ("Bangla", Bangla) : BD +বাংলা + +// xn--90ae ("bg", Bulgarian) : BG +бг + +// xn--mgbcpq6gpa1a ("albahrain", Arabic) : BH +البحرين + +// xn--90ais ("bel", Belarusian/Russian Cyrillic) : BY +// Operated by .by registry +бел + +// xn--fiqs8s ("Zhongguo/China", Chinese, Simplified) : CN +// CNNIC +// http://cnnic.cn/html/Dir/2005/10/11/3218.htm +中国 + +// xn--fiqz9s ("Zhongguo/China", Chinese, Traditional) : CN +// CNNIC +// http://cnnic.cn/html/Dir/2005/10/11/3218.htm +中國 + +// xn--lgbbat1ad8j ("Algeria/Al Jazair", Arabic) : DZ +الجزائر + +// xn--wgbh1c ("Egypt/Masr", Arabic) : EG +// http://www.dotmasr.eg/ +مصر + +// xn--e1a4c ("eu", Cyrillic) : EU +// https://eurid.eu +ею + +// xn--qxa6a ("eu", Greek) : EU +// https://eurid.eu +ευ + +// xn--mgbah1a3hjkrd ("Mauritania", Arabic) : MR +موريتانيا + +// xn--node ("ge", Georgian Mkhedruli) : GE +გე + +// xn--qxam ("el", Greek) : GR +// Hellenic Ministry of Infrastructure, Transport, and Networks +ελ + +// xn--j6w193g ("Hong Kong", Chinese) : HK +// https://www.hkirc.hk +// Submitted by registry <hk.tech@hkirc.hk> +// https://www.hkirc.hk/content.jsp?id=30#!/34 +香港 +公司.香港 +教育.香港 +政府.香港 +個人.香港 +網絡.香港 +組織.香港 + +// xn--2scrj9c ("Bharat", Kannada) : IN +// India +ಭಾರತ + +// xn--3hcrj9c ("Bharat", Oriya) : IN +// India +ଭାରତ + +// xn--45br5cyl ("Bharatam", Assamese) : IN +// India +ভাৰত + +// xn--h2breg3eve ("Bharatam", Sanskrit) : IN +// India +भारतम् + +// xn--h2brj9c8c ("Bharot", Santali) : IN +// India +भारोत + +// xn--mgbgu82a ("Bharat", Sindhi) : IN +// India +ڀارت + +// xn--rvc1e0am3e ("Bharatam", Malayalam) : IN +// India +ഭാരതം + +// xn--h2brj9c ("Bharat", Devanagari) : IN +// India +भारत + +// xn--mgbbh1a ("Bharat", Kashmiri) : IN +// India +بارت + +// xn--mgbbh1a71e ("Bharat", Arabic) : IN +// India +بھارت + +// xn--fpcrj9c3d ("Bharat", Telugu) : IN +// India +భారత్ + +// xn--gecrj9c ("Bharat", Gujarati) : IN +// India +ભારત + +// xn--s9brj9c ("Bharat", Gurmukhi) : IN +// India +ਭਾਰਤ + +// xn--45brj9c ("Bharat", Bengali) : IN +// India +ভারত + +// xn--xkc2dl3a5ee0h ("India", Tamil) : IN +// India +இந்தியா + +// xn--mgba3a4f16a ("Iran", Persian) : IR +ایران + +// xn--mgba3a4fra ("Iran", Arabic) : IR +ايران + +// xn--mgbtx2b ("Iraq", Arabic) : IQ +// Communications and Media Commission +عراق + +// xn--mgbayh7gpa ("al-Ordon", Arabic) : JO +// National Information Technology Center (NITC) +// Royal Scientific Society, Al-Jubeiha +الاردن + +// xn--3e0b707e ("Republic of Korea", Hangul) : KR +한국 + +// xn--80ao21a ("Kaz", Kazakh) : KZ +қаз + +// xn--q7ce6a ("Lao", Lao) : LA +ລາວ + +// xn--fzc2c9e2c ("Lanka", Sinhalese-Sinhala) : LK +// https://nic.lk +ලංකා + +// xn--xkc2al3hye2a ("Ilangai", Tamil) : LK +// https://nic.lk +இலங்கை + +// xn--mgbc0a9azcg ("Morocco/al-Maghrib", Arabic) : MA +المغرب + +// xn--d1alf ("mkd", Macedonian) : MK +// MARnet +мкд + +// xn--l1acc ("mon", Mongolian) : MN +мон + +// xn--mix891f ("Macao", Chinese, Traditional) : MO +// MONIC / HNET Asia (Registry Operator for .mo) +澳門 + +// xn--mix082f ("Macao", Chinese, Simplified) : MO +澳门 + +// xn--mgbx4cd0ab ("Malaysia", Malay) : MY +مليسيا + +// xn--mgb9awbf ("Oman", Arabic) : OM +عمان + +// xn--mgbai9azgqp6j ("Pakistan", Urdu/Arabic) : PK +پاکستان + +// xn--mgbai9a5eva00b ("Pakistan", Urdu/Arabic, variant) : PK +پاكستان + +// xn--ygbi2ammx ("Falasteen", Arabic) : PS +// The Palestinian National Internet Naming Authority (PNINA) +// http://www.pnina.ps +فلسطين + +// xn--90a3ac ("srb", Cyrillic) : RS +// https://www.rnids.rs/en/domains/national-domains +срб +пр.срб +орг.срб +обр.срб +од.срб +упр.срб +ак.срб + +// xn--p1ai ("rf", Russian-Cyrillic) : RU +// https://cctld.ru/files/pdf/docs/en/rules_ru-rf.pdf +// Submitted by George Georgievsky <gug@cctld.ru> +рф + +// xn--wgbl6a ("Qatar", Arabic) : QA +// http://www.ict.gov.qa/ +قطر + +// xn--mgberp4a5d4ar ("AlSaudiah", Arabic) : SA +// http://www.nic.net.sa/ +السعودية + +// xn--mgberp4a5d4a87g ("AlSaudiah", Arabic, variant) : SA +السعودیة + +// xn--mgbqly7c0a67fbc ("AlSaudiah", Arabic, variant) : SA +السعودیۃ + +// xn--mgbqly7cvafr ("AlSaudiah", Arabic, variant) : SA +السعوديه + +// xn--mgbpl2fh ("sudan", Arabic) : SD +// Operated by .sd registry +سودان + +// xn--yfro4i67o Singapore ("Singapore", Chinese) : SG +新加坡 + +// xn--clchc0ea0b2g2a9gcd ("Singapore", Tamil) : SG +சிங்கப்பூர் + +// xn--ogbpf8fl ("Syria", Arabic) : SY +سورية + +// xn--mgbtf8fl ("Syria", Arabic, variant) : SY +سوريا + +// xn--o3cw4h ("Thai", Thai) : TH +// http://www.thnic.co.th +ไทย +ศึกษา.ไทย +ธุรกิจ.ไทย +รัฐบาล.ไทย +ทหาร.ไทย +เน็ต.ไทย +องค์กร.ไทย + +// xn--pgbs0dh ("Tunisia", Arabic) : TN +// http://nic.tn +تونس + +// xn--kpry57d ("Taiwan", Chinese, Traditional) : TW +// http://www.twnic.net/english/dn/dn_07a.htm +台灣 + +// xn--kprw13d ("Taiwan", Chinese, Simplified) : TW +// http://www.twnic.net/english/dn/dn_07a.htm +台湾 + +// xn--nnx388a ("Taiwan", Chinese, variant) : TW +臺灣 + +// xn--j1amh ("ukr", Cyrillic) : UA +укр + +// xn--mgb2ddes ("AlYemen", Arabic) : YE +اليمن + +// xxx : http://icmregistry.com +xxx + +// ye : http://www.y.net.ye/services/domain_name.htm +ye +com.ye +edu.ye +gov.ye +net.ye +mil.ye +org.ye + +// za : https://www.zadna.org.za/content/page/domain-information/ +ac.za +agric.za +alt.za +co.za +edu.za +gov.za +grondar.za +law.za +mil.za +net.za +ngo.za +nic.za +nis.za +nom.za +org.za +school.za +tm.za +web.za + +// zm : https://zicta.zm/ +// Submitted by registry <info@zicta.zm> +zm +ac.zm +biz.zm +co.zm +com.zm +edu.zm +gov.zm +info.zm +mil.zm +net.zm +org.zm +sch.zm + +// zw : https://www.potraz.gov.zw/ +// Confirmed by registry <bmtengwa@potraz.gov.zw> 2017-01-25 +zw +ac.zw +co.zw +gov.zw +mil.zw +org.zw + + +// newGTLDs + +// List of new gTLDs imported from https://www.icann.org/resources/registries/gtlds/v2/gtlds.json on 2023-06-16T15:12:40Z +// This list is auto-generated, don't edit it manually. +// aaa : 2015-02-26 American Automobile Association, Inc. +aaa + +// aarp : 2015-05-21 AARP +aarp + +// abb : 2014-10-24 ABB Ltd +abb + +// abbott : 2014-07-24 Abbott Laboratories, Inc. +abbott + +// abbvie : 2015-07-30 AbbVie Inc. +abbvie + +// abc : 2015-07-30 Disney Enterprises, Inc. +abc + +// able : 2015-06-25 Able Inc. +able + +// abogado : 2014-04-24 Registry Services, LLC +abogado + +// abudhabi : 2015-07-30 Abu Dhabi Systems and Information Centre +abudhabi + +// academy : 2013-11-07 Binky Moon, LLC +academy + +// accenture : 2014-08-15 Accenture plc +accenture + +// accountant : 2014-11-20 dot Accountant Limited +accountant + +// accountants : 2014-03-20 Binky Moon, LLC +accountants + +// aco : 2015-01-08 ACO Severin Ahlmann GmbH & Co. KG +aco + +// actor : 2013-12-12 Dog Beach, LLC +actor + +// ads : 2014-12-04 Charleston Road Registry Inc. +ads + +// adult : 2014-10-16 ICM Registry AD LLC +adult + +// aeg : 2015-03-19 Aktiebolaget Electrolux +aeg + +// aetna : 2015-05-21 Aetna Life Insurance Company +aetna + +// afl : 2014-10-02 Australian Football League +afl + +// africa : 2014-03-24 ZA Central Registry NPC trading as Registry.Africa +africa + +// agakhan : 2015-04-23 Fondation Aga Khan (Aga Khan Foundation) +agakhan + +// agency : 2013-11-14 Binky Moon, LLC +agency + +// aig : 2014-12-18 American International Group, Inc. +aig + +// airbus : 2015-07-30 Airbus S.A.S. +airbus + +// airforce : 2014-03-06 Dog Beach, LLC +airforce + +// airtel : 2014-10-24 Bharti Airtel Limited +airtel + +// akdn : 2015-04-23 Fondation Aga Khan (Aga Khan Foundation) +akdn + +// alibaba : 2015-01-15 Alibaba Group Holding Limited +alibaba + +// alipay : 2015-01-15 Alibaba Group Holding Limited +alipay + +// allfinanz : 2014-07-03 Allfinanz Deutsche Vermögensberatung Aktiengesellschaft +allfinanz + +// allstate : 2015-07-31 Allstate Fire and Casualty Insurance Company +allstate + +// ally : 2015-06-18 Ally Financial Inc. +ally + +// alsace : 2014-07-02 Region Grand Est +alsace + +// alstom : 2015-07-30 ALSTOM +alstom + +// amazon : 2019-12-19 Amazon Registry Services, Inc. +amazon + +// americanexpress : 2015-07-31 American Express Travel Related Services Company, Inc. +americanexpress + +// americanfamily : 2015-07-23 AmFam, Inc. +americanfamily + +// amex : 2015-07-31 American Express Travel Related Services Company, Inc. +amex + +// amfam : 2015-07-23 AmFam, Inc. +amfam + +// amica : 2015-05-28 Amica Mutual Insurance Company +amica + +// amsterdam : 2014-07-24 Gemeente Amsterdam +amsterdam + +// analytics : 2014-12-18 Campus IP LLC +analytics + +// android : 2014-08-07 Charleston Road Registry Inc. +android + +// anquan : 2015-01-08 Beijing Qihu Keji Co., Ltd. +anquan + +// anz : 2015-07-31 Australia and New Zealand Banking Group Limited +anz + +// aol : 2015-09-17 Oath Inc. +aol + +// apartments : 2014-12-11 Binky Moon, LLC +apartments + +// app : 2015-05-14 Charleston Road Registry Inc. +app + +// apple : 2015-05-14 Apple Inc. +apple + +// aquarelle : 2014-07-24 Aquarelle.com +aquarelle + +// arab : 2015-11-12 League of Arab States +arab + +// aramco : 2014-11-20 Aramco Services Company +aramco + +// archi : 2014-02-06 Identity Digital Limited +archi + +// army : 2014-03-06 Dog Beach, LLC +army + +// art : 2016-03-24 UK Creative Ideas Limited +art + +// arte : 2014-12-11 Association Relative à la Télévision Européenne G.E.I.E. +arte + +// asda : 2015-07-31 Wal-Mart Stores, Inc. +asda + +// associates : 2014-03-06 Binky Moon, LLC +associates + +// athleta : 2015-07-30 The Gap, Inc. +athleta + +// attorney : 2014-03-20 Dog Beach, LLC +attorney + +// auction : 2014-03-20 Dog Beach, LLC +auction + +// audi : 2015-05-21 AUDI Aktiengesellschaft +audi + +// audible : 2015-06-25 Amazon Registry Services, Inc. +audible + +// audio : 2014-03-20 XYZ.COM LLC +audio + +// auspost : 2015-08-13 Australian Postal Corporation +auspost + +// author : 2014-12-18 Amazon Registry Services, Inc. +author + +// auto : 2014-11-13 XYZ.COM LLC +auto + +// autos : 2014-01-09 XYZ.COM LLC +autos + +// avianca : 2015-01-08 Avianca Inc. +avianca + +// aws : 2015-06-25 AWS Registry LLC +aws + +// axa : 2013-12-19 AXA Group Operations SAS +axa + +// azure : 2014-12-18 Microsoft Corporation +azure + +// baby : 2015-04-09 XYZ.COM LLC +baby + +// baidu : 2015-01-08 Baidu, Inc. +baidu + +// banamex : 2015-07-30 Citigroup Inc. +banamex + +// bananarepublic : 2015-07-31 The Gap, Inc. +bananarepublic + +// band : 2014-06-12 Dog Beach, LLC +band + +// bank : 2014-09-25 fTLD Registry Services LLC +bank + +// bar : 2013-12-12 Punto 2012 Sociedad Anonima Promotora de Inversion de Capital Variable +bar + +// barcelona : 2014-07-24 Municipi de Barcelona +barcelona + +// barclaycard : 2014-11-20 Barclays Bank PLC +barclaycard + +// barclays : 2014-11-20 Barclays Bank PLC +barclays + +// barefoot : 2015-06-11 Gallo Vineyards, Inc. +barefoot + +// bargains : 2013-11-14 Binky Moon, LLC +bargains + +// baseball : 2015-10-29 MLB Advanced Media DH, LLC +baseball + +// basketball : 2015-08-20 Fédération Internationale de Basketball (FIBA) +basketball + +// bauhaus : 2014-04-17 Werkhaus GmbH +bauhaus + +// bayern : 2014-01-23 Bayern Connect GmbH +bayern + +// bbc : 2014-12-18 British Broadcasting Corporation +bbc + +// bbt : 2015-07-23 BB&T Corporation +bbt + +// bbva : 2014-10-02 BANCO BILBAO VIZCAYA ARGENTARIA, S.A. +bbva + +// bcg : 2015-04-02 The Boston Consulting Group, Inc. +bcg + +// bcn : 2014-07-24 Municipi de Barcelona +bcn + +// beats : 2015-05-14 Beats Electronics, LLC +beats + +// beauty : 2015-12-03 XYZ.COM LLC +beauty + +// beer : 2014-01-09 Registry Services, LLC +beer + +// bentley : 2014-12-18 Bentley Motors Limited +bentley + +// berlin : 2013-10-31 dotBERLIN GmbH & Co. KG +berlin + +// best : 2013-12-19 BestTLD Pty Ltd +best + +// bestbuy : 2015-07-31 BBY Solutions, Inc. +bestbuy + +// bet : 2015-05-07 Identity Digital Limited +bet + +// bharti : 2014-01-09 Bharti Enterprises (Holding) Private Limited +bharti + +// bible : 2014-06-19 American Bible Society +bible + +// bid : 2013-12-19 dot Bid Limited +bid + +// bike : 2013-08-27 Binky Moon, LLC +bike + +// bing : 2014-12-18 Microsoft Corporation +bing + +// bingo : 2014-12-04 Binky Moon, LLC +bingo + +// bio : 2014-03-06 Identity Digital Limited +bio + +// black : 2014-01-16 Identity Digital Limited +black + +// blackfriday : 2014-01-16 Registry Services, LLC +blackfriday + +// blockbuster : 2015-07-30 Dish DBS Corporation +blockbuster + +// blog : 2015-05-14 Knock Knock WHOIS There, LLC +blog + +// bloomberg : 2014-07-17 Bloomberg IP Holdings LLC +bloomberg + +// blue : 2013-11-07 Identity Digital Limited +blue + +// bms : 2014-10-30 Bristol-Myers Squibb Company +bms + +// bmw : 2014-01-09 Bayerische Motoren Werke Aktiengesellschaft +bmw + +// bnpparibas : 2014-05-29 BNP Paribas +bnpparibas + +// boats : 2014-12-04 XYZ.COM LLC +boats + +// boehringer : 2015-07-09 Boehringer Ingelheim International GmbH +boehringer + +// bofa : 2015-07-31 Bank of America Corporation +bofa + +// bom : 2014-10-16 Núcleo de Informação e Coordenação do Ponto BR - NIC.br +bom + +// bond : 2014-06-05 ShortDot SA +bond + +// boo : 2014-01-30 Charleston Road Registry Inc. +boo + +// book : 2015-08-27 Amazon Registry Services, Inc. +book + +// booking : 2015-07-16 Booking.com B.V. +booking + +// bosch : 2015-06-18 Robert Bosch GMBH +bosch + +// bostik : 2015-05-28 Bostik SA +bostik + +// boston : 2015-12-10 Registry Services, LLC +boston + +// bot : 2014-12-18 Amazon Registry Services, Inc. +bot + +// boutique : 2013-11-14 Binky Moon, LLC +boutique + +// box : 2015-11-12 Intercap Registry Inc. +box + +// bradesco : 2014-12-18 Banco Bradesco S.A. +bradesco + +// bridgestone : 2014-12-18 Bridgestone Corporation +bridgestone + +// broadway : 2014-12-22 Celebrate Broadway, Inc. +broadway + +// broker : 2014-12-11 Dog Beach, LLC +broker + +// brother : 2015-01-29 Brother Industries, Ltd. +brother + +// brussels : 2014-02-06 DNS.be vzw +brussels + +// build : 2013-11-07 Plan Bee LLC +build + +// builders : 2013-11-07 Binky Moon, LLC +builders + +// business : 2013-11-07 Binky Moon, LLC +business + +// buy : 2014-12-18 Amazon Registry Services, Inc. +buy + +// buzz : 2013-10-02 DOTSTRATEGY CO. +buzz + +// bzh : 2014-02-27 Association www.bzh +bzh + +// cab : 2013-10-24 Binky Moon, LLC +cab + +// cafe : 2015-02-11 Binky Moon, LLC +cafe + +// cal : 2014-07-24 Charleston Road Registry Inc. +cal + +// call : 2014-12-18 Amazon Registry Services, Inc. +call + +// calvinklein : 2015-07-30 PVH gTLD Holdings LLC +calvinklein + +// cam : 2016-04-21 Cam Connecting SARL +cam + +// camera : 2013-08-27 Binky Moon, LLC +camera + +// camp : 2013-11-07 Binky Moon, LLC +camp + +// canon : 2014-09-12 Canon Inc. +canon + +// capetown : 2014-03-24 ZA Central Registry NPC trading as ZA Central Registry +capetown + +// capital : 2014-03-06 Binky Moon, LLC +capital + +// capitalone : 2015-08-06 Capital One Financial Corporation +capitalone + +// car : 2015-01-22 XYZ.COM LLC +car + +// caravan : 2013-12-12 Caravan International, Inc. +caravan + +// cards : 2013-12-05 Binky Moon, LLC +cards + +// care : 2014-03-06 Binky Moon, LLC +care + +// career : 2013-10-09 dotCareer LLC +career + +// careers : 2013-10-02 Binky Moon, LLC +careers + +// cars : 2014-11-13 XYZ.COM LLC +cars + +// casa : 2013-11-21 Registry Services, LLC +casa + +// case : 2015-09-03 Digity, LLC +case + +// cash : 2014-03-06 Binky Moon, LLC +cash + +// casino : 2014-12-18 Binky Moon, LLC +casino + +// catering : 2013-12-05 Binky Moon, LLC +catering + +// catholic : 2015-10-21 Pontificium Consilium de Comunicationibus Socialibus (PCCS) (Pontifical Council for Social Communication) +catholic + +// cba : 2014-06-26 COMMONWEALTH BANK OF AUSTRALIA +cba + +// cbn : 2014-08-22 The Christian Broadcasting Network, Inc. +cbn + +// cbre : 2015-07-02 CBRE, Inc. +cbre + +// cbs : 2015-08-06 CBS Domains Inc. +cbs + +// center : 2013-11-07 Binky Moon, LLC +center + +// ceo : 2013-11-07 CEOTLD Pty Ltd +ceo + +// cern : 2014-06-05 European Organization for Nuclear Research ("CERN") +cern + +// cfa : 2014-08-28 CFA Institute +cfa + +// cfd : 2014-12-11 ShortDot SA +cfd + +// chanel : 2015-04-09 Chanel International B.V. +chanel + +// channel : 2014-05-08 Charleston Road Registry Inc. +channel + +// charity : 2018-04-11 Public Interest Registry +charity + +// chase : 2015-04-30 JPMorgan Chase Bank, National Association +chase + +// chat : 2014-12-04 Binky Moon, LLC +chat + +// cheap : 2013-11-14 Binky Moon, LLC +cheap + +// chintai : 2015-06-11 CHINTAI Corporation +chintai + +// christmas : 2013-11-21 XYZ.COM LLC +christmas + +// chrome : 2014-07-24 Charleston Road Registry Inc. +chrome + +// church : 2014-02-06 Binky Moon, LLC +church + +// cipriani : 2015-02-19 Hotel Cipriani Srl +cipriani + +// circle : 2014-12-18 Amazon Registry Services, Inc. +circle + +// cisco : 2014-12-22 Cisco Technology, Inc. +cisco + +// citadel : 2015-07-23 Citadel Domain LLC +citadel + +// citi : 2015-07-30 Citigroup Inc. +citi + +// citic : 2014-01-09 CITIC Group Corporation +citic + +// city : 2014-05-29 Binky Moon, LLC +city + +// cityeats : 2014-12-11 Lifestyle Domain Holdings, Inc. +cityeats + +// claims : 2014-03-20 Binky Moon, LLC +claims + +// cleaning : 2013-12-05 Binky Moon, LLC +cleaning + +// click : 2014-06-05 Internet Naming Company LLC +click + +// clinic : 2014-03-20 Binky Moon, LLC +clinic + +// clinique : 2015-10-01 The Estée Lauder Companies Inc. +clinique + +// clothing : 2013-08-27 Binky Moon, LLC +clothing + +// cloud : 2015-04-16 Aruba PEC S.p.A. +cloud + +// club : 2013-11-08 Registry Services, LLC +club + +// clubmed : 2015-06-25 Club Méditerranée S.A. +clubmed + +// coach : 2014-10-09 Binky Moon, LLC +coach + +// codes : 2013-10-31 Binky Moon, LLC +codes + +// coffee : 2013-10-17 Binky Moon, LLC +coffee + +// college : 2014-01-16 XYZ.COM LLC +college + +// cologne : 2014-02-05 dotKoeln GmbH +cologne + +// comcast : 2015-07-23 Comcast IP Holdings I, LLC +comcast + +// commbank : 2014-06-26 COMMONWEALTH BANK OF AUSTRALIA +commbank + +// community : 2013-12-05 Binky Moon, LLC +community + +// company : 2013-11-07 Binky Moon, LLC +company + +// compare : 2015-10-08 Registry Services, LLC +compare + +// computer : 2013-10-24 Binky Moon, LLC +computer + +// comsec : 2015-01-08 VeriSign, Inc. +comsec + +// condos : 2013-12-05 Binky Moon, LLC +condos + +// construction : 2013-09-16 Binky Moon, LLC +construction + +// consulting : 2013-12-05 Dog Beach, LLC +consulting + +// contact : 2015-01-08 Dog Beach, LLC +contact + +// contractors : 2013-09-10 Binky Moon, LLC +contractors + +// cooking : 2013-11-21 Registry Services, LLC +cooking + +// cool : 2013-11-14 Binky Moon, LLC +cool + +// corsica : 2014-09-25 Collectivité de Corse +corsica + +// country : 2013-12-19 Internet Naming Company LLC +country + +// coupon : 2015-02-26 Amazon Registry Services, Inc. +coupon + +// coupons : 2015-03-26 Binky Moon, LLC +coupons + +// courses : 2014-12-04 Registry Services, LLC +courses + +// cpa : 2019-06-10 American Institute of Certified Public Accountants +cpa + +// credit : 2014-03-20 Binky Moon, LLC +credit + +// creditcard : 2014-03-20 Binky Moon, LLC +creditcard + +// creditunion : 2015-01-22 DotCooperation LLC +creditunion + +// cricket : 2014-10-09 dot Cricket Limited +cricket + +// crown : 2014-10-24 Crown Equipment Corporation +crown + +// crs : 2014-04-03 Federated Co-operatives Limited +crs + +// cruise : 2015-12-10 Viking River Cruises (Bermuda) Ltd. +cruise + +// cruises : 2013-12-05 Binky Moon, LLC +cruises + +// cuisinella : 2014-04-03 SCHMIDT GROUPE S.A.S. +cuisinella + +// cymru : 2014-05-08 Nominet UK +cymru + +// cyou : 2015-01-22 ShortDot SA +cyou + +// dabur : 2014-02-06 Dabur India Limited +dabur + +// dad : 2014-01-23 Charleston Road Registry Inc. +dad + +// dance : 2013-10-24 Dog Beach, LLC +dance + +// data : 2016-06-02 Dish DBS Corporation +data + +// date : 2014-11-20 dot Date Limited +date + +// dating : 2013-12-05 Binky Moon, LLC +dating + +// datsun : 2014-03-27 NISSAN MOTOR CO., LTD. +datsun + +// day : 2014-01-30 Charleston Road Registry Inc. +day + +// dclk : 2014-11-20 Charleston Road Registry Inc. +dclk + +// dds : 2015-05-07 Registry Services, LLC +dds + +// deal : 2015-06-25 Amazon Registry Services, Inc. +deal + +// dealer : 2014-12-22 Intercap Registry Inc. +dealer + +// deals : 2014-05-22 Binky Moon, LLC +deals + +// degree : 2014-03-06 Dog Beach, LLC +degree + +// delivery : 2014-09-11 Binky Moon, LLC +delivery + +// dell : 2014-10-24 Dell Inc. +dell + +// deloitte : 2015-07-31 Deloitte Touche Tohmatsu +deloitte + +// delta : 2015-02-19 Delta Air Lines, Inc. +delta + +// democrat : 2013-10-24 Dog Beach, LLC +democrat + +// dental : 2014-03-20 Binky Moon, LLC +dental + +// dentist : 2014-03-20 Dog Beach, LLC +dentist + +// desi : 2013-11-14 Desi Networks LLC +desi + +// design : 2014-11-07 Registry Services, LLC +design + +// dev : 2014-10-16 Charleston Road Registry Inc. +dev + +// dhl : 2015-07-23 Deutsche Post AG +dhl + +// diamonds : 2013-09-22 Binky Moon, LLC +diamonds + +// diet : 2014-06-26 XYZ.COM LLC +diet + +// digital : 2014-03-06 Binky Moon, LLC +digital + +// direct : 2014-04-10 Binky Moon, LLC +direct + +// directory : 2013-09-20 Binky Moon, LLC +directory + +// discount : 2014-03-06 Binky Moon, LLC +discount + +// discover : 2015-07-23 Discover Financial Services +discover + +// dish : 2015-07-30 Dish DBS Corporation +dish + +// diy : 2015-11-05 Lifestyle Domain Holdings, Inc. +diy + +// dnp : 2013-12-13 Dai Nippon Printing Co., Ltd. +dnp + +// docs : 2014-10-16 Charleston Road Registry Inc. +docs + +// doctor : 2016-06-02 Binky Moon, LLC +doctor + +// dog : 2014-12-04 Binky Moon, LLC +dog + +// domains : 2013-10-17 Binky Moon, LLC +domains + +// dot : 2015-05-21 Dish DBS Corporation +dot + +// download : 2014-11-20 dot Support Limited +download + +// drive : 2015-03-05 Charleston Road Registry Inc. +drive + +// dtv : 2015-06-04 Dish DBS Corporation +dtv + +// dubai : 2015-01-01 Dubai Smart Government Department +dubai + +// dunlop : 2015-07-02 The Goodyear Tire & Rubber Company +dunlop + +// dupont : 2015-06-25 DuPont Specialty Products USA, LLC +dupont + +// durban : 2014-03-24 ZA Central Registry NPC trading as ZA Central Registry +durban + +// dvag : 2014-06-23 Deutsche Vermögensberatung Aktiengesellschaft DVAG +dvag + +// dvr : 2016-05-26 DISH Technologies L.L.C. +dvr + +// earth : 2014-12-04 Interlink Systems Innovation Institute K.K. +earth + +// eat : 2014-01-23 Charleston Road Registry Inc. +eat + +// eco : 2016-07-08 Big Room Inc. +eco + +// edeka : 2014-12-18 EDEKA Verband kaufmännischer Genossenschaften e.V. +edeka + +// education : 2013-11-07 Binky Moon, LLC +education + +// email : 2013-10-31 Binky Moon, LLC +email + +// emerck : 2014-04-03 Merck KGaA +emerck + +// energy : 2014-09-11 Binky Moon, LLC +energy + +// engineer : 2014-03-06 Dog Beach, LLC +engineer + +// engineering : 2014-03-06 Binky Moon, LLC +engineering + +// enterprises : 2013-09-20 Binky Moon, LLC +enterprises + +// epson : 2014-12-04 Seiko Epson Corporation +epson + +// equipment : 2013-08-27 Binky Moon, LLC +equipment + +// ericsson : 2015-07-09 Telefonaktiebolaget L M Ericsson +ericsson + +// erni : 2014-04-03 ERNI Group Holding AG +erni + +// esq : 2014-05-08 Charleston Road Registry Inc. +esq + +// estate : 2013-08-27 Binky Moon, LLC +estate + +// etisalat : 2015-09-03 Emirates Telecommunications Corporation (trading as Etisalat) +etisalat + +// eurovision : 2014-04-24 European Broadcasting Union (EBU) +eurovision + +// eus : 2013-12-12 Puntueus Fundazioa +eus + +// events : 2013-12-05 Binky Moon, LLC +events + +// exchange : 2014-03-06 Binky Moon, LLC +exchange + +// expert : 2013-11-21 Binky Moon, LLC +expert + +// exposed : 2013-12-05 Binky Moon, LLC +exposed + +// express : 2015-02-11 Binky Moon, LLC +express + +// extraspace : 2015-05-14 Extra Space Storage LLC +extraspace + +// fage : 2014-12-18 Fage International S.A. +fage + +// fail : 2014-03-06 Binky Moon, LLC +fail + +// fairwinds : 2014-11-13 FairWinds Partners, LLC +fairwinds + +// faith : 2014-11-20 dot Faith Limited +faith + +// family : 2015-04-02 Dog Beach, LLC +family + +// fan : 2014-03-06 Dog Beach, LLC +fan + +// fans : 2014-11-07 ZDNS International Limited +fans + +// farm : 2013-11-07 Binky Moon, LLC +farm + +// farmers : 2015-07-09 Farmers Insurance Exchange +farmers + +// fashion : 2014-07-03 Registry Services, LLC +fashion + +// fast : 2014-12-18 Amazon Registry Services, Inc. +fast + +// fedex : 2015-08-06 Federal Express Corporation +fedex + +// feedback : 2013-12-19 Top Level Spectrum, Inc. +feedback + +// ferrari : 2015-07-31 Fiat Chrysler Automobiles N.V. +ferrari + +// ferrero : 2014-12-18 Ferrero Trading Lux S.A. +ferrero + +// fidelity : 2015-07-30 Fidelity Brokerage Services LLC +fidelity + +// fido : 2015-08-06 Rogers Communications Canada Inc. +fido + +// film : 2015-01-08 Motion Picture Domain Registry Pty Ltd +film + +// final : 2014-10-16 Núcleo de Informação e Coordenação do Ponto BR - NIC.br +final + +// finance : 2014-03-20 Binky Moon, LLC +finance + +// financial : 2014-03-06 Binky Moon, LLC +financial + +// fire : 2015-06-25 Amazon Registry Services, Inc. +fire + +// firestone : 2014-12-18 Bridgestone Licensing Services, Inc +firestone + +// firmdale : 2014-03-27 Firmdale Holdings Limited +firmdale + +// fish : 2013-12-12 Binky Moon, LLC +fish + +// fishing : 2013-11-21 Registry Services, LLC +fishing + +// fit : 2014-11-07 Registry Services, LLC +fit + +// fitness : 2014-03-06 Binky Moon, LLC +fitness + +// flickr : 2015-04-02 Flickr, Inc. +flickr + +// flights : 2013-12-05 Binky Moon, LLC +flights + +// flir : 2015-07-23 FLIR Systems, Inc. +flir + +// florist : 2013-11-07 Binky Moon, LLC +florist + +// flowers : 2014-10-09 XYZ.COM LLC +flowers + +// fly : 2014-05-08 Charleston Road Registry Inc. +fly + +// foo : 2014-01-23 Charleston Road Registry Inc. +foo + +// food : 2016-04-21 Lifestyle Domain Holdings, Inc. +food + +// football : 2014-12-18 Binky Moon, LLC +football + +// ford : 2014-11-13 Ford Motor Company +ford + +// forex : 2014-12-11 Dog Beach, LLC +forex + +// forsale : 2014-05-22 Dog Beach, LLC +forsale + +// forum : 2015-04-02 Fegistry, LLC +forum + +// foundation : 2013-12-05 Public Interest Registry +foundation + +// fox : 2015-09-11 FOX Registry, LLC +fox + +// free : 2015-12-10 Amazon Registry Services, Inc. +free + +// fresenius : 2015-07-30 Fresenius Immobilien-Verwaltungs-GmbH +fresenius + +// frl : 2014-05-15 FRLregistry B.V. +frl + +// frogans : 2013-12-19 OP3FT +frogans + +// frontdoor : 2015-07-02 Lifestyle Domain Holdings, Inc. +frontdoor + +// frontier : 2015-02-05 Frontier Communications Corporation +frontier + +// ftr : 2015-07-16 Frontier Communications Corporation +ftr + +// fujitsu : 2015-07-30 Fujitsu Limited +fujitsu + +// fun : 2016-01-14 Radix FZC +fun + +// fund : 2014-03-20 Binky Moon, LLC +fund + +// furniture : 2014-03-20 Binky Moon, LLC +furniture + +// futbol : 2013-09-20 Dog Beach, LLC +futbol + +// fyi : 2015-04-02 Binky Moon, LLC +fyi + +// gal : 2013-11-07 Asociación puntoGAL +gal + +// gallery : 2013-09-13 Binky Moon, LLC +gallery + +// gallo : 2015-06-11 Gallo Vineyards, Inc. +gallo + +// gallup : 2015-02-19 Gallup, Inc. +gallup + +// game : 2015-05-28 XYZ.COM LLC +game + +// games : 2015-05-28 Dog Beach, LLC +games + +// gap : 2015-07-31 The Gap, Inc. +gap + +// garden : 2014-06-26 Registry Services, LLC +garden + +// gay : 2019-05-23 Registry Services, LLC +gay + +// gbiz : 2014-07-17 Charleston Road Registry Inc. +gbiz + +// gdn : 2014-07-31 Joint Stock Company "Navigation-information systems" +gdn + +// gea : 2014-12-04 GEA Group Aktiengesellschaft +gea + +// gent : 2014-01-23 Easyhost BV +gent + +// genting : 2015-03-12 Resorts World Inc Pte. Ltd. +genting + +// george : 2015-07-31 Wal-Mart Stores, Inc. +george + +// ggee : 2014-01-09 GMO Internet, Inc. +ggee + +// gift : 2013-10-17 DotGift, LLC +gift + +// gifts : 2014-07-03 Binky Moon, LLC +gifts + +// gives : 2014-03-06 Public Interest Registry +gives + +// giving : 2014-11-13 Public Interest Registry +giving + +// glass : 2013-11-07 Binky Moon, LLC +glass + +// gle : 2014-07-24 Charleston Road Registry Inc. +gle + +// global : 2014-04-17 Identity Digital Limited +global + +// globo : 2013-12-19 Globo Comunicação e Participações S.A +globo + +// gmail : 2014-05-01 Charleston Road Registry Inc. +gmail + +// gmbh : 2016-01-29 Binky Moon, LLC +gmbh + +// gmo : 2014-01-09 GMO Internet, Inc. +gmo + +// gmx : 2014-04-24 1&1 Mail & Media GmbH +gmx + +// godaddy : 2015-07-23 Go Daddy East, LLC +godaddy + +// gold : 2015-01-22 Binky Moon, LLC +gold + +// goldpoint : 2014-11-20 YODOBASHI CAMERA CO.,LTD. +goldpoint + +// golf : 2014-12-18 Binky Moon, LLC +golf + +// goo : 2014-12-18 NTT Resonant Inc. +goo + +// goodyear : 2015-07-02 The Goodyear Tire & Rubber Company +goodyear + +// goog : 2014-11-20 Charleston Road Registry Inc. +goog + +// google : 2014-07-24 Charleston Road Registry Inc. +google + +// gop : 2014-01-16 Republican State Leadership Committee, Inc. +gop + +// got : 2014-12-18 Amazon Registry Services, Inc. +got + +// grainger : 2015-05-07 Grainger Registry Services, LLC +grainger + +// graphics : 2013-09-13 Binky Moon, LLC +graphics + +// gratis : 2014-03-20 Binky Moon, LLC +gratis + +// green : 2014-05-08 Identity Digital Limited +green + +// gripe : 2014-03-06 Binky Moon, LLC +gripe + +// grocery : 2016-06-16 Wal-Mart Stores, Inc. +grocery + +// group : 2014-08-15 Binky Moon, LLC +group + +// guardian : 2015-07-30 The Guardian Life Insurance Company of America +guardian + +// gucci : 2014-11-13 Guccio Gucci S.p.a. +gucci + +// guge : 2014-08-28 Charleston Road Registry Inc. +guge + +// guide : 2013-09-13 Binky Moon, LLC +guide + +// guitars : 2013-11-14 XYZ.COM LLC +guitars + +// guru : 2013-08-27 Binky Moon, LLC +guru + +// hair : 2015-12-03 XYZ.COM LLC +hair + +// hamburg : 2014-02-20 Hamburg Top-Level-Domain GmbH +hamburg + +// hangout : 2014-11-13 Charleston Road Registry Inc. +hangout + +// haus : 2013-12-05 Dog Beach, LLC +haus + +// hbo : 2015-07-30 HBO Registry Services, Inc. +hbo + +// hdfc : 2015-07-30 HOUSING DEVELOPMENT FINANCE CORPORATION LIMITED +hdfc + +// hdfcbank : 2015-02-12 HDFC Bank Limited +hdfcbank + +// health : 2015-02-11 Registry Services, LLC +health + +// healthcare : 2014-06-12 Binky Moon, LLC +healthcare + +// help : 2014-06-26 Innovation service Limited +help + +// helsinki : 2015-02-05 City of Helsinki +helsinki + +// here : 2014-02-06 Charleston Road Registry Inc. +here + +// hermes : 2014-07-10 HERMES INTERNATIONAL +hermes + +// hiphop : 2014-03-06 Dot Hip Hop, LLC +hiphop + +// hisamitsu : 2015-07-16 Hisamitsu Pharmaceutical Co.,Inc. +hisamitsu + +// hitachi : 2014-10-31 Hitachi, Ltd. +hitachi + +// hiv : 2014-03-13 Internet Naming Company LLC +hiv + +// hkt : 2015-05-14 PCCW-HKT DataCom Services Limited +hkt + +// hockey : 2015-03-19 Binky Moon, LLC +hockey + +// holdings : 2013-08-27 Binky Moon, LLC +holdings + +// holiday : 2013-11-07 Binky Moon, LLC +holiday + +// homedepot : 2015-04-02 Home Depot Product Authority, LLC +homedepot + +// homegoods : 2015-07-16 The TJX Companies, Inc. +homegoods + +// homes : 2014-01-09 XYZ.COM LLC +homes + +// homesense : 2015-07-16 The TJX Companies, Inc. +homesense + +// honda : 2014-12-18 Honda Motor Co., Ltd. +honda + +// horse : 2013-11-21 Registry Services, LLC +horse + +// hospital : 2016-10-20 Binky Moon, LLC +hospital + +// host : 2014-04-17 Radix FZC +host + +// hosting : 2014-05-29 XYZ.COM LLC +hosting + +// hot : 2015-08-27 Amazon Registry Services, Inc. +hot + +// hoteles : 2015-03-05 Travel Reservations SRL +hoteles + +// hotels : 2016-04-07 Booking.com B.V. +hotels + +// hotmail : 2014-12-18 Microsoft Corporation +hotmail + +// house : 2013-11-07 Binky Moon, LLC +house + +// how : 2014-01-23 Charleston Road Registry Inc. +how + +// hsbc : 2014-10-24 HSBC Global Services (UK) Limited +hsbc + +// hughes : 2015-07-30 Hughes Satellite Systems Corporation +hughes + +// hyatt : 2015-07-30 Hyatt GTLD, L.L.C. +hyatt + +// hyundai : 2015-07-09 Hyundai Motor Company +hyundai + +// ibm : 2014-07-31 International Business Machines Corporation +ibm + +// icbc : 2015-02-19 Industrial and Commercial Bank of China Limited +icbc + +// ice : 2014-10-30 IntercontinentalExchange, Inc. +ice + +// icu : 2015-01-08 ShortDot SA +icu + +// ieee : 2015-07-23 IEEE Global LLC +ieee + +// ifm : 2014-01-30 ifm electronic gmbh +ifm + +// ikano : 2015-07-09 Ikano S.A. +ikano + +// imamat : 2015-08-06 Fondation Aga Khan (Aga Khan Foundation) +imamat + +// imdb : 2015-06-25 Amazon Registry Services, Inc. +imdb + +// immo : 2014-07-10 Binky Moon, LLC +immo + +// immobilien : 2013-11-07 Dog Beach, LLC +immobilien + +// inc : 2018-03-10 Intercap Registry Inc. +inc + +// industries : 2013-12-05 Binky Moon, LLC +industries + +// infiniti : 2014-03-27 NISSAN MOTOR CO., LTD. +infiniti + +// ing : 2014-01-23 Charleston Road Registry Inc. +ing + +// ink : 2013-12-05 Registry Services, LLC +ink + +// institute : 2013-11-07 Binky Moon, LLC +institute + +// insurance : 2015-02-19 fTLD Registry Services LLC +insurance + +// insure : 2014-03-20 Binky Moon, LLC +insure + +// international : 2013-11-07 Binky Moon, LLC +international + +// intuit : 2015-07-30 Intuit Administrative Services, Inc. +intuit + +// investments : 2014-03-20 Binky Moon, LLC +investments + +// ipiranga : 2014-08-28 Ipiranga Produtos de Petroleo S.A. +ipiranga + +// irish : 2014-08-07 Binky Moon, LLC +irish + +// ismaili : 2015-08-06 Fondation Aga Khan (Aga Khan Foundation) +ismaili + +// ist : 2014-08-28 Istanbul Metropolitan Municipality +ist + +// istanbul : 2014-08-28 Istanbul Metropolitan Municipality +istanbul + +// itau : 2014-10-02 Itau Unibanco Holding S.A. +itau + +// itv : 2015-07-09 ITV Services Limited +itv + +// jaguar : 2014-11-13 Jaguar Land Rover Ltd +jaguar + +// java : 2014-06-19 Oracle Corporation +java + +// jcb : 2014-11-20 JCB Co., Ltd. +jcb + +// jeep : 2015-07-30 FCA US LLC. +jeep + +// jetzt : 2014-01-09 Binky Moon, LLC +jetzt + +// jewelry : 2015-03-05 Binky Moon, LLC +jewelry + +// jio : 2015-04-02 Reliance Industries Limited +jio + +// jll : 2015-04-02 Jones Lang LaSalle Incorporated +jll + +// jmp : 2015-03-26 Matrix IP LLC +jmp + +// jnj : 2015-06-18 Johnson & Johnson Services, Inc. +jnj + +// joburg : 2014-03-24 ZA Central Registry NPC trading as ZA Central Registry +joburg + +// jot : 2014-12-18 Amazon Registry Services, Inc. +jot + +// joy : 2014-12-18 Amazon Registry Services, Inc. +joy + +// jpmorgan : 2015-04-30 JPMorgan Chase Bank, National Association +jpmorgan + +// jprs : 2014-09-18 Japan Registry Services Co., Ltd. +jprs + +// juegos : 2014-03-20 Internet Naming Company LLC +juegos + +// juniper : 2015-07-30 JUNIPER NETWORKS, INC. +juniper + +// kaufen : 2013-11-07 Dog Beach, LLC +kaufen + +// kddi : 2014-09-12 KDDI CORPORATION +kddi + +// kerryhotels : 2015-04-30 Kerry Trading Co. Limited +kerryhotels + +// kerrylogistics : 2015-04-09 Kerry Trading Co. Limited +kerrylogistics + +// kerryproperties : 2015-04-09 Kerry Trading Co. Limited +kerryproperties + +// kfh : 2014-12-04 Kuwait Finance House +kfh + +// kia : 2015-07-09 KIA MOTORS CORPORATION +kia + +// kids : 2021-08-13 DotKids Foundation Limited +kids + +// kim : 2013-09-23 Identity Digital Limited +kim + +// kinder : 2014-11-07 Ferrero Trading Lux S.A. +kinder + +// kindle : 2015-06-25 Amazon Registry Services, Inc. +kindle + +// kitchen : 2013-09-20 Binky Moon, LLC +kitchen + +// kiwi : 2013-09-20 DOT KIWI LIMITED +kiwi + +// koeln : 2014-01-09 dotKoeln GmbH +koeln + +// komatsu : 2015-01-08 Komatsu Ltd. +komatsu + +// kosher : 2015-08-20 Kosher Marketing Assets LLC +kosher + +// kpmg : 2015-04-23 KPMG International Cooperative (KPMG International Genossenschaft) +kpmg + +// kpn : 2015-01-08 Koninklijke KPN N.V. +kpn + +// krd : 2013-12-05 KRG Department of Information Technology +krd + +// kred : 2013-12-19 KredTLD Pty Ltd +kred + +// kuokgroup : 2015-04-09 Kerry Trading Co. Limited +kuokgroup + +// kyoto : 2014-11-07 Academic Institution: Kyoto Jyoho Gakuen +kyoto + +// lacaixa : 2014-01-09 Fundación Bancaria Caixa d’Estalvis i Pensions de Barcelona, “la Caixa” +lacaixa + +// lamborghini : 2015-06-04 Automobili Lamborghini S.p.A. +lamborghini + +// lamer : 2015-10-01 The Estée Lauder Companies Inc. +lamer + +// lancaster : 2015-02-12 LANCASTER +lancaster + +// land : 2013-09-10 Binky Moon, LLC +land + +// landrover : 2014-11-13 Jaguar Land Rover Ltd +landrover + +// lanxess : 2015-07-30 LANXESS Corporation +lanxess + +// lasalle : 2015-04-02 Jones Lang LaSalle Incorporated +lasalle + +// lat : 2014-10-16 XYZ.COM LLC +lat + +// latino : 2015-07-30 Dish DBS Corporation +latino + +// latrobe : 2014-06-16 La Trobe University +latrobe + +// law : 2015-01-22 Registry Services, LLC +law + +// lawyer : 2014-03-20 Dog Beach, LLC +lawyer + +// lds : 2014-03-20 IRI Domain Management, LLC +lds + +// lease : 2014-03-06 Binky Moon, LLC +lease + +// leclerc : 2014-08-07 A.C.D. LEC Association des Centres Distributeurs Edouard Leclerc +leclerc + +// lefrak : 2015-07-16 LeFrak Organization, Inc. +lefrak + +// legal : 2014-10-16 Binky Moon, LLC +legal + +// lego : 2015-07-16 LEGO Juris A/S +lego + +// lexus : 2015-04-23 TOYOTA MOTOR CORPORATION +lexus + +// lgbt : 2014-05-08 Identity Digital Limited +lgbt + +// lidl : 2014-09-18 Schwarz Domains und Services GmbH & Co. KG +lidl + +// life : 2014-02-06 Binky Moon, LLC +life + +// lifeinsurance : 2015-01-15 American Council of Life Insurers +lifeinsurance + +// lifestyle : 2014-12-11 Lifestyle Domain Holdings, Inc. +lifestyle + +// lighting : 2013-08-27 Binky Moon, LLC +lighting + +// like : 2014-12-18 Amazon Registry Services, Inc. +like + +// lilly : 2015-07-31 Eli Lilly and Company +lilly + +// limited : 2014-03-06 Binky Moon, LLC +limited + +// limo : 2013-10-17 Binky Moon, LLC +limo + +// lincoln : 2014-11-13 Ford Motor Company +lincoln + +// link : 2013-11-14 Nova Registry Ltd +link + +// lipsy : 2015-06-25 Lipsy Ltd +lipsy + +// live : 2014-12-04 Dog Beach, LLC +live + +// living : 2015-07-30 Lifestyle Domain Holdings, Inc. +living + +// llc : 2017-12-14 Identity Digital Limited +llc + +// llp : 2019-08-26 Intercap Registry Inc. +llp + +// loan : 2014-11-20 dot Loan Limited +loan + +// loans : 2014-03-20 Binky Moon, LLC +loans + +// locker : 2015-06-04 Dish DBS Corporation +locker + +// locus : 2015-06-25 Locus Analytics LLC +locus + +// lol : 2015-01-30 XYZ.COM LLC +lol + +// london : 2013-11-14 Dot London Domains Limited +london + +// lotte : 2014-11-07 Lotte Holdings Co., Ltd. +lotte + +// lotto : 2014-04-10 Identity Digital Limited +lotto + +// love : 2014-12-22 Merchant Law Group LLP +love + +// lpl : 2015-07-30 LPL Holdings, Inc. +lpl + +// lplfinancial : 2015-07-30 LPL Holdings, Inc. +lplfinancial + +// ltd : 2014-09-25 Binky Moon, LLC +ltd + +// ltda : 2014-04-17 InterNetX, Corp +ltda + +// lundbeck : 2015-08-06 H. Lundbeck A/S +lundbeck + +// luxe : 2014-01-09 Registry Services, LLC +luxe + +// luxury : 2013-10-17 Luxury Partners, LLC +luxury + +// madrid : 2014-05-01 Comunidad de Madrid +madrid + +// maif : 2014-10-02 Mutuelle Assurance Instituteur France (MAIF) +maif + +// maison : 2013-12-05 Binky Moon, LLC +maison + +// makeup : 2015-01-15 XYZ.COM LLC +makeup + +// man : 2014-12-04 MAN SE +man + +// management : 2013-11-07 Binky Moon, LLC +management + +// mango : 2013-10-24 PUNTO FA S.L. +mango + +// map : 2016-06-09 Charleston Road Registry Inc. +map + +// market : 2014-03-06 Dog Beach, LLC +market + +// marketing : 2013-11-07 Binky Moon, LLC +marketing + +// markets : 2014-12-11 Dog Beach, LLC +markets + +// marriott : 2014-10-09 Marriott Worldwide Corporation +marriott + +// marshalls : 2015-07-16 The TJX Companies, Inc. +marshalls + +// mattel : 2015-08-06 Mattel Sites, Inc. +mattel + +// mba : 2015-04-02 Binky Moon, LLC +mba + +// mckinsey : 2015-07-31 McKinsey Holdings, Inc. +mckinsey + +// med : 2015-08-06 Medistry LLC +med + +// media : 2014-03-06 Binky Moon, LLC +media + +// meet : 2014-01-16 Charleston Road Registry Inc. +meet + +// melbourne : 2014-05-29 The Crown in right of the State of Victoria, represented by its Department of State Development, Business and Innovation +melbourne + +// meme : 2014-01-30 Charleston Road Registry Inc. +meme + +// memorial : 2014-10-16 Dog Beach, LLC +memorial + +// men : 2015-02-26 Exclusive Registry Limited +men + +// menu : 2013-09-11 Dot Menu Registry, LLC +menu + +// merckmsd : 2016-07-14 MSD Registry Holdings, Inc. +merckmsd + +// miami : 2013-12-19 Registry Services, LLC +miami + +// microsoft : 2014-12-18 Microsoft Corporation +microsoft + +// mini : 2014-01-09 Bayerische Motoren Werke Aktiengesellschaft +mini + +// mint : 2015-07-30 Intuit Administrative Services, Inc. +mint + +// mit : 2015-07-02 Massachusetts Institute of Technology +mit + +// mitsubishi : 2015-07-23 Mitsubishi Corporation +mitsubishi + +// mlb : 2015-05-21 MLB Advanced Media DH, LLC +mlb + +// mls : 2015-04-23 The Canadian Real Estate Association +mls + +// mma : 2014-11-07 MMA IARD +mma + +// mobile : 2016-06-02 Dish DBS Corporation +mobile + +// moda : 2013-11-07 Dog Beach, LLC +moda + +// moe : 2013-11-13 Interlink Systems Innovation Institute K.K. +moe + +// moi : 2014-12-18 Amazon Registry Services, Inc. +moi + +// mom : 2015-04-16 XYZ.COM LLC +mom + +// monash : 2013-09-30 Monash University +monash + +// money : 2014-10-16 Binky Moon, LLC +money + +// monster : 2015-09-11 XYZ.COM LLC +monster + +// mormon : 2013-12-05 IRI Domain Management, LLC +mormon + +// mortgage : 2014-03-20 Dog Beach, LLC +mortgage + +// moscow : 2013-12-19 Foundation for Assistance for Internet Technologies and Infrastructure Development (FAITID) +moscow + +// moto : 2015-06-04 Motorola Trademark Holdings, LLC +moto + +// motorcycles : 2014-01-09 XYZ.COM LLC +motorcycles + +// mov : 2014-01-30 Charleston Road Registry Inc. +mov + +// movie : 2015-02-05 Binky Moon, LLC +movie + +// msd : 2015-07-23 MSD Registry Holdings, Inc. +msd + +// mtn : 2014-12-04 MTN Dubai Limited +mtn + +// mtr : 2015-03-12 MTR Corporation Limited +mtr + +// music : 2021-05-04 DotMusic Limited +music + +// mutual : 2015-04-02 Northwestern Mutual MU TLD Registry, LLC +mutual + +// nab : 2015-08-20 National Australia Bank Limited +nab + +// nagoya : 2013-10-24 GMO Registry, Inc. +nagoya + +// natura : 2015-03-12 NATURA COSMÉTICOS S.A. +natura + +// navy : 2014-03-06 Dog Beach, LLC +navy + +// nba : 2015-07-31 NBA REGISTRY, LLC +nba + +// nec : 2015-01-08 NEC Corporation +nec + +// netbank : 2014-06-26 COMMONWEALTH BANK OF AUSTRALIA +netbank + +// netflix : 2015-06-18 Netflix, Inc. +netflix + +// network : 2013-11-14 Binky Moon, LLC +network + +// neustar : 2013-12-05 NeuStar, Inc. +neustar + +// new : 2014-01-30 Charleston Road Registry Inc. +new + +// news : 2014-12-18 Dog Beach, LLC +news + +// next : 2015-06-18 Next plc +next + +// nextdirect : 2015-06-18 Next plc +nextdirect + +// nexus : 2014-07-24 Charleston Road Registry Inc. +nexus + +// nfl : 2015-07-23 NFL Reg Ops LLC +nfl + +// ngo : 2014-03-06 Public Interest Registry +ngo + +// nhk : 2014-02-13 Japan Broadcasting Corporation (NHK) +nhk + +// nico : 2014-12-04 DWANGO Co., Ltd. +nico + +// nike : 2015-07-23 NIKE, Inc. +nike + +// nikon : 2015-05-21 NIKON CORPORATION +nikon + +// ninja : 2013-11-07 Dog Beach, LLC +ninja + +// nissan : 2014-03-27 NISSAN MOTOR CO., LTD. +nissan + +// nissay : 2015-10-29 Nippon Life Insurance Company +nissay + +// nokia : 2015-01-08 Nokia Corporation +nokia + +// northwesternmutual : 2015-06-18 Northwestern Mutual Registry, LLC +northwesternmutual + +// norton : 2014-12-04 NortonLifeLock Inc. +norton + +// now : 2015-06-25 Amazon Registry Services, Inc. +now + +// nowruz : 2014-09-04 Asia Green IT System Bilgisayar San. ve Tic. Ltd. Sti. +nowruz + +// nowtv : 2015-05-14 Starbucks (HK) Limited +nowtv + +// nra : 2014-05-22 NRA Holdings Company, INC. +nra + +// nrw : 2013-11-21 Minds + Machines GmbH +nrw + +// ntt : 2014-10-31 NIPPON TELEGRAPH AND TELEPHONE CORPORATION +ntt + +// nyc : 2014-01-23 The City of New York by and through the New York City Department of Information Technology & Telecommunications +nyc + +// obi : 2014-09-25 OBI Group Holding SE & Co. KGaA +obi + +// observer : 2015-04-30 Dog Beach, LLC +observer + +// office : 2015-03-12 Microsoft Corporation +office + +// okinawa : 2013-12-05 BRregistry, Inc. +okinawa + +// olayan : 2015-05-14 Competrol (Luxembourg) Sarl +olayan + +// olayangroup : 2015-05-14 Competrol (Luxembourg) Sarl +olayangroup + +// oldnavy : 2015-07-31 The Gap, Inc. +oldnavy + +// ollo : 2015-06-04 Dish DBS Corporation +ollo + +// omega : 2015-01-08 The Swatch Group Ltd +omega + +// one : 2014-11-07 One.com A/S +one + +// ong : 2014-03-06 Public Interest Registry +ong + +// onl : 2013-09-16 iRegistry GmbH +onl + +// online : 2015-01-15 Radix FZC +online + +// ooo : 2014-01-09 INFIBEAM AVENUES LIMITED +ooo + +// open : 2015-07-31 American Express Travel Related Services Company, Inc. +open + +// oracle : 2014-06-19 Oracle Corporation +oracle + +// orange : 2015-03-12 Orange Brand Services Limited +orange + +// organic : 2014-03-27 Identity Digital Limited +organic + +// origins : 2015-10-01 The Estée Lauder Companies Inc. +origins + +// osaka : 2014-09-04 Osaka Registry Co., Ltd. +osaka + +// otsuka : 2013-10-11 Otsuka Holdings Co., Ltd. +otsuka + +// ott : 2015-06-04 Dish DBS Corporation +ott + +// ovh : 2014-01-16 MédiaBC +ovh + +// page : 2014-12-04 Charleston Road Registry Inc. +page + +// panasonic : 2015-07-30 Panasonic Holdings Corporation +panasonic + +// paris : 2014-01-30 City of Paris +paris + +// pars : 2014-09-04 Asia Green IT System Bilgisayar San. ve Tic. Ltd. Sti. +pars + +// partners : 2013-12-05 Binky Moon, LLC +partners + +// parts : 2013-12-05 Binky Moon, LLC +parts + +// party : 2014-09-11 Blue Sky Registry Limited +party + +// passagens : 2015-03-05 Travel Reservations SRL +passagens + +// pay : 2015-08-27 Amazon Registry Services, Inc. +pay + +// pccw : 2015-05-14 PCCW Enterprises Limited +pccw + +// pet : 2015-05-07 Identity Digital Limited +pet + +// pfizer : 2015-09-11 Pfizer Inc. +pfizer + +// pharmacy : 2014-06-19 National Association of Boards of Pharmacy +pharmacy + +// phd : 2016-07-28 Charleston Road Registry Inc. +phd + +// philips : 2014-11-07 Koninklijke Philips N.V. +philips + +// phone : 2016-06-02 Dish DBS Corporation +phone + +// photo : 2013-11-14 Registry Services, LLC +photo + +// photography : 2013-09-20 Binky Moon, LLC +photography + +// photos : 2013-10-17 Binky Moon, LLC +photos + +// physio : 2014-05-01 PhysBiz Pty Ltd +physio + +// pics : 2013-11-14 XYZ.COM LLC +pics + +// pictet : 2014-06-26 Pictet Europe S.A. +pictet + +// pictures : 2014-03-06 Binky Moon, LLC +pictures + +// pid : 2015-01-08 Top Level Spectrum, Inc. +pid + +// pin : 2014-12-18 Amazon Registry Services, Inc. +pin + +// ping : 2015-06-11 Ping Registry Provider, Inc. +ping + +// pink : 2013-10-01 Identity Digital Limited +pink + +// pioneer : 2015-07-16 Pioneer Corporation +pioneer + +// pizza : 2014-06-26 Binky Moon, LLC +pizza + +// place : 2014-04-24 Binky Moon, LLC +place + +// play : 2015-03-05 Charleston Road Registry Inc. +play + +// playstation : 2015-07-02 Sony Interactive Entertainment Inc. +playstation + +// plumbing : 2013-09-10 Binky Moon, LLC +plumbing + +// plus : 2015-02-05 Binky Moon, LLC +plus + +// pnc : 2015-07-02 PNC Domain Co., LLC +pnc + +// pohl : 2014-06-23 Deutsche Vermögensberatung Aktiengesellschaft DVAG +pohl + +// poker : 2014-07-03 Identity Digital Limited +poker + +// politie : 2015-08-20 Politie Nederland +politie + +// porn : 2014-10-16 ICM Registry PN LLC +porn + +// pramerica : 2015-07-30 Prudential Financial, Inc. +pramerica + +// praxi : 2013-12-05 Praxi S.p.A. +praxi + +// press : 2014-04-03 Radix FZC +press + +// prime : 2015-06-25 Amazon Registry Services, Inc. +prime + +// prod : 2014-01-23 Charleston Road Registry Inc. +prod + +// productions : 2013-12-05 Binky Moon, LLC +productions + +// prof : 2014-07-24 Charleston Road Registry Inc. +prof + +// progressive : 2015-07-23 Progressive Casualty Insurance Company +progressive + +// promo : 2014-12-18 Identity Digital Limited +promo + +// properties : 2013-12-05 Binky Moon, LLC +properties + +// property : 2014-05-22 Internet Naming Company LLC +property + +// protection : 2015-04-23 XYZ.COM LLC +protection + +// pru : 2015-07-30 Prudential Financial, Inc. +pru + +// prudential : 2015-07-30 Prudential Financial, Inc. +prudential + +// pub : 2013-12-12 Dog Beach, LLC +pub + +// pwc : 2015-10-29 PricewaterhouseCoopers LLP +pwc + +// qpon : 2013-11-14 dotQPON LLC +qpon + +// quebec : 2013-12-19 PointQuébec Inc +quebec + +// quest : 2015-03-26 XYZ.COM LLC +quest + +// racing : 2014-12-04 Premier Registry Limited +racing + +// radio : 2016-07-21 European Broadcasting Union (EBU) +radio + +// read : 2014-12-18 Amazon Registry Services, Inc. +read + +// realestate : 2015-09-11 dotRealEstate LLC +realestate + +// realtor : 2014-05-29 Real Estate Domains LLC +realtor + +// realty : 2015-03-19 Dog Beach, LLC +realty + +// recipes : 2013-10-17 Binky Moon, LLC +recipes + +// red : 2013-11-07 Identity Digital Limited +red + +// redstone : 2014-10-31 Redstone Haute Couture Co., Ltd. +redstone + +// redumbrella : 2015-03-26 Travelers TLD, LLC +redumbrella + +// rehab : 2014-03-06 Dog Beach, LLC +rehab + +// reise : 2014-03-13 Binky Moon, LLC +reise + +// reisen : 2014-03-06 Binky Moon, LLC +reisen + +// reit : 2014-09-04 National Association of Real Estate Investment Trusts, Inc. +reit + +// reliance : 2015-04-02 Reliance Industries Limited +reliance + +// ren : 2013-12-12 ZDNS International Limited +ren + +// rent : 2014-12-04 XYZ.COM LLC +rent + +// rentals : 2013-12-05 Binky Moon, LLC +rentals + +// repair : 2013-11-07 Binky Moon, LLC +repair + +// report : 2013-12-05 Binky Moon, LLC +report + +// republican : 2014-03-20 Dog Beach, LLC +republican + +// rest : 2013-12-19 Punto 2012 Sociedad Anonima Promotora de Inversion de Capital Variable +rest + +// restaurant : 2014-07-03 Binky Moon, LLC +restaurant + +// review : 2014-11-20 dot Review Limited +review + +// reviews : 2013-09-13 Dog Beach, LLC +reviews + +// rexroth : 2015-06-18 Robert Bosch GMBH +rexroth + +// rich : 2013-11-21 iRegistry GmbH +rich + +// richardli : 2015-05-14 Pacific Century Asset Management (HK) Limited +richardli + +// ricoh : 2014-11-20 Ricoh Company, Ltd. +ricoh + +// ril : 2015-04-02 Reliance Industries Limited +ril + +// rio : 2014-02-27 Empresa Municipal de Informática SA - IPLANRIO +rio + +// rip : 2014-07-10 Dog Beach, LLC +rip + +// rocher : 2014-12-18 Ferrero Trading Lux S.A. +rocher + +// rocks : 2013-11-14 Dog Beach, LLC +rocks + +// rodeo : 2013-12-19 Registry Services, LLC +rodeo + +// rogers : 2015-08-06 Rogers Communications Canada Inc. +rogers + +// room : 2014-12-18 Amazon Registry Services, Inc. +room + +// rsvp : 2014-05-08 Charleston Road Registry Inc. +rsvp + +// rugby : 2016-12-15 World Rugby Strategic Developments Limited +rugby + +// ruhr : 2013-10-02 dotSaarland GmbH +ruhr + +// run : 2015-03-19 Binky Moon, LLC +run + +// rwe : 2015-04-02 RWE AG +rwe + +// ryukyu : 2014-01-09 BRregistry, Inc. +ryukyu + +// saarland : 2013-12-12 dotSaarland GmbH +saarland + +// safe : 2014-12-18 Amazon Registry Services, Inc. +safe + +// safety : 2015-01-08 Safety Registry Services, LLC. +safety + +// sakura : 2014-12-18 SAKURA Internet Inc. +sakura + +// sale : 2014-10-16 Dog Beach, LLC +sale + +// salon : 2014-12-11 Binky Moon, LLC +salon + +// samsclub : 2015-07-31 Wal-Mart Stores, Inc. +samsclub + +// samsung : 2014-04-03 SAMSUNG SDS CO., LTD +samsung + +// sandvik : 2014-11-13 Sandvik AB +sandvik + +// sandvikcoromant : 2014-11-07 Sandvik AB +sandvikcoromant + +// sanofi : 2014-10-09 Sanofi +sanofi + +// sap : 2014-03-27 SAP AG +sap + +// sarl : 2014-07-03 Binky Moon, LLC +sarl + +// sas : 2015-04-02 Research IP LLC +sas + +// save : 2015-06-25 Amazon Registry Services, Inc. +save + +// saxo : 2014-10-31 Saxo Bank A/S +saxo + +// sbi : 2015-03-12 STATE BANK OF INDIA +sbi + +// sbs : 2014-11-07 ShortDot SA +sbs + +// sca : 2014-03-13 SVENSKA CELLULOSA AKTIEBOLAGET SCA (publ) +sca + +// scb : 2014-02-20 The Siam Commercial Bank Public Company Limited ("SCB") +scb + +// schaeffler : 2015-08-06 Schaeffler Technologies AG & Co. KG +schaeffler + +// schmidt : 2014-04-03 SCHMIDT GROUPE S.A.S. +schmidt + +// scholarships : 2014-04-24 Scholarships.com, LLC +scholarships + +// school : 2014-12-18 Binky Moon, LLC +school + +// schule : 2014-03-06 Binky Moon, LLC +schule + +// schwarz : 2014-09-18 Schwarz Domains und Services GmbH & Co. KG +schwarz + +// science : 2014-09-11 dot Science Limited +science + +// scot : 2014-01-23 Dot Scot Registry Limited +scot + +// search : 2016-06-09 Charleston Road Registry Inc. +search + +// seat : 2014-05-22 SEAT, S.A. (Sociedad Unipersonal) +seat + +// secure : 2015-08-27 Amazon Registry Services, Inc. +secure + +// security : 2015-05-14 XYZ.COM LLC +security + +// seek : 2014-12-04 Seek Limited +seek + +// select : 2015-10-08 Registry Services, LLC +select + +// sener : 2014-10-24 Sener Ingeniería y Sistemas, S.A. +sener + +// services : 2014-02-27 Binky Moon, LLC +services + +// seven : 2015-08-06 Seven West Media Ltd +seven + +// sew : 2014-07-17 SEW-EURODRIVE GmbH & Co KG +sew + +// sex : 2014-11-13 ICM Registry SX LLC +sex + +// sexy : 2013-09-11 Internet Naming Company LLC +sexy + +// sfr : 2015-08-13 Societe Francaise du Radiotelephone - SFR +sfr + +// shangrila : 2015-09-03 Shangri‐La International Hotel Management Limited +shangrila + +// sharp : 2014-05-01 Sharp Corporation +sharp + +// shaw : 2015-04-23 Shaw Cablesystems G.P. +shaw + +// shell : 2015-07-30 Shell Information Technology International Inc +shell + +// shia : 2014-09-04 Asia Green IT System Bilgisayar San. ve Tic. Ltd. Sti. +shia + +// shiksha : 2013-11-14 Identity Digital Limited +shiksha + +// shoes : 2013-10-02 Binky Moon, LLC +shoes + +// shop : 2016-04-08 GMO Registry, Inc. +shop + +// shopping : 2016-03-31 Binky Moon, LLC +shopping + +// shouji : 2015-01-08 Beijing Qihu Keji Co., Ltd. +shouji + +// show : 2015-03-05 Binky Moon, LLC +show + +// showtime : 2015-08-06 CBS Domains Inc. +showtime + +// silk : 2015-06-25 Amazon Registry Services, Inc. +silk + +// sina : 2015-03-12 Sina Corporation +sina + +// singles : 2013-08-27 Binky Moon, LLC +singles + +// site : 2015-01-15 Radix FZC +site + +// ski : 2015-04-09 Identity Digital Limited +ski + +// skin : 2015-01-15 XYZ.COM LLC +skin + +// sky : 2014-06-19 Sky International AG +sky + +// skype : 2014-12-18 Microsoft Corporation +skype + +// sling : 2015-07-30 DISH Technologies L.L.C. +sling + +// smart : 2015-07-09 Smart Communications, Inc. (SMART) +smart + +// smile : 2014-12-18 Amazon Registry Services, Inc. +smile + +// sncf : 2015-02-19 Société Nationale SNCF +sncf + +// soccer : 2015-03-26 Binky Moon, LLC +soccer + +// social : 2013-11-07 Dog Beach, LLC +social + +// softbank : 2015-07-02 SoftBank Group Corp. +softbank + +// software : 2014-03-20 Dog Beach, LLC +software + +// sohu : 2013-12-19 Sohu.com Limited +sohu + +// solar : 2013-11-07 Binky Moon, LLC +solar + +// solutions : 2013-11-07 Binky Moon, LLC +solutions + +// song : 2015-02-26 Amazon Registry Services, Inc. +song + +// sony : 2015-01-08 Sony Corporation +sony + +// soy : 2014-01-23 Charleston Road Registry Inc. +soy + +// spa : 2019-09-19 Asia Spa and Wellness Promotion Council Limited +spa + +// space : 2014-04-03 Radix FZC +space + +// sport : 2017-11-16 Global Association of International Sports Federations (GAISF) +sport + +// spot : 2015-02-26 Amazon Registry Services, Inc. +spot + +// srl : 2015-05-07 InterNetX, Corp +srl + +// stada : 2014-11-13 STADA Arzneimittel AG +stada + +// staples : 2015-07-30 Staples, Inc. +staples + +// star : 2015-01-08 Star India Private Limited +star + +// statebank : 2015-03-12 STATE BANK OF INDIA +statebank + +// statefarm : 2015-07-30 State Farm Mutual Automobile Insurance Company +statefarm + +// stc : 2014-10-09 Saudi Telecom Company +stc + +// stcgroup : 2014-10-09 Saudi Telecom Company +stcgroup + +// stockholm : 2014-12-18 Stockholms kommun +stockholm + +// storage : 2014-12-22 XYZ.COM LLC +storage + +// store : 2015-04-09 Radix FZC +store + +// stream : 2016-01-08 dot Stream Limited +stream + +// studio : 2015-02-11 Dog Beach, LLC +studio + +// study : 2014-12-11 Registry Services, LLC +study + +// style : 2014-12-04 Binky Moon, LLC +style + +// sucks : 2014-12-22 Vox Populi Registry Ltd. +sucks + +// supplies : 2013-12-19 Binky Moon, LLC +supplies + +// supply : 2013-12-19 Binky Moon, LLC +supply + +// support : 2013-10-24 Binky Moon, LLC +support + +// surf : 2014-01-09 Registry Services, LLC +surf + +// surgery : 2014-03-20 Binky Moon, LLC +surgery + +// suzuki : 2014-02-20 SUZUKI MOTOR CORPORATION +suzuki + +// swatch : 2015-01-08 The Swatch Group Ltd +swatch + +// swiss : 2014-10-16 Swiss Confederation +swiss + +// sydney : 2014-09-18 State of New South Wales, Department of Premier and Cabinet +sydney + +// systems : 2013-11-07 Binky Moon, LLC +systems + +// tab : 2014-12-04 Tabcorp Holdings Limited +tab + +// taipei : 2014-07-10 Taipei City Government +taipei + +// talk : 2015-04-09 Amazon Registry Services, Inc. +talk + +// taobao : 2015-01-15 Alibaba Group Holding Limited +taobao + +// target : 2015-07-31 Target Domain Holdings, LLC +target + +// tatamotors : 2015-03-12 Tata Motors Ltd +tatamotors + +// tatar : 2014-04-24 Limited Liability Company "Coordination Center of Regional Domain of Tatarstan Republic" +tatar + +// tattoo : 2013-08-30 Registry Services, LLC +tattoo + +// tax : 2014-03-20 Binky Moon, LLC +tax + +// taxi : 2015-03-19 Binky Moon, LLC +taxi + +// tci : 2014-09-12 Asia Green IT System Bilgisayar San. ve Tic. Ltd. Sti. +tci + +// tdk : 2015-06-11 TDK Corporation +tdk + +// team : 2015-03-05 Binky Moon, LLC +team + +// tech : 2015-01-30 Radix FZC +tech + +// technology : 2013-09-13 Binky Moon, LLC +technology + +// temasek : 2014-08-07 Temasek Holdings (Private) Limited +temasek + +// tennis : 2014-12-04 Binky Moon, LLC +tennis + +// teva : 2015-07-02 Teva Pharmaceutical Industries Limited +teva + +// thd : 2015-04-02 Home Depot Product Authority, LLC +thd + +// theater : 2015-03-19 Binky Moon, LLC +theater + +// theatre : 2015-05-07 XYZ.COM LLC +theatre + +// tiaa : 2015-07-23 Teachers Insurance and Annuity Association of America +tiaa + +// tickets : 2015-02-05 XYZ.COM LLC +tickets + +// tienda : 2013-11-14 Binky Moon, LLC +tienda + +// tiffany : 2015-01-30 Tiffany and Company +tiffany + +// tips : 2013-09-20 Binky Moon, LLC +tips + +// tires : 2014-11-07 Binky Moon, LLC +tires + +// tirol : 2014-04-24 punkt Tirol GmbH +tirol + +// tjmaxx : 2015-07-16 The TJX Companies, Inc. +tjmaxx + +// tjx : 2015-07-16 The TJX Companies, Inc. +tjx + +// tkmaxx : 2015-07-16 The TJX Companies, Inc. +tkmaxx + +// tmall : 2015-01-15 Alibaba Group Holding Limited +tmall + +// today : 2013-09-20 Binky Moon, LLC +today + +// tokyo : 2013-11-13 GMO Registry, Inc. +tokyo + +// tools : 2013-11-21 Binky Moon, LLC +tools + +// top : 2014-03-20 .TOP Registry +top + +// toray : 2014-12-18 Toray Industries, Inc. +toray + +// toshiba : 2014-04-10 TOSHIBA Corporation +toshiba + +// total : 2015-08-06 TotalEnergies SE +total + +// tours : 2015-01-22 Binky Moon, LLC +tours + +// town : 2014-03-06 Binky Moon, LLC +town + +// toyota : 2015-04-23 TOYOTA MOTOR CORPORATION +toyota + +// toys : 2014-03-06 Binky Moon, LLC +toys + +// trade : 2014-01-23 Elite Registry Limited +trade + +// trading : 2014-12-11 Dog Beach, LLC +trading + +// training : 2013-11-07 Binky Moon, LLC +training + +// travel : 2015-10-09 Dog Beach, LLC +travel + +// travelers : 2015-03-26 Travelers TLD, LLC +travelers + +// travelersinsurance : 2015-03-26 Travelers TLD, LLC +travelersinsurance + +// trust : 2014-10-16 Internet Naming Company LLC +trust + +// trv : 2015-03-26 Travelers TLD, LLC +trv + +// tube : 2015-06-11 Latin American Telecom LLC +tube + +// tui : 2014-07-03 TUI AG +tui + +// tunes : 2015-02-26 Amazon Registry Services, Inc. +tunes + +// tushu : 2014-12-18 Amazon Registry Services, Inc. +tushu + +// tvs : 2015-02-19 T V SUNDRAM IYENGAR & SONS LIMITED +tvs + +// ubank : 2015-08-20 National Australia Bank Limited +ubank + +// ubs : 2014-12-11 UBS AG +ubs + +// unicom : 2015-10-15 China United Network Communications Corporation Limited +unicom + +// university : 2014-03-06 Binky Moon, LLC +university + +// uno : 2013-09-11 Radix FZC +uno + +// uol : 2014-05-01 UBN INTERNET LTDA. +uol + +// ups : 2015-06-25 UPS Market Driver, Inc. +ups + +// vacations : 2013-12-05 Binky Moon, LLC +vacations + +// vana : 2014-12-11 Lifestyle Domain Holdings, Inc. +vana + +// vanguard : 2015-09-03 The Vanguard Group, Inc. +vanguard + +// vegas : 2014-01-16 Dot Vegas, Inc. +vegas + +// ventures : 2013-08-27 Binky Moon, LLC +ventures + +// verisign : 2015-08-13 VeriSign, Inc. +verisign + +// versicherung : 2014-03-20 tldbox GmbH +versicherung + +// vet : 2014-03-06 Dog Beach, LLC +vet + +// viajes : 2013-10-17 Binky Moon, LLC +viajes + +// video : 2014-10-16 Dog Beach, LLC +video + +// vig : 2015-05-14 VIENNA INSURANCE GROUP AG Wiener Versicherung Gruppe +vig + +// viking : 2015-04-02 Viking River Cruises (Bermuda) Ltd. +viking + +// villas : 2013-12-05 Binky Moon, LLC +villas + +// vin : 2015-06-18 Binky Moon, LLC +vin + +// vip : 2015-01-22 Registry Services, LLC +vip + +// virgin : 2014-09-25 Virgin Enterprises Limited +virgin + +// visa : 2015-07-30 Visa Worldwide Pte. Limited +visa + +// vision : 2013-12-05 Binky Moon, LLC +vision + +// viva : 2014-11-07 Saudi Telecom Company +viva + +// vivo : 2015-07-31 Telefonica Brasil S.A. +vivo + +// vlaanderen : 2014-02-06 DNS.be vzw +vlaanderen + +// vodka : 2013-12-19 Registry Services, LLC +vodka + +// volkswagen : 2015-05-14 Volkswagen Group of America Inc. +volkswagen + +// volvo : 2015-11-12 Volvo Holding Sverige Aktiebolag +volvo + +// vote : 2013-11-21 Monolith Registry LLC +vote + +// voting : 2013-11-13 Valuetainment Corp. +voting + +// voto : 2013-11-21 Monolith Registry LLC +voto + +// voyage : 2013-08-27 Binky Moon, LLC +voyage + +// vuelos : 2015-03-05 Travel Reservations SRL +vuelos + +// wales : 2014-05-08 Nominet UK +wales + +// walmart : 2015-07-31 Wal-Mart Stores, Inc. +walmart + +// walter : 2014-11-13 Sandvik AB +walter + +// wang : 2013-10-24 Zodiac Wang Limited +wang + +// wanggou : 2014-12-18 Amazon Registry Services, Inc. +wanggou + +// watch : 2013-11-14 Binky Moon, LLC +watch + +// watches : 2014-12-22 Identity Digital Limited +watches + +// weather : 2015-01-08 International Business Machines Corporation +weather + +// weatherchannel : 2015-03-12 International Business Machines Corporation +weatherchannel + +// webcam : 2014-01-23 dot Webcam Limited +webcam + +// weber : 2015-06-04 Saint-Gobain Weber SA +weber + +// website : 2014-04-03 Radix FZC +website + +// wedding : 2014-04-24 Registry Services, LLC +wedding + +// weibo : 2015-03-05 Sina Corporation +weibo + +// weir : 2015-01-29 Weir Group IP Limited +weir + +// whoswho : 2014-02-20 Who's Who Registry +whoswho + +// wien : 2013-10-28 punkt.wien GmbH +wien + +// wiki : 2013-11-07 Registry Services, LLC +wiki + +// williamhill : 2014-03-13 William Hill Organization Limited +williamhill + +// win : 2014-11-20 First Registry Limited +win + +// windows : 2014-12-18 Microsoft Corporation +windows + +// wine : 2015-06-18 Binky Moon, LLC +wine + +// winners : 2015-07-16 The TJX Companies, Inc. +winners + +// wme : 2014-02-13 William Morris Endeavor Entertainment, LLC +wme + +// wolterskluwer : 2015-08-06 Wolters Kluwer N.V. +wolterskluwer + +// woodside : 2015-07-09 Woodside Petroleum Limited +woodside + +// work : 2013-12-19 Registry Services, LLC +work + +// works : 2013-11-14 Binky Moon, LLC +works + +// world : 2014-06-12 Binky Moon, LLC +world + +// wow : 2015-10-08 Amazon Registry Services, Inc. +wow + +// wtc : 2013-12-19 World Trade Centers Association, Inc. +wtc + +// wtf : 2014-03-06 Binky Moon, LLC +wtf + +// xbox : 2014-12-18 Microsoft Corporation +xbox + +// xerox : 2014-10-24 Xerox DNHC LLC +xerox + +// xfinity : 2015-07-09 Comcast IP Holdings I, LLC +xfinity + +// xihuan : 2015-01-08 Beijing Qihu Keji Co., Ltd. +xihuan + +// xin : 2014-12-11 Elegant Leader Limited +xin + +// xn--11b4c3d : 2015-01-15 VeriSign Sarl +कॉम + +// xn--1ck2e1b : 2015-02-26 Amazon Registry Services, Inc. +セール + +// xn--1qqw23a : 2014-01-09 Guangzhou YU Wei Information Technology Co., Ltd. +佛山 + +// xn--30rr7y : 2014-06-12 Excellent First Limited +慈善 + +// xn--3bst00m : 2013-09-13 Eagle Horizon Limited +集团 + +// xn--3ds443g : 2013-09-08 TLD REGISTRY LIMITED OY +在线 + +// xn--3pxu8k : 2015-01-15 VeriSign Sarl +点看 + +// xn--42c2d9a : 2015-01-15 VeriSign Sarl +คอม + +// xn--45q11c : 2013-11-21 Zodiac Gemini Ltd +八卦 + +// xn--4gbrim : 2013-10-04 Helium TLDs Ltd +موقع + +// xn--55qw42g : 2013-11-08 China Organizational Name Administration Center +公益 + +// xn--55qx5d : 2013-11-14 China Internet Network Information Center (CNNIC) +公司 + +// xn--5su34j936bgsg : 2015-09-03 Shangri‐La International Hotel Management Limited +香格里拉 + +// xn--5tzm5g : 2014-12-22 Global Website TLD Asia Limited +网站 + +// xn--6frz82g : 2013-09-23 Identity Digital Limited +移动 + +// xn--6qq986b3xl : 2013-09-13 Tycoon Treasure Limited +我爱你 + +// xn--80adxhks : 2013-12-19 Foundation for Assistance for Internet Technologies and Infrastructure Development (FAITID) +москва + +// xn--80aqecdr1a : 2015-10-21 Pontificium Consilium de Comunicationibus Socialibus (PCCS) (Pontifical Council for Social Communication) +католик + +// xn--80asehdb : 2013-07-14 CORE Association +онлайн + +// xn--80aswg : 2013-07-14 CORE Association +сайт + +// xn--8y0a063a : 2015-03-26 China United Network Communications Corporation Limited +联通 + +// xn--9dbq2a : 2015-01-15 VeriSign Sarl +קום + +// xn--9et52u : 2014-06-12 RISE VICTORY LIMITED +时尚 + +// xn--9krt00a : 2015-03-12 Sina Corporation +微博 + +// xn--b4w605ferd : 2014-08-07 Temasek Holdings (Private) Limited +淡马锡 + +// xn--bck1b9a5dre4c : 2015-02-26 Amazon Registry Services, Inc. +ファッション + +// xn--c1avg : 2013-11-14 Public Interest Registry +орг + +// xn--c2br7g : 2015-01-15 VeriSign Sarl +नेट + +// xn--cck2b3b : 2015-02-26 Amazon Registry Services, Inc. +ストア + +// xn--cckwcxetd : 2019-12-19 Amazon Registry Services, Inc. +アマゾン + +// xn--cg4bki : 2013-09-27 SAMSUNG SDS CO., LTD +삼성 + +// xn--czr694b : 2014-01-16 Internet DotTrademark Organisation Limited +商标 + +// xn--czrs0t : 2013-12-19 Binky Moon, LLC +商店 + +// xn--czru2d : 2013-11-21 Zodiac Aquarius Limited +商城 + +// xn--d1acj3b : 2013-11-20 The Foundation for Network Initiatives “The Smart Internet” +дети + +// xn--eckvdtc9d : 2014-12-18 Amazon Registry Services, Inc. +ポイント + +// xn--efvy88h : 2014-08-22 Guangzhou YU Wei Information Technology Co., Ltd. +新闻 + +// xn--fct429k : 2015-04-09 Amazon Registry Services, Inc. +家電 + +// xn--fhbei : 2015-01-15 VeriSign Sarl +كوم + +// xn--fiq228c5hs : 2013-09-08 TLD REGISTRY LIMITED OY +中文网 + +// xn--fiq64b : 2013-10-14 CITIC Group Corporation +中信 + +// xn--fjq720a : 2014-05-22 Binky Moon, LLC +娱乐 + +// xn--flw351e : 2014-07-31 Charleston Road Registry Inc. +谷歌 + +// xn--fzys8d69uvgm : 2015-05-14 PCCW Enterprises Limited +電訊盈科 + +// xn--g2xx48c : 2015-01-30 Nawang Heli(Xiamen) Network Service Co., LTD. +购物 + +// xn--gckr3f0f : 2015-02-26 Amazon Registry Services, Inc. +クラウド + +// xn--gk3at1e : 2015-10-08 Amazon Registry Services, Inc. +通販 + +// xn--hxt814e : 2014-05-15 Zodiac Taurus Limited +网店 + +// xn--i1b6b1a6a2e : 2013-11-14 Public Interest Registry +संगठन + +// xn--imr513n : 2014-12-11 Internet DotTrademark Organisation Limited +餐厅 + +// xn--io0a7i : 2013-11-14 China Internet Network Information Center (CNNIC) +网络 + +// xn--j1aef : 2015-01-15 VeriSign Sarl +ком + +// xn--jlq480n2rg : 2019-12-19 Amazon Registry Services, Inc. +亚马逊 + +// xn--jvr189m : 2015-02-26 Amazon Registry Services, Inc. +食品 + +// xn--kcrx77d1x4a : 2014-11-07 Koninklijke Philips N.V. +飞利浦 + +// xn--kput3i : 2014-02-13 Beijing RITT-Net Technology Development Co., Ltd +手机 + +// xn--mgba3a3ejt : 2014-11-20 Aramco Services Company +ارامكو + +// xn--mgba7c0bbn0a : 2015-05-14 Competrol (Luxembourg) Sarl +العليان + +// xn--mgbaakc7dvf : 2015-09-03 Emirates Telecommunications Corporation (trading as Etisalat) +اتصالات + +// xn--mgbab2bd : 2013-10-31 CORE Association +بازار + +// xn--mgbca7dzdo : 2015-07-30 Abu Dhabi Systems and Information Centre +ابوظبي + +// xn--mgbi4ecexp : 2015-10-21 Pontificium Consilium de Comunicationibus Socialibus (PCCS) (Pontifical Council for Social Communication) +كاثوليك + +// xn--mgbt3dhd : 2014-09-04 Asia Green IT System Bilgisayar San. ve Tic. Ltd. Sti. +همراه + +// xn--mk1bu44c : 2015-01-15 VeriSign Sarl +닷컴 + +// xn--mxtq1m : 2014-03-06 Net-Chinese Co., Ltd. +政府 + +// xn--ngbc5azd : 2013-07-13 International Domain Registry Pty. Ltd. +شبكة + +// xn--ngbe9e0a : 2014-12-04 Kuwait Finance House +بيتك + +// xn--ngbrx : 2015-11-12 League of Arab States +عرب + +// xn--nqv7f : 2013-11-14 Public Interest Registry +机构 + +// xn--nqv7fs00ema : 2013-11-14 Public Interest Registry +组织机构 + +// xn--nyqy26a : 2014-11-07 Stable Tone Limited +健康 + +// xn--otu796d : 2017-08-06 Jiang Yu Liang Cai Technology Company Limited +招聘 + +// xn--p1acf : 2013-12-12 Rusnames Limited +рус + +// xn--pssy2u : 2015-01-15 VeriSign Sarl +大拿 + +// xn--q9jyb4c : 2013-09-17 Charleston Road Registry Inc. +みんな + +// xn--qcka1pmc : 2014-07-31 Charleston Road Registry Inc. +グーグル + +// xn--rhqv96g : 2013-09-11 Stable Tone Limited +世界 + +// xn--rovu88b : 2015-02-26 Amazon Registry Services, Inc. +書籍 + +// xn--ses554g : 2014-01-16 KNET Co., Ltd. +网址 + +// xn--t60b56a : 2015-01-15 VeriSign Sarl +닷넷 + +// xn--tckwe : 2015-01-15 VeriSign Sarl +コム + +// xn--tiq49xqyj : 2015-10-21 Pontificium Consilium de Comunicationibus Socialibus (PCCS) (Pontifical Council for Social Communication) +天主教 + +// xn--unup4y : 2013-07-14 Binky Moon, LLC +游戏 + +// xn--vermgensberater-ctb : 2014-06-23 Deutsche Vermögensberatung Aktiengesellschaft DVAG +vermögensberater + +// xn--vermgensberatung-pwb : 2014-06-23 Deutsche Vermögensberatung Aktiengesellschaft DVAG +vermögensberatung + +// xn--vhquv : 2013-08-27 Binky Moon, LLC +企业 + +// xn--vuq861b : 2014-10-16 Beijing Tele-info Technology Co., Ltd. +信息 + +// xn--w4r85el8fhu5dnra : 2015-04-30 Kerry Trading Co. Limited +嘉里大酒店 + +// xn--w4rs40l : 2015-07-30 Kerry Trading Co. Limited +嘉里 + +// xn--xhq521b : 2013-11-14 Guangzhou YU Wei Information Technology Co., Ltd. +广东 + +// xn--zfr164b : 2013-11-08 China Organizational Name Administration Center +政务 + +// xyz : 2013-12-05 XYZ.COM LLC +xyz + +// yachts : 2014-01-09 XYZ.COM LLC +yachts + +// yahoo : 2015-04-02 Oath Inc. +yahoo + +// yamaxun : 2014-12-18 Amazon Registry Services, Inc. +yamaxun + +// yandex : 2014-04-10 Yandex Europe B.V. +yandex + +// yodobashi : 2014-11-20 YODOBASHI CAMERA CO.,LTD. +yodobashi + +// yoga : 2014-05-29 Registry Services, LLC +yoga + +// yokohama : 2013-12-12 GMO Registry, Inc. +yokohama + +// you : 2015-04-09 Amazon Registry Services, Inc. +you + +// youtube : 2014-05-01 Charleston Road Registry Inc. +youtube + +// yun : 2015-01-08 Beijing Qihu Keji Co., Ltd. +yun + +// zappos : 2015-06-25 Amazon Registry Services, Inc. +zappos + +// zara : 2014-11-07 Industria de Diseño Textil, S.A. (INDITEX, S.A.) +zara + +// zero : 2014-12-18 Amazon Registry Services, Inc. +zero + +// zip : 2014-05-08 Charleston Road Registry Inc. +zip + +// zone : 2013-11-14 Binky Moon, LLC +zone + +// zuerich : 2014-11-07 Kanton Zürich (Canton of Zurich) +zuerich + + +// ===END ICANN DOMAINS=== +// ===BEGIN PRIVATE DOMAINS=== +// (Note: these are in alphabetical order by company name) + +// 1GB LLC : https://www.1gb.ua/ +// Submitted by 1GB LLC <noc@1gb.com.ua> +cc.ua +inf.ua +ltd.ua + +// 611coin : https://611project.org/ +611.to + +// Aaron Marais' Gitlab pages: https://lab.aaronleem.co.za +// Submitted by Aaron Marais <its_me@aaronleem.co.za> +graphox.us + +// accesso Technology Group, plc. : https://accesso.com/ +// Submitted by accesso Team <accessoecommerce@accesso.com> +*.devcdnaccesso.com + +// Acorn Labs : https://acorn.io +// Submitted by Craig Jellick <domains@acorn.io> +*.on-acorn.io + +// ActiveTrail: https://www.activetrail.biz/ +// Submitted by Ofer Kalaora <postmaster@activetrail.com> +activetrail.biz + +// Adobe : https://www.adobe.com/ +// Submitted by Ian Boston <boston@adobe.com> and Lars Trieloff <trieloff@adobe.com> +adobeaemcloud.com +*.dev.adobeaemcloud.com +hlx.live +adobeaemcloud.net +hlx.page +hlx3.page + +// Adobe Developer Platform : https://developer.adobe.com +// Submitted by Jesse MacFadyen<jessem@adobe.com> +adobeio-static.net +adobeioruntime.net + +// Agnat sp. z o.o. : https://domena.pl +// Submitted by Przemyslaw Plewa <it-admin@domena.pl> +beep.pl + +// Airkit : https://www.airkit.com/ +// Submitted by Grant Cooksey <security@airkit.com> +airkitapps.com +airkitapps-au.com +airkitapps.eu + +// Aiven: https://aiven.io/ +// Submitted by Etienne Stalmans <security@aiven.io> +aivencloud.com + +// Akamai : https://www.akamai.com/ +// Submitted by Akamai Team <publicsuffixlist@akamai.com> +akadns.net +akamai.net +akamai-staging.net +akamaiedge.net +akamaiedge-staging.net +akamaihd.net +akamaihd-staging.net +akamaiorigin.net +akamaiorigin-staging.net +akamaized.net +akamaized-staging.net +edgekey.net +edgekey-staging.net +edgesuite.net +edgesuite-staging.net + +// alboto.ca : http://alboto.ca +// Submitted by Anton Avramov <avramov@alboto.ca> +barsy.ca + +// Alces Software Ltd : http://alces-software.com +// Submitted by Mark J. Titorenko <mark.titorenko@alces-software.com> +*.compute.estate +*.alces.network + +// all-inkl.com : https://all-inkl.com +// Submitted by Werner Kaltofen <wk@all-inkl.com> +kasserver.com + +// Altervista: https://www.altervista.org +// Submitted by Carlo Cannas <tech_staff@altervista.it> +altervista.org + +// alwaysdata : https://www.alwaysdata.com +// Submitted by Cyril <admin@alwaysdata.com> +alwaysdata.net + +// Amaze Software : https://amaze.co +// Submitted by Domain Admin <domainadmin@amaze.co> +myamaze.net + +// Amazon : https://www.amazon.com/ +// Submitted by AWS Security <psl-maintainers@amazon.com> +// Subsections of Amazon/subsidiaries will appear until "concludes" tag + +// Amazon CloudFront +// Submitted by Donavan Miller <donavanm@amazon.com> +// Reference: 54144616-fd49-4435-8535-19c6a601bdb3 +cloudfront.net + +// Amazon EC2 +// Submitted by Luke Wells <psl-maintainers@amazon.com> +// Reference: 4c38fa71-58ac-4768-99e5-689c1767e537 +*.compute.amazonaws.com +*.compute-1.amazonaws.com +*.compute.amazonaws.com.cn +us-east-1.amazonaws.com + +// Amazon S3 +// Submitted by Luke Wells <psl-maintainers@amazon.com> +// Reference: d068bd97-f0a9-4838-a6d8-954b622ef4ae +s3.cn-north-1.amazonaws.com.cn +s3.dualstack.ap-northeast-1.amazonaws.com +s3.dualstack.ap-northeast-2.amazonaws.com +s3.ap-northeast-2.amazonaws.com +s3-website.ap-northeast-2.amazonaws.com +s3.dualstack.ap-south-1.amazonaws.com +s3.ap-south-1.amazonaws.com +s3-website.ap-south-1.amazonaws.com +s3.dualstack.ap-southeast-1.amazonaws.com +s3.dualstack.ap-southeast-2.amazonaws.com +s3.dualstack.ca-central-1.amazonaws.com +s3.ca-central-1.amazonaws.com +s3-website.ca-central-1.amazonaws.com +s3.dualstack.eu-central-1.amazonaws.com +s3.eu-central-1.amazonaws.com +s3-website.eu-central-1.amazonaws.com +s3.dualstack.eu-west-1.amazonaws.com +s3.dualstack.eu-west-2.amazonaws.com +s3.eu-west-2.amazonaws.com +s3-website.eu-west-2.amazonaws.com +s3.dualstack.eu-west-3.amazonaws.com +s3.eu-west-3.amazonaws.com +s3-website.eu-west-3.amazonaws.com +s3.amazonaws.com +s3-ap-northeast-1.amazonaws.com +s3-ap-northeast-2.amazonaws.com +s3-ap-south-1.amazonaws.com +s3-ap-southeast-1.amazonaws.com +s3-ap-southeast-2.amazonaws.com +s3-ca-central-1.amazonaws.com +s3-eu-central-1.amazonaws.com +s3-eu-west-1.amazonaws.com +s3-eu-west-2.amazonaws.com +s3-eu-west-3.amazonaws.com +s3-external-1.amazonaws.com +s3-fips-us-gov-west-1.amazonaws.com +s3-sa-east-1.amazonaws.com +s3-us-east-2.amazonaws.com +s3-us-gov-west-1.amazonaws.com +s3-us-west-1.amazonaws.com +s3-us-west-2.amazonaws.com +s3-website-ap-northeast-1.amazonaws.com +s3-website-ap-southeast-1.amazonaws.com +s3-website-ap-southeast-2.amazonaws.com +s3-website-eu-west-1.amazonaws.com +s3-website-sa-east-1.amazonaws.com +s3-website-us-east-1.amazonaws.com +s3-website-us-west-1.amazonaws.com +s3-website-us-west-2.amazonaws.com +s3.dualstack.sa-east-1.amazonaws.com +s3.dualstack.us-east-1.amazonaws.com +s3.dualstack.us-east-2.amazonaws.com +s3.us-east-2.amazonaws.com +s3-website.us-east-2.amazonaws.com + +// AWS Cloud9 +// Submitted by: AWS Security <psl-maintainers@amazon.com> +// Reference: 2b6dfa9a-3a7f-4367-b2e7-0321e77c0d59 +vfs.cloud9.af-south-1.amazonaws.com +webview-assets.cloud9.af-south-1.amazonaws.com +vfs.cloud9.ap-east-1.amazonaws.com +webview-assets.cloud9.ap-east-1.amazonaws.com +vfs.cloud9.ap-northeast-1.amazonaws.com +webview-assets.cloud9.ap-northeast-1.amazonaws.com +vfs.cloud9.ap-northeast-2.amazonaws.com +webview-assets.cloud9.ap-northeast-2.amazonaws.com +vfs.cloud9.ap-northeast-3.amazonaws.com +webview-assets.cloud9.ap-northeast-3.amazonaws.com +vfs.cloud9.ap-south-1.amazonaws.com +webview-assets.cloud9.ap-south-1.amazonaws.com +vfs.cloud9.ap-southeast-1.amazonaws.com +webview-assets.cloud9.ap-southeast-1.amazonaws.com +vfs.cloud9.ap-southeast-2.amazonaws.com +webview-assets.cloud9.ap-southeast-2.amazonaws.com +vfs.cloud9.ca-central-1.amazonaws.com +webview-assets.cloud9.ca-central-1.amazonaws.com +vfs.cloud9.eu-central-1.amazonaws.com +webview-assets.cloud9.eu-central-1.amazonaws.com +vfs.cloud9.eu-north-1.amazonaws.com +webview-assets.cloud9.eu-north-1.amazonaws.com +vfs.cloud9.eu-south-1.amazonaws.com +webview-assets.cloud9.eu-south-1.amazonaws.com +vfs.cloud9.eu-west-1.amazonaws.com +webview-assets.cloud9.eu-west-1.amazonaws.com +vfs.cloud9.eu-west-2.amazonaws.com +webview-assets.cloud9.eu-west-2.amazonaws.com +vfs.cloud9.eu-west-3.amazonaws.com +webview-assets.cloud9.eu-west-3.amazonaws.com +vfs.cloud9.me-south-1.amazonaws.com +webview-assets.cloud9.me-south-1.amazonaws.com +vfs.cloud9.sa-east-1.amazonaws.com +webview-assets.cloud9.sa-east-1.amazonaws.com +vfs.cloud9.us-east-1.amazonaws.com +webview-assets.cloud9.us-east-1.amazonaws.com +vfs.cloud9.us-east-2.amazonaws.com +webview-assets.cloud9.us-east-2.amazonaws.com +vfs.cloud9.us-west-1.amazonaws.com +webview-assets.cloud9.us-west-1.amazonaws.com +vfs.cloud9.us-west-2.amazonaws.com +webview-assets.cloud9.us-west-2.amazonaws.com + +// AWS Elastic Beanstalk +// Submitted by Luke Wells <psl-maintainers@amazon.com> +// Reference: aa202394-43a0-4857-b245-8db04549137e +cn-north-1.eb.amazonaws.com.cn +cn-northwest-1.eb.amazonaws.com.cn +elasticbeanstalk.com +ap-northeast-1.elasticbeanstalk.com +ap-northeast-2.elasticbeanstalk.com +ap-northeast-3.elasticbeanstalk.com +ap-south-1.elasticbeanstalk.com +ap-southeast-1.elasticbeanstalk.com +ap-southeast-2.elasticbeanstalk.com +ca-central-1.elasticbeanstalk.com +eu-central-1.elasticbeanstalk.com +eu-west-1.elasticbeanstalk.com +eu-west-2.elasticbeanstalk.com +eu-west-3.elasticbeanstalk.com +sa-east-1.elasticbeanstalk.com +us-east-1.elasticbeanstalk.com +us-east-2.elasticbeanstalk.com +us-gov-west-1.elasticbeanstalk.com +us-west-1.elasticbeanstalk.com +us-west-2.elasticbeanstalk.com + +// (AWS) Elastic Load Balancing +// Submitted by Luke Wells <psl-maintainers@amazon.com> +// Reference: 12a3d528-1bac-4433-a359-a395867ffed2 +*.elb.amazonaws.com.cn +*.elb.amazonaws.com + +// AWS Global Accelerator +// Submitted by Daniel Massaguer <psl-maintainers@amazon.com> +// Reference: d916759d-a08b-4241-b536-4db887383a6a +awsglobalaccelerator.com + +// eero +// Submitted by Yue Kang <eero-dynamic-dns@amazon.com> +// Reference: 264afe70-f62c-4c02-8ab9-b5281ed24461 +eero.online +eero-stage.online + +// concludes Amazon + +// Amune : https://amune.org/ +// Submitted by Team Amune <cert@amune.org> +t3l3p0rt.net +tele.amune.org + +// Apigee : https://apigee.com/ +// Submitted by Apigee Security Team <security@apigee.com> +apigee.io + +// Apphud : https://apphud.com +// Submitted by Alexander Selivanov <alex@apphud.com> +siiites.com + +// Appspace : https://www.appspace.com +// Submitted by Appspace Security Team <security@appspace.com> +appspacehosted.com +appspaceusercontent.com + +// Appudo UG (haftungsbeschränkt) : https://www.appudo.com +// Submitted by Alexander Hochbaum <admin@appudo.com> +appudo.net + +// Aptible : https://www.aptible.com/ +// Submitted by Thomas Orozco <thomas@aptible.com> +on-aptible.com + +// ASEINet : https://www.aseinet.com/ +// Submitted by Asei SEKIGUCHI <mail@aseinet.com> +user.aseinet.ne.jp +gv.vc +d.gv.vc + +// Asociación Amigos de la Informática "Euskalamiga" : http://encounter.eus/ +// Submitted by Hector Martin <marcan@euskalencounter.org> +user.party.eus + +// Association potager.org : https://potager.org/ +// Submitted by Lunar <jardiniers@potager.org> +pimienta.org +poivron.org +potager.org +sweetpepper.org + +// ASUSTOR Inc. : http://www.asustor.com +// Submitted by Vincent Tseng <vincenttseng@asustor.com> +myasustor.com + +// Atlassian : https://atlassian.com +// Submitted by Sam Smyth <devloop@atlassian.com> +cdn.prod.atlassian-dev.net + +// Authentick UG (haftungsbeschränkt) : https://authentick.net +// Submitted by Lukas Reschke <lukas@authentick.net> +translated.page + +// Autocode : https://autocode.com +// Submitted by Jacob Lee <jacob@autocode.com> +autocode.dev + +// AVM : https://avm.de +// Submitted by Andreas Weise <a.weise@avm.de> +myfritz.net + +// AVStack Pte. Ltd. : https://avstack.io +// Submitted by Jasper Hugo <jasper@avstack.io> +onavstack.net + +// AW AdvisorWebsites.com Software Inc : https://advisorwebsites.com +// Submitted by James Kennedy <domains@advisorwebsites.com> +*.awdev.ca +*.advisor.ws + +// AZ.pl sp. z.o.o: https://az.pl +// Submitted by Krzysztof Wolski <krzysztof.wolski@home.eu> +ecommerce-shop.pl + +// b-data GmbH : https://www.b-data.io +// Submitted by Olivier Benz <olivier.benz@b-data.ch> +b-data.io + +// backplane : https://www.backplane.io +// Submitted by Anthony Voutas <anthony@backplane.io> +backplaneapp.io + +// Balena : https://www.balena.io +// Submitted by Petros Angelatos <petrosagg@balena.io> +balena-devices.com + +// University of Banja Luka : https://unibl.org +// Domains for Republic of Srpska administrative entity. +// Submitted by Marko Ivanovic <kormang@hotmail.rs> +rs.ba + +// Banzai Cloud +// Submitted by Janos Matyas <info@banzaicloud.com> +*.banzai.cloud +app.banzaicloud.io +*.backyards.banzaicloud.io + +// BASE, Inc. : https://binc.jp +// Submitted by Yuya NAGASAWA <public-suffix-list@binc.jp> +base.ec +official.ec +buyshop.jp +fashionstore.jp +handcrafted.jp +kawaiishop.jp +supersale.jp +theshop.jp +shopselect.net +base.shop + +// BeagleBoard.org Foundation : https://beagleboard.org +// Submitted by Jason Kridner <jkridner@beagleboard.org> +beagleboard.io + +// Beget Ltd +// Submitted by Lev Nekrasov <lnekrasov@beget.com> +*.beget.app + +// BetaInABox +// Submitted by Adrian <adrian@betainabox.com> +betainabox.com + +// BinaryLane : http://www.binarylane.com +// Submitted by Nathan O'Sullivan <nathan@mammoth.com.au> +bnr.la + +// Bitbucket : http://bitbucket.org +// Submitted by Andy Ortlieb <aortlieb@atlassian.com> +bitbucket.io + +// Blackbaud, Inc. : https://www.blackbaud.com +// Submitted by Paul Crowder <paul.crowder@blackbaud.com> +blackbaudcdn.net + +// Blatech : http://www.blatech.net +// Submitted by Luke Bratch <luke@bratch.co.uk> +of.je + +// Blue Bite, LLC : https://bluebite.com +// Submitted by Joshua Weiss <admin.engineering@bluebite.com> +bluebite.io + +// Boomla : https://boomla.com +// Submitted by Tibor Halter <thalter@boomla.com> +boomla.net + +// Boutir : https://www.boutir.com +// Submitted by Eric Ng Ka Ka <ngkaka@boutir.com> +boutir.com + +// Boxfuse : https://boxfuse.com +// Submitted by Axel Fontaine <axel@boxfuse.com> +boxfuse.io + +// bplaced : https://www.bplaced.net/ +// Submitted by Miroslav Bozic <security@bplaced.net> +square7.ch +bplaced.com +bplaced.de +square7.de +bplaced.net +square7.net + +// Brendly : https://brendly.rs +// Submitted by Dusan Radovanovic <dusan.radovanovic@brendly.rs> +shop.brendly.rs + +// BrowserSafetyMark +// Submitted by Dave Tharp <browsersafetymark.io@quicinc.com> +browsersafetymark.io + +// Bytemark Hosting : https://www.bytemark.co.uk +// Submitted by Paul Cammish <paul.cammish@bytemark.co.uk> +uk0.bigv.io +dh.bytemark.co.uk +vm.bytemark.co.uk + +// Caf.js Labs LLC : https://www.cafjs.com +// Submitted by Antonio Lain <antlai@cafjs.com> +cafjs.com + +// callidomus : https://www.callidomus.com/ +// Submitted by Marcus Popp <admin@callidomus.com> +mycd.eu + +// Canva Pty Ltd : https://canva.com/ +// Submitted by Joel Aquilina <publicsuffixlist@canva.com> +canva-apps.cn +canva-apps.com + +// Carrd : https://carrd.co +// Submitted by AJ <aj@carrd.co> +drr.ac +uwu.ai +carrd.co +crd.co +ju.mp + +// CentralNic : http://www.centralnic.com/names/domains +// Submitted by registry <gavin.brown@centralnic.com> +ae.org +br.com +cn.com +com.de +com.se +de.com +eu.com +gb.net +hu.net +jp.net +jpn.com +mex.com +ru.com +sa.com +se.net +uk.com +uk.net +us.com +za.bz +za.com + +// No longer operated by CentralNic, these entries should be adopted and/or removed by current operators +// Submitted by Gavin Brown <gavin.brown@centralnic.com> +ar.com +hu.com +kr.com +no.com +qc.com +uy.com + +// Africa.com Web Solutions Ltd : https://registry.africa.com +// Submitted by Gavin Brown <gavin.brown@centralnic.com> +africa.com + +// iDOT Services Limited : http://www.domain.gr.com +// Submitted by Gavin Brown <gavin.brown@centralnic.com> +gr.com + +// Radix FZC : http://domains.in.net +// Submitted by Gavin Brown <gavin.brown@centralnic.com> +in.net +web.in + +// US REGISTRY LLC : http://us.org +// Submitted by Gavin Brown <gavin.brown@centralnic.com> +us.org + +// co.com Registry, LLC : https://registry.co.com +// Submitted by Gavin Brown <gavin.brown@centralnic.com> +co.com + +// Roar Domains LLC : https://roar.basketball/ +// Submitted by Gavin Brown <gavin.brown@centralnic.com> +aus.basketball +nz.basketball + +// BRS Media : https://brsmedia.com/ +// Submitted by Gavin Brown <gavin.brown@centralnic.com> +radio.am +radio.fm + +// c.la : http://www.c.la/ +c.la + +// certmgr.org : https://certmgr.org +// Submitted by B. Blechschmidt <hostmaster@certmgr.org> +certmgr.org + +// Cityhost LLC : https://cityhost.ua +// Submitted by Maksym Rivtin <support@cityhost.net.ua> +cx.ua + +// Civilized Discourse Construction Kit, Inc. : https://www.discourse.org/ +// Submitted by Rishabh Nambiar & Michael Brown <team@discourse.org> +discourse.group +discourse.team + +// Clever Cloud : https://www.clever-cloud.com/ +// Submitted by Quentin Adam <noc@clever-cloud.com> +cleverapps.io + +// Clerk : https://www.clerk.dev +// Submitted by Colin Sidoti <systems@clerk.dev> +clerk.app +clerkstage.app +*.lcl.dev +*.lclstage.dev +*.stg.dev +*.stgstage.dev + +// ClickRising : https://clickrising.com/ +// Submitted by Umut Gumeli <infrastructure-publicsuffixlist@clickrising.com> +clickrising.net + +// Cloud66 : https://www.cloud66.com/ +// Submitted by Khash Sajadi <khash@cloud66.com> +c66.me +cloud66.ws +cloud66.zone + +// CloudAccess.net : https://www.cloudaccess.net/ +// Submitted by Pawel Panek <noc@cloudaccess.net> +jdevcloud.com +wpdevcloud.com +cloudaccess.host +freesite.host +cloudaccess.net + +// cloudControl : https://www.cloudcontrol.com/ +// Submitted by Tobias Wilken <tw@cloudcontrol.com> +cloudcontrolled.com +cloudcontrolapp.com + +// Cloudera, Inc. : https://www.cloudera.com/ +// Submitted by Kedarnath Waikar <security@cloudera.com> +*.cloudera.site + +// Cloudflare, Inc. : https://www.cloudflare.com/ +// Submitted by Cloudflare Team <publicsuffixlist@cloudflare.com> +cf-ipfs.com +cloudflare-ipfs.com +trycloudflare.com +pages.dev +r2.dev +workers.dev + +// Clovyr : https://clovyr.io +// Submitted by Patrick Nielsen <patrick@clovyr.io> +wnext.app + +// co.ca : http://registry.co.ca/ +co.ca + +// Co & Co : https://co-co.nl/ +// Submitted by Govert Versluis <govert@co-co.nl> +*.otap.co + +// i-registry s.r.o. : http://www.i-registry.cz/ +// Submitted by Martin Semrad <semrad@i-registry.cz> +co.cz + +// CDN77.com : http://www.cdn77.com +// Submitted by Jan Krpes <jan.krpes@cdn77.com> +c.cdn77.org +cdn77-ssl.net +r.cdn77.net +rsc.cdn77.org +ssl.origin.cdn77-secure.org + +// Cloud DNS Ltd : http://www.cloudns.net +// Submitted by Aleksander Hristov <noc@cloudns.net> +cloudns.asia +cloudns.biz +cloudns.club +cloudns.cc +cloudns.eu +cloudns.in +cloudns.info +cloudns.org +cloudns.pro +cloudns.pw +cloudns.us + +// CNPY : https://cnpy.gdn +// Submitted by Angelo Gladding <angelo@lahacker.net> +cnpy.gdn + +// Codeberg e. V. : https://codeberg.org +// Submitted by Moritz Marquardt <git@momar.de> +codeberg.page + +// CoDNS B.V. +co.nl +co.no + +// Combell.com : https://www.combell.com +// Submitted by Thomas Wouters <thomas.wouters@combellgroup.com> +webhosting.be +hosting-cluster.nl + +// Coordination Center for TLD RU and XN--P1AI : https://cctld.ru/en/domains/domens_ru/reserved/ +// Submitted by George Georgievsky <gug@cctld.ru> +ac.ru +edu.ru +gov.ru +int.ru +mil.ru +test.ru + +// COSIMO GmbH : http://www.cosimo.de +// Submitted by Rene Marticke <rmarticke@cosimo.de> +dyn.cosidns.de +dynamisches-dns.de +dnsupdater.de +internet-dns.de +l-o-g-i-n.de +dynamic-dns.info +feste-ip.net +knx-server.net +static-access.net + +// Craynic, s.r.o. : http://www.craynic.com/ +// Submitted by Ales Krajnik <ales.krajnik@craynic.com> +realm.cz + +// Cryptonomic : https://cryptonomic.net/ +// Submitted by Andrew Cady <public-suffix-list@cryptonomic.net> +*.cryptonomic.net + +// Cupcake : https://cupcake.io/ +// Submitted by Jonathan Rudenberg <jonathan@cupcake.io> +cupcake.is + +// Curv UG : https://curv-labs.de/ +// Submitted by Marvin Wiesner <Marvin@curv-labs.de> +curv.dev + +// Customer OCI - Oracle Dyn https://cloud.oracle.com/home https://dyn.com/dns/ +// Submitted by Gregory Drake <support@dyn.com> +// Note: This is intended to also include customer-oci.com due to wildcards implicitly including the current label +*.customer-oci.com +*.oci.customer-oci.com +*.ocp.customer-oci.com +*.ocs.customer-oci.com + +// cyon GmbH : https://www.cyon.ch/ +// Submitted by Dominic Luechinger <dol@cyon.ch> +cyon.link +cyon.site + +// Danger Science Group: https://dangerscience.com/ +// Submitted by Skylar MacDonald <skylar@dangerscience.com> +fnwk.site +folionetwork.site +platform0.app + +// Daplie, Inc : https://daplie.com +// Submitted by AJ ONeal <aj@daplie.com> +daplie.me +localhost.daplie.me + +// Datto, Inc. : https://www.datto.com/ +// Submitted by Philipp Heckel <ph@datto.com> +dattolocal.com +dattorelay.com +dattoweb.com +mydatto.com +dattolocal.net +mydatto.net + +// Dansk.net : http://www.dansk.net/ +// Submitted by Anani Voule <digital@digital.co.dk> +biz.dk +co.dk +firm.dk +reg.dk +store.dk + +// dappnode.io : https://dappnode.io/ +// Submitted by Abel Boldu / DAppNode Team <community@dappnode.io> +dyndns.dappnode.io + +// dapps.earth : https://dapps.earth/ +// Submitted by Daniil Burdakov <icqkill@gmail.com> +*.dapps.earth +*.bzz.dapps.earth + +// Dark, Inc. : https://darklang.com +// Submitted by Paul Biggar <ops@darklang.com> +builtwithdark.com + +// DataDetect, LLC. : https://datadetect.com +// Submitted by Andrew Banchich <abanchich@sceven.com> +demo.datadetect.com +instance.datadetect.com + +// Datawire, Inc : https://www.datawire.io +// Submitted by Richard Li <secalert@datawire.io> +edgestack.me + +// DDNS5 : https://ddns5.com +// Submitted by Cameron Elliott <cameron@cameronelliott.com> +ddns5.com + +// Debian : https://www.debian.org/ +// Submitted by Peter Palfrader / Debian Sysadmin Team <dsa-publicsuffixlist@debian.org> +debian.net + +// Deno Land Inc : https://deno.com/ +// Submitted by Luca Casonato <hostmaster@deno.com> +deno.dev +deno-staging.dev + +// deSEC : https://desec.io/ +// Submitted by Peter Thomassen <peter@desec.io> +dedyn.io + +// Deta: https://www.deta.sh/ +// Submitted by Aavash Shrestha <aavash@deta.sh> +deta.app +deta.dev + +// Diher Solutions : https://diher.solutions +// Submitted by Didi Hermawan <mail@diher.solutions> +*.rss.my.id +*.diher.solutions + +// Discord Inc : https://discord.com +// Submitted by Sahn Lam <slam@discordapp.com> +discordsays.com +discordsez.com + +// DNS Africa Ltd https://dns.business +// Submitted by Calvin Browne <calvin@dns.business> +jozi.biz + +// DNShome : https://www.dnshome.de/ +// Submitted by Norbert Auler <mail@dnshome.de> +dnshome.de + +// DotArai : https://www.dotarai.com/ +// Submitted by Atsadawat Netcharadsang <atsadawat@dotarai.co.th> +online.th +shop.th + +// DrayTek Corp. : https://www.draytek.com/ +// Submitted by Paul Fang <mis@draytek.com> +drayddns.com + +// DreamCommerce : https://shoper.pl/ +// Submitted by Konrad Kotarba <konrad.kotarba@dreamcommerce.com> +shoparena.pl + +// DreamHost : http://www.dreamhost.com/ +// Submitted by Andrew Farmer <andrew.farmer@dreamhost.com> +dreamhosters.com + +// Drobo : http://www.drobo.com/ +// Submitted by Ricardo Padilha <rpadilha@drobo.com> +mydrobo.com + +// Drud Holdings, LLC. : https://www.drud.com/ +// Submitted by Kevin Bridges <kevin@drud.com> +drud.io +drud.us + +// DuckDNS : http://www.duckdns.org/ +// Submitted by Richard Harper <richard@duckdns.org> +duckdns.org + +// Bip : https://bip.sh +// Submitted by Joel Kennedy <joel@bip.sh> +bip.sh + +// bitbridge.net : Submitted by Craig Welch, abeliidev@gmail.com +bitbridge.net + +// dy.fi : http://dy.fi/ +// Submitted by Heikki Hannikainen <hessu@hes.iki.fi> +dy.fi +tunk.org + +// DynDNS.com : http://www.dyndns.com/services/dns/dyndns/ +dyndns-at-home.com +dyndns-at-work.com +dyndns-blog.com +dyndns-free.com +dyndns-home.com +dyndns-ip.com +dyndns-mail.com +dyndns-office.com +dyndns-pics.com +dyndns-remote.com +dyndns-server.com +dyndns-web.com +dyndns-wiki.com +dyndns-work.com +dyndns.biz +dyndns.info +dyndns.org +dyndns.tv +at-band-camp.net +ath.cx +barrel-of-knowledge.info +barrell-of-knowledge.info +better-than.tv +blogdns.com +blogdns.net +blogdns.org +blogsite.org +boldlygoingnowhere.org +broke-it.net +buyshouses.net +cechire.com +dnsalias.com +dnsalias.net +dnsalias.org +dnsdojo.com +dnsdojo.net +dnsdojo.org +does-it.net +doesntexist.com +doesntexist.org +dontexist.com +dontexist.net +dontexist.org +doomdns.com +doomdns.org +dvrdns.org +dyn-o-saur.com +dynalias.com +dynalias.net +dynalias.org +dynathome.net +dyndns.ws +endofinternet.net +endofinternet.org +endoftheinternet.org +est-a-la-maison.com +est-a-la-masion.com +est-le-patron.com +est-mon-blogueur.com +for-better.biz +for-more.biz +for-our.info +for-some.biz +for-the.biz +forgot.her.name +forgot.his.name +from-ak.com +from-al.com +from-ar.com +from-az.net +from-ca.com +from-co.net +from-ct.com +from-dc.com +from-de.com +from-fl.com +from-ga.com +from-hi.com +from-ia.com +from-id.com +from-il.com +from-in.com +from-ks.com +from-ky.com +from-la.net +from-ma.com +from-md.com +from-me.org +from-mi.com +from-mn.com +from-mo.com +from-ms.com +from-mt.com +from-nc.com +from-nd.com +from-ne.com +from-nh.com +from-nj.com +from-nm.com +from-nv.com +from-ny.net +from-oh.com +from-ok.com +from-or.com +from-pa.com +from-pr.com +from-ri.com +from-sc.com +from-sd.com +from-tn.com +from-tx.com +from-ut.com +from-va.com +from-vt.com +from-wa.com +from-wi.com +from-wv.com +from-wy.com +ftpaccess.cc +fuettertdasnetz.de +game-host.org +game-server.cc +getmyip.com +gets-it.net +go.dyndns.org +gotdns.com +gotdns.org +groks-the.info +groks-this.info +ham-radio-op.net +here-for-more.info +hobby-site.com +hobby-site.org +home.dyndns.org +homedns.org +homeftp.net +homeftp.org +homeip.net +homelinux.com +homelinux.net +homelinux.org +homeunix.com +homeunix.net +homeunix.org +iamallama.com +in-the-band.net +is-a-anarchist.com +is-a-blogger.com +is-a-bookkeeper.com +is-a-bruinsfan.org +is-a-bulls-fan.com +is-a-candidate.org +is-a-caterer.com +is-a-celticsfan.org +is-a-chef.com +is-a-chef.net +is-a-chef.org +is-a-conservative.com +is-a-cpa.com +is-a-cubicle-slave.com +is-a-democrat.com +is-a-designer.com +is-a-doctor.com +is-a-financialadvisor.com +is-a-geek.com +is-a-geek.net +is-a-geek.org +is-a-green.com +is-a-guru.com +is-a-hard-worker.com +is-a-hunter.com +is-a-knight.org +is-a-landscaper.com +is-a-lawyer.com +is-a-liberal.com +is-a-libertarian.com +is-a-linux-user.org +is-a-llama.com +is-a-musician.com +is-a-nascarfan.com +is-a-nurse.com +is-a-painter.com +is-a-patsfan.org +is-a-personaltrainer.com +is-a-photographer.com +is-a-player.com +is-a-republican.com +is-a-rockstar.com +is-a-socialist.com +is-a-soxfan.org +is-a-student.com +is-a-teacher.com +is-a-techie.com +is-a-therapist.com +is-an-accountant.com +is-an-actor.com +is-an-actress.com +is-an-anarchist.com +is-an-artist.com +is-an-engineer.com +is-an-entertainer.com +is-by.us +is-certified.com +is-found.org +is-gone.com +is-into-anime.com +is-into-cars.com +is-into-cartoons.com +is-into-games.com +is-leet.com +is-lost.org +is-not-certified.com +is-saved.org +is-slick.com +is-uberleet.com +is-very-bad.org +is-very-evil.org +is-very-good.org +is-very-nice.org +is-very-sweet.org +is-with-theband.com +isa-geek.com +isa-geek.net +isa-geek.org +isa-hockeynut.com +issmarterthanyou.com +isteingeek.de +istmein.de +kicks-ass.net +kicks-ass.org +knowsitall.info +land-4-sale.us +lebtimnetz.de +leitungsen.de +likes-pie.com +likescandy.com +merseine.nu +mine.nu +misconfused.org +mypets.ws +myphotos.cc +neat-url.com +office-on-the.net +on-the-web.tv +podzone.net +podzone.org +readmyblog.org +saves-the-whales.com +scrapper-site.net +scrapping.cc +selfip.biz +selfip.com +selfip.info +selfip.net +selfip.org +sells-for-less.com +sells-for-u.com +sells-it.net +sellsyourhome.org +servebbs.com +servebbs.net +servebbs.org +serveftp.net +serveftp.org +servegame.org +shacknet.nu +simple-url.com +space-to-rent.com +stuff-4-sale.org +stuff-4-sale.us +teaches-yoga.com +thruhere.net +traeumtgerade.de +webhop.biz +webhop.info +webhop.net +webhop.org +worse-than.tv +writesthisblog.com + +// ddnss.de : https://www.ddnss.de/ +// Submitted by Robert Niedziela <webmaster@ddnss.de> +ddnss.de +dyn.ddnss.de +dyndns.ddnss.de +dyndns1.de +dyn-ip24.de +home-webserver.de +dyn.home-webserver.de +myhome-server.de +ddnss.org + +// Definima : http://www.definima.com/ +// Submitted by Maxence Bitterli <maxence@definima.com> +definima.net +definima.io + +// DigitalOcean App Platform : https://www.digitalocean.com/products/app-platform/ +// Submitted by Braxton Huggins <psl-maintainers@digitalocean.com> +ondigitalocean.app + +// DigitalOcean Spaces : https://www.digitalocean.com/products/spaces/ +// Submitted by Robin H. Johnson <psl-maintainers@digitalocean.com> +*.digitaloceanspaces.com + +// dnstrace.pro : https://dnstrace.pro/ +// Submitted by Chris Partridge <chris@partridge.tech> +bci.dnstrace.pro + +// Dynu.com : https://www.dynu.com/ +// Submitted by Sue Ye <sue@dynu.com> +ddnsfree.com +ddnsgeek.com +giize.com +gleeze.com +kozow.com +loseyourip.com +ooguy.com +theworkpc.com +casacam.net +dynu.net +accesscam.org +camdvr.org +freeddns.org +mywire.org +webredirect.org +myddns.rocks +blogsite.xyz + +// dynv6 : https://dynv6.com +// Submitted by Dominik Menke <dom@digineo.de> +dynv6.net + +// E4YOU spol. s.r.o. : https://e4you.cz/ +// Submitted by Vladimir Dudr <info@e4you.cz> +e4.cz + +// Easypanel : https://easypanel.io +// Submitted by Andrei Canta <andrei@easypanel.io> +easypanel.app +easypanel.host + +// Elementor : Elementor Ltd. +// Submitted by Anton Barkan <antonb@elementor.com> +elementor.cloud +elementor.cool + +// En root‽ : https://en-root.org +// Submitted by Emmanuel Raviart <emmanuel@raviart.com> +en-root.fr + +// Enalean SAS: https://www.enalean.com +// Submitted by Thomas Cottier <thomas.cottier@enalean.com> +mytuleap.com +tuleap-partners.com + +// Encoretivity AB: https://encore.dev +// Submitted by André Eriksson <andre@encore.dev> +encr.app +encoreapi.com + +// ECG Robotics, Inc: https://ecgrobotics.org +// Submitted by <frc1533@ecgrobotics.org> +onred.one +staging.onred.one + +// encoway GmbH : https://www.encoway.de +// Submitted by Marcel Daus <cloudops@encoway.de> +eu.encoway.cloud + +// EU.org https://eu.org/ +// Submitted by Pierre Beyssac <hostmaster@eu.org> +eu.org +al.eu.org +asso.eu.org +at.eu.org +au.eu.org +be.eu.org +bg.eu.org +ca.eu.org +cd.eu.org +ch.eu.org +cn.eu.org +cy.eu.org +cz.eu.org +de.eu.org +dk.eu.org +edu.eu.org +ee.eu.org +es.eu.org +fi.eu.org +fr.eu.org +gr.eu.org +hr.eu.org +hu.eu.org +ie.eu.org +il.eu.org +in.eu.org +int.eu.org +is.eu.org +it.eu.org +jp.eu.org +kr.eu.org +lt.eu.org +lu.eu.org +lv.eu.org +mc.eu.org +me.eu.org +mk.eu.org +mt.eu.org +my.eu.org +net.eu.org +ng.eu.org +nl.eu.org +no.eu.org +nz.eu.org +paris.eu.org +pl.eu.org +pt.eu.org +q-a.eu.org +ro.eu.org +ru.eu.org +se.eu.org +si.eu.org +sk.eu.org +tr.eu.org +uk.eu.org +us.eu.org + +// Eurobyte : https://eurobyte.ru +// Submitted by Evgeniy Subbotin <e.subbotin@eurobyte.ru> +eurodir.ru + +// Evennode : http://www.evennode.com/ +// Submitted by Michal Kralik <support@evennode.com> +eu-1.evennode.com +eu-2.evennode.com +eu-3.evennode.com +eu-4.evennode.com +us-1.evennode.com +us-2.evennode.com +us-3.evennode.com +us-4.evennode.com + +// eDirect Corp. : https://hosting.url.com.tw/ +// Submitted by C.S. chang <cschang@corp.url.com.tw> +twmail.cc +twmail.net +twmail.org +mymailer.com.tw +url.tw + +// Fabrica Technologies, Inc. : https://www.fabrica.dev/ +// Submitted by Eric Jiang <eric@fabrica.dev> +onfabrica.com + +// Facebook, Inc. +// Submitted by Peter Ruibal <public-suffix@fb.com> +apps.fbsbx.com + +// FAITID : https://faitid.org/ +// Submitted by Maxim Alzoba <tech.contact@faitid.org> +// https://www.flexireg.net/stat_info +ru.net +adygeya.ru +bashkiria.ru +bir.ru +cbg.ru +com.ru +dagestan.ru +grozny.ru +kalmykia.ru +kustanai.ru +marine.ru +mordovia.ru +msk.ru +mytis.ru +nalchik.ru +nov.ru +pyatigorsk.ru +spb.ru +vladikavkaz.ru +vladimir.ru +abkhazia.su +adygeya.su +aktyubinsk.su +arkhangelsk.su +armenia.su +ashgabad.su +azerbaijan.su +balashov.su +bashkiria.su +bryansk.su +bukhara.su +chimkent.su +dagestan.su +east-kazakhstan.su +exnet.su +georgia.su +grozny.su +ivanovo.su +jambyl.su +kalmykia.su +kaluga.su +karacol.su +karaganda.su +karelia.su +khakassia.su +krasnodar.su +kurgan.su +kustanai.su +lenug.su +mangyshlak.su +mordovia.su +msk.su +murmansk.su +nalchik.su +navoi.su +north-kazakhstan.su +nov.su +obninsk.su +penza.su +pokrovsk.su +sochi.su +spb.su +tashkent.su +termez.su +togliatti.su +troitsk.su +tselinograd.su +tula.su +tuva.su +vladikavkaz.su +vladimir.su +vologda.su + +// Fancy Bits, LLC : http://getchannels.com +// Submitted by Aman Gupta <aman@getchannels.com> +channelsdvr.net +u.channelsdvr.net + +// Fastly Inc. : http://www.fastly.com/ +// Submitted by Fastly Security <security@fastly.com> +edgecompute.app +fastly-edge.com +fastly-terrarium.com +fastlylb.net +map.fastlylb.net +freetls.fastly.net +map.fastly.net +a.prod.fastly.net +global.prod.fastly.net +a.ssl.fastly.net +b.ssl.fastly.net +global.ssl.fastly.net + +// Fastmail : https://www.fastmail.com/ +// Submitted by Marc Bradshaw <marc@fastmailteam.com> +*.user.fm + +// FASTVPS EESTI OU : https://fastvps.ru/ +// Submitted by Likhachev Vasiliy <lihachev@fastvps.ru> +fastvps-server.com +fastvps.host +myfast.host +fastvps.site +myfast.space + +// Fedora : https://fedoraproject.org/ +// submitted by Patrick Uiterwijk <puiterwijk@fedoraproject.org> +fedorainfracloud.org +fedorapeople.org +cloud.fedoraproject.org +app.os.fedoraproject.org +app.os.stg.fedoraproject.org + +// FearWorks Media Ltd. : https://fearworksmedia.co.uk +// submitted by Keith Fairley <domains@fearworksmedia.co.uk> +conn.uk +copro.uk +hosp.uk + +// Fermax : https://fermax.com/ +// submitted by Koen Van Isterdael <k.vanisterdael@fermax.be> +mydobiss.com + +// FH Muenster : https://www.fh-muenster.de +// Submitted by Robin Naundorf <r.naundorf@fh-muenster.de> +fh-muenster.io + +// Filegear Inc. : https://www.filegear.com +// Submitted by Jason Zhu <jason@owtware.com> +filegear.me +filegear-au.me +filegear-de.me +filegear-gb.me +filegear-ie.me +filegear-jp.me +filegear-sg.me + +// Firebase, Inc. +// Submitted by Chris Raynor <chris@firebase.com> +firebaseapp.com + +// Firewebkit : https://www.firewebkit.com +// Submitted by Majid Qureshi <mqureshi@amrayn.com> +fireweb.app + +// FLAP : https://www.flap.cloud +// Submitted by Louis Chemineau <louis@chmn.me> +flap.id + +// FlashDrive : https://flashdrive.io +// Submitted by Eric Chan <support@flashdrive.io> +onflashdrive.app +fldrv.com + +// fly.io: https://fly.io +// Submitted by Kurt Mackey <kurt@fly.io> +fly.dev +edgeapp.net +shw.io + +// Flynn : https://flynn.io +// Submitted by Jonathan Rudenberg <jonathan@flynn.io> +flynnhosting.net + +// Forgerock : https://www.forgerock.com +// Submitted by Roderick Parr <roderick.parr@forgerock.com> +forgeblocks.com +id.forgerock.io + +// Framer : https://www.framer.com +// Submitted by Koen Rouwhorst <koenrh@framer.com> +framer.app +framercanvas.com +framer.media +framer.photos +framer.website +framer.wiki + +// Frusky MEDIA&PR : https://www.frusky.de +// Submitted by Victor Pupynin <hallo@frusky.de> +*.frusky.de + +// RavPage : https://www.ravpage.co.il +// Submitted by Roni Horowitz <roni@responder.co.il> +ravpage.co.il + +// Frederik Braun https://frederik-braun.com +// Submitted by Frederik Braun <fb@frederik-braun.com> +0e.vc + +// Freebox : http://www.freebox.fr +// Submitted by Romain Fliedel <rfliedel@freebox.fr> +freebox-os.com +freeboxos.com +fbx-os.fr +fbxos.fr +freebox-os.fr +freeboxos.fr + +// freedesktop.org : https://www.freedesktop.org +// Submitted by Daniel Stone <daniel@fooishbar.org> +freedesktop.org + +// freemyip.com : https://freemyip.com +// Submitted by Cadence <contact@freemyip.com> +freemyip.com + +// FunkFeuer - Verein zur Förderung freier Netze : https://www.funkfeuer.at +// Submitted by Daniel A. Maierhofer <vorstand@funkfeuer.at> +wien.funkfeuer.at + +// Futureweb OG : http://www.futureweb.at +// Submitted by Andreas Schnederle-Wagner <schnederle@futureweb.at> +*.futurecms.at +*.ex.futurecms.at +*.in.futurecms.at +futurehosting.at +futuremailing.at +*.ex.ortsinfo.at +*.kunden.ortsinfo.at +*.statics.cloud + +// GDS : https://www.gov.uk/service-manual/technology/managing-domain-names +// Submitted by Stephen Ford <hostmaster@digital.cabinet-office.gov.uk> +independent-commission.uk +independent-inquest.uk +independent-inquiry.uk +independent-panel.uk +independent-review.uk +public-inquiry.uk +royal-commission.uk +campaign.gov.uk +service.gov.uk + +// CDDO : https://www.gov.uk/guidance/get-an-api-domain-on-govuk +// Submitted by Jamie Tanna <jamie.tanna@digital.cabinet-office.gov.uk> +api.gov.uk + +// Gehirn Inc. : https://www.gehirn.co.jp/ +// Submitted by Kohei YOSHIDA <tech@gehirn.co.jp> +gehirn.ne.jp +usercontent.jp + +// Gentlent, Inc. : https://www.gentlent.com +// Submitted by Tom Klein <tom@gentlent.com> +gentapps.com +gentlentapis.com +lab.ms +cdn-edges.net + +// Ghost Foundation : https://ghost.org +// Submitted by Matt Hanley <security@ghost.org> +ghost.io + +// GignoSystemJapan: http://gsj.bz +// Submitted by GignoSystemJapan <kakutou-ec@gsj.bz> +gsj.bz + +// GitHub, Inc. +// Submitted by Patrick Toomey <security@github.com> +githubusercontent.com +githubpreview.dev +github.io + +// GitLab, Inc. +// Submitted by Alex Hanselka <alex@gitlab.com> +gitlab.io + +// Gitplac.si - https://gitplac.si +// Submitted by Aljaž Starc <me@aljaxus.eu> +gitapp.si +gitpage.si + +// Glitch, Inc : https://glitch.com +// Submitted by Mads Hartmann <mads@glitch.com> +glitch.me + +// Global NOG Alliance : https://nogalliance.org/ +// Submitted by Sander Steffann <sander@nogalliance.org> +nog.community + +// Globe Hosting SRL : https://www.globehosting.com/ +// Submitted by Gavin Brown <gavin.brown@centralnic.com> +co.ro +shop.ro + +// GMO Pepabo, Inc. : https://pepabo.com/ +// Submitted by Hosting Div <admin@pepabo.com> +lolipop.io +angry.jp +babyblue.jp +babymilk.jp +backdrop.jp +bambina.jp +bitter.jp +blush.jp +boo.jp +boy.jp +boyfriend.jp +but.jp +candypop.jp +capoo.jp +catfood.jp +cheap.jp +chicappa.jp +chillout.jp +chips.jp +chowder.jp +chu.jp +ciao.jp +cocotte.jp +coolblog.jp +cranky.jp +cutegirl.jp +daa.jp +deca.jp +deci.jp +digick.jp +egoism.jp +fakefur.jp +fem.jp +flier.jp +floppy.jp +fool.jp +frenchkiss.jp +girlfriend.jp +girly.jp +gloomy.jp +gonna.jp +greater.jp +hacca.jp +heavy.jp +her.jp +hiho.jp +hippy.jp +holy.jp +hungry.jp +icurus.jp +itigo.jp +jellybean.jp +kikirara.jp +kill.jp +kilo.jp +kuron.jp +littlestar.jp +lolipopmc.jp +lolitapunk.jp +lomo.jp +lovepop.jp +lovesick.jp +main.jp +mods.jp +mond.jp +mongolian.jp +moo.jp +namaste.jp +nikita.jp +nobushi.jp +noor.jp +oops.jp +parallel.jp +parasite.jp +pecori.jp +peewee.jp +penne.jp +pepper.jp +perma.jp +pigboat.jp +pinoko.jp +punyu.jp +pupu.jp +pussycat.jp +pya.jp +raindrop.jp +readymade.jp +sadist.jp +schoolbus.jp +secret.jp +staba.jp +stripper.jp +sub.jp +sunnyday.jp +thick.jp +tonkotsu.jp +under.jp +upper.jp +velvet.jp +verse.jp +versus.jp +vivian.jp +watson.jp +weblike.jp +whitesnow.jp +zombie.jp +heteml.net + +// GOV.UK Platform as a Service : https://www.cloud.service.gov.uk/ +// Submitted by Tom Whitwell <gov-uk-paas-support@digital.cabinet-office.gov.uk> +cloudapps.digital +london.cloudapps.digital + +// GOV.UK Pay : https://www.payments.service.gov.uk/ +// Submitted by Richard Baker <richard.baker@digital.cabinet-office.gov.uk> +pymnt.uk + +// UKHomeOffice : https://www.gov.uk/government/organisations/home-office +// Submitted by Jon Shanks <jon.shanks@digital.homeoffice.gov.uk> +homeoffice.gov.uk + +// GlobeHosting, Inc. +// Submitted by Zoltan Egresi <egresi@globehosting.com> +ro.im + +// GoIP DNS Services : http://www.goip.de +// Submitted by Christian Poulter <milchstrasse@goip.de> +goip.de + +// Google, Inc. +// Submitted by Eduardo Vela <evn@google.com> +run.app +a.run.app +web.app +*.0emm.com +appspot.com +*.r.appspot.com +codespot.com +googleapis.com +googlecode.com +pagespeedmobilizer.com +publishproxy.com +withgoogle.com +withyoutube.com +*.gateway.dev +cloud.goog +translate.goog +*.usercontent.goog +cloudfunctions.net +blogspot.ae +blogspot.al +blogspot.am +blogspot.ba +blogspot.be +blogspot.bg +blogspot.bj +blogspot.ca +blogspot.cf +blogspot.ch +blogspot.cl +blogspot.co.at +blogspot.co.id +blogspot.co.il +blogspot.co.ke +blogspot.co.nz +blogspot.co.uk +blogspot.co.za +blogspot.com +blogspot.com.ar +blogspot.com.au +blogspot.com.br +blogspot.com.by +blogspot.com.co +blogspot.com.cy +blogspot.com.ee +blogspot.com.eg +blogspot.com.es +blogspot.com.mt +blogspot.com.ng +blogspot.com.tr +blogspot.com.uy +blogspot.cv +blogspot.cz +blogspot.de +blogspot.dk +blogspot.fi +blogspot.fr +blogspot.gr +blogspot.hk +blogspot.hr +blogspot.hu +blogspot.ie +blogspot.in +blogspot.is +blogspot.it +blogspot.jp +blogspot.kr +blogspot.li +blogspot.lt +blogspot.lu +blogspot.md +blogspot.mk +blogspot.mr +blogspot.mx +blogspot.my +blogspot.nl +blogspot.no +blogspot.pe +blogspot.pt +blogspot.qa +blogspot.re +blogspot.ro +blogspot.rs +blogspot.ru +blogspot.se +blogspot.sg +blogspot.si +blogspot.sk +blogspot.sn +blogspot.td +blogspot.tw +blogspot.ug +blogspot.vn + +// Goupile : https://goupile.fr +// Submitted by Niels Martignene <hello@goupile.fr> +goupile.fr + +// Government of the Netherlands: https://www.government.nl +// Submitted by <domeinnaam@minaz.nl> +gov.nl + +// Group 53, LLC : https://www.group53.com +// Submitted by Tyler Todd <noc@nova53.net> +awsmppl.com + +// GünstigBestellen : https://günstigbestellen.de +// Submitted by Furkan Akkoc <info@hendelzon.de> +günstigbestellen.de +günstigliefern.de + +// Hakaran group: http://hakaran.cz +// Submitted by Arseniy Sokolov <security@hakaran.cz> +fin.ci +free.hr +caa.li +ua.rs +conf.se + +// Handshake : https://handshake.org +// Submitted by Mike Damm <md@md.vc> +hs.zone +hs.run + +// Hashbang : https://hashbang.sh +hashbang.sh + +// Hasura : https://hasura.io +// Submitted by Shahidh K Muhammed <shahidh@hasura.io> +hasura.app +hasura-app.io + +// Heilbronn University of Applied Sciences - Faculty Informatics (GitLab Pages): https://www.hs-heilbronn.de +// Submitted by Richard Zowalla <mi-admin@hs-heilbronn.de> +pages.it.hs-heilbronn.de + +// Hepforge : https://www.hepforge.org +// Submitted by David Grellscheid <admin@hepforge.org> +hepforge.org + +// Heroku : https://www.heroku.com/ +// Submitted by Tom Maher <tmaher@heroku.com> +herokuapp.com +herokussl.com + +// Hibernating Rhinos +// Submitted by Oren Eini <oren@ravendb.net> +ravendb.cloud +ravendb.community +ravendb.me +development.run +ravendb.run + +// home.pl S.A.: https://home.pl +// Submitted by Krzysztof Wolski <krzysztof.wolski@home.eu> +homesklep.pl + +// Hong Kong Productivity Council: https://www.hkpc.org/ +// Submitted by SECaaS Team <summchan@hkpc.org> +secaas.hk + +// Hoplix : https://www.hoplix.com +// Submitted by Danilo De Franco<info@hoplix.shop> +hoplix.shop + + +// HOSTBIP REGISTRY : https://www.hostbip.com/ +// Submitted by Atanunu Igbunuroghene <publicsuffixlist@hostbip.com> +orx.biz +biz.gl +col.ng +firm.ng +gen.ng +ltd.ng +ngo.ng +edu.scot +sch.so + +// HostFly : https://www.ie.ua +// Submitted by Bohdan Dub <support@hostfly.com.ua> +ie.ua + +// HostyHosting (hostyhosting.com) +hostyhosting.io + +// Häkkinen.fi +// Submitted by Eero Häkkinen <Eero+psl@Häkkinen.fi> +häkkinen.fi + +// Ici la Lune : http://www.icilalune.com/ +// Submitted by Simon Morvan <simon@icilalune.com> +*.moonscale.io +moonscale.net + +// iki.fi +// Submitted by Hannu Aronsson <haa@iki.fi> +iki.fi + +// iliad italia: https://www.iliad.it +// Submitted by Marios Makassikis <mmakassikis@freebox.fr> +ibxos.it +iliadboxos.it + +// Impertrix Solutions : <https://impertrixcdn.com> +// Submitted by Zhixiang Zhao <csuite@impertrix.com> +impertrixcdn.com +impertrix.com + +// Incsub, LLC: https://incsub.com/ +// Submitted by Aaron Edwards <sysadmins@incsub.com> +smushcdn.com +wphostedmail.com +wpmucdn.com +tempurl.host +wpmudev.host + +// Individual Network Berlin e.V. : https://www.in-berlin.de/ +// Submitted by Christian Seitz <chris@in-berlin.de> +dyn-berlin.de +in-berlin.de +in-brb.de +in-butter.de +in-dsl.de +in-dsl.net +in-dsl.org +in-vpn.de +in-vpn.net +in-vpn.org + +// info.at : http://www.info.at/ +biz.at +info.at + +// info.cx : http://info.cx +// Submitted by Jacob Slater <whois@igloo.to> +info.cx + +// Interlegis : http://www.interlegis.leg.br +// Submitted by Gabriel Ferreira <registrobr@interlegis.leg.br> +ac.leg.br +al.leg.br +am.leg.br +ap.leg.br +ba.leg.br +ce.leg.br +df.leg.br +es.leg.br +go.leg.br +ma.leg.br +mg.leg.br +ms.leg.br +mt.leg.br +pa.leg.br +pb.leg.br +pe.leg.br +pi.leg.br +pr.leg.br +rj.leg.br +rn.leg.br +ro.leg.br +rr.leg.br +rs.leg.br +sc.leg.br +se.leg.br +sp.leg.br +to.leg.br + +// intermetrics GmbH : https://pixolino.com/ +// Submitted by Wolfgang Schwarz <admin@intermetrics.de> +pixolino.com + +// Internet-Pro, LLP: https://netangels.ru/ +// Submitted by Vasiliy Sheredeko <piphon@gmail.com> +na4u.ru + +// iopsys software solutions AB : https://iopsys.eu/ +// Submitted by Roman Azarenko <roman.azarenko@iopsys.eu> +iopsys.se + +// IPiFony Systems, Inc. : https://www.ipifony.com/ +// Submitted by Matthew Hardeman <mhardeman@ipifony.com> +ipifony.net + +// IServ GmbH : https://iserv.de +// Submitted by Mario Hoberg <info@iserv.de> +iservschule.de +mein-iserv.de +schulplattform.de +schulserver.de +test-iserv.de +iserv.dev + +// I-O DATA DEVICE, INC. : http://www.iodata.com/ +// Submitted by Yuji Minagawa <domains-admin@iodata.jp> +iobb.net + +// Jelastic, Inc. : https://jelastic.com/ +// Submitted by Ihor Kolodyuk <ik@jelastic.com> +mel.cloudlets.com.au +cloud.interhostsolutions.be +users.scale.virtualcloud.com.br +mycloud.by +alp1.ae.flow.ch +appengine.flow.ch +es-1.axarnet.cloud +diadem.cloud +vip.jelastic.cloud +jele.cloud +it1.eur.aruba.jenv-aruba.cloud +it1.jenv-aruba.cloud +keliweb.cloud +cs.keliweb.cloud +oxa.cloud +tn.oxa.cloud +uk.oxa.cloud +primetel.cloud +uk.primetel.cloud +ca.reclaim.cloud +uk.reclaim.cloud +us.reclaim.cloud +ch.trendhosting.cloud +de.trendhosting.cloud +jele.club +amscompute.com +clicketcloud.com +dopaas.com +hidora.com +paas.hosted-by-previder.com +rag-cloud.hosteur.com +rag-cloud-ch.hosteur.com +jcloud.ik-server.com +jcloud-ver-jpc.ik-server.com +demo.jelastic.com +kilatiron.com +paas.massivegrid.com +jed.wafaicloud.com +lon.wafaicloud.com +ryd.wafaicloud.com +j.scaleforce.com.cy +jelastic.dogado.eu +fi.cloudplatform.fi +demo.datacenter.fi +paas.datacenter.fi +jele.host +mircloud.host +paas.beebyte.io +sekd1.beebyteapp.io +jele.io +cloud-fr1.unispace.io +jc.neen.it +cloud.jelastic.open.tim.it +jcloud.kz +upaas.kazteleport.kz +cloudjiffy.net +fra1-de.cloudjiffy.net +west1-us.cloudjiffy.net +jls-sto1.elastx.net +jls-sto2.elastx.net +jls-sto3.elastx.net +faststacks.net +fr-1.paas.massivegrid.net +lon-1.paas.massivegrid.net +lon-2.paas.massivegrid.net +ny-1.paas.massivegrid.net +ny-2.paas.massivegrid.net +sg-1.paas.massivegrid.net +jelastic.saveincloud.net +nordeste-idc.saveincloud.net +j.scaleforce.net +jelastic.tsukaeru.net +sdscloud.pl +unicloud.pl +mircloud.ru +jelastic.regruhosting.ru +enscaled.sg +jele.site +jelastic.team +orangecloud.tn +j.layershift.co.uk +phx.enscaled.us +mircloud.us + +// Jino : https://www.jino.ru +// Submitted by Sergey Ulyashin <ulyashin@jino.ru> +myjino.ru +*.hosting.myjino.ru +*.landing.myjino.ru +*.spectrum.myjino.ru +*.vps.myjino.ru + +// Jotelulu S.L. : https://jotelulu.com +// Submitted by Daniel Fariña <ingenieria@jotelulu.com> +jotelulu.cloud + +// Joyent : https://www.joyent.com/ +// Submitted by Brian Bennett <brian.bennett@joyent.com> +*.triton.zone +*.cns.joyent.com + +// JS.ORG : http://dns.js.org +// Submitted by Stefan Keim <admin@js.org> +js.org + +// KaasHosting : http://www.kaashosting.nl/ +// Submitted by Wouter Bakker <hostmaster@kaashosting.nl> +kaas.gg +khplay.nl + +// Kakao : https://www.kakaocorp.com/ +// Submitted by JaeYoong Lee <cec@kakaocorp.com> +ktistory.com + +// Kapsi : https://kapsi.fi +// Submitted by Tomi Juntunen <erani@kapsi.fi> +kapsi.fi + +// Keyweb AG : https://www.keyweb.de +// Submitted by Martin Dannehl <postmaster@keymachine.de> +keymachine.de + +// KingHost : https://king.host +// Submitted by Felipe Keller Braz <felipebraz@kinghost.com.br> +kinghost.net +uni5.net + +// KnightPoint Systems, LLC : http://www.knightpoint.com/ +// Submitted by Roy Keene <rkeene@knightpoint.com> +knightpoint.systems + +// KoobinEvent, SL: https://www.koobin.com +// Submitted by Iván Oliva <ivan.oliva@koobin.com> +koobin.events + +// KUROKU LTD : https://kuroku.ltd/ +// Submitted by DisposaBoy <security@oya.to> +oya.to + +// Katholieke Universiteit Leuven: https://www.kuleuven.be +// Submitted by Abuse KU Leuven <abuse@kuleuven.be> +kuleuven.cloud +ezproxy.kuleuven.be + +// .KRD : http://nic.krd/data/krd/Registration%20Policy.pdf +co.krd +edu.krd + +// Krellian Ltd. : https://krellian.com +// Submitted by Ben Francis <ben@krellian.com> +krellian.net +webthings.io + +// LCube - Professional hosting e.K. : https://www.lcube-webhosting.de +// Submitted by Lars Laehn <info@lcube.de> +git-repos.de +lcube-server.de +svn-repos.de + +// Leadpages : https://www.leadpages.net +// Submitted by Greg Dallavalle <domains@leadpages.net> +leadpages.co +lpages.co +lpusercontent.com + +// Lelux.fi : https://lelux.fi/ +// Submitted by Lelux Admin <publisuffix@lelux.site> +lelux.site + +// Lifetime Hosting : https://Lifetime.Hosting/ +// Submitted by Mike Fillator <support@lifetime.hosting> +co.business +co.education +co.events +co.financial +co.network +co.place +co.technology + +// Lightmaker Property Manager, Inc. : https://app.lmpm.com/ +// Submitted by Greg Holland <greg.holland@lmpm.com> +app.lmpm.com + +// linkyard ldt: https://www.linkyard.ch/ +// Submitted by Mario Siegenthaler <mario.siegenthaler@linkyard.ch> +linkyard.cloud +linkyard-cloud.ch + +// Linode : https://linode.com +// Submitted by <security@linode.com> +members.linode.com +*.nodebalancer.linode.com +*.linodeobjects.com +ip.linodeusercontent.com + +// LiquidNet Ltd : http://www.liquidnetlimited.com/ +// Submitted by Victor Velchev <admin@liquidnetlimited.com> +we.bs + +// Localcert : https://localcert.dev +// Submitted by Lann Martin <security@localcert.dev> +*.user.localcert.dev + +// localzone.xyz +// Submitted by Kenny Niehage <hello@yahe.sh> +localzone.xyz + +// Log'in Line : https://www.loginline.com/ +// Submitted by Rémi Mach <remi.mach@loginline.com> +loginline.app +loginline.dev +loginline.io +loginline.services +loginline.site + +// Lokalized : https://lokalized.nl +// Submitted by Noah Taheij <noah@lokalized.nl> +servers.run + +// Lõhmus Family, The +// Submitted by Heiki Lõhmus <hostmaster at lohmus dot me> +lohmus.me + +// LubMAN UMCS Sp. z o.o : https://lubman.pl/ +// Submitted by Ireneusz Maliszewski <ireneusz.maliszewski@lubman.pl> +krasnik.pl +leczna.pl +lubartow.pl +lublin.pl +poniatowa.pl +swidnik.pl + +// Lug.org.uk : https://lug.org.uk +// Submitted by Jon Spriggs <admin@lug.org.uk> +glug.org.uk +lug.org.uk +lugs.org.uk + +// Lukanet Ltd : https://lukanet.com +// Submitted by Anton Avramov <register@lukanet.com> +barsy.bg +barsy.co.uk +barsyonline.co.uk +barsycenter.com +barsyonline.com +barsy.club +barsy.de +barsy.eu +barsy.in +barsy.info +barsy.io +barsy.me +barsy.menu +barsy.mobi +barsy.net +barsy.online +barsy.org +barsy.pro +barsy.pub +barsy.ro +barsy.shop +barsy.site +barsy.support +barsy.uk + +// Magento Commerce +// Submitted by Damien Tournoud <dtournoud@magento.cloud> +*.magentosite.cloud + +// May First - People Link : https://mayfirst.org/ +// Submitted by Jamie McClelland <info@mayfirst.org> +mayfirst.info +mayfirst.org + +// Mail.Ru Group : https://hb.cldmail.ru +// Submitted by Ilya Zaretskiy <zaretskiy@corp.mail.ru> +hb.cldmail.ru + +// Mail Transfer Platform : https://www.neupeer.com +// Submitted by Li Hui <lihui@neupeer.com> +cn.vu + +// Maze Play: https://www.mazeplay.com +// Submitted by Adam Humpherys <adam@mws.dev> +mazeplay.com + +// mcpe.me : https://mcpe.me +// Submitted by Noa Heyl <hi@noa.dev> +mcpe.me + +// McHost : https://mchost.ru +// Submitted by Evgeniy Subbotin <e.subbotin@mchost.ru> +mcdir.me +mcdir.ru +mcpre.ru +vps.mcdir.ru + +// Mediatech : https://mediatech.by +// Submitted by Evgeniy Kozhuhovskiy <ugenk@mediatech.by> +mediatech.by +mediatech.dev + +// Medicom Health : https://medicomhealth.com +// Submitted by Michael Olson <molson@medicomhealth.com> +hra.health + +// Memset hosting : https://www.memset.com +// Submitted by Tom Whitwell <domains@memset.com> +miniserver.com +memset.net + +// Messerli Informatik AG : https://www.messerli.ch/ +// Submitted by Ruben Schmidmeister <psl-maintainers@messerli.ch> +messerli.app + +// MetaCentrum, CESNET z.s.p.o. : https://www.metacentrum.cz/en/ +// Submitted by Zdeněk Šustr <zdenek.sustr@cesnet.cz> +*.cloud.metacentrum.cz +custom.metacentrum.cz + +// MetaCentrum, CESNET z.s.p.o. : https://www.metacentrum.cz/en/ +// Submitted by Radim Janča <janca@cesnet.cz> +flt.cloud.muni.cz +usr.cloud.muni.cz + +// Meteor Development Group : https://www.meteor.com/hosting +// Submitted by Pierre Carrier <pierre@meteor.com> +meteorapp.com +eu.meteorapp.com + +// Michau Enterprises Limited : http://www.co.pl/ +co.pl + +// Microsoft Corporation : http://microsoft.com +// Submitted by Public Suffix List Admin <msftpsladmin@microsoft.com> +*.azurecontainer.io +azurewebsites.net +azure-mobile.net +cloudapp.net +azurestaticapps.net +1.azurestaticapps.net +2.azurestaticapps.net +3.azurestaticapps.net +centralus.azurestaticapps.net +eastasia.azurestaticapps.net +eastus2.azurestaticapps.net +westeurope.azurestaticapps.net +westus2.azurestaticapps.net + +// minion.systems : http://minion.systems +// Submitted by Robert Böttinger <r@minion.systems> +csx.cc + +// Mintere : https://mintere.com/ +// Submitted by Ben Aubin <security@mintere.com> +mintere.site + +// MobileEducation, LLC : https://joinforte.com +// Submitted by Grayson Martin <grayson.martin@mobileeducation.us> +forte.id + +// Mozilla Corporation : https://mozilla.com +// Submitted by Ben Francis <bfrancis@mozilla.com> +mozilla-iot.org + +// Mozilla Foundation : https://mozilla.org/ +// Submitted by glob <glob@mozilla.com> +bmoattachments.org + +// MSK-IX : https://www.msk-ix.ru/ +// Submitted by Khannanov Roman <r.khannanov@msk-ix.ru> +net.ru +org.ru +pp.ru + +// Mythic Beasts : https://www.mythic-beasts.com +// Submitted by Paul Cammish <kelduum@mythic-beasts.com> +hostedpi.com +customer.mythic-beasts.com +caracal.mythic-beasts.com +fentiger.mythic-beasts.com +lynx.mythic-beasts.com +ocelot.mythic-beasts.com +oncilla.mythic-beasts.com +onza.mythic-beasts.com +sphinx.mythic-beasts.com +vs.mythic-beasts.com +x.mythic-beasts.com +yali.mythic-beasts.com +cust.retrosnub.co.uk + +// Nabu Casa : https://www.nabucasa.com +// Submitted by Paulus Schoutsen <infra@nabucasa.com> +ui.nabu.casa + +// Net at Work Gmbh : https://www.netatwork.de +// Submitted by Jan Jaeschke <jan.jaeschke@netatwork.de> +cloud.nospamproxy.com + +// Netlify : https://www.netlify.com +// Submitted by Jessica Parsons <jessica@netlify.com> +netlify.app + +// Neustar Inc. +// Submitted by Trung Tran <Trung.Tran@neustar.biz> +4u.com + +// ngrok : https://ngrok.com/ +// Submitted by Alan Shreve <alan@ngrok.com> +ngrok.app +ngrok-free.app +ngrok.dev +ngrok-free.dev +ngrok.io +ap.ngrok.io +au.ngrok.io +eu.ngrok.io +in.ngrok.io +jp.ngrok.io +sa.ngrok.io +us.ngrok.io +ngrok.pizza + +// Nimbus Hosting Ltd. : https://www.nimbushosting.co.uk/ +// Submitted by Nicholas Ford <nick@nimbushosting.co.uk> +nh-serv.co.uk + +// NFSN, Inc. : https://www.NearlyFreeSpeech.NET/ +// Submitted by Jeff Wheelhouse <support@nearlyfreespeech.net> +nfshost.com + +// Noop : https://noop.app +// Submitted by Nathaniel Schweinberg <noop@rearc.io> +*.developer.app +noop.app + +// Northflank Ltd. : https://northflank.com/ +// Submitted by Marco Suter <marco@northflank.com> +*.northflank.app +*.build.run +*.code.run +*.database.run +*.migration.run + +// Noticeable : https://noticeable.io +// Submitted by Laurent Pellegrino <security@noticeable.io> +noticeable.news + +// Now-DNS : https://now-dns.com +// Submitted by Steve Russell <steve@now-dns.com> +dnsking.ch +mypi.co +n4t.co +001www.com +ddnslive.com +myiphost.com +forumz.info +16-b.it +32-b.it +64-b.it +soundcast.me +tcp4.me +dnsup.net +hicam.net +now-dns.net +ownip.net +vpndns.net +dynserv.org +now-dns.org +x443.pw +now-dns.top +ntdll.top +freeddns.us +crafting.xyz +zapto.xyz + +// nsupdate.info : https://www.nsupdate.info/ +// Submitted by Thomas Waldmann <info@nsupdate.info> +nsupdate.info +nerdpol.ovh + +// No-IP.com : https://noip.com/ +// Submitted by Deven Reza <publicsuffixlist@noip.com> +blogsyte.com +brasilia.me +cable-modem.org +ciscofreak.com +collegefan.org +couchpotatofries.org +damnserver.com +ddns.me +ditchyourip.com +dnsfor.me +dnsiskinky.com +dvrcam.info +dynns.com +eating-organic.net +fantasyleague.cc +geekgalaxy.com +golffan.us +health-carereform.com +homesecuritymac.com +homesecuritypc.com +hopto.me +ilovecollege.info +loginto.me +mlbfan.org +mmafan.biz +myactivedirectory.com +mydissent.net +myeffect.net +mymediapc.net +mypsx.net +mysecuritycamera.com +mysecuritycamera.net +mysecuritycamera.org +net-freaks.com +nflfan.org +nhlfan.net +no-ip.ca +no-ip.co.uk +no-ip.net +noip.us +onthewifi.com +pgafan.net +point2this.com +pointto.us +privatizehealthinsurance.net +quicksytes.com +read-books.org +securitytactics.com +serveexchange.com +servehumour.com +servep2p.com +servesarcasm.com +stufftoread.com +ufcfan.org +unusualperson.com +workisboring.com +3utilities.com +bounceme.net +ddns.net +ddnsking.com +gotdns.ch +hopto.org +myftp.biz +myftp.org +myvnc.com +no-ip.biz +no-ip.info +no-ip.org +noip.me +redirectme.net +servebeer.com +serveblog.net +servecounterstrike.com +serveftp.com +servegame.com +servehalflife.com +servehttp.com +serveirc.com +serveminecraft.net +servemp3.com +servepics.com +servequake.com +sytes.net +webhop.me +zapto.org + +// NodeArt : https://nodeart.io +// Submitted by Konstantin Nosov <Nosov@nodeart.io> +stage.nodeart.io + +// Nucleos Inc. : https://nucleos.com +// Submitted by Piotr Zduniak <piotr@nucleos.com> +pcloud.host + +// NYC.mn : http://www.information.nyc.mn +// Submitted by Matthew Brown <mattbrown@nyc.mn> +nyc.mn + +// Observable, Inc. : https://observablehq.com +// Submitted by Mike Bostock <dns@observablehq.com> +static.observableusercontent.com + +// Octopodal Solutions, LLC. : https://ulterius.io/ +// Submitted by Andrew Sampson <andrew@ulterius.io> +cya.gg + +// OMG.LOL : <https://omg.lol> +// Submitted by Adam Newbold <adam@omg.lol> +omg.lol + +// Omnibond Systems, LLC. : https://www.omnibond.com +// Submitted by Cole Estep <cole@omnibond.com> +cloudycluster.net + +// OmniWe Limited: https://omniwe.com +// Submitted by Vicary Archangel <vicary@omniwe.com> +omniwe.site + +// One.com: https://www.one.com/ +// Submitted by Jacob Bunk Nielsen <jbn@one.com> +123hjemmeside.dk +123hjemmeside.no +123homepage.it +123kotisivu.fi +123minsida.se +123miweb.es +123paginaweb.pt +123sait.ru +123siteweb.fr +123webseite.at +123webseite.de +123website.be +123website.ch +123website.lu +123website.nl +service.one +simplesite.com +simplesite.com.br +simplesite.gr +simplesite.pl + +// One Fold Media : http://www.onefoldmedia.com/ +// Submitted by Eddie Jones <eddie@onefoldmedia.com> +nid.io + +// Open Social : https://www.getopensocial.com/ +// Submitted by Alexander Varwijk <security@getopensocial.com> +opensocial.site + +// OpenCraft GmbH : http://opencraft.com/ +// Submitted by Sven Marnach <sven@opencraft.com> +opencraft.hosting + +// OpenResearch GmbH: https://openresearch.com/ +// Submitted by Philipp Schmid <ops@openresearch.com> +orsites.com + +// Opera Software, A.S.A. +// Submitted by Yngve Pettersen <yngve@opera.com> +operaunite.com + +// Orange : https://www.orange.com +// Submitted by Alexandre Linte <alexandre.linte@orange.com> +tech.orange + +// Oursky Limited : https://authgear.com/, https://skygear.io/ +// Submitted by Authgear Team <hello@authgear.com>, Skygear Developer <hello@skygear.io> +authgear-staging.com +authgearapps.com +skygearapp.com + +// OutSystems +// Submitted by Duarte Santos <domain-admin@outsystemscloud.com> +outsystemscloud.com + +// OVHcloud: https://ovhcloud.com +// Submitted by Vincent Cassé <vincent.casse@ovhcloud.com> +*.webpaas.ovh.net +*.hosting.ovh.net + +// OwnProvider GmbH: http://www.ownprovider.com +// Submitted by Jan Moennich <jan.moennich@ownprovider.com> +ownprovider.com +own.pm + +// OwO : https://whats-th.is/ +// Submitted by Dean Sheather <dean@deansheather.com> +*.owo.codes + +// OX : http://www.ox.rs +// Submitted by Adam Grand <webmaster@mail.ox.rs> +ox.rs + +// oy.lc +// Submitted by Charly Coste <changaco@changaco.oy.lc> +oy.lc + +// Pagefog : https://pagefog.com/ +// Submitted by Derek Myers <derek@pagefog.com> +pgfog.com + +// Pagefront : https://www.pagefronthq.com/ +// Submitted by Jason Kriss <jason@pagefronthq.com> +pagefrontapp.com + +// PageXL : https://pagexl.com +// Submitted by Yann Guichard <yann@pagexl.com> +pagexl.com + +// Paywhirl, Inc : https://paywhirl.com/ +// Submitted by Daniel Netzer <dan@paywhirl.com> +*.paywhirl.com + +// pcarrier.ca Software Inc: https://pcarrier.ca/ +// Submitted by Pierre Carrier <pc@rrier.ca> +bar0.net +bar1.net +bar2.net +rdv.to + +// .pl domains (grandfathered) +art.pl +gliwice.pl +krakow.pl +poznan.pl +wroc.pl +zakopane.pl + +// Pantheon Systems, Inc. : https://pantheon.io/ +// Submitted by Gary Dylina <gary@pantheon.io> +pantheonsite.io +gotpantheon.com + +// Peplink | Pepwave : http://peplink.com/ +// Submitted by Steve Leung <steveleung@peplink.com> +mypep.link + +// Perspecta : https://perspecta.com/ +// Submitted by Kenneth Van Alstyne <kvanalstyne@perspecta.com> +perspecta.cloud + +// PE Ulyanov Kirill Sergeevich : https://airy.host +// Submitted by Kirill Ulyanov <k.ulyanov@airy.host> +lk3.ru + +// Planet-Work : https://www.planet-work.com/ +// Submitted by Frédéric VANNIÈRE <f.vanniere@planet-work.com> +on-web.fr + +// Platform.sh : https://platform.sh +// Submitted by Nikola Kotur <nikola@platform.sh> +bc.platform.sh +ent.platform.sh +eu.platform.sh +us.platform.sh +*.platformsh.site +*.tst.site + +// Platter: https://platter.dev +// Submitted by Patrick Flor <patrick@platter.dev> +platter-app.com +platter-app.dev +platterp.us + +// Plesk : https://www.plesk.com/ +// Submitted by Anton Akhtyamov <program-managers@plesk.com> +pdns.page +plesk.page +pleskns.com + +// Port53 : https://port53.io/ +// Submitted by Maximilian Schieder <maxi@zeug.co> +dyn53.io + +// Porter : https://porter.run/ +// Submitted by Rudraksh MK <rudi@porter.run> +onporter.run + +// Positive Codes Technology Company : http://co.bn/faq.html +// Submitted by Zulfais <pc@co.bn> +co.bn + +// Postman, Inc : https://postman.com +// Submitted by Rahul Dhawan <security@postman.com> +postman-echo.com +pstmn.io +mock.pstmn.io +httpbin.org + +//prequalifyme.today : https://prequalifyme.today +//Submitted by DeepakTiwari deepak@ivylead.io +prequalifyme.today + +// prgmr.com : https://prgmr.com/ +// Submitted by Sarah Newman <owner@prgmr.com> +xen.prgmr.com + +// priv.at : http://www.nic.priv.at/ +// Submitted by registry <lendl@nic.at> +priv.at + +// privacytools.io : https://www.privacytools.io/ +// Submitted by Jonah Aragon <jonah@privacytools.io> +prvcy.page + +// Protocol Labs : https://protocol.ai/ +// Submitted by Michael Burns <noc@protocol.ai> +*.dweb.link + +// Protonet GmbH : http://protonet.io +// Submitted by Martin Meier <admin@protonet.io> +protonet.io + +// Publication Presse Communication SARL : https://ppcom.fr +// Submitted by Yaacov Akiba Slama <admin@chirurgiens-dentistes-en-france.fr> +chirurgiens-dentistes-en-france.fr +byen.site + +// pubtls.org: https://www.pubtls.org +// Submitted by Kor Nielsen <kor@pubtls.org> +pubtls.org + +// PythonAnywhere LLP: https://www.pythonanywhere.com +// Submitted by Giles Thomas <giles@pythonanywhere.com> +pythonanywhere.com +eu.pythonanywhere.com + +// QOTO, Org. +// Submitted by Jeffrey Phillips Freeman <jeffrey.freeman@qoto.org> +qoto.io + +// Qualifio : https://qualifio.com/ +// Submitted by Xavier De Cock <xdecock@gmail.com> +qualifioapp.com + +// Quality Unit: https://qualityunit.com +// Submitted by Vasyl Tsalko <vtsalko@qualityunit.com> +ladesk.com + +// QuickBackend: https://www.quickbackend.com +// Submitted by Dani Biro <dani@pymet.com> +qbuser.com + +// Rad Web Hosting: https://radwebhosting.com +// Submitted by Scott Claeys <s.claeys@radwebhosting.com> +cloudsite.builders + +// Redgate Software: https://red-gate.com +// Submitted by Andrew Farries <andrew.farries@red-gate.com> +instances.spawn.cc + +// Redstar Consultants : https://www.redstarconsultants.com/ +// Submitted by Jons Slemmer <jons@redstarconsultants.com> +instantcloud.cn + +// Russian Academy of Sciences +// Submitted by Tech Support <support@rasnet.ru> +ras.ru + +// QA2 +// Submitted by Daniel Dent (https://www.danieldent.com/) +qa2.com + +// QCX +// Submitted by Cassandra Beelen <cassandra@beelen.one> +qcx.io +*.sys.qcx.io + +// QNAP System Inc : https://www.qnap.com +// Submitted by Nick Chang <nickchang@qnap.com> +dev-myqnapcloud.com +alpha-myqnapcloud.com +myqnapcloud.com + +// Quip : https://quip.com +// Submitted by Patrick Linehan <plinehan@quip.com> +*.quipelements.com + +// Qutheory LLC : http://qutheory.io +// Submitted by Jonas Schwartz <jonas@qutheory.io> +vapor.cloud +vaporcloud.io + +// Rackmaze LLC : https://www.rackmaze.com +// Submitted by Kirill Pertsev <kika@rackmaze.com> +rackmaze.com +rackmaze.net + +// Rakuten Games, Inc : https://dev.viberplay.io +// Submitted by Joshua Zhang <public-suffix@rgames.jp> +g.vbrplsbx.io + +// Rancher Labs, Inc : https://rancher.com +// Submitted by Vincent Fiduccia <domains@rancher.com> +*.on-k3s.io +*.on-rancher.cloud +*.on-rio.io + +// Read The Docs, Inc : https://www.readthedocs.org +// Submitted by David Fischer <team@readthedocs.org> +readthedocs.io + +// Red Hat, Inc. OpenShift : https://openshift.redhat.com/ +// Submitted by Tim Kramer <tkramer@rhcloud.com> +rhcloud.com + +// Render : https://render.com +// Submitted by Anurag Goel <dev@render.com> +app.render.com +onrender.com + +// Repl.it : https://repl.it +// Submitted by Lincoln Bergeson <lincoln@replit.com> +firewalledreplit.co +id.firewalledreplit.co +repl.co +id.repl.co +repl.run + +// Resin.io : https://resin.io +// Submitted by Tim Perry <tim@resin.io> +resindevice.io +devices.resinstaging.io + +// RethinkDB : https://www.rethinkdb.com/ +// Submitted by Chris Kastorff <info@rethinkdb.com> +hzc.io + +// Revitalised Limited : http://www.revitalised.co.uk +// Submitted by Jack Price <jack@revitalised.co.uk> +wellbeingzone.eu +wellbeingzone.co.uk + +// Rico Developments Limited : https://adimo.co +// Submitted by Colin Brown <hello@adimo.co> +adimo.co.uk + +// Riseup Networks : https://riseup.net +// Submitted by Micah Anderson <micah@riseup.net> +itcouldbewor.se + +// Rochester Institute of Technology : http://www.rit.edu/ +// Submitted by Jennifer Herting <jchits@rit.edu> +git-pages.rit.edu + +// Rocky Enterprise Software Foundation : https://resf.org +// Submitted by Neil Hanlon <neil@resf.org> +rocky.page + +// Rusnames Limited: http://rusnames.ru/ +// Submitted by Sergey Zotov <admin@rusnames.ru> +биз.рус +ком.рус +крым.рус +мир.рус +мск.рус +орг.рус +самара.рус +сочи.рус +спб.рус +я.рус + +// SAKURA Internet Inc. : https://www.sakura.ad.jp/ +// Submitted by Internet Service Department <rs-vendor-ml@sakura.ad.jp> +180r.com +dojin.com +sakuratan.com +sakuraweb.com +x0.com +2-d.jp +bona.jp +crap.jp +daynight.jp +eek.jp +flop.jp +halfmoon.jp +jeez.jp +matrix.jp +mimoza.jp +ivory.ne.jp +mail-box.ne.jp +mints.ne.jp +mokuren.ne.jp +opal.ne.jp +sakura.ne.jp +sumomo.ne.jp +topaz.ne.jp +netgamers.jp +nyanta.jp +o0o0.jp +rdy.jp +rgr.jp +rulez.jp +s3.isk01.sakurastorage.jp +s3.isk02.sakurastorage.jp +saloon.jp +sblo.jp +skr.jp +tank.jp +uh-oh.jp +undo.jp +rs.webaccel.jp +user.webaccel.jp +websozai.jp +xii.jp +squares.net +jpn.org +kirara.st +x0.to +from.tv +sakura.tv + +// Salesforce.com, Inc. https://salesforce.com/ +// Submitted by Michael Biven <mbiven@salesforce.com> +*.builder.code.com +*.dev-builder.code.com +*.stg-builder.code.com + +// Sandstorm Development Group, Inc. : https://sandcats.io/ +// Submitted by Asheesh Laroia <asheesh@sandstorm.io> +sandcats.io + +// SBE network solutions GmbH : https://www.sbe.de/ +// Submitted by Norman Meilick <nm@sbe.de> +logoip.de +logoip.com + +// Scaleway : https://www.scaleway.com/ +// Submitted by Rémy Léone <rleone@scaleway.com> +fr-par-1.baremetal.scw.cloud +fr-par-2.baremetal.scw.cloud +nl-ams-1.baremetal.scw.cloud +fnc.fr-par.scw.cloud +functions.fnc.fr-par.scw.cloud +k8s.fr-par.scw.cloud +nodes.k8s.fr-par.scw.cloud +s3.fr-par.scw.cloud +s3-website.fr-par.scw.cloud +whm.fr-par.scw.cloud +priv.instances.scw.cloud +pub.instances.scw.cloud +k8s.scw.cloud +k8s.nl-ams.scw.cloud +nodes.k8s.nl-ams.scw.cloud +s3.nl-ams.scw.cloud +s3-website.nl-ams.scw.cloud +whm.nl-ams.scw.cloud +k8s.pl-waw.scw.cloud +nodes.k8s.pl-waw.scw.cloud +s3.pl-waw.scw.cloud +s3-website.pl-waw.scw.cloud +scalebook.scw.cloud +smartlabeling.scw.cloud +dedibox.fr + +// schokokeks.org GbR : https://schokokeks.org/ +// Submitted by Hanno Böck <hanno@schokokeks.org> +schokokeks.net + +// Scottish Government: https://www.gov.scot +// Submitted by Martin Ellis <martin.ellis@gov.scot> +gov.scot +service.gov.scot + +// Scry Security : http://www.scrysec.com +// Submitted by Shante Adam <shante@skyhat.io> +scrysec.com + +// Securepoint GmbH : https://www.securepoint.de +// Submitted by Erik Anders <erik.anders@securepoint.de> +firewall-gateway.com +firewall-gateway.de +my-gateway.de +my-router.de +spdns.de +spdns.eu +firewall-gateway.net +my-firewall.org +myfirewall.org +spdns.org + +// Seidat : https://www.seidat.com +// Submitted by Artem Kondratev <accounts@seidat.com> +seidat.net + +// Sellfy : https://sellfy.com +// Submitted by Yuriy Romadin <contact@sellfy.com> +sellfy.store + +// Senseering GmbH : https://www.senseering.de +// Submitted by Felix Mönckemeyer <f.moenckemeyer@senseering.de> +senseering.net + +// Sendmsg: https://www.sendmsg.co.il +// Submitted by Assaf Stern <domains@comstar.co.il> +minisite.ms + +// Service Magnet : https://myservicemagnet.com +// Submitted by Dave Sanders <dave@myservicemagnet.com> +magnet.page + +// Service Online LLC : http://drs.ua/ +// Submitted by Serhii Bulakh <support@drs.ua> +biz.ua +co.ua +pp.ua + +// Shift Crypto AG : https://shiftcrypto.ch +// Submitted by alex <alex@shiftcrypto.ch> +shiftcrypto.dev +shiftcrypto.io + +// ShiftEdit : https://shiftedit.net/ +// Submitted by Adam Jimenez <adam@shiftcreate.com> +shiftedit.io + +// Shopblocks : http://www.shopblocks.com/ +// Submitted by Alex Bowers <alex@shopblocks.com> +myshopblocks.com + +// Shopify : https://www.shopify.com +// Submitted by Alex Richter <alex.richter@shopify.com> +myshopify.com + +// Shopit : https://www.shopitcommerce.com/ +// Submitted by Craig McMahon <craig@shopitcommerce.com> +shopitsite.com + +// shopware AG : https://shopware.com +// Submitted by Jens Küper <cloud@shopware.com> +shopware.store + +// Siemens Mobility GmbH +// Submitted by Oliver Graebner <security@mo-siemens.io> +mo-siemens.io + +// SinaAppEngine : http://sae.sina.com.cn/ +// Submitted by SinaAppEngine <saesupport@sinacloud.com> +1kapp.com +appchizi.com +applinzi.com +sinaapp.com +vipsinaapp.com + +// Siteleaf : https://www.siteleaf.com/ +// Submitted by Skylar Challand <support@siteleaf.com> +siteleaf.net + +// Skyhat : http://www.skyhat.io +// Submitted by Shante Adam <shante@skyhat.io> +bounty-full.com +alpha.bounty-full.com +beta.bounty-full.com + +// Small Technology Foundation : https://small-tech.org +// Submitted by Aral Balkan <aral@small-tech.org> +small-web.org + +// Smoove.io : https://www.smoove.io/ +// Submitted by Dan Kozak <dan@smoove.io> +vp4.me + +// Snowflake Inc : https://www.snowflake.com/ +// Submitted by Faith Olapade <faith.olapade@snowflake.com> +snowflake.app +privatelink.snowflake.app +streamlit.app +streamlitapp.com + +// Snowplow Analytics : https://snowplowanalytics.com/ +// Submitted by Ian Streeter <ian@snowplowanalytics.com> +try-snowplow.com + +// SourceHut : https://sourcehut.org +// Submitted by Drew DeVault <sir@cmpwn.com> +srht.site + +// Stackhero : https://www.stackhero.io +// Submitted by Adrien Gillon <adrien+public-suffix-list@stackhero.io> +stackhero-network.com + +// Staclar : https://staclar.com +// Submitted by Q Misell <q@staclar.com> +musician.io +// Submitted by Matthias Merkel <matthias.merkel@staclar.com> +novecore.site + +// staticland : https://static.land +// Submitted by Seth Vincent <sethvincent@gmail.com> +static.land +dev.static.land +sites.static.land + +// Storebase : https://www.storebase.io +// Submitted by Tony Schirmer <tony@storebase.io> +storebase.store + +// Strategic System Consulting (eApps Hosting): https://www.eapps.com/ +// Submitted by Alex Oancea <aoancea@cloudscale365.com> +vps-host.net +atl.jelastic.vps-host.net +njs.jelastic.vps-host.net +ric.jelastic.vps-host.net + +// Sony Interactive Entertainment LLC : https://sie.com/ +// Submitted by David Coles <david.coles@sony.com> +playstation-cloud.com + +// SourceLair PC : https://www.sourcelair.com +// Submitted by Antonis Kalipetis <akalipetis@sourcelair.com> +apps.lair.io +*.stolos.io + +// SpaceKit : https://www.spacekit.io/ +// Submitted by Reza Akhavan <spacekit.io@gmail.com> +spacekit.io + +// SpeedPartner GmbH: https://www.speedpartner.de/ +// Submitted by Stefan Neufeind <info@speedpartner.de> +customer.speedpartner.de + +// Spreadshop (sprd.net AG) : https://www.spreadshop.com/ +// Submitted by Martin Breest <security@spreadshop.com> +myspreadshop.at +myspreadshop.com.au +myspreadshop.be +myspreadshop.ca +myspreadshop.ch +myspreadshop.com +myspreadshop.de +myspreadshop.dk +myspreadshop.es +myspreadshop.fi +myspreadshop.fr +myspreadshop.ie +myspreadshop.it +myspreadshop.net +myspreadshop.nl +myspreadshop.no +myspreadshop.pl +myspreadshop.se +myspreadshop.co.uk + +// Standard Library : https://stdlib.com +// Submitted by Jacob Lee <jacob@stdlib.com> +api.stdlib.com + +// Storj Labs Inc. : https://storj.io/ +// Submitted by Philip Hutchins <hostmaster@storj.io> +storj.farm + +// Studenten Net Twente : http://www.snt.utwente.nl/ +// Submitted by Silke Hofstra <syscom@snt.utwente.nl> +utwente.io + +// Student-Run Computing Facility : https://www.srcf.net/ +// Submitted by Edwin Balani <sysadmins@srcf.net> +soc.srcf.net +user.srcf.net + +// Sub 6 Limited: http://www.sub6.com +// Submitted by Dan Miller <dm@sub6.com> +temp-dns.com + +// Supabase : https://supabase.io +// Submitted by Inian Parameshwaran <security@supabase.io> +supabase.co +supabase.in +supabase.net +su.paba.se + +// Symfony, SAS : https://symfony.com/ +// Submitted by Fabien Potencier <fabien@symfony.com> +*.s5y.io +*.sensiosite.cloud + +// Syncloud : https://syncloud.org +// Submitted by Boris Rybalkin <syncloud@syncloud.it> +syncloud.it + +// Synology, Inc. : https://www.synology.com/ +// Submitted by Rony Weng <ronyweng@synology.com> +dscloud.biz +direct.quickconnect.cn +dsmynas.com +familyds.com +diskstation.me +dscloud.me +i234.me +myds.me +synology.me +dscloud.mobi +dsmynas.net +familyds.net +dsmynas.org +familyds.org +vpnplus.to +direct.quickconnect.to + +// Tabit Technologies Ltd. : https://tabit.cloud/ +// Submitted by Oren Agiv <oren@tabit.cloud> +tabitorder.co.il +mytabit.co.il +mytabit.com + +// TAIFUN Software AG : http://taifun-software.de +// Submitted by Bjoern Henke <dev-server@taifun-software.de> +taifun-dns.de + +// Tailscale Inc. : https://www.tailscale.com +// Submitted by David Anderson <danderson@tailscale.com> +beta.tailscale.net +ts.net + +// TASK geographical domains (www.task.gda.pl/uslugi/dns) +gda.pl +gdansk.pl +gdynia.pl +med.pl +sopot.pl + +// team.blue https://team.blue +// Submitted by Cedric Dubois <cedric.dubois@team.blue> +site.tb-hosting.com + +// Teckids e.V. : https://www.teckids.org +// Submitted by Dominik George <dominik.george@teckids.org> +edugit.io +s3.teckids.org + +// Telebit : https://telebit.cloud +// Submitted by AJ ONeal <aj@telebit.cloud> +telebit.app +telebit.io +*.telebit.xyz + +// Thingdust AG : https://thingdust.com/ +// Submitted by Adrian Imboden <adi@thingdust.com> +*.firenet.ch +*.svc.firenet.ch +reservd.com +thingdustdata.com +cust.dev.thingdust.io +cust.disrec.thingdust.io +cust.prod.thingdust.io +cust.testing.thingdust.io +reservd.dev.thingdust.io +reservd.disrec.thingdust.io +reservd.testing.thingdust.io + +// ticket i/O GmbH : https://ticket.io +// Submitted by Christian Franke <it@ticket.io> +tickets.io + +// Tlon.io : https://tlon.io +// Submitted by Mark Staarink <mark@tlon.io> +arvo.network +azimuth.network +tlon.network + +// Tor Project, Inc. : https://torproject.org +// Submitted by Antoine Beaupré <anarcat@torproject.org +torproject.net +pages.torproject.net + +// TownNews.com : http://www.townnews.com +// Submitted by Dustin Ward <dward@townnews.com> +bloxcms.com +townnews-staging.com + +// TrafficPlex GmbH : https://www.trafficplex.de/ +// Submitted by Phillipp Röll <phillipp.roell@trafficplex.de> +12hp.at +2ix.at +4lima.at +lima-city.at +12hp.ch +2ix.ch +4lima.ch +lima-city.ch +trafficplex.cloud +de.cool +12hp.de +2ix.de +4lima.de +lima-city.de +1337.pictures +clan.rip +lima-city.rocks +webspace.rocks +lima.zone + +// TransIP : https://www.transip.nl +// Submitted by Rory Breuk <rbreuk@transip.nl> +*.transurl.be +*.transurl.eu +*.transurl.nl + +// TransIP: https://www.transip.nl +// Submitted by Cedric Dubois <cedric.dubois@team.blue> +site.transip.me + +// TuxFamily : http://tuxfamily.org +// Submitted by TuxFamily administrators <adm@staff.tuxfamily.org> +tuxfamily.org + +// TwoDNS : https://www.twodns.de/ +// Submitted by TwoDNS-Support <support@two-dns.de> +dd-dns.de +diskstation.eu +diskstation.org +dray-dns.de +draydns.de +dyn-vpn.de +dynvpn.de +mein-vigor.de +my-vigor.de +my-wan.de +syno-ds.de +synology-diskstation.de +synology-ds.de + +// Typedream : https://typedream.com +// Submitted by Putri Karunia <putri@typedream.com> +typedream.app + +// Typeform : https://www.typeform.com +// Submitted by Sergi Ferriz <sergi.ferriz@typeform.com> +pro.typeform.com + +// Uberspace : https://uberspace.de +// Submitted by Moritz Werner <mwerner@jonaspasche.com> +uber.space +*.uberspace.de + +// UDR Limited : http://www.udr.hk.com +// Submitted by registry <hostmaster@udr.hk.com> +hk.com +hk.org +ltd.hk +inc.hk + +// UK Intis Telecom LTD : https://it.com +// Submitted by ITComdomains <to@it.com> +it.com + +// UNIVERSAL DOMAIN REGISTRY : https://www.udr.org.yt/ +// see also: whois -h whois.udr.org.yt help +// Submitted by Atanunu Igbunuroghene <publicsuffixlist@udr.org.yt> +name.pm +sch.tf +biz.wf +sch.wf +org.yt + +// United Gameserver GmbH : https://united-gameserver.de +// Submitted by Stefan Schwarz <sysadm@united-gameserver.de> +virtualuser.de +virtual-user.de + +// Upli : https://upli.io +// Submitted by Lenny Bakkalian <lenny.bakkalian@gmail.com> +upli.io + +// urown.net : https://urown.net +// Submitted by Hostmaster <hostmaster@urown.net> +urown.cloud +dnsupdate.info + +// .US +// Submitted by Ed Moore <Ed.Moore@lib.de.us> +lib.de.us + +// VeryPositive SIA : http://very.lv +// Submitted by Danko Aleksejevs <danko@very.lv> +2038.io + +// Vercel, Inc : https://vercel.com/ +// Submitted by Connor Davis <security@vercel.com> +vercel.app +vercel.dev +now.sh + +// Viprinet Europe GmbH : http://www.viprinet.com +// Submitted by Simon Kissel <hostmaster@viprinet.com> +router.management + +// Virtual-Info : https://www.virtual-info.info/ +// Submitted by Adnan RIHAN <hostmaster@v-info.info> +v-info.info + +// Voorloper.com: https://voorloper.com +// Submitted by Nathan van Bakel <info@voorloper.com> +voorloper.cloud + +// Voxel.sh DNS : https://voxel.sh/dns/ +// Submitted by Mia Rehlinger <dns@voxel.sh> +neko.am +nyaa.am +be.ax +cat.ax +es.ax +eu.ax +gg.ax +mc.ax +us.ax +xy.ax +nl.ci +xx.gl +app.gp +blog.gt +de.gt +to.gt +be.gy +cc.hn +blog.kg +io.kg +jp.kg +tv.kg +uk.kg +us.kg +de.ls +at.md +de.md +jp.md +to.md +indie.porn +vxl.sh +ch.tc +me.tc +we.tc +nyan.to +at.vg +blog.vu +dev.vu +me.vu + +// V.UA Domain Administrator : https://domain.v.ua/ +// Submitted by Serhii Rostilo <sergey@rostilo.kiev.ua> +v.ua + +// Vultr Objects : https://www.vultr.com/products/object-storage/ +// Submitted by Niels Maumenee <storage@vultr.com> +*.vultrobjects.com + +// Waffle Computer Inc., Ltd. : https://docs.waffleinfo.com +// Submitted by Masayuki Note <masa@blade.wafflecell.com> +wafflecell.com + +// WebHare bv: https://www.webhare.com/ +// Submitted by Arnold Hendriks <info@webhare.com> +*.webhare.dev + +// WebHotelier Technologies Ltd: https://www.webhotelier.net/ +// Submitted by Apostolos Tsakpinis <apostolos.tsakpinis@gmail.com> +reserve-online.net +reserve-online.com +bookonline.app +hotelwithflight.com + +// WeDeploy by Liferay, Inc. : https://www.wedeploy.com +// Submitted by Henrique Vicente <security@wedeploy.com> +wedeploy.io +wedeploy.me +wedeploy.sh + +// Western Digital Technologies, Inc : https://www.wdc.com +// Submitted by Jung Jin <jungseok.jin@wdc.com> +remotewd.com + +// WIARD Enterprises : https://wiardweb.com +// Submitted by Kidd Hustle <kiddhustle@wiardweb.com> +pages.wiardweb.com + +// Wikimedia Labs : https://wikitech.wikimedia.org +// Submitted by Arturo Borrero Gonzalez <aborrero@wikimedia.org> +wmflabs.org +toolforge.org +wmcloud.org + +// WISP : https://wisp.gg +// Submitted by Stepan Fedotov <stepan@wisp.gg> +panel.gg +daemon.panel.gg + +// Wizard Zines : https://wizardzines.com +// Submitted by Julia Evans <julia@wizardzines.com> +messwithdns.com + +// WoltLab GmbH : https://www.woltlab.com +// Submitted by Tim Düsterhus <security@woltlab.cloud> +woltlab-demo.com +myforum.community +community-pro.de +diskussionsbereich.de +community-pro.net +meinforum.net + +// Woods Valldata : https://www.woodsvalldata.co.uk/ +// Submitted by Chris Whittle <chris.whittle@woodsvalldata.co.uk> +affinitylottery.org.uk +raffleentry.org.uk +weeklylottery.org.uk + +// WP Engine : https://wpengine.com/ +// Submitted by Michael Smith <michael.smith@wpengine.com> +// Submitted by Brandon DuRette <brandon.durette@wpengine.com> +wpenginepowered.com +js.wpenginepowered.com + +// Wix.com, Inc. : https://www.wix.com +// Submitted by Shahar Talmi <shahar@wix.com> +wixsite.com +editorx.io + +// XenonCloud GbR: https://xenoncloud.net +// Submitted by Julian Uphoff <publicsuffixlist@xenoncloud.net> +half.host + +// XnBay Technology : http://www.xnbay.com/ +// Submitted by XnBay Developer <developer.xncloud@gmail.com> +xnbay.com +u2.xnbay.com +u2-local.xnbay.com + +// XS4ALL Internet bv : https://www.xs4all.nl/ +// Submitted by Daniel Mostertman <unixbeheer+publicsuffix@xs4all.net> +cistron.nl +demon.nl +xs4all.space + +// Yandex.Cloud LLC: https://cloud.yandex.com +// Submitted by Alexander Lodin <security+psl@yandex-team.ru> +yandexcloud.net +storage.yandexcloud.net +website.yandexcloud.net + +// YesCourse Pty Ltd : https://yescourse.com +// Submitted by Atul Bhouraskar <atul@yescourse.com> +official.academy + +// Yola : https://www.yola.com/ +// Submitted by Stefano Rivera <stefano@yola.com> +yolasite.com + +// Yombo : https://yombo.net +// Submitted by Mitch Schwenk <mitch@yombo.net> +ybo.faith +yombo.me +homelink.one +ybo.party +ybo.review +ybo.science +ybo.trade + +// Yunohost : https://yunohost.org +// Submitted by Valentin Grimaud <security@yunohost.org> +ynh.fr +nohost.me +noho.st + +// ZaNiC : http://www.za.net/ +// Submitted by registry <hostmaster@nic.za.net> +za.net +za.org + +// Zine EOOD : https://zine.bg/ +// Submitted by Martin Angelov <martin@zine.bg> +bss.design + +// Zitcom A/S : https://www.zitcom.dk +// Submitted by Emil Stahl <esp@zitcom.dk> +basicserver.io +virtualserver.io +enterprisecloud.nu + +// ===END PRIVATE DOMAINS=== diff --git a/ansible_collections/community/dns/plugins/public_suffix_list.dat.license b/ansible_collections/community/dns/plugins/public_suffix_list.dat.license new file mode 100644 index 000000000..b94dfff7a --- /dev/null +++ b/ansible_collections/community/dns/plugins/public_suffix_list.dat.license @@ -0,0 +1,2 @@ +SPDX-License-Identifier: MPL-2.0 +SPDX-FileCopyrightText: The Public Suffix List Authors diff --git a/ansible_collections/community/dns/tests/config.yml b/ansible_collections/community/dns/tests/config.yml new file mode 100644 index 000000000..38590f2e4 --- /dev/null +++ b/ansible_collections/community/dns/tests/config.yml @@ -0,0 +1,9 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# See template for more information: +# https://github.com/ansible/ansible/blob/devel/test/lib/ansible_test/config/config.yml +modules: + python_requires: default diff --git a/ansible_collections/community/dns/tests/ee/all.yml b/ansible_collections/community/dns/tests/ee/all.yml new file mode 100644 index 000000000..26f198b4f --- /dev/null +++ b/ansible_collections/community/dns/tests/ee/all.yml @@ -0,0 +1,18 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- hosts: localhost + tasks: + - name: Find all roles + find: + paths: + - "{{ (playbook_dir | default('.')) ~ '/roles' }}" + file_type: directory + depth: 1 + register: result + - name: Include all roles + include_role: + name: "{{ item }}" + loop: "{{ result.files | map(attribute='path') | map('regex_replace', '.*/', '') | sort }}" diff --git a/ansible_collections/community/dns/tests/ee/roles/filter_domain_suffix/tasks/main.yml b/ansible_collections/community/dns/tests/ee/roles/filter_domain_suffix/tasks/main.yml new file mode 100644 index 000000000..4473a743a --- /dev/null +++ b/ansible_collections/community/dns/tests/ee/roles/filter_domain_suffix/tasks/main.yml @@ -0,0 +1,78 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: "Test get_public_suffix filter" + assert: + that: + - "'' | community.dns.get_public_suffix == ''" + - "'' | community.dns.get_public_suffix(keep_leading_period=false) == ''" + - "'www.ansible.com' | community.dns.get_public_suffix == '.com'" + - "'www.ansible.com' | community.dns.get_public_suffix(keep_leading_period=false) == 'com'" + - "'some.random.prefixes.ansible.co.uk' | community.dns.get_public_suffix == '.co.uk'" + - "'no.known.suffixafdlkjsaflkjsaflkjslkjfds' | community.dns.get_public_suffix == '.suffixafdlkjsaflkjsaflkjslkjfds'" + - "'no.known.suffixafdlkjsaflkjsaflkjslkjfds' | community.dns.get_public_suffix(keep_unknown_suffix=False) == ''" + - "'ëçãmplê' | community.dns.get_public_suffix(normalize_result=True) == 'xn--mpl-llatwb'" + - "'www.example.cloudfront.net' | community.dns.get_public_suffix(icann_only=false) == '.cloudfront.net'" + - "'www.example.cloudfront.net' | community.dns.get_public_suffix(icann_only=true) == '.net'" + - "'www.ck' | community.dns.get_public_suffix == '.ck'" + - "'thisisaninvalidlabelbecauseitiswaytoolongitscharacterlengthislargerthan63' | community.dns.get_public_suffix == ''" + +- name: "Test remove_public_suffix filter" + assert: + that: + - "'' | community.dns.remove_public_suffix == ''" + - "'' | community.dns.remove_public_suffix(keep_trailing_period=true) == ''" + - "'com' | community.dns.remove_public_suffix == ''" + - "'com' | community.dns.remove_public_suffix(keep_trailing_period=true) == ''" + - "'www.ansible.com' | community.dns.remove_public_suffix == 'www.ansible'" + - "'www.ansible.com' | community.dns.remove_public_suffix(keep_trailing_period=true) == 'www.ansible.'" + - "'some.random.prefixes.ansible.co.uk' | community.dns.remove_public_suffix == 'some.random.prefixes.ansible'" + - "'no.known.suffixafdlkjsaflkjsaflkjslkjfds' | community.dns.remove_public_suffix == 'no.known'" + - "'no.known.suffixafdlkjsaflkjsaflkjslkjfds' | community.dns.remove_public_suffix(keep_unknown_suffix=False) == 'no.known.suffixafdlkjsaflkjsaflkjslkjfds'" + - "'www.example.cloudfront.net' | community.dns.remove_public_suffix(icann_only=false) == 'www.example'" + - "'www.example.cloudfront.net' | community.dns.remove_public_suffix(icann_only=true) == 'www.example.cloudfront'" + - "'www.ck' | community.dns.remove_public_suffix == 'www'" + - "'thisisaninvalidlabelbecauseitiswaytoolongitscharacterlengthislargerthan63' | community.dns.remove_public_suffix == 'thisisaninvalidlabelbecauseitiswaytoolongitscharacterlengthislargerthan63'" + +- name: "Test get_registrable_domain filter" + assert: + that: + - "'' | community.dns.get_registrable_domain == ''" + - "'com' | community.dns.get_registrable_domain == ''" + - "'com' | community.dns.get_registrable_domain(only_if_registerable=true) == ''" + - "'com' | community.dns.get_registrable_domain(only_if_registerable=false) == 'com'" + - "'www.ansible.com' | community.dns.get_registrable_domain == 'ansible.com'" + - "'www.ansible.com.' | community.dns.get_registrable_domain == 'ansible.com.'" + - "'www.ansible.com' | community.dns.get_registrable_domain(only_if_registerable=true) == 'ansible.com'" + - "'some.random.prefixes.ansible.co.uk' | community.dns.get_registrable_domain == 'ansible.co.uk'" + - "'some.invalid.example' | community.dns.get_registrable_domain == 'invalid.example'" + - "'some.invalid.example' | community.dns.get_registrable_domain(keep_unknown_suffix=False) == ''" + - "'ëçãmplê.com' | community.dns.get_registrable_domain(normalize_result=True) == 'xn--mpl-llatwb.com'" + - "'www.example.cloudfront.net' | community.dns.get_registrable_domain(icann_only=false) == 'example.cloudfront.net'" + - "'www.example.cloudfront.net' | community.dns.get_registrable_domain(icann_only=true) == 'cloudfront.net'" + - "'prefix.www.ck' | community.dns.get_registrable_domain == 'www.ck'" + - "'thisisaninvalidlabelbecauseitiswaytoolongitscharacterlengthislargerthan63' | community.dns.get_registrable_domain == ''" + +- name: "Test remove_registrable_domain filter" + assert: + that: + - "'' | community.dns.remove_registrable_domain == ''" + - "'' | community.dns.remove_registrable_domain(keep_trailing_period=true) == ''" + - "'com' | community.dns.remove_registrable_domain == 'com'" + - "'com' | community.dns.remove_registrable_domain(only_if_registerable=true) == 'com'" + - "'com' | community.dns.remove_registrable_domain(only_if_registerable=false) == ''" + - "'com' | community.dns.remove_registrable_domain(keep_trailing_period=true) == 'com'" + - "'www.ansible.com' | community.dns.remove_registrable_domain == 'www'" + - "'www.ansible.com' | community.dns.remove_registrable_domain(only_if_registerable=true) == 'www'" + - "'www.ansible.com' | community.dns.remove_registrable_domain(keep_trailing_period=true) == 'www.'" + - "'www.ansible.com.' | community.dns.remove_registrable_domain == 'www'" + - "'ansible.com.' | community.dns.remove_registrable_domain == ''" + - "'some.random.prefixes.ansible.co.uk' | community.dns.remove_registrable_domain == 'some.random.prefixes'" + - "'some.invalid.example' | community.dns.remove_registrable_domain == 'some'" + - "'some.invalid.example' | community.dns.remove_registrable_domain(keep_unknown_suffix=False) == 'some.invalid.example'" + - "'www.example.cloudfront.net' | community.dns.remove_registrable_domain(icann_only=false) == 'www'" + - "'www.example.cloudfront.net' | community.dns.remove_registrable_domain(icann_only=true) == 'www.example'" + - "'prefix.www.ck' | community.dns.remove_registrable_domain == 'prefix'" + - "'thisisaninvalidlabelbecauseitiswaytoolongitscharacterlengthislargerthan63' | community.dns.remove_registrable_domain == 'thisisaninvalidlabelbecauseitiswaytoolongitscharacterlengthislargerthan63'" diff --git a/ansible_collections/community/dns/tests/ee/roles/wait_for_txt/tasks/main.yml b/ansible_collections/community/dns/tests/ee/roles/wait_for_txt/tasks/main.yml new file mode 100644 index 000000000..43941d042 --- /dev/null +++ b/ansible_collections/community/dns/tests/ee/roles/wait_for_txt/tasks/main.yml @@ -0,0 +1,77 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Get TXT records for github.com + shell: | + dig -t TXT github.com +short | sed -e 's/" "//g' -e 's/"//g' + register: dig + +- name: Wait for existing TXT entry + community.dns.wait_for_txt: + records: + - name: github.com + values: "{{ dig.stdout_lines }}" + - name: github.io + values: [] + query_timeout: 20 + timeout: 60 + register: success + +- name: Validate results + assert: + that: + - success is not changed + - success.msg == 'All checks passed' + - success.completed == 2 + - success.records | length == 2 + - success.records[0].name == 'github.com' + - success.records[0].done == true + - "'values' in success.records[0]" + - success.records[0].check_count == 1 + - success.records[1].name == 'github.io' + - success.records[1].done == true + - "'values' in success.records[1]" + - success.records[1].check_count == 1 + +- name: Wait for non-existing TXT entry + community.dns.wait_for_txt: + records: + - name: does_not_exist.ansible.com + values: test + timeout: 0 + register: timeout + failed_when: timeout is not failed + +- name: Validate results + assert: + that: + - timeout.msg == 'Timeout (0 out of 1 check(s) passed).' + - timeout.completed == 0 + - timeout.records | length == 1 + - timeout.records[0].name == 'does_not_exist.ansible.com' + - timeout.records[0].done == false + - "'values' in timeout.records[0]" + - timeout.records[0].check_count == 1 + +- name: Wait for non-existing TXT value + community.dns.wait_for_txt: + records: + - name: github.com + # random digits generated by https://www.random.org/ + values: x9717627475397185312575692320809591701005198751588993668249007340758823426405452359342719842260291210 + timeout: 0 + register: timeout + failed_when: timeout is not failed + +- name: Validate results + assert: + that: + - timeout.msg == 'Timeout (0 out of 1 check(s) passed).' + - timeout.completed == 0 + - timeout.records | length == 1 + - timeout.records[0].name == 'github.com' + - timeout.records[0].done == false + - "'values' in timeout.records[0]" + - timeout.records[0].check_count == 1 diff --git a/ansible_collections/community/dns/tests/integration/integration_config.yml.hetzner-template b/ansible_collections/community/dns/tests/integration/integration_config.yml.hetzner-template new file mode 100644 index 000000000..63cba8a31 --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/integration_config.yml.hetzner-template @@ -0,0 +1,9 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Template for Hetzner DNS tests: + +hetzner_test_zone: ... +hetzner_token: ... diff --git a/ansible_collections/community/dns/tests/integration/integration_config.yml.hosttech-template b/ansible_collections/community/dns/tests/integration/integration_config.yml.hosttech-template new file mode 100644 index 000000000..da2ec3e5c --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/integration_config.yml.hosttech-template @@ -0,0 +1,15 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Template for Hosttech DNS tests: + +test_zone: ... +test_record_prefix: ... # must not be empty! +# Specify username/password: +hosttech_username: ... +hosttech_password: ... +# Alternatively (or additionally), specify token: +hosttech_token: ... +# Specifying both will run the same tests with both. diff --git a/ansible_collections/community/dns/tests/integration/requirements.yml b/ansible_collections/community/dns/tests/integration/requirements.yml new file mode 100644 index 000000000..1c4b3b0c0 --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/requirements.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +collections: +- community.general diff --git a/ansible_collections/community/dns/tests/integration/targets/filter_domain_suffix/aliases b/ansible_collections/community/dns/tests/integration/targets/filter_domain_suffix/aliases new file mode 100644 index 000000000..b7419a24d --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/filter_domain_suffix/aliases @@ -0,0 +1,6 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +shippable/posix/group1 +skip/python2.6 # filters are controller only, and we no longer support Python 2.6 on the controller diff --git a/ansible_collections/community/dns/tests/integration/targets/filter_domain_suffix/tasks/main.yml b/ansible_collections/community/dns/tests/integration/targets/filter_domain_suffix/tasks/main.yml new file mode 100644 index 000000000..4473a743a --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/filter_domain_suffix/tasks/main.yml @@ -0,0 +1,78 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: "Test get_public_suffix filter" + assert: + that: + - "'' | community.dns.get_public_suffix == ''" + - "'' | community.dns.get_public_suffix(keep_leading_period=false) == ''" + - "'www.ansible.com' | community.dns.get_public_suffix == '.com'" + - "'www.ansible.com' | community.dns.get_public_suffix(keep_leading_period=false) == 'com'" + - "'some.random.prefixes.ansible.co.uk' | community.dns.get_public_suffix == '.co.uk'" + - "'no.known.suffixafdlkjsaflkjsaflkjslkjfds' | community.dns.get_public_suffix == '.suffixafdlkjsaflkjsaflkjslkjfds'" + - "'no.known.suffixafdlkjsaflkjsaflkjslkjfds' | community.dns.get_public_suffix(keep_unknown_suffix=False) == ''" + - "'ëçãmplê' | community.dns.get_public_suffix(normalize_result=True) == 'xn--mpl-llatwb'" + - "'www.example.cloudfront.net' | community.dns.get_public_suffix(icann_only=false) == '.cloudfront.net'" + - "'www.example.cloudfront.net' | community.dns.get_public_suffix(icann_only=true) == '.net'" + - "'www.ck' | community.dns.get_public_suffix == '.ck'" + - "'thisisaninvalidlabelbecauseitiswaytoolongitscharacterlengthislargerthan63' | community.dns.get_public_suffix == ''" + +- name: "Test remove_public_suffix filter" + assert: + that: + - "'' | community.dns.remove_public_suffix == ''" + - "'' | community.dns.remove_public_suffix(keep_trailing_period=true) == ''" + - "'com' | community.dns.remove_public_suffix == ''" + - "'com' | community.dns.remove_public_suffix(keep_trailing_period=true) == ''" + - "'www.ansible.com' | community.dns.remove_public_suffix == 'www.ansible'" + - "'www.ansible.com' | community.dns.remove_public_suffix(keep_trailing_period=true) == 'www.ansible.'" + - "'some.random.prefixes.ansible.co.uk' | community.dns.remove_public_suffix == 'some.random.prefixes.ansible'" + - "'no.known.suffixafdlkjsaflkjsaflkjslkjfds' | community.dns.remove_public_suffix == 'no.known'" + - "'no.known.suffixafdlkjsaflkjsaflkjslkjfds' | community.dns.remove_public_suffix(keep_unknown_suffix=False) == 'no.known.suffixafdlkjsaflkjsaflkjslkjfds'" + - "'www.example.cloudfront.net' | community.dns.remove_public_suffix(icann_only=false) == 'www.example'" + - "'www.example.cloudfront.net' | community.dns.remove_public_suffix(icann_only=true) == 'www.example.cloudfront'" + - "'www.ck' | community.dns.remove_public_suffix == 'www'" + - "'thisisaninvalidlabelbecauseitiswaytoolongitscharacterlengthislargerthan63' | community.dns.remove_public_suffix == 'thisisaninvalidlabelbecauseitiswaytoolongitscharacterlengthislargerthan63'" + +- name: "Test get_registrable_domain filter" + assert: + that: + - "'' | community.dns.get_registrable_domain == ''" + - "'com' | community.dns.get_registrable_domain == ''" + - "'com' | community.dns.get_registrable_domain(only_if_registerable=true) == ''" + - "'com' | community.dns.get_registrable_domain(only_if_registerable=false) == 'com'" + - "'www.ansible.com' | community.dns.get_registrable_domain == 'ansible.com'" + - "'www.ansible.com.' | community.dns.get_registrable_domain == 'ansible.com.'" + - "'www.ansible.com' | community.dns.get_registrable_domain(only_if_registerable=true) == 'ansible.com'" + - "'some.random.prefixes.ansible.co.uk' | community.dns.get_registrable_domain == 'ansible.co.uk'" + - "'some.invalid.example' | community.dns.get_registrable_domain == 'invalid.example'" + - "'some.invalid.example' | community.dns.get_registrable_domain(keep_unknown_suffix=False) == ''" + - "'ëçãmplê.com' | community.dns.get_registrable_domain(normalize_result=True) == 'xn--mpl-llatwb.com'" + - "'www.example.cloudfront.net' | community.dns.get_registrable_domain(icann_only=false) == 'example.cloudfront.net'" + - "'www.example.cloudfront.net' | community.dns.get_registrable_domain(icann_only=true) == 'cloudfront.net'" + - "'prefix.www.ck' | community.dns.get_registrable_domain == 'www.ck'" + - "'thisisaninvalidlabelbecauseitiswaytoolongitscharacterlengthislargerthan63' | community.dns.get_registrable_domain == ''" + +- name: "Test remove_registrable_domain filter" + assert: + that: + - "'' | community.dns.remove_registrable_domain == ''" + - "'' | community.dns.remove_registrable_domain(keep_trailing_period=true) == ''" + - "'com' | community.dns.remove_registrable_domain == 'com'" + - "'com' | community.dns.remove_registrable_domain(only_if_registerable=true) == 'com'" + - "'com' | community.dns.remove_registrable_domain(only_if_registerable=false) == ''" + - "'com' | community.dns.remove_registrable_domain(keep_trailing_period=true) == 'com'" + - "'www.ansible.com' | community.dns.remove_registrable_domain == 'www'" + - "'www.ansible.com' | community.dns.remove_registrable_domain(only_if_registerable=true) == 'www'" + - "'www.ansible.com' | community.dns.remove_registrable_domain(keep_trailing_period=true) == 'www.'" + - "'www.ansible.com.' | community.dns.remove_registrable_domain == 'www'" + - "'ansible.com.' | community.dns.remove_registrable_domain == ''" + - "'some.random.prefixes.ansible.co.uk' | community.dns.remove_registrable_domain == 'some.random.prefixes'" + - "'some.invalid.example' | community.dns.remove_registrable_domain == 'some'" + - "'some.invalid.example' | community.dns.remove_registrable_domain(keep_unknown_suffix=False) == 'some.invalid.example'" + - "'www.example.cloudfront.net' | community.dns.remove_registrable_domain(icann_only=false) == 'www'" + - "'www.example.cloudfront.net' | community.dns.remove_registrable_domain(icann_only=true) == 'www.example'" + - "'prefix.www.ck' | community.dns.remove_registrable_domain == 'prefix'" + - "'thisisaninvalidlabelbecauseitiswaytoolongitscharacterlengthislargerthan63' | community.dns.remove_registrable_domain == 'thisisaninvalidlabelbecauseitiswaytoolongitscharacterlengthislargerthan63'" diff --git a/ansible_collections/community/dns/tests/integration/targets/hetzner/aliases b/ansible_collections/community/dns/tests/integration/targets/hetzner/aliases new file mode 100644 index 000000000..3e469b26c --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/hetzner/aliases @@ -0,0 +1,9 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +unsupported +hetzner_dns_record +hetzner_dns_record_set +hetzner_dns_record_set_info +hetzner_dns_zone_info diff --git a/ansible_collections/community/dns/tests/integration/targets/hetzner/tasks/main.yml b/ansible_collections/community/dns/tests/integration/targets/hetzner/tasks/main.yml new file mode 100644 index 000000000..40efc4ded --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/hetzner/tasks/main.yml @@ -0,0 +1,10 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Run tests with record sets + include_tasks: record-sets.yml + +- name: Run tests with records + include_tasks: records.yml diff --git a/ansible_collections/community/dns/tests/integration/targets/hetzner/tasks/record-sets.yml b/ansible_collections/community/dns/tests/integration/targets/hetzner/tasks/record-sets.yml new file mode 100644 index 000000000..6bde67d55 --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/hetzner/tasks/record-sets.yml @@ -0,0 +1,435 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Basic tests for record sets + block: + - name: Test graceful error handling + hetzner_dns_record_set: + zone_name: none_existing + prefix: dns_ansible_collection + state: present + value: "osuv.de." + type: CNAME + ttl: 300 + hetzner_token: "{{ hetzner_token }}" + register: RECORD + failed_when: RECORD is not failed + + - name: verify graceful error handling + assert: + that: + - RECORD is not changed + - 'RECORD.msg == "Zone not found"' + + - name: make sure that CNAME record isn't there + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + type: CNAME + state: present + value: [] + hetzner_token: "{{ hetzner_token }}" + + - name: make sure that A record isn't there + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + type: A + state: present + value: [] + hetzner_token: "{{ hetzner_token }}" + + - name: make sure that A record isn't there + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: website1 + type: A + state: present + value: [] + hetzner_token: "{{ hetzner_token }}" + + - name: fetch zone info + hetzner_dns_zone_info: + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: ZONES + + - name: fetch zone info must also work in check_mode + hetzner_dns_zone_info: + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + check_mode: true + register: ZONES + + - name: add record + hetzner_dns_record_set: + zone_id: "{{ ZONES.zone_id }}" + prefix: dns_ansible_collection + state: present + value: "{{ hetzner_test_zone }}." + type: CNAME + ttl: 300 + hetzner_token: "{{ hetzner_token }}" + on_existing: keep_and_fail + register: RECORD + + - name: assert add record 1 + assert: + that: + - RECORD is changed + # - RECORD.record_set_info.record.ttl == 300 + # - RECORD.record_set_info | count == 1 + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: dns_ansible_collection + type: CNAME + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - debug: + msg: "{{ RECORD }}" + + - name: assert add record 2 + assert: + that: + - RECORD.set.ttl == 300 + - RECORD.set.type == 'CNAME' + - RECORD.set.value == [hetzner_test_zone ~ "."] + + - name: fetch record info must also work in check_mode + hetzner_dns_record_set_info: + prefix: dns_ansible_collection + type: CNAME + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + check_mode: true + + - name: add record no change + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + state: present + value: "{{ hetzner_test_zone }}." + type: CNAME + ttl: 300 + hetzner_token: "{{ hetzner_token }}" + register: RECORD + diff: true + + - debug: + var: RECORD + + - name: assert add record no change + assert: + that: + - RECORD is not changed + - RECORD.diff.before.ttl == 300 + - RECORD.diff.after.ttl == 300 + - RECORD.diff.after.value | count == 1 + + - name: modify record change in check_mode + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + state: present + value: "{{ hetzner_test_zone }}." + type: CNAME + ttl: 60 + hetzner_token: "{{ hetzner_token }}" + check_mode: true + register: RECORD + diff: true + + - name: assert change record check_mode + assert: + that: + - RECORD is changed + - RECORD.diff.before.ttl == 300 + - RECORD.diff.after.ttl == 60 + - RECORD.diff.after.value | count == 1 + + - name: modify record change + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + state: present + value: "{{ hetzner_test_zone }}." + type: CNAME + ttl: 60 + hetzner_token: "{{ hetzner_token }}" + register: RECORD + diff: true + + - name: assert change record + assert: + that: + - RECORD is changed + - RECORD.diff.before.ttl == 300 + - RECORD.diff.after.ttl == 60 + - RECORD.diff.after.value | count == 1 + + - name: delete record + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + type: CNAME + state: absent + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert delete record + assert: + that: + - RECORD is changed + + - name: delete record no change + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + type: CNAME + state: absent + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert delete record + assert: + that: + - RECORD is not changed + + - name: add single A record + hetzner_dns_record_set: + zone_id: "{{ ZONES.zone_id }}" + prefix: dns_ansible_collection + state: present + value: + - 1.1.1.1 + type: A + ttl: 60 + on_existing: keep_and_fail + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: append A record + hetzner_dns_record_set: + zone_id: "{{ ZONES.zone_id }}" + prefix: dns_ansible_collection + state: present + value: + - 1.1.1.1 + - 8.8.8.8 + type: A + ttl: 60 + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: append A record + hetzner_dns_record_set: + zone_id: "{{ ZONES.zone_id }}" + prefix: dns_ansible_collection + state: present + value: + - 1.1.1.1 + - 8.8.8.8 + - 8.8.4.4 + type: A + ttl: 60 + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: dns_ansible_collection + type: A + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert 3 A records + assert: + that: + - RECORD.set.value | count == 3 + + - name: delete single A record + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + state: present + value: + - 1.1.1.1 + - 8.8.4.4 + type: A + hetzner_token: "{{ hetzner_token }}" + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: dns_ansible_collection + type: A + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert 2 A records + assert: + that: + - RECORD.set.value | count == 2 + + - name: delete all A records + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + type: A + state: absent + hetzner_token: "{{ hetzner_token }}" + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: dns_ansible_collection + type: A + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert 0 A records + assert: + that: + - RECORD.set == {} + + - name: add record + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: website1 + state: present + type: A + value: + - 127.0.0.1 + on_existing: keep_and_fail + hetzner_token: "{{ hetzner_token }}" + + - name: replace record + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: website1 + state: present + type: A + value: + - 127.0.0.2 + hetzner_token: "{{ hetzner_token }}" + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: website1 + type: A + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - debug: + msg: "{{ RECORD }}" + + - name: assert 1 A record + assert: + that: + - RECORD.set.ttl is none + - RECORD.set.type == 'A' + - RECORD.set.value | count == 1 + - RECORD.set.value[0] == '127.0.0.2' + + - name: append record website1 + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: website1 + state: present + type: A + value: + - 127.0.0.2 + - 127.0.0.1 + hetzner_token: "{{ hetzner_token }}" + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: website1 + type: A + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert 2 A record + assert: + that: + - RECORD.set.value | count == 2 + + - name: replace all + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: website1 + state: present + type: A + value: + - 8.8.8.8 + hetzner_token: "{{ hetzner_token }}" + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: website1 + type: A + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert 1 A record + assert: + that: + - RECORD.set.value | count == 1 + + - name: remove all records website1 + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: website1 + type: A + state: absent + hetzner_token: "{{ hetzner_token }}" + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: website1 + type: A + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert 0 A records + assert: + that: + - RECORD.set == {} + + always: + - name: always delete CNAME record + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + type: CNAME + state: present + value: [] + hetzner_token: "{{ hetzner_token }}" + + - name: always delete A record + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + type: A + state: present + value: [] + hetzner_token: "{{ hetzner_token }}" + + - name: always delete A record + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: website1 + type: A + state: present + value: [] + hetzner_token: "{{ hetzner_token }}" diff --git a/ansible_collections/community/dns/tests/integration/targets/hetzner/tasks/records.yml b/ansible_collections/community/dns/tests/integration/targets/hetzner/tasks/records.yml new file mode 100644 index 000000000..d10d2a392 --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/hetzner/tasks/records.yml @@ -0,0 +1,368 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Basic tests for records + block: + - name: Test graceful error handling + hetzner_dns_record: + zone_name: none_existing + prefix: dns_ansible_collection + state: present + value: "osuv.de." + type: CNAME + ttl: 300 + hetzner_token: "{{ hetzner_token }}" + register: RECORD + failed_when: RECORD is not failed + + - name: verify graceful error handling + assert: + that: + - RECORD is not changed + - 'RECORD.msg == "Zone not found"' + + - name: fetch zone info + hetzner_dns_zone_info: + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: ZONES + + - name: fetch zone info must also work in check_mode + hetzner_dns_zone_info: + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + check_mode: true + register: ZONES + + - name: add record + hetzner_dns_record: + zone_id: "{{ ZONES.zone_id }}" + prefix: dns_ansible_collection + state: present + value: "{{ hetzner_test_zone }}." + type: CNAME + ttl: 300 + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert add record 1 + assert: + that: + - RECORD is changed + # - RECORD.record_set_info.record.ttl == 300 + # - RECORD.record_set_info | count == 1 + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: dns_ansible_collection + type: CNAME + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - debug: + msg: "{{ RECORD }}" + + - name: assert add record 2 + assert: + that: + - RECORD.set.ttl == 300 + - RECORD.set.type == 'CNAME' + - RECORD.set.value == [hetzner_test_zone ~ "."] + + - name: fetch record info must also work in check_mode + hetzner_dns_record_set_info: + prefix: dns_ansible_collection + type: CNAME + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + check_mode: true + + - name: add record no change + hetzner_dns_record: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + state: present + value: "{{ hetzner_test_zone }}." + type: CNAME + ttl: 300 + hetzner_token: "{{ hetzner_token }}" + register: RECORD + diff: true + + - debug: + var: RECORD + + - name: assert add record no change + assert: + that: + - RECORD is not changed + - RECORD.diff.before.ttl == 300 + - RECORD.diff.after.ttl == 300 + - RECORD.diff.after.value == hetzner_test_zone ~ '.' + + - name: modify record change in check_mode + hetzner_dns_record: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + state: present + value: "{{ hetzner_test_zone }}." + type: CNAME + ttl: 60 + hetzner_token: "{{ hetzner_token }}" + check_mode: true + register: RECORD + diff: true + + - name: assert change record check_mode + assert: + that: + - RECORD is changed + - RECORD.diff.before.ttl == 300 + - RECORD.diff.after.ttl == 60 + - RECORD.diff.after.value == hetzner_test_zone ~ '.' + + - name: modify record change + hetzner_dns_record: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + state: present + value: "{{ hetzner_test_zone }}." + type: CNAME + ttl: 60 + hetzner_token: "{{ hetzner_token }}" + register: RECORD + diff: true + + - name: assert change record + assert: + that: + - RECORD is changed + - RECORD.diff.before.ttl == 300 + - RECORD.diff.after.ttl == 60 + - RECORD.diff.after.value == hetzner_test_zone ~ '.' + + - name: delete record + hetzner_dns_record: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + type: CNAME + value: "{{ hetzner_test_zone }}." + state: absent + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert delete record + assert: + that: + - RECORD is changed + + - name: delete record no change + hetzner_dns_record: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + type: CNAME + value: "{{ hetzner_test_zone }}." + state: absent + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert delete record + assert: + that: + - RECORD is not changed + + - name: add single A record + hetzner_dns_record: + zone_id: "{{ ZONES.zone_id }}" + prefix: dns_ansible_collection + state: present + value: 1.1.1.1 + type: A + ttl: 60 + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: append A record + hetzner_dns_record: + zone_id: "{{ ZONES.zone_id }}" + prefix: dns_ansible_collection + state: present + value: 8.8.8.8 + type: A + ttl: 60 + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: append A record + hetzner_dns_record: + zone_id: "{{ ZONES.zone_id }}" + prefix: dns_ansible_collection + state: present + value: 8.8.4.4 + type: A + ttl: 60 + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: dns_ansible_collection + type: A + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert 3 A records + assert: + that: + - RECORD.set.value | count == 3 + + - name: delete single A record + hetzner_dns_record: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + state: absent + value: 8.8.8.8 + type: A + hetzner_token: "{{ hetzner_token }}" + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: dns_ansible_collection + type: A + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert 2 A records + assert: + that: + - RECORD.set.value | count == 2 + + - name: delete another A records + hetzner_dns_record: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + state: absent + value: 8.8.4.4 + type: A + hetzner_token: "{{ hetzner_token }}" + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: dns_ansible_collection + type: A + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert 1 A records + assert: + that: + - RECORD.set.value | count == 1 + + - name: delete last A records + hetzner_dns_record: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + state: absent + value: 1.1.1.1 + type: A + hetzner_token: "{{ hetzner_token }}" + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: dns_ansible_collection + type: A + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert 0 A records + assert: + that: + - RECORD.set == {} + + - name: add record + hetzner_dns_record: + zone_name: "{{ hetzner_test_zone }}" + prefix: website1 + state: present + type: A + value: 127.0.0.1 + hetzner_token: "{{ hetzner_token }}" + + - name: append record website1 + hetzner_dns_record: + zone_name: "{{ hetzner_test_zone }}" + prefix: website1 + state: present + type: A + value: 127.0.0.2 + hetzner_token: "{{ hetzner_token }}" + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: website1 + type: A + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert 2 A record + assert: + that: + - RECORD.set.value | count == 2 + + - name: delete first record for website1 + hetzner_dns_record: + zone_name: "{{ hetzner_test_zone }}" + prefix: website1 + state: absent + type: A + value: 127.0.0.1 + hetzner_token: "{{ hetzner_token }}" + + - name: fetch record info + hetzner_dns_record_set_info: + prefix: website1 + type: A + zone_name: "{{ hetzner_test_zone }}" + hetzner_token: "{{ hetzner_token }}" + register: RECORD + + - name: assert 1 A records + assert: + that: + - RECORD.set.value | count == 1 + + always: + - name: always delete CNAME record + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + type: CNAME + state: present + value: [] + hetzner_token: "{{ hetzner_token }}" + + - name: always delete A record + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: dns_ansible_collection + type: A + state: present + value: [] + hetzner_token: "{{ hetzner_token }}" + + - name: always delete A record + hetzner_dns_record_set: + zone_name: "{{ hetzner_test_zone }}" + prefix: website1 + type: A + state: present + value: [] + hetzner_token: "{{ hetzner_token }}" diff --git a/ansible_collections/community/dns/tests/integration/targets/hosttech/aliases b/ansible_collections/community/dns/tests/integration/targets/hosttech/aliases new file mode 100644 index 000000000..33054ccfa --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/hosttech/aliases @@ -0,0 +1,9 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +unsupported +hosttech_dns_record +hosttech_dns_record_set +hosttech_dns_record_set_info +hosttech_dns_zone_info diff --git a/ansible_collections/community/dns/tests/integration/targets/hosttech/tasks/main.yml b/ansible_collections/community/dns/tests/integration/targets/hosttech/tasks/main.yml new file mode 100644 index 000000000..4df6ec723 --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/hosttech/tasks/main.yml @@ -0,0 +1,16 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Compute test record from prefix and zone + set_fact: + test_record: '{{ test_record_prefix ~ "." ~ test_zone }}' + +- name: Run tests with username and password + include_tasks: username-password.yml + when: hosttech_username is defined and hosttech_password is defined + +- name: Run tests with token + include_tasks: token.yml + when: hosttech_token is defined diff --git a/ansible_collections/community/dns/tests/integration/targets/hosttech/tasks/token.yml b/ansible_collections/community/dns/tests/integration/targets/hosttech/tasks/token.yml new file mode 100644 index 000000000..30cb6f118 --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/hosttech/tasks/token.yml @@ -0,0 +1,146 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: (token) Get zone info + hosttech_dns_zone_info: + zone_name: '{{ test_zone }}' + hosttech_token: '{{ hosttech_token }}' + register: result_zone + +- name: (token) Get zone info by ID + hosttech_dns_zone_info: + zone_id: '{{ result_zone.zone_id }}' + hosttech_token: '{{ hosttech_token }}' + register: result_zone_id + +- name: (token) Get all NS records + hosttech_dns_record_set_info: + zone_name: '{{ test_zone }}' + record: '{{ test_zone }}' + type: NS + hosttech_token: '{{ hosttech_token }}' + register: result + +- name: (token) Output all records + debug: + var: result + +- assert: + that: + - result_zone == result_zone_id + - result_zone.zone_name == test_zone + - result_zone.zone_id == result.zone_id + - result.set + +- name: (token) Set zone ID + set_fact: + hosttech_zone_id: "{{ result.zone_id }}" + +- name: (token) Ensure that test record is not present + hosttech_dns_record_set: + state: present + zone_name: '{{ test_zone }}' + record: '{{ test_record }}' + type: A + ttl: 600 + value: [] + hosttech_token: '{{ hosttech_token }}' + +- block: + + - name: (token) Get test record + hosttech_dns_record_set_info: + zone_id: '{{ hosttech_zone_id }}' + record: '{{ test_record }}' + type: A + hosttech_token: '{{ hosttech_token }}' + register: rec + + - assert: + that: + - not rec.set + + - name: (token) Create test record + hosttech_dns_record_set: + state: present + zone_name: '{{ test_zone }}' + record: '{{ test_record }}' + type: A + ttl: 600 + value: + - 1.2.3.4 + - 1.2.3.5 + - 1.2.3.6 + hosttech_token: '{{ hosttech_token }}' + + - name: (token) Modify test record + hosttech_dns_record_set: + state: present + zone_name: '{{ test_zone }}' + prefix: '{{ test_record_prefix }}' + type: A + ttl: 600 + value: + - 1.2.3.4 + - 1.2.3.7 + hosttech_token: '{{ hosttech_token }}' + + - name: (token) Add test record + hosttech_dns_record: + state: present + zone_name: '{{ test_zone }}' + prefix: '{{ test_record_prefix }}' + type: A + ttl: 600 + value: 1.2.3.8 + hosttech_token: '{{ hosttech_token }}' + + - name: (token) Remove test record + hosttech_dns_record: + state: absent + zone_name: '{{ test_zone }}' + prefix: '{{ test_record_prefix }}' + type: A + ttl: 600 + value: 1.2.3.4 + hosttech_token: '{{ hosttech_token }}' + + - name: (token) Get test record + hosttech_dns_record_set_info: + zone_name: '{{ test_zone }}' + record: '{{ test_record }}' + type: A + hosttech_token: '{{ hosttech_token }}' + register: rec + + - assert: + that: + - rec.set + - rec.set.record == test_record + - rec.set.type == 'A' + - rec.set.ttl == 600 + - rec.set.value | length == 2 + - rec.set.value | sort == ['1.2.3.7', '1.2.3.8'] + + - name: (token) Delete test record + hosttech_dns_record_set: + state: absent + zone_id: '{{ hosttech_zone_id }}' + record: '{{ rec.set.record }}' + ttl: '{{ rec.set.ttl }}' + type: '{{ rec.set.type }}' + value: '{{ rec.set.value }}' + on_existing: keep_and_fail + hosttech_token: '{{ hosttech_token }}' + + always: + - name: (token) Ensure that test record is not present + hosttech_dns_record_set: + state: absent + zone_name: '{{ test_zone }}' + record: '{{ test_record }}' + type: A + ttl: 600 + hosttech_token: '{{ hosttech_token }}' diff --git a/ansible_collections/community/dns/tests/integration/targets/hosttech/tasks/username-password.yml b/ansible_collections/community/dns/tests/integration/targets/hosttech/tasks/username-password.yml new file mode 100644 index 000000000..364314b68 --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/hosttech/tasks/username-password.yml @@ -0,0 +1,162 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: (username/password) Ensure that lxml is installed + pip: + name: lxml + +- name: (username/password) Get zone info + hosttech_dns_zone_info: + zone_name: '{{ test_zone }}' + hosttech_username: '{{ hosttech_username }}' + hosttech_password: '{{ hosttech_password }}' + register: result_zone + +- name: (username/password) Get zone info by ID + hosttech_dns_zone_info: + zone_id: '{{ result_zone.zone_id }}' + hosttech_username: '{{ hosttech_username }}' + hosttech_password: '{{ hosttech_password }}' + register: result_zone_id + +- name: (username/password) Get all NS records + hosttech_dns_record_set_info: + zone_name: '{{ test_zone }}' + record: '{{ test_zone }}' + type: NS + hosttech_username: '{{ hosttech_username }}' + hosttech_password: '{{ hosttech_password }}' + register: result + +- name: (username/password) Output all records + debug: + var: result + +- assert: + that: + - result_zone == result_zone_id + - result_zone.zone_name == test_zone + - result_zone.zone_id == result.zone_id + - result.set + +- name: (username/password) Set zone ID + set_fact: + hosttech_zone_id: "{{ result.zone_id }}" + +- name: (username/password) Ensure that test record is not present + hosttech_dns_record_set: + state: present + zone_name: '{{ test_zone }}' + record: '{{ test_record }}' + type: A + ttl: 300 + value: [] + hosttech_username: '{{ hosttech_username }}' + hosttech_password: '{{ hosttech_password }}' + +- block: + + - name: (username/password) Get test record + hosttech_dns_record_set_info: + zone_id: '{{ hosttech_zone_id }}' + record: '{{ test_record }}' + type: A + hosttech_username: '{{ hosttech_username }}' + hosttech_password: '{{ hosttech_password }}' + register: rec + + - assert: + that: + - not rec.set + + - name: (username/password) Create test record + hosttech_dns_record_set: + state: present + zone_name: '{{ test_zone }}' + record: '{{ test_record }}' + type: A + ttl: 300 + value: + - 1.2.3.4 + - 1.2.3.5 + - 1.2.3.6 + hosttech_username: '{{ hosttech_username }}' + hosttech_password: '{{ hosttech_password }}' + + - name: (username/password) Modify test record + hosttech_dns_record_set: + state: present + zone_name: '{{ test_zone }}' + prefix: '{{ test_record_prefix }}' + type: A + ttl: 300 + value: + - 1.2.3.4 + - 1.2.3.7 + hosttech_username: '{{ hosttech_username }}' + hosttech_password: '{{ hosttech_password }}' + + - name: (username/password) Add test record + hosttech_dns_record: + state: present + zone_name: '{{ test_zone }}' + prefix: '{{ test_record_prefix }}' + type: A + ttl: 300 + value: 1.2.3.8 + hosttech_username: '{{ hosttech_username }}' + hosttech_password: '{{ hosttech_password }}' + + - name: (username/password) Remove test record + hosttech_dns_record: + state: absent + zone_name: '{{ test_zone }}' + prefix: '{{ test_record_prefix }}' + type: A + ttl: 300 + value: 1.2.3.4 + hosttech_username: '{{ hosttech_username }}' + hosttech_password: '{{ hosttech_password }}' + + - name: (username/password) Get test record + hosttech_dns_record_set_info: + zone_name: '{{ test_zone }}' + record: '{{ test_record }}' + type: A + hosttech_username: '{{ hosttech_username }}' + hosttech_password: '{{ hosttech_password }}' + register: rec + + - assert: + that: + - rec.set + - rec.set.record == test_record + - rec.set.type == 'A' + - rec.set.ttl == 300 + - rec.set.value | length == 2 + - rec.set.value | sort == ['1.2.3.7', '1.2.3.8'] + + - name: (username/password) Delete test record + hosttech_dns_record_set: + state: absent + zone_id: '{{ hosttech_zone_id }}' + record: '{{ rec.set.record }}' + ttl: '{{ rec.set.ttl }}' + type: '{{ rec.set.type }}' + value: '{{ rec.set.value }}' + on_existing: keep_and_fail + hosttech_username: '{{ hosttech_username }}' + hosttech_password: '{{ hosttech_password }}' + + always: + - name: (username/password) Ensure that test record is not present + hosttech_dns_record_set: + state: absent + zone_name: '{{ test_zone }}' + record: '{{ test_record }}' + type: A + ttl: 300 + hosttech_username: '{{ hosttech_username }}' + hosttech_password: '{{ hosttech_password }}' diff --git a/ansible_collections/community/dns/tests/integration/targets/required_module_params/aliases b/ansible_collections/community/dns/tests/integration/targets/required_module_params/aliases new file mode 100644 index 000000000..f7fadad2d --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/required_module_params/aliases @@ -0,0 +1,6 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +context/target +shippable/posix/group1 diff --git a/ansible_collections/community/dns/tests/integration/targets/required_module_params/tasks/main.yml b/ansible_collections/community/dns/tests/integration/targets/required_module_params/tasks/main.yml new file mode 100644 index 000000000..8d83de244 --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/required_module_params/tasks/main.yml @@ -0,0 +1,149 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Run hetzner_dns_record without options + hetzner_dns_record: + register: result + failed_when: result is not failed + +- name: Validate hetzner_dns_record run + assert: + that: + - "'hetzner_token' in result.msg" + - "'state' in result.msg" + - "'type' in result.msg" + - "'value' in result.msg" + +- name: Run hetzner_dns_record_info without options + hetzner_dns_record_info: + register: result + failed_when: result is not failed + +- name: Validate hetzner_dns_record_info run + assert: + that: + - "'hetzner_token' in result.msg" + +- name: Run hetzner_dns_record_set_info without options + hetzner_dns_record_set_info: + register: result + failed_when: result is not failed + +- name: Validate hetzner_dns_record_set_info run + assert: + that: + - "'hetzner_token' in result.msg" + +- name: Run hetzner_dns_record_set without options + hetzner_dns_record_set: + register: result + failed_when: result is not failed + +- name: Validate hetzner_dns_record_set run + assert: + that: + - "'hetzner_token' in result.msg" + - "'state' in result.msg" + - "'type' in result.msg" + +- name: Run hetzner_dns_record_sets without options + hetzner_dns_record_sets: + register: result + failed_when: result is not failed + +- name: Validate hetzner_dns_record_sets run + assert: + that: + - "'hetzner_token' in result.msg" + - "'record_sets' in result.msg" + +- name: Run hetzner_dns_zone_info without options + hetzner_dns_zone_info: + register: result + failed_when: result is not failed + +- name: Validate hetzner_dns_zone_info run + assert: + that: + - "'hetzner_token' in result.msg" + +- name: Run hosttech_dns_record without options + hosttech_dns_record: + register: result + failed_when: result is not failed + +- name: Validate hosttech_dns_record run + assert: + that: + - "'state' in result.msg" + - "'type' in result.msg" + - "'value' in result.msg" + +- name: Run hosttech_dns_record_set_info without options + hosttech_dns_record_set_info: + register: result + failed_when: result is not failed + +- name: Validate hosttech_dns_record_set_info run + assert: + that: + - "'one of the following is required' in result.msg" + - "'zone_name' in result.msg" + - "'zone_id' in result.msg" + +- name: Run hosttech_dns_record_info without options + hosttech_dns_record_info: + register: result + failed_when: result is not failed + +- name: Validate hosttech_dns_record_info run + assert: + that: + - "'one of the following is required' in result.msg" + - "'zone_name' in result.msg" + - "'zone_id' in result.msg" + +- name: Run hosttech_dns_record_set without options + hosttech_dns_record_set: + register: result + failed_when: result is not failed + +- name: Validate hosttech_dns_record_set run + assert: + that: + - "'state' in result.msg" + - "'type' in result.msg" + +- name: Run hosttech_dns_record_sets without options + hosttech_dns_record_sets: + register: result + failed_when: result is not failed + +- name: Validate hosttech_dns_record_sets run + assert: + that: + - "'record_sets' in result.msg" + +- name: Run hosttech_dns_zone_info without options + hosttech_dns_zone_info: + register: result + failed_when: result is not failed + +- name: Validate hosttech_dns_zone_info run + assert: + that: + - "'one of the following is required' in result.msg" + - "'zone_name' in result.msg" + - "'zone_id' in result.msg" + +- name: Run wait_for_txt without options + wait_for_txt: + register: result + failed_when: result is not failed + +- name: Validate wait_for_txt run + assert: + that: + - "'records' in result.msg" diff --git a/ansible_collections/community/dns/tests/integration/targets/wait_for_txt/aliases b/ansible_collections/community/dns/tests/integration/targets/wait_for_txt/aliases new file mode 100644 index 000000000..34334fd82 --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/wait_for_txt/aliases @@ -0,0 +1,6 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +shippable/posix/group1 +destructive diff --git a/ansible_collections/community/dns/tests/integration/targets/wait_for_txt/runme.sh b/ansible_collections/community/dns/tests/integration/targets/wait_for_txt/runme.sh new file mode 100755 index 000000000..9265e7ee1 --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/wait_for_txt/runme.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +set -eux + +export ANSIBLE_TEST_PREFER_VENV=1 # see https://github.com/ansible/ansible/pull/73000#issuecomment-757012395; can be removed once Ansible 2.9 and ansible-base 2.10 support has been dropped +source virtualenv.sh + +# Requirements have to be installed prior to running ansible-playbook +# because plugins and requirements are loaded before the task runs + +pip install dnspython + +ANSIBLE_ROLES_PATH=../ ansible-playbook runme.yml "$@" diff --git a/ansible_collections/community/dns/tests/integration/targets/wait_for_txt/runme.yml b/ansible_collections/community/dns/tests/integration/targets/wait_for_txt/runme.yml new file mode 100644 index 000000000..bcecb88e9 --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/wait_for_txt/runme.yml @@ -0,0 +1,8 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- hosts: localhost + roles: + - { role: wait_for_txt } diff --git a/ansible_collections/community/dns/tests/integration/targets/wait_for_txt/tasks/main.yml b/ansible_collections/community/dns/tests/integration/targets/wait_for_txt/tasks/main.yml new file mode 100644 index 000000000..fce443423 --- /dev/null +++ b/ansible_collections/community/dns/tests/integration/targets/wait_for_txt/tasks/main.yml @@ -0,0 +1,73 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +- name: Wait for existing TXT entry + wait_for_txt: + records: + - name: github.com + values: | + {{ query('community.general.dig', 'github.com', 'qtype=TXT') | map('regex_replace', '" "', '') | list }} + - name: github.io + values: [] + query_timeout: 20 + timeout: 60 + register: success + +- name: Validate results + assert: + that: + - success is not changed + - success.msg == 'All checks passed' + - success.completed == 2 + - success.records | length == 2 + - success.records[0].name == 'github.com' + - success.records[0].done == true + - "'values' in success.records[0]" + - success.records[0].check_count == 1 + - success.records[1].name == 'github.io' + - success.records[1].done == true + - "'values' in success.records[1]" + - success.records[1].check_count == 1 + +- name: Wait for non-existing TXT entry + wait_for_txt: + records: + - name: does_not_exist.ansible.com + values: test + timeout: 0 + register: timeout + failed_when: timeout is not failed + +- name: Validate results + assert: + that: + - timeout.msg == 'Timeout (0 out of 1 check(s) passed).' + - timeout.completed == 0 + - timeout.records | length == 1 + - timeout.records[0].name == 'does_not_exist.ansible.com' + - timeout.records[0].done == false + - "'values' in timeout.records[0]" + - timeout.records[0].check_count == 1 + +- name: Wait for non-existing TXT value + wait_for_txt: + records: + - name: github.com + # random digits generated by https://www.random.org/ + values: x9717627475397185312575692320809591701005198751588993668249007340758823426405452359342719842260291210 + timeout: 0 + register: timeout + failed_when: timeout is not failed + +- name: Validate results + assert: + that: + - timeout.msg == 'Timeout (0 out of 1 check(s) passed).' + - timeout.completed == 0 + - timeout.records | length == 1 + - timeout.records[0].name == 'github.com' + - timeout.records[0].done == false + - "'values' in timeout.records[0]" + - timeout.records[0].check_count == 1 diff --git a/ansible_collections/community/dns/tests/sanity/extra/extra-docs.json b/ansible_collections/community/dns/tests/sanity/extra/extra-docs.json new file mode 100644 index 000000000..9a28d174f --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/extra/extra-docs.json @@ -0,0 +1,13 @@ +{ + "include_symlinks": false, + "prefixes": [ + "docs/docsite/", + "plugins/", + "roles/" + ], + "output": "path-line-column-message", + "requirements": [ + "ansible-core", + "antsibull-docs" + ] +} diff --git a/ansible_collections/community/dns/tests/sanity/extra/extra-docs.json.license b/ansible_collections/community/dns/tests/sanity/extra/extra-docs.json.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/extra/extra-docs.json.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/tests/sanity/extra/extra-docs.py b/ansible_collections/community/dns/tests/sanity/extra/extra-docs.py new file mode 100755 index 000000000..c636beb08 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/extra/extra-docs.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +"""Check extra collection docs with antsibull-docs.""" +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os +import sys +import subprocess + + +def main(): + """Main entry point.""" + env = os.environ.copy() + suffix = ':{env}'.format(env=env["ANSIBLE_COLLECTIONS_PATH"]) if 'ANSIBLE_COLLECTIONS_PATH' in env else '' + env['ANSIBLE_COLLECTIONS_PATH'] = '{root}{suffix}'.format(root=os.path.dirname(os.path.dirname(os.path.dirname(os.getcwd()))), suffix=suffix) + p = subprocess.run( + ['antsibull-docs', 'lint-collection-docs', '--plugin-docs', '--disallow-semantic-markup', '--skip-rstcheck', '.'], + env=env, + check=False, + ) + if p.returncode not in (0, 3): + print('{0}:0:0: unexpected return code {1}'.format(sys.argv[0], p.returncode)) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/tests/sanity/extra/licenses.json b/ansible_collections/community/dns/tests/sanity/extra/licenses.json new file mode 100644 index 000000000..50e47ca88 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/extra/licenses.json @@ -0,0 +1,4 @@ +{ + "include_symlinks": false, + "output": "path-message" +} diff --git a/ansible_collections/community/dns/tests/sanity/extra/licenses.json.license b/ansible_collections/community/dns/tests/sanity/extra/licenses.json.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/extra/licenses.json.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/tests/sanity/extra/licenses.py b/ansible_collections/community/dns/tests/sanity/extra/licenses.py new file mode 100755 index 000000000..7b8b9b262 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/extra/licenses.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python +# Copyright (c) 2022, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +"""Prevent files without a correct license identifier from being added to the source tree.""" +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os +import glob +import sys + + +def format_license_list(licenses): + if not licenses: + return '(empty)' + return ', '.join(['"%s"' % license for license in licenses]) + + +def find_licenses(filename, relax=False): + spdx_license_identifiers = [] + other_license_identifiers = [] + has_copyright = False + try: + with open(filename, 'r', encoding='utf-8') as f: + for line in f: + line = line.rstrip() + if 'Copyright ' in line: + has_copyright = True + if 'Copyright: ' in line: + print('%s: found copyright line with "Copyright:". Please remove the colon.' % (filename, )) + if 'SPDX-FileCopyrightText: ' in line: + has_copyright = True + idx = line.find('SPDX-License-Identifier: ') + if idx >= 0: + lic_id = line[idx + len('SPDX-License-Identifier: '):] + spdx_license_identifiers.extend(lic_id.split(' OR ')) + if 'GNU General Public License' in line: + if 'v3.0+' in line: + other_license_identifiers.append('GPL-3.0-or-later') + if 'version 3 or later' in line: + other_license_identifiers.append('GPL-3.0-or-later') + if 'Simplified BSD License' in line: + other_license_identifiers.append('BSD-2-Clause') + if 'Apache License 2.0' in line: + other_license_identifiers.append('Apache-2.0') + if 'PSF License' in line or 'Python-2.0' in line: + other_license_identifiers.append('PSF-2.0') + if 'MIT License' in line: + other_license_identifiers.append('MIT') + except Exception as exc: + print('%s: error while processing file: %s' % (filename, exc)) + if len(set(spdx_license_identifiers)) < len(spdx_license_identifiers): + print('%s: found identical SPDX-License-Identifier values' % (filename, )) + if other_license_identifiers and set(other_license_identifiers) != set(spdx_license_identifiers): + print('%s: SPDX-License-Identifier yielded the license list %s, while manual guessing yielded the license list %s' % ( + filename, format_license_list(spdx_license_identifiers), format_license_list(other_license_identifiers))) + if not has_copyright and not relax: + print('%s: found no copyright notice' % (filename, )) + return sorted(spdx_license_identifiers) + + +def main(): + """Main entry point.""" + paths = sys.argv[1:] or sys.stdin.read().splitlines() + + # The following paths are allowed to have no license identifier + no_comments_allowed = [ + 'changelogs/fragments/*.yml', + 'changelogs/fragments/*.yaml', + ] + + # These files are completely ignored + ignore_paths = [ + '.ansible-test-timeout.json', + '.reuse/dep5', + 'LICENSES/*.txt', + 'COPYING', + ] + + no_comments_allowed = [fn for pattern in no_comments_allowed for fn in glob.glob(pattern)] + ignore_paths = [fn for pattern in ignore_paths for fn in glob.glob(pattern)] + + valid_licenses = [license_file[len('LICENSES/'):-len('.txt')] for license_file in glob.glob('LICENSES/*.txt')] + + for path in paths: + if path.startswith('./'): + path = path[2:] + if path in ignore_paths or path.startswith('tests/output/'): + continue + if os.stat(path).st_size == 0: + continue + if not path.endswith('.license') and os.path.exists(path + '.license'): + path = path + '.license' + valid_licenses_for_path = valid_licenses + if path.startswith('plugins/') and path.count('/') > 1 and not path.startswith(('plugins/modules/', 'plugins/module_utils/')): + valid_licenses_for_path = [license for license in valid_licenses if license == 'GPL-3.0-or-later'] + licenses = find_licenses(path, relax=path in no_comments_allowed) + if not licenses: + if path not in no_comments_allowed: + print('%s: must have at least one license' % (path, )) + else: + for license in licenses: + if license not in valid_licenses_for_path: + print('%s: found not allowed license "%s", must be one of %s' % ( + path, license, format_license_list(valid_licenses_for_path))) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/tests/sanity/extra/licenses.py.license b/ansible_collections/community/dns/tests/sanity/extra/licenses.py.license new file mode 100644 index 000000000..6c4958feb --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/extra/licenses.py.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: 2022, Felix Fontein <felix@fontein.de> diff --git a/ansible_collections/community/dns/tests/sanity/extra/no-unwanted-files.json b/ansible_collections/community/dns/tests/sanity/extra/no-unwanted-files.json new file mode 100644 index 000000000..cc9bfb002 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/extra/no-unwanted-files.json @@ -0,0 +1,10 @@ +{ + "include_symlinks": true, + "prefixes": [ + "plugins/" + ], + "exclude_prefixes": [ + "plugins/public_suffix_list.dat" + ], + "output": "path-message" +} diff --git a/ansible_collections/community/dns/tests/sanity/extra/no-unwanted-files.json.license b/ansible_collections/community/dns/tests/sanity/extra/no-unwanted-files.json.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/extra/no-unwanted-files.json.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/tests/sanity/extra/no-unwanted-files.py b/ansible_collections/community/dns/tests/sanity/extra/no-unwanted-files.py new file mode 100755 index 000000000..ebb73d0d4 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/extra/no-unwanted-files.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +"""Prevent unwanted files from being added to the source tree.""" +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import os +import os.path +import sys + + +def main(): + """Main entry point.""" + paths = sys.argv[1:] or sys.stdin.read().splitlines() + + allowed_extensions = ( + '.cs', + '.ps1', + '.psm1', + '.py', + ) + + skip_paths = set([ + ]) + + skip_directories = ( + ) + + yaml_directories = ( + 'plugins/test/', + 'plugins/filter/', + ) + + for path in paths: + if path in skip_paths: + continue + + if any(path.startswith(skip_directory) for skip_directory in skip_directories): + continue + + if os.path.islink(path): + # Enable this once we no longer support Ansible 2.9: + # print('%s: is a symbolic link' % (path, )) + pass + elif not os.path.isfile(path): + print('%s: is not a regular file' % (path, )) + + ext = os.path.splitext(path)[1] + + if ext in ('.yml', ) and any(path.startswith(yaml_directory) for yaml_directory in yaml_directories): + continue + + if ext not in allowed_extensions: + print('%s: extension must be one of: %s' % (path, ', '.join(allowed_extensions))) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/tests/sanity/extra/update-docs-fragments.json b/ansible_collections/community/dns/tests/sanity/extra/update-docs-fragments.json new file mode 100644 index 000000000..e44922799 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/extra/update-docs-fragments.json @@ -0,0 +1,12 @@ +{ + "include_symlinks": false, + "prefixes": [ + "plugins/doc_fragments/", + "plugins/module_utils/" + ], + "output": "path-message", + "requirements": [ + "PyYAML", + "ansible-core" + ] +} diff --git a/ansible_collections/community/dns/tests/sanity/extra/update-docs-fragments.json.license b/ansible_collections/community/dns/tests/sanity/extra/update-docs-fragments.json.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/extra/update-docs-fragments.json.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/tests/sanity/extra/update-docs-fragments.py b/ansible_collections/community/dns/tests/sanity/extra/update-docs-fragments.py new file mode 100644 index 000000000..707a52487 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/extra/update-docs-fragments.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +# Copyright (c) 2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later +"""Run update-docs-fragments.py --lint.""" + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import sys +import subprocess + + +def main(): + """Main entry point.""" + p = subprocess.run([sys.executable, 'update-docs-fragments.py', '--lint'], check=False) + if p.returncode not in (0, 5): + print('{0}:0:0: unexpected return code {1}'.format(sys.argv[0], p.returncode)) + + +if __name__ == '__main__': + main() diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.10.txt b/ansible_collections/community/dns/tests/sanity/ignore-2.10.txt new file mode 100644 index 000000000..dc9da1161 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.10.txt @@ -0,0 +1 @@ +plugins/public_suffix_list.dat no-smart-quotes diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.10.txt.license b/ansible_collections/community/dns/tests/sanity/ignore-2.10.txt.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.10.txt.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.11.txt b/ansible_collections/community/dns/tests/sanity/ignore-2.11.txt new file mode 100644 index 000000000..dc9da1161 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.11.txt @@ -0,0 +1 @@ +plugins/public_suffix_list.dat no-smart-quotes diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.11.txt.license b/ansible_collections/community/dns/tests/sanity/ignore-2.11.txt.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.11.txt.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.12.txt b/ansible_collections/community/dns/tests/sanity/ignore-2.12.txt new file mode 100644 index 000000000..dc9da1161 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.12.txt @@ -0,0 +1 @@ +plugins/public_suffix_list.dat no-smart-quotes diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.12.txt.license b/ansible_collections/community/dns/tests/sanity/ignore-2.12.txt.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.12.txt.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.13.txt b/ansible_collections/community/dns/tests/sanity/ignore-2.13.txt new file mode 100644 index 000000000..dc9da1161 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.13.txt @@ -0,0 +1 @@ +plugins/public_suffix_list.dat no-smart-quotes diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.13.txt.license b/ansible_collections/community/dns/tests/sanity/ignore-2.13.txt.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.13.txt.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.14.txt b/ansible_collections/community/dns/tests/sanity/ignore-2.14.txt new file mode 100644 index 000000000..dc9da1161 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.14.txt @@ -0,0 +1 @@ +plugins/public_suffix_list.dat no-smart-quotes diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.14.txt.license b/ansible_collections/community/dns/tests/sanity/ignore-2.14.txt.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.14.txt.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.15.txt b/ansible_collections/community/dns/tests/sanity/ignore-2.15.txt new file mode 100644 index 000000000..dc9da1161 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.15.txt @@ -0,0 +1 @@ +plugins/public_suffix_list.dat no-smart-quotes diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.15.txt.license b/ansible_collections/community/dns/tests/sanity/ignore-2.15.txt.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.15.txt.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.16.txt b/ansible_collections/community/dns/tests/sanity/ignore-2.16.txt new file mode 100644 index 000000000..dc9da1161 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.16.txt @@ -0,0 +1 @@ +plugins/public_suffix_list.dat no-smart-quotes diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.16.txt.license b/ansible_collections/community/dns/tests/sanity/ignore-2.16.txt.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.16.txt.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.9.txt b/ansible_collections/community/dns/tests/sanity/ignore-2.9.txt new file mode 100644 index 000000000..dc9da1161 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.9.txt @@ -0,0 +1 @@ +plugins/public_suffix_list.dat no-smart-quotes diff --git a/ansible_collections/community/dns/tests/sanity/ignore-2.9.txt.license b/ansible_collections/community/dns/tests/sanity/ignore-2.9.txt.license new file mode 100644 index 000000000..edff8c768 --- /dev/null +++ b/ansible_collections/community/dns/tests/sanity/ignore-2.9.txt.license @@ -0,0 +1,3 @@ +GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +SPDX-License-Identifier: GPL-3.0-or-later +SPDX-FileCopyrightText: Ansible Project diff --git a/ansible_collections/community/dns/tests/unit/plugins/inventory/test_hetzner_dns_records.py b/ansible_collections/community/dns/tests/unit/plugins/inventory/test_hetzner_dns_records.py new file mode 100644 index 000000000..3b01de586 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/inventory/test_hetzner_dns_records.py @@ -0,0 +1,529 @@ +# Copyright (c), Felix Fontein <felix@fontein.de>, 2021 +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import os +import textwrap + +from ansible import constants as C +from ansible.inventory.manager import InventoryManager +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.internal_test_tools.tests.unit.mock.path import mock_unfrackpath_noop +from ansible_collections.community.internal_test_tools.tests.unit.mock.loader import DictDataLoader +from ansible_collections.community.internal_test_tools.tests.unit.utils.open_url_framework import ( + OpenUrlCall, + OpenUrlProxy, +) + + +HETZNER_DEFAULT_ZONE = { + 'id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + 'legacy_dns_host': 'string', + 'legacy_ns': ['string'], + 'name': 'example.com', + 'ns': ['string'], + 'owner': 'Example', + 'paused': True, + 'permission': 'string', + 'project': 'string', + 'registrar': 'string', + 'status': 'verified', + 'ttl': 10800, + 'verified': '2021-07-09T11:18:37Z', + 'records_count': 0, + 'is_secondary_dns': True, + 'txt_verification': { + 'name': 'string', + 'token': 'string', + }, +} + +HETZNER_JSON_DEFAULT_ENTRIES = [ + { + 'id': '125', + 'type': 'A', + 'name': '@', + 'value': '1.2.3.4', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '126', + 'type': 'A', + 'name': '*', + 'value': '1.2.3.5', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '127', + 'type': 'AAAA', + 'name': '@', + 'value': '2001:1:2::3', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '128', + 'type': 'AAAA', + 'name': 'foo', + 'value': '2001:1:2::4', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '129', + 'type': 'MX', + 'name': '@', + 'value': '10 example.com', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '130', + 'type': 'CNAME', + 'name': 'bar', + 'value': 'example.org.', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, +] + +HETZNER_JSON_BAD_ENTRIES = [ + { + 'id': '125', + 'type': 'TXT', + 'name': '@', + 'value': '"this is wrongly quoted', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, +] + +HETZNER_JSON_ZONE_LIST_RESULT = { + 'zones': [ + HETZNER_DEFAULT_ZONE, + ], +} + +HETZNER_JSON_ZONE_GET_RESULT = { + 'zone': HETZNER_DEFAULT_ZONE, +} + +HETZNER_JSON_ZONE_RECORDS_GET_RESULT = { + 'records': HETZNER_JSON_DEFAULT_ENTRIES, +} + +HETZNER_JSON_ZONE_RECORDS_GET_RESULT_2 = { + 'records': HETZNER_JSON_BAD_ENTRIES, +} + + +original_exists = os.path.exists +original_access = os.access + + +def exists_mock(path, exists=True): + def exists(f): + if to_native(f) == path: + return exists + return original_exists(f) + + return exists + + +def access_mock(path, can_access=True): + def access(f, m, *args, **kwargs): + if to_native(f) == path: + return can_access + return original_access(f, m, *args, **kwargs) + + return access + + +def test_inventory_file_simple(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + zone_name: example.com + filters: + type: A + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert im._inventory.hosts + assert 'example.com' in im._inventory.hosts + assert '*.example.com' in im._inventory.hosts + assert 'foo.example.com' not in im._inventory.hosts + assert 'bar.example.com' not in im._inventory.hosts + assert im._inventory.get_host('example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('*.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('example.com').get_vars()['ansible_host'] == '1.2.3.4' + assert im._inventory.get_host('*.example.com').get_vars()['ansible_host'] == '1.2.3.5' + assert len(im._inventory.groups['ungrouped'].hosts) == 2 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_collision(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: '{{ "foo" }}' + zone_name: '{{ "example." ~ "com" }}' + filters: + type: + - A + - AAAA + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert im._inventory.hosts + assert 'example.com' in im._inventory.hosts + assert '*.example.com' in im._inventory.hosts + assert 'foo.example.com' in im._inventory.hosts + assert 'bar.example.com' not in im._inventory.hosts + assert im._inventory.get_host('example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('*.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('foo.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('example.com').get_vars()['ansible_host'] == '2001:1:2::3' + assert im._inventory.get_host('*.example.com').get_vars()['ansible_host'] == '1.2.3.5' + assert im._inventory.get_host('foo.example.com').get_vars()['ansible_host'] == '2001:1:2::4' + assert len(im._inventory.groups['ungrouped'].hosts) == 3 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_no_filter(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + zone_id: '42' + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert im._inventory.hosts + assert 'example.com' in im._inventory.hosts + assert '*.example.com' in im._inventory.hosts + assert 'foo.example.com' in im._inventory.hosts + assert 'bar.example.com' in im._inventory.hosts + assert im._inventory.get_host('example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('*.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('foo.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('bar.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('example.com').get_vars()['ansible_host'] == '2001:1:2::3' + assert im._inventory.get_host('*.example.com').get_vars()['ansible_host'] == '1.2.3.5' + assert im._inventory.get_host('foo.example.com').get_vars()['ansible_host'] == '2001:1:2::4' + assert im._inventory.get_host('bar.example.com').get_vars()['ansible_host'] == 'example.org.' + assert len(im._inventory.groups['ungrouped'].hosts) == 4 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_record_conversion_error(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + zone_id: "{{ '42' }}" + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT_2), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_missing_zone(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + """)} + + open_url = OpenUrlProxy([ + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_zone_not_found(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + zone_id: '23' + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_unauthorized(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + zone_id: '23' + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .result_json(dict(message="")), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_error(mocker): + inventory_filename = "test.hetzner_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + zone_id: '42' + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .result_json({}), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_wrong_file(mocker): + inventory_filename = "test.hetznerdns.yml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hetzner_dns_records + hetzner_token: foo + """)} + + open_url = OpenUrlProxy([]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_no_file(mocker): + inventory_filename = "test.hetzner_dns.yml" + C.INVENTORY_ENABLED = ['community.dns.hetzner_dns_records'] + + open_url = OpenUrlProxy([]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename, False)) + mocker.patch('os.access', access_mock(inventory_filename, False)) + im = InventoryManager(loader=DictDataLoader({}), sources=inventory_filename) + + open_url.assert_is_done() + + # TODO: make sure that the correct error was reported + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 diff --git a/ansible_collections/community/dns/tests/unit/plugins/inventory/test_hosttech_dns_records.py b/ansible_collections/community/dns/tests/unit/plugins/inventory/test_hosttech_dns_records.py new file mode 100644 index 000000000..a7adb2c09 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/inventory/test_hosttech_dns_records.py @@ -0,0 +1,465 @@ +# Copyright (c), Felix Fontein <felix@fontein.de>, 2021 +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import os +import textwrap + +from ansible import constants as C +from ansible.inventory.manager import InventoryManager +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.internal_test_tools.tests.unit.mock.path import mock_unfrackpath_noop +from ansible_collections.community.internal_test_tools.tests.unit.mock.loader import DictDataLoader +from ansible_collections.community.internal_test_tools.tests.unit.utils.open_url_framework import ( + OpenUrlCall, + OpenUrlProxy, +) + + +HOSTTECH_WSDL_DEFAULT_ENTRIES = [ + (125, 42, 'A', '', '1.2.3.4', 3600, None, None), + (126, 42, 'A', '*', '1.2.3.5', 3600, None, None), + (127, 42, 'AAAA', '', '2001:1:2::3', 3600, None, None), + (128, 42, 'AAAA', 'foo', '2001:1:2::4', 3600, None, None), + (129, 42, 'MX', '', 'example.com', 3600, None, '10'), + (130, 42, 'CNAME', 'bar', 'example.org.', 10800, None, None), +] + +HOSTTECH_JSON_DEFAULT_ENTRIES = [ + # (125, 42, 'A', '', '1.2.3.4', 3600, None, None), + { + 'id': 125, + 'type': 'A', + 'name': '', + 'ipv4': '1.2.3.4', + 'ttl': 3600, + 'comment': '', + }, + # (126, 42, 'A', '*', '1.2.3.5', 3600, None, None), + { + 'id': 126, + 'type': 'A', + 'name': '*', + 'ipv4': '1.2.3.5', + 'ttl': 3600, + 'comment': '', + }, + # (127, 42, 'AAAA', '', '2001:1:2::3', 3600, None, None), + { + 'id': 127, + 'type': 'AAAA', + 'name': '', + 'ipv6': '2001:1:2::3', + 'ttl': 3600, + 'comment': '', + }, + # (128, 42, 'AAAA', '*', '2001:1:2::4', 3600, None, None), + { + 'id': 128, + 'type': 'AAAA', + 'name': 'foo', + 'ipv6': '2001:1:2::4', + 'ttl': 3600, + 'comment': '', + }, + # (129, 42, 'MX', '', 'example.com', 3600, None, '10'), + { + 'id': 129, + 'type': 'MX', + 'ownername': '', + 'name': 'example.com', + 'pref': 10, + 'ttl': 3600, + 'comment': '', + }, + # (130, 42, 'CNAME', 'bar', 'example.org.', 10800, None, None), + { + 'id': 130, + 'type': 'CNAME', + 'name': 'bar', + 'cname': 'example.org.', + 'ttl': 10800, + 'comment': '', + }, +] + + +HOSTTECH_JSON_ZONE_LIST_RESULT = { + "data": [ + { + "id": 42, + "name": "example.com", + "email": "test@example.com", + "ttl": 10800, + "nameserver": "ns1.hosttech.ch", + "dnssec": False, + }, + ], +} + +HOSTTECH_JSON_ZONE_GET_RESULT = { + "data": { + "id": 42, + "name": "example.com", + "email": "test@example.com", + "ttl": 10800, + "nameserver": "ns1.hosttech.ch", + "dnssec": False, + "records": HOSTTECH_JSON_DEFAULT_ENTRIES, + } +} + +HOSTTECH_JSON_ZONE_RECORDS_GET_RESULT = { + "data": HOSTTECH_JSON_DEFAULT_ENTRIES, +} + + +original_exists = os.path.exists +original_access = os.access + + +def exists_mock(path, exists=True): + def exists(f): + if to_native(f) == path: + return exists + return original_exists(f) + + return exists + + +def access_mock(path, can_access=True): + def access(f, m, *args, **kwargs): + if to_native(f) == path: + return can_access + return original_access(f, m, *args, **kwargs) + + return access + + +def test_inventory_file_simple(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + zone_name: example.com + filters: + type: A + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert im._inventory.hosts + assert 'example.com' in im._inventory.hosts + assert '*.example.com' in im._inventory.hosts + assert 'foo.example.com' not in im._inventory.hosts + assert 'bar.example.com' not in im._inventory.hosts + assert im._inventory.get_host('example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('*.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('example.com').get_vars()['ansible_host'] == '1.2.3.4' + assert im._inventory.get_host('*.example.com').get_vars()['ansible_host'] == '1.2.3.5' + assert len(im._inventory.groups['ungrouped'].hosts) == 2 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_collision(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: "{{ 'foo' }}" + zone_name: "{{ 'example' ~ '.com' }}" + filters: + type: + - A + - AAAA + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert im._inventory.hosts + assert 'example.com' in im._inventory.hosts + assert '*.example.com' in im._inventory.hosts + assert 'foo.example.com' in im._inventory.hosts + assert 'bar.example.com' not in im._inventory.hosts + assert im._inventory.get_host('example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('*.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('foo.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('example.com').get_vars()['ansible_host'] == '2001:1:2::3' + assert im._inventory.get_host('*.example.com').get_vars()['ansible_host'] == '1.2.3.5' + assert im._inventory.get_host('foo.example.com').get_vars()['ansible_host'] == '2001:1:2::4' + assert len(im._inventory.groups['ungrouped'].hosts) == 3 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_no_filter(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + zone_id: 42 + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert im._inventory.hosts + assert 'example.com' in im._inventory.hosts + assert '*.example.com' in im._inventory.hosts + assert 'foo.example.com' in im._inventory.hosts + assert 'bar.example.com' in im._inventory.hosts + assert im._inventory.get_host('example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('*.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('foo.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('bar.example.com') in im._inventory.groups['ungrouped'].hosts + assert im._inventory.get_host('example.com').get_vars()['ansible_host'] == '2001:1:2::3' + assert im._inventory.get_host('*.example.com').get_vars()['ansible_host'] == '1.2.3.5' + assert im._inventory.get_host('foo.example.com').get_vars()['ansible_host'] == '2001:1:2::4' + assert im._inventory.get_host('bar.example.com').get_vars()['ansible_host'] == 'example.org.' + assert len(im._inventory.groups['ungrouped'].hosts) == 4 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_invalid_zone_id(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + zone_id: invalid + """)} + + open_url = OpenUrlProxy([ + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_missing_zone(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + """)} + + open_url = OpenUrlProxy([ + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_zone_not_found(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + zone_id: "{{ 11 + 12 }}" + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_unauthorized(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + zone_id: 23 + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_file_error(mocker): + inventory_filename = "test.hosttech_dns.yaml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + zone_id: 42 + """)} + + open_url = OpenUrlProxy([ + OpenUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .result_json({}), + ]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_wrong_file(mocker): + inventory_filename = "test.hetznerdns.yml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + inventory_file = {inventory_filename: textwrap.dedent("""\ + --- + plugin: community.dns.hosttech_dns_records + hosttech_token: foo + """)} + + open_url = OpenUrlProxy([]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename)) + mocker.patch('os.access', access_mock(inventory_filename)) + im = InventoryManager(loader=DictDataLoader(inventory_file), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 + + +def test_inventory_no_file(mocker): + inventory_filename = "test.hosttech_dns.yml" + C.INVENTORY_ENABLED = ['community.dns.hosttech_dns_records'] + + open_url = OpenUrlProxy([]) + mocker.patch('ansible_collections.community.dns.plugins.module_utils.http.open_url', open_url) + mocker.patch('ansible.inventory.manager.unfrackpath', mock_unfrackpath_noop) + mocker.patch('os.path.exists', exists_mock(inventory_filename, False)) + mocker.patch('os.access', access_mock(inventory_filename, False)) + im = InventoryManager(loader=DictDataLoader({}), sources=inventory_filename) + + open_url.assert_is_done() + + assert not im._inventory.hosts + assert len(im._inventory.groups['ungrouped'].hosts) == 0 + assert len(im._inventory.groups['all'].hosts) == 0 diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/conversion/test_converter.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/conversion/test_converter.py new file mode 100644 index 000000000..e9911ad73 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/conversion/test_converter.py @@ -0,0 +1,260 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.dns.plugins.module_utils.conversion.base import ( + DNSConversionError, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.converter import ( + RecordConverter, +) + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, +) + +from ..helper import ( + CustomProviderInformation, + CustomProvideOptions, +) + + +def test_user_api(): + converter = RecordConverter( + CustomProviderInformation(txt_record_handling='decoded', txt_character_encoding='decimal'), + CustomProvideOptions({'txt_transformation': 'api', 'txt_character_encoding': 'decimal'})) + assert converter.process_value_from_user('TXT', u'"xyz \\') == u'"xyz \\' + assert converter.process_values_from_user('TXT', [u'"xyz \\']) == [u'"xyz \\'] + assert converter.process_value_to_user('TXT', u'"xyz \\') == u'"xyz \\' + assert converter.process_values_to_user('TXT', [u'"xyz \\']) == [u'"xyz \\'] + + record = DNSRecord() + record.type = 'TXT' + + record.target = u'"xyz \\' + converter.process_from_user(record) + assert record.target == u'"xyz \\' + + record.target = u'"xyz \\' + converter.process_multiple_from_user([record]) + assert record.target == u'"xyz \\' + + record.target = u'"xyz \\' + converter.process_to_user(record) + assert record.target == u'"xyz \\' + + record.target = u'"xyz \\' + converter.process_multiple_to_user([record]) + assert record.target == u'"xyz \\' + + +def test_user_quoted(): + converter = RecordConverter( + CustomProviderInformation(txt_record_handling='decoded', txt_character_encoding='decimal'), + CustomProvideOptions({'txt_transformation': 'quoted', 'txt_character_encoding': 'decimal'})) + assert converter.process_value_from_user('TXT', u'hëllo " w\\195\\182rld"') == u'hëllo wörld' + assert converter.process_values_from_user('TXT', [u'hëllo " w\\195\\182rld"']) == [u'hëllo wörld'] + assert converter.process_value_to_user('TXT', u'hello wörld') == u'"hello w\\195\\182rld"' + assert converter.process_values_to_user('TXT', [u'hello wörld']) == [u'"hello w\\195\\182rld"'] + + record = DNSRecord() + record.type = 'TXT' + + record.target = u'hëllo " w\\195\\182rld"' + converter.process_from_user(record) + assert record.target == u'hëllo wörld' + + record.target = u'hëllo " w\\195\\182rld"' + converter.process_multiple_from_user([record]) + assert record.target == u'hëllo wörld' + + record.target = u'hello wörld' + converter.process_to_user(record) + assert record.target == u'"hello w\\195\\182rld"' + + record.target = u'hello wörld' + converter.process_multiple_to_user([record]) + assert record.target == u'"hello w\\195\\182rld"' + + record.target = u'"a\\o' + with pytest.raises(DNSConversionError) as exc: + converter.process_from_user(record) + print(exc.value.error_message) + assert exc.value.error_message == ( + u'While processing record from the user: A backslash must not be followed by "o" (index 4)' + ) + + +def test_user_unquoted(): + converter = RecordConverter( + CustomProviderInformation(txt_record_handling='decoded', txt_character_encoding='decimal'), + CustomProvideOptions({'txt_transformation': 'unquoted', 'txt_character_encoding': 'decimal'})) + assert converter.process_value_from_user('TXT', u'hello "wörl\\d"') == u'hello "wörl\\d"' + assert converter.process_values_from_user('TXT', [u'hello "wörl\\d"']) == [u'hello "wörl\\d"'] + assert converter.process_value_to_user('TXT', u'hello "wörl\\d"') == u'hello "wörl\\d"' + assert converter.process_values_to_user('TXT', [u'hello "wörl\\d"']) == [u'hello "wörl\\d"'] + + record = DNSRecord() + record.type = 'TXT' + + record.target = u'hello "wörl\\d"' + converter.process_from_user(record) + assert record.target == u'hello "wörl\\d"' + + record.target = u'hello "wörl\\d"' + converter.process_multiple_from_user([record]) + assert record.target == u'hello "wörl\\d"' + + record.target = u'hello "wörl\\d"' + converter.process_to_user(record) + assert record.target == u'hello "wörl\\d"' + + record.target = u'hello "wörl\\d"' + converter.process_multiple_to_user([record]) + assert record.target == u'hello "wörl\\d"' + + +def test_api_decoded(): + converter = RecordConverter( + CustomProviderInformation(txt_record_handling='decoded', txt_character_encoding='decimal'), + CustomProvideOptions({'txt_transformation': 'unquoted', 'txt_character_encoding': 'decimal'})) + record = DNSRecord() + record.type = 'TXT' + + record.target = u'"xyz \\' + record_2 = converter.clone_from_api(record) + assert record is not record_2 + assert record.target == u'"xyz \\' + assert record_2.target == u'"xyz \\' + converter.process_from_api(record) + assert record.target == u'"xyz \\' + + record.target = u'"xyz \\' + records = converter.clone_multiple_from_api([record]) + assert len(records) == 1 + assert record is not records[0] + assert record.target == u'"xyz \\' + assert records[0].target == u'"xyz \\' + converter.process_multiple_from_api([record]) + assert record.target == u'"xyz \\' + + record.target = u'"xyz \\' + record_2 = converter.clone_to_api(record) + assert record is not record_2 + assert record.target == u'"xyz \\' + assert record_2.target == u'"xyz \\' + converter.process_to_api(record) + assert record.target == u'"xyz \\' + + record.target = u'"xyz \\' + records = converter.clone_multiple_to_api([record]) + assert len(records) == 1 + assert record is not records[0] + assert record.target == u'"xyz \\' + assert records[0].target == u'"xyz \\' + converter.process_multiple_to_api([record]) + assert record.target == u'"xyz \\' + + +def test_api_encoded(): + converter = RecordConverter( + CustomProviderInformation(txt_record_handling='encoded', txt_character_encoding='decimal'), + CustomProvideOptions({'txt_transformation': 'unquoted', 'txt_character_encoding': 'decimal'})) + record = DNSRecord() + record.type = 'TXT' + + record.target = u'xyz " " \\\\\\195\\182' + record_2 = converter.clone_from_api(record) + assert record is not record_2 + assert record.target == u'xyz " " \\\\\\195\\182' + print(record_2.target) + assert record_2.target == u'xyz \\ö' + converter.process_from_api(record) + assert record.target == u'xyz \\ö' + + record.target = u'xyz " " \\\\\\195\\182' + records = converter.clone_multiple_from_api([record]) + assert len(records) == 1 + assert record is not records[0] + assert record.target == u'xyz " " \\\\\\195\\182' + assert records[0].target == u'xyz \\ö' + converter.process_multiple_from_api([record]) + assert record.target == u'xyz \\ö' + + record.target = u'xyz \\ö' + record_2 = converter.clone_to_api(record) + assert record is not record_2 + assert record.target == u'xyz \\ö' + assert record_2.target == u'"xyz \\\\\\195\\182"' + converter.process_to_api(record) + assert record.target == u'"xyz \\\\\\195\\182"' + + record.target = u'xyz \\ö' + records = converter.clone_multiple_to_api([record]) + assert len(records) == 1 + assert record is not records[0] + assert record.target == u'xyz \\ö' + assert records[0].target == u'"xyz \\\\\\195\\182"' + converter.process_multiple_to_api([record]) + assert record.target == u'"xyz \\\\\\195\\182"' + + record.target = u'"a' + with pytest.raises(DNSConversionError) as exc: + converter.process_from_api(record) + print(exc.value.error_message) + assert exc.value.error_message == ( + u'While processing record from API: Missing double quotation mark at the end of value' + ) + + +def test_api_encoded_no_octal(): + converter = RecordConverter( + CustomProviderInformation(txt_record_handling='encoded-no-octal', txt_character_encoding='decimal'), + CustomProvideOptions({'txt_transformation': 'unquoted', 'txt_character_encoding': 'decimal'})) + record = DNSRecord() + record.type = 'TXT' + + record.target = u'xyz " " \\\\\\195\\182' + record_2 = converter.clone_from_api(record) + assert record is not record_2 + assert record.target == u'xyz " " \\\\\\195\\182' + print(record_2.target) + assert record_2.target == u'xyz \\ö' + converter.process_from_api(record) + assert record.target == u'xyz \\ö' + + record.target = u'xyz " " \\\\\\195\\182' + records = converter.clone_multiple_from_api([record]) + assert len(records) == 1 + assert record is not records[0] + assert record.target == u'xyz " " \\\\\\195\\182' + assert records[0].target == u'xyz \\ö' + converter.process_multiple_from_api([record]) + assert record.target == u'xyz \\ö' + + record.target = u'xyz \\ö"' + record_2 = converter.clone_to_api(record) + assert record is not record_2 + assert record.target == u'xyz \\ö"' + assert record_2.target == u'"xyz \\\\ö\\""' + converter.process_to_api(record) + assert record.target == u'"xyz \\\\ö\\""' + + record.target = u'xyz \\ö"' + records = converter.clone_multiple_to_api([record]) + assert len(records) == 1 + assert record is not records[0] + assert record.target == u'xyz \\ö"' + assert records[0].target == u'"xyz \\\\ö\\""' + converter.process_multiple_to_api([record]) + assert record.target == u'"xyz \\\\ö\\""' diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/conversion/test_txt.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/conversion/test_txt.py new file mode 100644 index 000000000..e5984444b --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/conversion/test_txt.py @@ -0,0 +1,277 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import warnings + +import pytest + +from ansible_collections.community.dns.plugins.module_utils.conversion.base import ( + DNSConversionError, +) + +from ansible_collections.community.dns.plugins.module_utils.conversion.txt import ( + _get_utf8_length, + decode_txt_value, + encode_txt_value, +) + + +TEST_DECODE = [ + (r'', 'decimal', u''), + (r'"" "" ""', 'decimal', u''), + (r' "" "" ', 'decimal', u''), + (r'\032\033', 'decimal', u' !'), + (r'"\032" \033 ""', 'decimal', u' !'), + (r'\040\041', 'octal', u' !'), + (r'"\040" \041 ""', 'octal', u' !'), +] + + +@pytest.mark.parametrize("encoded, character_encoding, decoded", TEST_DECODE) +def test_decode(encoded, character_encoding, decoded): + decoded_ = decode_txt_value(encoded, character_encoding=character_encoding) + print(repr(decoded_), repr(decoded)) + assert decoded_ == decoded + + +TEST_GET_UTF8_LENGTH = [ + # See https://en.wikipedia.org/wiki/UTF-8#Examples + (0xC2, 2), # first byte of UTF-8 encoding of U+0024 + (0xC3, 2), # first byte of UTF-8 encoding of ä + (0xE0, 3), # first byte of UTF-8 encoding of U+0939 + (0xE2, 3), # first byte of UTF-8 encoding of U+20AC + (0xED, 3), # first byte of UTF-8 encoding of U+D55C + (0xF0, 4), # first byte of UTF-8 encoding of U+10348 + (0x00, 1), + (0xFF, 1), +] + + +@pytest.mark.parametrize("letter_code, length", TEST_GET_UTF8_LENGTH) +def test_get_utf8_length(letter_code, length): + length_ = _get_utf8_length(letter_code) + print(length_, length) + assert length_ == length + + +TEST_ENCODE_DECODE = [ + (u'', u'""', False, True, 'decimal'), + (u'', u'""', True, True, 'decimal'), + (u'Hi', u'Hi', False, True, 'decimal'), + (u'Hi', u'"Hi"', True, True, 'decimal'), + (u'"\\', u'\\\"\\\\', False, True, 'decimal'), + (u'"\\', u'"\\"\\\\"', True, True, 'decimal'), + (u'ä', u'ä', False, False, 'decimal'), + (u'ä', u'"ä"', True, False, 'decimal'), + (u'ä', u'\\195\\164', False, True, 'decimal'), + (u'ä', u'"\\195\\164"', True, True, 'decimal'), + (u'a b', u'"a b"', False, True, 'decimal'), + (u'a b', u'"a b"', True, True, 'decimal'), + ( + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuv' + u'wxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefg hijklmnopqrstu' + u'vwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + False, True, 'decimal' + ), + ( + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuv' + u'wxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + u'"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzA' + u'BCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012' + u'3456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefg" "hijklmnopqr' + u'stuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"', + True, True, 'decimal' + ), + ( + u'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzA' + u'BCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012' + u'3456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstu' + u'vwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + u'"abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz' + u'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01' + u'23456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef" ghijklmnopqr' + u'stuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + False, True, 'decimal' + ), + ( + u'abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzA' + u'BCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012' + u'3456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstu' + u'vwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', + u'"abcdefghijklmnopqrstuvwxyz ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyz' + u'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ01' + u'23456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef" "ghijklmnopq' + u'rstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"', + True, True, 'decimal' + ), + ( + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefg', + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefg', + False, True, 'decimal' + ), + ( + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefg', + u'"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzA' + u'BCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012' + u'3456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefg"', + True, True, 'decimal' + ), + ( + # Avoid splitting up an decimal sequence into multiple TXT strings + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789aä', + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789a\\195 \\164', + False, True, 'decimal' + ), + ( + # Avoid splitting up a UTF-8 character into multiple TXT strings + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefä', + u'"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzA' + u'BCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012' + u'3456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef" "ä"', + True, False, 'decimal' + ), + ( + # Avoid splitting up an octal sequence into multiple TXT strings + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789aä', + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789a\\303 \\244', + False, True, 'octal' + ), + ( + # Avoid splitting up a UTF-8 character into multiple TXT strings + u'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzAB' + u'CDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123' + u'456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefä', + u'"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzA' + u'BCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ012' + u'3456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789abcdef" "ä"', + True, False, 'octal' + ), +] + + +@pytest.mark.parametrize("decoded, encoded, always_quote, use_character_encoding, character_encoding", TEST_ENCODE_DECODE) +def test_encode_decode(decoded, encoded, always_quote, use_character_encoding, character_encoding): + decoded_ = decode_txt_value(encoded, character_encoding=character_encoding) + print(repr(decoded_), repr(decoded)) + assert decoded_ == decoded + encoded_ = encode_txt_value(decoded, always_quote=always_quote, use_character_encoding=use_character_encoding, character_encoding=character_encoding) + print(repr(encoded_), repr(encoded)) + assert encoded_ == encoded + + +TEST_DECODE_ERROR = [ + (u'\\', 'decimal', 'Unexpected backslash at end of string'), + (u'\\a', 'decimal', 'A backslash must not be followed by "a" (index 2)'), + (u'\\0', 'decimal', 'The decimal sequence at the end requires 2 more digit(s)'), + (u'\\00', 'decimal', 'The decimal sequence at the end requires 1 more digit(s)'), + (u'\\0a', 'decimal', 'The decimal sequence at the end requires 1 more digit(s)'), + (u'\\0a0', 'decimal', 'The second letter of the decimal sequence at index 3 is not a decimal digit, but "a"'), + (u'\\00a', 'decimal', 'The third letter of the decimal sequence at index 4 is not a decimal digit, but "a"'), + (u'\\0', 'octal', 'The octal sequence at the end requires 2 more digit(s)'), + (u'\\00', 'octal', 'The octal sequence at the end requires 1 more digit(s)'), + (u'\\0a', 'octal', 'The octal sequence at the end requires 1 more digit(s)'), + (u'\\0a0', 'octal', 'The second letter of the octal sequence at index 3 is not a octal digit, but "a"'), + (u'\\00a', 'octal', 'The third letter of the octal sequence at index 4 is not a octal digit, but "a"'), + (u'a"b', 'decimal', 'Unexpected double quotation mark inside an unquoted block at position 2'), + (u'"', 'decimal', 'Missing double quotation mark at the end of value'), +] + + +@pytest.mark.parametrize("encoded, character_encoding, error", TEST_DECODE_ERROR) +def test_decode_error(encoded, character_encoding, error): + with pytest.raises(DNSConversionError) as exc: + decode_txt_value(encoded, character_encoding=character_encoding) + print(exc.value.error_message) + assert exc.value.error_message == error + + +def test_validation(): + with pytest.raises(ValueError) as exc: + decode_txt_value('foo', character_encoding='foo') + print(exc.value.args) + assert exc.value.args == ('character_encoding must be set to "octal" or "decimal"', ) + + with pytest.raises(ValueError) as exc: + encode_txt_value('foo', character_encoding='foo') + print(exc.value.args) + assert exc.value.args == ('character_encoding must be set to "octal" or "decimal"', ) + + +def test_deprecation(): + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + encode_txt_value('foo') + + print(len(w), w) + assert len(w) >= 1 + warning = w[0] + assert issubclass(warning.category, DeprecationWarning) + msg = ( + 'The default value of the encode_txt_value parameter character_encoding is deprecated.' + ' Set explicitly to "octal" for the old behavior, or set to "decimal" for the new and correct behavior.' + ) + print(str(warning.message)) + assert msg == str(warning.message) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + encode_txt_value('foo', use_octal=True, character_encoding='octal') + + print(len(w), w) + assert len(w) >= 1 + warning = w[0] + assert issubclass(warning.category, DeprecationWarning) + msg = 'The encode_txt_value parameter use_octal is deprecated. Use use_character_encoding instead.' + print(str(warning.message)) + assert msg == str(warning.message) + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + decode_txt_value('foo') + + print(len(w), w) + assert len(w) >= 1 + warning = w[0] + assert issubclass(warning.category, DeprecationWarning) + msg = ( + 'The default value of the decode_txt_value parameter character_encoding is deprecated.' + ' Set explicitly to "octal" for the old behavior, or set to "decimal" for the new and correct behavior.' + ) + print(str(warning.message)) + assert msg == str(warning.message) + + with pytest.raises(ValueError) as exc: + encode_txt_value('foo', use_octal=True, use_character_encoding=True) + print(exc.value.args) + assert exc.value.args == ('Cannot use both use_character_encoding and use_octal. Use only use_character_encoding!', ) diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/helper.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/helper.py new file mode 100644 index 000000000..aa0f8d6e1 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/helper.py @@ -0,0 +1,47 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +from ansible_collections.community.dns.plugins.module_utils.provider import ( + ProviderInformation, +) + + +class CustomProviderInformation(ProviderInformation): + def __init__(self, txt_record_handling='decoded', txt_character_encoding='decimal'): + super(CustomProviderInformation, self).__init__() + self._txt_record_handling = txt_record_handling + self._txt_character_encoding = txt_character_encoding + + def get_supported_record_types(self): + return ['A'] + + def get_zone_id_type(self): + return 'str' + + def get_record_id_type(self): + return 'str' + + def get_record_default_ttl(self): + return 300 + + def txt_record_handling(self): + return self._txt_record_handling + + def txt_character_encoding(self): + return self._txt_character_encoding + + +class CustomProvideOptions(object): + def __init__(self, option_dict): + self._option_dict = option_dict + + def get_option(self, name): + return self._option_dict.get(name) diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/hetzner/test_api.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/hetzner/test_api.py new file mode 100644 index 000000000..c7d440857 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/hetzner/test_api.py @@ -0,0 +1,150 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, +) + +from ansible_collections.community.dns.plugins.module_utils.hetzner.api import ( + HetznerAPI, +) + + +def test_list_pagination(): + def get_1(url, query=None, must_have_content=True, expected=None): + assert url == 'https://example.com' + assert must_have_content == [200] + assert expected == [200] + assert query is not None + assert len(query) == 2 + assert query['per_page'] == 1 + assert query['page'] in [1, 2, 3] + if query['page'] < 3: + return { + 'data': [query['page']], + 'meta': { + 'pagination': { + 'page': query['page'], + 'per_page': 1, + 'last_page': 3, + 'total_entries': 2, + }, + }, + }, {'status': 200} + else: + return { + 'data': [], + 'meta': { + 'pagination': { + 'page': query['page'], + 'per_page': 1, + 'last_page': 3, + 'total_entries': 2, + }, + }, + }, {'status': 200} + + def get_2(url, query=None, must_have_content=True, expected=None): + assert url == 'https://example.com' + assert must_have_content == [200] + assert expected == ([200, 404] if query['page'] == 1 else [200]) + assert query is not None + assert len(query) == 3 + assert query['foo'] == 'bar' + assert query['per_page'] == 2 + assert query['page'] in [1, 2] + if query['page'] < 2: + return { + 'foobar': ['bar', 'baz'], + 'meta': { + 'pagination': { + 'page': query['page'], + 'per_page': 2, + 'last_page': 2, + 'total_entries': 3, + }, + }, + }, {'status': 200} + else: + return { + 'foobar': ['foo'], + 'meta': { + 'pagination': { + 'page': query['page'], + 'per_page': 2, + 'last_page': 2, + 'total_entries': 3, + }, + }, + }, {'status': 200} + + def get_3(url, query=None, must_have_content=True, expected=None): + assert url == 'https://example.com' + assert must_have_content == [200] + assert expected == [200, 404] + assert query is not None + assert len(query) == 2 + assert query['per_page'] == 100 + assert query['page'] == 1 + return None, {'status': 404} + + api = HetznerAPI(MagicMock(), '123') + + api._get = MagicMock(side_effect=get_1) + result = api._list_pagination('https://example.com', 'data', block_size=1, accept_404=False) + assert result == [1, 2] + + api._get = MagicMock(side_effect=get_2) + result = api._list_pagination('https://example.com', 'foobar', query=dict(foo='bar'), block_size=2, accept_404=True) + assert result == ['bar', 'baz', 'foo'] + + api._get = MagicMock(side_effect=get_3) + result = api._list_pagination('https://example.com', 'baz', accept_404=True) + assert result is None + + +def test_update_id_missing(): + api = HetznerAPI(MagicMock(), '123') + with pytest.raises(DNSAPIError) as exc: + api.update_record(1, DNSRecord()) + assert exc.value.args[0] == 'Need record ID to update record!' + + +def test_update_id_delete(): + api = HetznerAPI(MagicMock(), '123') + with pytest.raises(DNSAPIError) as exc: + api.delete_record(1, DNSRecord()) + assert exc.value.args[0] == 'Need record ID to delete record!' + + +def test_extract_error_message(): + api = HetznerAPI(MagicMock(), '123') + assert api._extract_error_message(None) == '' + assert api._extract_error_message('foo') == ' with data: foo' + assert api._extract_error_message(dict()) == ' with data: {}' + assert api._extract_error_message(dict(message='')) == " with data: {'message': ''}" + assert api._extract_error_message(dict(message='foo')) == ' with message "foo"' + assert api._extract_error_message(dict(message='foo', error='')) == ' with message "foo"' + assert api._extract_error_message(dict(message='foo', error=dict())) == ' with message "foo"' + assert api._extract_error_message(dict(message='foo', error=dict(code=123))) == ' (error code 123) with message "foo"' + assert api._extract_error_message(dict(message='foo', error=dict(message='baz'))) == ' with error message "baz" with message "foo"' + assert api._extract_error_message(dict(message='foo', error=dict(message='baz', code=123))) == ( + ' with error message "baz" (error code 123) with message "foo"' + ) + assert api._extract_error_message(dict(error=dict(message='baz', code=123))) == ' with error message "baz" (error code 123)' diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/hosttech/test_api.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/hosttech/test_api.py new file mode 100644 index 000000000..cd2ef3402 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/hosttech/test_api.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech import api + +from ..helper import ( + CustomProvideOptions, +) + + +def test_internal_error(): + option_provider = CustomProvideOptions({}) + with pytest.raises(DNSAPIError) as exc: + api.create_hosttech_api(option_provider, MagicMock()) + assert exc.value.args[0] == 'One of hosttech_token or both hosttech_username and hosttech_password must be provided!' + + +def test_wsdl_missing(): + option_provider = CustomProvideOptions({ + 'hosttech_username': 'foo', + 'hosttech_password': 'foo', + }) + old_value = api.HAS_LXML_ETREE + try: + api.HAS_LXML_ETREE = False + with pytest.raises(DNSAPIError) as exc: + api.create_hosttech_api(option_provider, MagicMock()) + assert exc.value.args[0] == 'Needs lxml Python module (pip install lxml)' + finally: + api.HAS_LXML_ETREE = old_value diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/hosttech/test_json_api.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/hosttech/test_json_api.py new file mode 100644 index 000000000..b1c6dad20 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/hosttech/test_json_api.py @@ -0,0 +1,381 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, +) + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, +) + +from ansible_collections.community.dns.plugins.module_utils.hosttech.json_api import ( + _create_record_from_json, + _record_to_json, + HostTechJSONAPI, +) + + +# The example JSONs for all record types are taken from https://api.ns1.hosttech.eu/api/documentation/ + +def test_AAAA(): + data = { + "id": 11, + "type": "AAAA", + "name": "www", + "ipv6": "2001:db8:1234::1", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 11 + assert record.type == 'AAAA' + assert record.prefix == 'www' + assert record.target == '2001:db8:1234::1' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + +def test_A(): + data = { + "id": 10, + "type": "A", + "name": "www", + "ipv4": "1.2.3.4", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 10 + assert record.type == 'A' + assert record.prefix == 'www' + assert record.target == '1.2.3.4' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + +def test_CAA(): + data = { + "id": 12, + "type": "CAA", + "name": "", + "flag": "0", + "tag": "issue", + "value": "letsencrypt.org", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 12 + assert record.type == 'CAA' + assert record.prefix is None + assert record.target == '0 issue "letsencrypt.org"' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + # We also accept versions without quotes: + record.target = '0 issue letsencrypt.org' + assert _record_to_json(record, include_id=True) == data + + record.target = '0\tissue "letsencrypt.org"' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith('Cannot split CAA record "0\tissue "letsencrypt.org"" into flag, tag and value: ') + + +def test_CNAME(): + data = { + "id": 13, + "type": "CNAME", + "name": "www", + "cname": "site.example.com", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 13 + assert record.type == 'CNAME' + assert record.prefix == 'www' + assert record.target == 'site.example.com' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + +def test_MX(): + data = { + "id": 14, + "type": "MX", + "ownername": "", + "name": "mail.example.com", + "pref": 10, + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 14 + assert record.type == 'MX' + assert record.prefix is None + assert record.target == '10 mail.example.com' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + record.target = 'mail.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith('Cannot split MX record "mail.example.com" into integer preference and name: ') + + record.target = 'x mail.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith('Cannot split MX record "x mail.example.com" into integer preference and name: ') + + +def test_NS(): + # WARNING: as opposed to documented on https://api.ns1.hosttech.eu/api/documentation/, + # NS records use 'targetname' and not 'name'! + data = { + "id": 14, + "type": "NS", + "ownername": "sub", + "targetname": "ns1.example.com", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 14 + assert record.type == 'NS' + assert record.prefix == 'sub' + assert record.target == 'ns1.example.com' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + +def test_PTR(): + data = { + "id": 15, + "type": "PTR", + "origin": "4.3.2.1", + "name": "smtp.example.com", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 15 + assert record.type == 'PTR' + assert record.prefix is None + assert record.target == '4.3.2.1 smtp.example.com' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + record.target = 'smtp.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith('Cannot split PTR record "smtp.example.com" into origin and name: ') + + +def test_SRV(): + data = { + "id": 16, + "type": "SRV", + "service": "_autodiscover._tcp", + "priority": 0, + "weight": 1, + "port": 443, + "target": "exchange.example.com", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 16 + assert record.type == 'SRV' + assert record.prefix == '_autodiscover._tcp' + assert record.target == '0 1 443 exchange.example.com' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + record.target = '1 443 exchange.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith( + 'Cannot split SRV record "1 443 exchange.example.com" into integer priority, integer weight, integer port and target: ') + + record.target = 'x 1 443 exchange.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith( + 'Cannot split SRV record "x 1 443 exchange.example.com" into integer priority, integer weight, integer port and target: ') + + record.target = '0 x 443 exchange.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith( + 'Cannot split SRV record "0 x 443 exchange.example.com" into integer priority, integer weight, integer port and target: ') + + record.target = '0 1 x exchange.example.com' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0].startswith( + 'Cannot split SRV record "0 1 x exchange.example.com" into integer priority, integer weight, integer port and target: ') + + +def test_TXT(): + data = { + "id": 17, + "type": "TXT", + "name": "", + "text": "v=spf1 ip4:1.2.3.4/32 -all", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 17 + assert record.type == 'TXT' + assert record.prefix is None + assert record.target == 'v=spf1 ip4:1.2.3.4/32 -all' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + +def test_TLSA(): + data = { + "id": 17, + "type": "TLSA", + "name": "", + "text": "0 0 1 d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971", + "ttl": 3600, + "comment": "my first record", + } + record = _create_record_from_json(data) + assert record.id == 17 + assert record.type == 'TLSA' + assert record.prefix is None + assert record.target == '0 0 1 d2abde240d7cd3ee6b4b28c54df034b97983a1d16e8a410e4561cb106618e971' + assert record.ttl == 3600 + assert record.extra == { + 'comment': 'my first record', + } + assert _record_to_json(record, include_id=True) == data + + +def test_unknown_records(): + data = { + "id": 17, + "type": "unknown", + "name": "", + "ttl": 3600, + "comment": "my first record", + } + with pytest.raises(DNSAPIError) as exc: + _create_record_from_json(data) + assert exc.value.args[0] == 'Cannot parse unknown record type: unknown' + + record = DNSRecord() + record.type = 'unknown' + with pytest.raises(DNSAPIError) as exc: + _record_to_json(record) + assert exc.value.args[0] == 'Cannot serialize unknown record type: unknown' + + +def test_list_pagination(): + def get_1(url, query=None, must_have_content=True, expected=None): + assert url == 'https://example.com' + assert must_have_content is True + assert expected == [200] + assert query is not None + assert len(query) == 2 + assert query['limit'] == 1 + assert query['offset'] in [0, 1, 2] + if query['offset'] < 2: + return {'data': [query['offset']]}, {} + else: + return {'data': []}, {} + + def get_2(url, query=None, must_have_content=True, expected=None): + assert url == 'https://example.com' + assert must_have_content is True + assert expected == [200] + assert query is not None + assert len(query) == 3 + assert query['foo'] == 'bar' + assert query['limit'] == 2 + assert query['offset'] in [0, 2] + if query['offset'] < 2: + return {'data': ['bar', 'baz']}, {} + else: + return {'data': ['foo']}, {} + + api = HostTechJSONAPI(MagicMock(), '123') + + api._get = MagicMock(side_effect=get_1) + result = api._list_pagination('https://example.com', block_size=1) + assert result == [0, 1] + + api._get = MagicMock(side_effect=get_2) + result = api._list_pagination('https://example.com', query=dict(foo='bar'), block_size=2) + assert result == ['bar', 'baz', 'foo'] + + +def test_update_id_missing(): + api = HostTechJSONAPI(MagicMock(), '123') + with pytest.raises(DNSAPIError) as exc: + api.update_record(1, DNSRecord()) + assert exc.value.args[0] == 'Need record ID to update record!' + + +def test_update_id_delete(): + api = HostTechJSONAPI(MagicMock(), '123') + with pytest.raises(DNSAPIError) as exc: + api.delete_record(1, DNSRecord()) + assert exc.value.args[0] == 'Need record ID to delete record!' + + +def test_extract_error_message(): + api = HostTechJSONAPI(MagicMock(), '123') + assert api._extract_error_message(None) == '' + assert api._extract_error_message('foo') == ' with data: foo' + assert api._extract_error_message(dict()) == ' with data: {}' + assert api._extract_error_message(dict(message='')) == " with data: {'message': ''}" + assert api._extract_error_message(dict(message='foo')) == ' with message "foo"' + assert api._extract_error_message(dict(message='foo', errors='')) == ' with message "foo"' + assert api._extract_error_message(dict(message='foo', errors=dict())) == ' with message "foo"' + assert api._extract_error_message(dict(message='foo', errors=dict(bar='baz'))) == ' with message "foo" (field "bar": baz)' + assert api._extract_error_message(dict(errors=dict(bar=['baz', 'bam'], arf='fra'))) == ' (field "arf": fra) (field "bar": baz; bam)' diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/module/test__utils.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/module/test__utils.py new file mode 100644 index 000000000..972b433e1 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/module/test__utils.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, +) + +from ansible_collections.community.dns.plugins.module_utils.module._utils import ( + normalize_dns_name, + get_prefix, +) + +from ..helper import ( + CustomProviderInformation, +) + + +def test_normalize_dns_name(): + assert normalize_dns_name('ExAMPLE.CoM.') == 'example.com' + assert normalize_dns_name('EXAMpLE.CoM') == 'example.com' + assert normalize_dns_name('Example.com') == 'example.com' + assert normalize_dns_name('.') == '' + assert normalize_dns_name(None) is None + + +def test_get_prefix(): + provider_information = CustomProviderInformation() + provider_information.get_supported_record_types() + assert get_prefix( + normalized_zone='example.com', normalized_record='example.com', provider_information=provider_information) == ('example.com', None) + assert get_prefix( + normalized_zone='example.com', normalized_record='www.example.com', provider_information=provider_information) == ('www.example.com', 'www') + assert get_prefix(normalized_zone='example.com', provider_information=provider_information) == ('example.com', None) + assert get_prefix(normalized_zone='example.com', prefix='', provider_information=provider_information) == ('example.com', None) + assert get_prefix(normalized_zone='example.com', prefix='.', provider_information=provider_information) == ('example.com', None) + assert get_prefix(normalized_zone='example.com', prefix='www', provider_information=provider_information) == ('www.example.com', 'www') + assert get_prefix(normalized_zone='example.com', prefix='www.', provider_information=provider_information) == ('www.example.com', 'www') + assert get_prefix(normalized_zone='example.com', prefix='wWw.', provider_information=provider_information) == ('www.example.com', 'www') + with pytest.raises(DNSAPIError): + get_prefix(normalized_zone='example.com', normalized_record='example.org', provider_information=provider_information) + with pytest.raises(DNSAPIError): + get_prefix(normalized_zone='example.com', normalized_record='wwwexample.com', provider_information=provider_information) diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/resolver_helper.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/resolver_helper.py new file mode 100644 index 000000000..e410d2076 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/resolver_helper.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock + + +def mock_resolver(default_nameservers, nameserver_resolve_sequence): + def create_resolver(configure=True): + resolver = MagicMock() + resolver.nameservers = default_nameservers if configure else [] + + def mock_resolver_resolve(target, rdtype=None, lifetime=None): + resolver_index = tuple(sorted(resolver.nameservers)) + assert resolver_index in nameserver_resolve_sequence, 'No resolver sequence for {0}'.format(resolver_index) + resolve_sequence = nameserver_resolve_sequence[resolver_index] + assert len(resolve_sequence) > 0, 'Resolver sequence for {0} is empty'.format(resolver_index) + resolve_data = resolve_sequence[0] + del resolve_sequence[0] + + assert target == resolve_data['target'], 'target: {0!r} vs {1!r}'.format(target, resolve_data['target']) + assert rdtype == resolve_data.get('rdtype'), 'rdtype: {0!r} vs {1!r}'.format(rdtype, resolve_data.get('rdtype')) + assert lifetime == resolve_data['lifetime'], 'lifetime: {0!r} vs {1!r}'.format(lifetime, resolve_data['lifetime']) + + if 'raise' in resolve_data: + raise resolve_data['raise'] + + return resolve_data['result'] + + resolver.resolve = MagicMock(side_effect=mock_resolver_resolve) + return resolver + + return create_resolver + + +def mock_query_udp(call_sequence): + def udp(query, nameserver, **kwargs): + assert len(call_sequence) > 0, 'UDP query call sequence is empty' + call = call_sequence[0] + del call_sequence[0] + + assert query.question[0].name == call['query_target'], 'query_target: {0!r} vs {1!r}'.format(query.question[0].name, call['query_target']) + assert query.question[0].rdtype == call['query_type'], 'query_type: {0!r} vs {1!r}'.format(query.question[0].rdtype, call['query_type']) + assert nameserver == call['nameserver'], 'nameserver: {0!r} vs {1!r}'.format(nameserver, call['nameserver']) + assert kwargs == call['kwargs'], 'kwargs: {0!r} vs {1!r}'.format(kwargs, call['kwargs']) + + if 'raise' in call: + raise call['raise'] + + return call['result'] + + return udp + + +def create_mock_answer(rrset=None): + answer = MagicMock() + answer.rrset = rrset + return answer + + +def create_mock_response(rcode, authority=None, answer=None): + response = MagicMock() + response.rcode = MagicMock(return_value=rcode) + response.authority = authority or [] + response.answer = answer or [] + return response diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_argspec.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_argspec.py new file mode 100644 index 000000000..296e1647d --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_argspec.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +from ansible_collections.community.dns.plugins.module_utils.argspec import ( + ArgumentSpec, +) + + +def test_argspec(): + empty = ArgumentSpec() + non_empty = ArgumentSpec( + argument_spec=dict(test=dict(type='str'), foo=dict()), + required_together=[('test', 'foo')], + required_if=[('test', 'bar', ['foo'])], + required_one_of=[('test', 'foo')], + mutually_exclusive=[('test', 'foo')] + ) + empty.merge(non_empty) + assert empty.to_kwargs() == non_empty.to_kwargs() diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_json_api_helper.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_json_api_helper.py new file mode 100644 index 000000000..0d5d7bd06 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_json_api_helper.py @@ -0,0 +1,96 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock + +from ansible_collections.community.dns.plugins.module_utils.zone_record_api import ( + DNSAPIError, +) + +from ansible_collections.community.dns.plugins.module_utils.json_api_helper import ( + _get_header_value, + JSONAPIHelper, +) + + +def test_get_header_value(): + assert _get_header_value({'Return-Type': 1}, 'return-type') == 1 + assert _get_header_value({'Return-Type': 1}, 'Return-Type') == 1 + assert _get_header_value({'return-Type': 1}, 'Return-type') == 1 + assert _get_header_value({'return_type': 1}, 'Return-type') is None + + +def test_extract_error_message(): + api = JSONAPIHelper(MagicMock(), '123', 'https://example.com') + assert api._extract_error_message(None) == '' + assert api._extract_error_message('foo') == ' with data: foo' + assert api._extract_error_message(dict()) == ' with data: {}' + assert api._extract_error_message(dict(message='')) == " with data: {'message': ''}" + assert api._extract_error_message(dict(message='foo')) == " with data: {'message': 'foo'}" + + +def test_validate(): + module = MagicMock() + api = JSONAPIHelper(module, '123', 'https://example.com') + with pytest.raises(DNSAPIError) as exc: + api._validate() + assert exc.value.args[0] == 'Internal error: info needs to be provided' + + +def test_process_json_result(): + http_helper = MagicMock() + api = JSONAPIHelper(http_helper, '123', 'https://example.com') + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content=None, info=dict(status=401, url='https://example.com')) + assert exc.value.args[0] == 'Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='{"message": ""}'.encode('utf-8'), info=dict(status=401, url='https://example.com')) + assert exc.value.args[0] == 'Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='{"message": "foo"}'.encode('utf-8'), info=dict(status=401, url='https://example.com')) + assert exc.value.args[0] == 'Unauthorized: the authentication parameters are incorrect (HTTP status 401): foo' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content=None, info=dict(status=403, url='https://example.com')) + assert exc.value.args[0] == 'Forbidden: you do not have access to this resource (HTTP status 403)' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='{"message": ""}'.encode('utf-8'), info=dict(status=403, url='https://example.com')) + assert exc.value.args[0] == 'Forbidden: you do not have access to this resource (HTTP status 403)' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='{"message": "foo"}'.encode('utf-8'), info=dict(status=403, url='https://example.com')) + assert exc.value.args[0] == 'Forbidden: you do not have access to this resource (HTTP status 403): foo' + + info = dict(status=200, url='https://example.com') + info['content-TYPE'] = 'application/json' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='not JSON'.encode('utf-8'), info=info) + assert exc.value.args[0] == 'GET https://example.com did not yield JSON data, but HTTP status code 200 with data: not JSON' + + info = dict(status=200, url='https://example.com') + info['Content-type'] = 'application/json' + r, i = api._process_json_result(content='not JSON'.encode('utf-8'), info=info, must_have_content=False) + assert r is None + info = dict(status=200, url='https://example.com') + info['Content-type'] = 'application/json' + assert i == info + + info = dict(status=404, url='https://example.com') + info['content-type'] = 'application/json' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='{}'.encode('utf-8'), info=info) + assert exc.value.args[0] == 'Expected successful HTTP status for GET https://example.com, but got HTTP status 404 (Not found) with data: {}' + + info = dict(status=404, url='https://example.com') + info['content-type'] = 'application/json' + with pytest.raises(DNSAPIError) as exc: + api._process_json_result(content='{}'.encode('utf-8'), info=info, expected=[200, 201]) + assert exc.value.args[0] == 'Expected HTTP status 200, 201 for GET https://example.com, but got HTTP status 404 (Not found) with data: {}' diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_names.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_names.py new file mode 100644 index 000000000..74dd83cc1 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_names.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.dns.plugins.module_utils.names import ( + join_labels, + is_ascii_label, + normalize_label, + split_into_labels, + InvalidDomainName, +) + + +TEST_IS_ASCII_LABEL = [ + ('asdf', True), + ('', True), + ('ä', False), + ('☹', False), + ('_dmarc', True), +] + + +@pytest.mark.parametrize("domain, result", TEST_IS_ASCII_LABEL) +def test_is_ascii_label(domain, result): + assert is_ascii_label(domain) == result + + +TEST_LABEL_SPLIT = [ + ('', [], ''), + ('.', [], '.'), + ('com', ['com'], ''), + ('com.', ['com'], '.'), + ('foo.bar', ['bar', 'foo'], ''), + ('foo.bar.', ['bar', 'foo'], '.'), + ('*.bar.', ['bar', '*'], '.'), + (u'☺.A', ['A', u'☺'], ''), +] + + +@pytest.mark.parametrize("domain, labels, tail", TEST_LABEL_SPLIT) +def test_split_into_labels(domain, labels, tail): + _labels, _tail = split_into_labels(domain) + assert _labels == labels + assert _tail == tail + assert join_labels(_labels, _tail) == domain + + +TEST_LABEL_SPLIT_ERRORS = [ + '.bar.', + '..bar', + '-bar', + 'bar-', +] + + +@pytest.mark.parametrize("domain", TEST_LABEL_SPLIT_ERRORS) +def test_split_into_labels_errors(domain): + with pytest.raises(InvalidDomainName): + split_into_labels(domain) + + +TEST_LABEL_JOIN = [ + ([], '', ''), + ([], '.', '.'), + (['a', 'b', 'c'], '', 'c.b.a'), + (['a', 'b', 'c'], '.', 'c.b.a.'), +] + + +@pytest.mark.parametrize("labels, tail, result", TEST_LABEL_JOIN) +def test_join_labels(labels, tail, result): + domain = join_labels(labels, tail) + assert domain == result + _labels, _tail = split_into_labels(domain) + assert _labels == labels + assert _tail == tail + + +TEST_LABEL_NORMALIZE = [ + ('', ''), + ('*', '*'), + ('foo', 'foo'), + ('Foo', 'foo'), + ('_dmarc', '_dmarc'), + (u'hëllö', 'xn--hll-jma1d'), + (u'食狮', 'xn--85x722f'), + (u'☺', 'xn--74h'), + (u'😉', 'xn--n28h'), +] + + +@pytest.mark.parametrize("label, normalized_label", TEST_LABEL_NORMALIZE) +def test_normalize_label(label, normalized_label): + print(normalize_label(label)) + assert normalize_label(label) == normalized_label diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_provider.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_provider.py new file mode 100644 index 000000000..79f688f60 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_provider.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2022, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.dns.plugins.module_utils.provider import ( + ensure_type, +) + + +CHECK_TYPE_DATA = [ + ('asdf', 'str', 'asdf'), + (1, 'str', '1'), + ([], 'list', []), + ({}, 'dict', {}), + ('yes', 'bool', True), + ('5', 'int', 5), + ('5.10', 'float', 5.10), + ('foobar', 'raw', 'foobar'), +] + + +@pytest.mark.parametrize("input, type_name, output", CHECK_TYPE_DATA) +def test_is_ascii_label(input, type_name, output): + assert ensure_type(input, type_name) == output diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_record.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_record.py new file mode 100644 index 000000000..d3a8ff385 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_record.py @@ -0,0 +1,142 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +from ansible_collections.community.dns.plugins.module_utils.record import ( + format_ttl, + DNSRecord, + format_records_for_output, +) + + +def test_format_ttl(): + assert format_ttl(None) == 'default' + assert format_ttl(1) == '1s' + assert format_ttl(59) == '59s' + assert format_ttl(60) == '1m' + assert format_ttl(61) == '1m 1s' + assert format_ttl(3539) == '58m 59s' + assert format_ttl(3540) == '59m' + assert format_ttl(3541) == '59m 1s' + assert format_ttl(3599) == '59m 59s' + assert format_ttl(3600) == '1h' + assert format_ttl(3601) == '1h 1s' + assert format_ttl(3661) == '1h 1m 1s' + + +def test_format_records_for_output(): + A1 = DNSRecord() + A1.type = 'A' + A1.ttl = 300 + A1.target = '1.2.3.4' + A1.extra['foo'] = 'bar' + A2 = DNSRecord() + A2.type = 'A' + A2.ttl = 300 + A2.target = '1.2.3.5' + A3 = DNSRecord() + A3.type = 'A' + A3.ttl = 3600 + A3.target = '1.2.3.6' + AAAA = DNSRecord() + AAAA.type = 'AAAA' + AAAA.ttl = 600 + AAAA.target = '::1' + AAAA2 = DNSRecord() + AAAA2.type = 'AAAA' + AAAA2.ttl = None + AAAA2.target = '::2' + assert format_records_for_output([], 'foo', '') == { + 'record': 'foo', + 'prefix': '', + 'type': None, + 'ttl': None, + 'value': [], + } + assert format_records_for_output([A1, A2], 'foo', 'bar') == { + 'record': 'foo', + 'prefix': 'bar', + 'type': 'A', + 'ttl': 300, + 'value': ['1.2.3.4', '1.2.3.5'], + } + assert format_records_for_output([A3, A1], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'A', + 'ttl': 300, + 'ttls': [300, 3600], + 'value': ['1.2.3.6', '1.2.3.4'], + } + assert format_records_for_output([A3], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'A', + 'ttl': 3600, + 'value': ['1.2.3.6'], + } + assert format_records_for_output([AAAA], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'AAAA', + 'ttl': 600, + 'value': ['::1'], + } + assert format_records_for_output([A3, AAAA], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'A', + 'ttl': 600, + 'ttls': [600, 3600], + 'value': ['1.2.3.6', '::1'], + } + assert format_records_for_output([AAAA, A3], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'A', + 'ttl': 600, + 'ttls': [600, 3600], + 'value': ['::1', '1.2.3.6'], + } + assert format_records_for_output([AAAA2], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'AAAA', + 'ttl': None, + 'value': ['::2'], + } + print(format_records_for_output([AAAA2, AAAA], 'foo', None)) + assert format_records_for_output([AAAA2, AAAA], 'foo', None) == { + 'record': 'foo', + 'prefix': '', + 'type': 'AAAA', + 'ttl': None, + 'ttls': [None, 600], + 'value': ['::2', '::1'], + } + + +def test_record_str_repr(): + A1 = DNSRecord() + A1.prefix = None + A1.type = 'A' + A1.ttl = 300 + A1.target = '1.2.3.4' + assert str(A1) == 'DNSRecord(type: A, prefix: (none), target: "1.2.3.4", ttl: 5m)' + assert repr(A1) == 'DNSRecord(type: A, prefix: (none), target: "1.2.3.4", ttl: 5m)' + A2 = DNSRecord() + A2.id = 23 + A2.prefix = 'bar' + A2.type = 'A' + A2.ttl = 1 + A2.target = '' + A2.extra['foo'] = 'bar' + assert str(A2) == 'DNSRecord(id: 23, type: A, prefix: "bar", target: "", ttl: 1s, extra: {\'foo\': \'bar\'})' + assert repr(A2) == 'DNSRecord(id: 23, type: A, prefix: "bar", target: "", ttl: 1s, extra: {\'foo\': \'bar\'})' diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_resolver.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_resolver.py new file mode 100644 index 000000000..1f51601e0 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_resolver.py @@ -0,0 +1,905 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock, patch + +from ansible_collections.community.dns.plugins.module_utils import resolver + +from ansible_collections.community.dns.plugins.module_utils.resolver import ( + ResolveDirectlyFromNameServers, + ResolverError, + assert_requirements_present, +) + +from .resolver_helper import ( + mock_resolver, + mock_query_udp, + create_mock_answer, + create_mock_response, +) + +# We need dnspython +dns = pytest.importorskip('dns') + + +def test_assert_requirements_present(): + class ModuleFailException(Exception): + pass + + def fail_json(**kwargs): + raise ModuleFailException(kwargs) + + module = MagicMock() + module.fail_json = MagicMock(side_effect=fail_json) + + orig_importerror = resolver.DNSPYTHON_IMPORTERROR + resolver.DNSPYTHON_IMPORTERROR = None + assert_requirements_present(module) + + resolver.DNSPYTHON_IMPORTERROR = 'asdf' + with pytest.raises(ModuleFailException) as exc: + assert_requirements_present(module) + + assert 'dnspython' in exc.value.args[0]['msg'] + assert 'asdf' == exc.value.args[0]['exception'] + + resolver.DNSPYTHON_IMPORTERROR = orig_importerror + + +def test_lookup_ns_names(): + resolver = mock_resolver(['1.1.1.1'], {}) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.org.'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com.'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '3.3.3.3', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 60, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'foo.bar.'), + )], authority=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com.'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + resolver = ResolveDirectlyFromNameServers(always_ask_default_resolver=False) + # Use default resolver + ns, cname = resolver._lookup_ns_names(dns.name.from_unicode(u'example.com')) + assert ns == ['ns.example.com.', 'ns.example.org.'] + assert cname is None + # Provide nameserver IPs + ns, cname = resolver._lookup_ns_names(dns.name.from_unicode(u'example.com'), nameserver_ips=['3.3.3.3', '1.1.1.1']) + assert ns == ['ns.example.com.'] + assert cname == dns.name.from_unicode(u'foo.bar.') + # Provide empty nameserver list + with pytest.raises(ResolverError) as exc: + resolver._lookup_ns_names(dns.name.from_unicode(u'example.com'), nameservers=[]) + assert exc.value.args[0] == 'Have neither nameservers nor nameserver IPs' + + +def test_resolver(): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.AAAA, '1:2::3'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.AAAA, '2:3::4'), + )), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '4.4.4.4'), + )), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '2.2.2.2'), + )), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('1:2::3', '2:3::4', '3.3.3.3'): [ + { + 'target': dns.name.from_unicode(u'example.org'), + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '1.2.3.4'), + )), + }, + ], + ('4.4.4.4', ): [ + { + 'target': dns.name.from_unicode(u'example.org'), + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '1.2.3.5'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + ), dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'example.org') + )]), + }, + { + 'query_target': dns.name.from_unicode(u'org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.org'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.org'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + resolver = ResolveDirectlyFromNameServers() + assert resolver.resolve_nameservers('example.com', resolve_addresses=True) == ['1:2::3', '2:3::4', '3.3.3.3'] + # www.example.com is a CNAME for example.org + rrset_dict = resolver.resolve('www.example.com') + assert sorted(rrset_dict.keys()) == ['ns.example.com', 'ns.example.org'] + rrset = rrset_dict['ns.example.com'] + assert len(rrset) == 1 + assert rrset.name == dns.name.from_unicode(u'example.org', origin=None) + assert rrset.rdtype == dns.rdatatype.A + assert rrset[0].to_text() == u'1.2.3.4' + rrset = rrset_dict['ns.example.org'] + assert len(rrset) == 1 + assert rrset.name == dns.name.from_unicode(u'example.org', origin=None) + assert rrset.rdtype == dns.rdatatype.A + assert rrset[0].to_text() == u'1.2.3.5' + # The following results should be cached: + assert resolver.resolve_nameservers('com', resolve_addresses=True) == ['2.2.2.2'] + assert resolver.resolve_nameservers('org') == ['ns.org'] + assert resolver.resolve_nameservers('example.com') == ['ns.example.com'] + assert resolver.resolve_nameservers('example.org') == ['ns.example.com', 'ns.example.org'] + + +def test_timeout_handling(): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'raise': dns.exception.Timeout(timeout=10), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.exception.Timeout(timeout=10), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'raise': dns.exception.Timeout(timeout=10), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'raise': dns.exception.Timeout(timeout=10), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '2.2.2.2'), + )), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'raise': dns.exception.Timeout(timeout=10), + }, + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + resolver = ResolveDirectlyFromNameServers() + assert resolver.resolve_nameservers('example.com', resolve_addresses=True) == ['3.3.3.3'] + # The following results should be cached: + assert resolver.resolve_nameservers('com') == ['ns.com'] + assert resolver.resolve_nameservers('com', resolve_addresses=True) == ['2.2.2.2'] + assert resolver.resolve_nameservers('example.com') == ['ns.example.com'] + assert resolver.resolve_nameservers('example.com', resolve_addresses=True) == ['3.3.3.3'] + + +def test_timeout_failure(): + resolver = mock_resolver(['1.1.1.1'], {}) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'raise': dns.exception.Timeout(timeout=1), + }, + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'raise': dns.exception.Timeout(timeout=2), + }, + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'raise': dns.exception.Timeout(timeout=3), + }, + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'raise': dns.exception.Timeout(timeout=4), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with pytest.raises(dns.exception.Timeout) as exc: + resolver = ResolveDirectlyFromNameServers() + resolver.resolve_nameservers('example.com') + assert exc.value.kwargs['timeout'] == 4 + + +def test_error_nxdomain(): + resolver = mock_resolver(['1.1.1.1'], {}) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NXDOMAIN), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with pytest.raises(dns.resolver.NXDOMAIN) as exc: + resolver = ResolveDirectlyFromNameServers() + resolver.resolve_nameservers('example.com') + assert exc.value.kwargs['qnames'] == [dns.name.from_unicode(u'com')] + + +def test_error_servfail(): + resolver = mock_resolver(['1.1.1.1'], {}) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.SERVFAIL), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with pytest.raises(ResolverError) as exc: + resolver = ResolveDirectlyFromNameServers() + resolver.resolve_nameservers('example.com') + assert exc.value.args[0] == 'Error SERVFAIL while querying 1.1.1.1 with query get NS for "com."' + + +def test_no_response(): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '5.5.5.5'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns2.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '4.4.4.4'), + )), + }, + { + 'target': 'ns2.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', '5.5.5.5'): [ + { + 'target': dns.name.from_unicode(u'example.com'), + 'lifetime': 10, + 'result': create_mock_answer(), + }, + ], + ('4.4.4.4', ): [ + { + 'target': dns.name.from_unicode(u'example.com'), + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns2.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + resolver = ResolveDirectlyFromNameServers() + rrset_dict = resolver.resolve('example.com') + assert sorted(rrset_dict.keys()) == ['ns.example.com', 'ns2.example.com'] + assert rrset_dict['ns.example.com'] is None + assert rrset_dict['ns2.example.com'] is None + # Verify nameserver IPs + assert resolver.resolve_nameservers('example.com') == ['ns.example.com', 'ns2.example.com'] + assert resolver.resolve_nameservers('example.com', resolve_addresses=True) == ['3.3.3.3', '4.4.4.4', '5.5.5.5'] + + +def test_cname_loop(): + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.com', + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '2.2.2.2'), + )), + }, + { + 'target': 'ns.example.com', + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.org', + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '2.2.3.3'), + )), + }, + { + 'target': 'ns.example.org', + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '4.4.4.4'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + ), dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'example.org') + )]), + }, + { + 'query_target': dns.name.from_unicode(u'org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.org'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.org'), + ), dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'www.example.com') + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + resolver = ResolveDirectlyFromNameServers() + with pytest.raises(ResolverError) as exc: + resolver.resolve('www.example.com') + assert exc.value.args[0] == 'Found CNAME loop starting at www.example.com' + + +def test_resolver_non_default(): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '2.2.2.2'), + )), + }, + { + 'target': 'ns.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns.org', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '2.2.3.3'), + )), + }, + { + 'target': 'ns.org', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '4.4.4.4'), + )), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'example.org'), + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '1.2.3.4'), + )), + }, + ], + ('4.4.4.4', ): [ + { + 'target': dns.name.from_unicode(u'example.org'), + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '1.2.3.4'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '2.2.2.2', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '3.3.3.3', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + ), dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'example.org') + )]), + }, + { + 'query_target': dns.name.from_unicode(u'org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.org'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '2.2.3.3', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.org'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + resolver = ResolveDirectlyFromNameServers(always_ask_default_resolver=False) + assert resolver.resolve_nameservers('example.com') == ['ns.example.com'] + # www.example.com is a CNAME for example.org + rrset_dict = resolver.resolve('www.example.com') + assert sorted(rrset_dict.keys()) == ['ns.example.com', 'ns.example.org'] + rrset = rrset_dict['ns.example.com'] + assert len(rrset) == 1 + assert rrset.name == dns.name.from_unicode(u'example.org', origin=None) + assert rrset.rdtype == dns.rdatatype.A + assert rrset[0].to_text() == u'1.2.3.4' + rrset = rrset_dict['ns.example.org'] + assert len(rrset) == 1 + assert rrset.name == dns.name.from_unicode(u'example.org', origin=None) + assert rrset.rdtype == dns.rdatatype.A + assert rrset[0].to_text() == u'1.2.3.4' + # The following results should be cached: + assert resolver.resolve_nameservers('com') == ['ns.com'] + print(resolver.resolve_nameservers('example.com', resolve_addresses=True)) + assert resolver.resolve_nameservers('example.com', resolve_addresses=True) == ['3.3.3.3'] + print(resolver.resolve_nameservers('example.org', resolve_addresses=True)) + assert resolver.resolve_nameservers('example.org', resolve_addresses=True) == ['3.3.3.3', '4.4.4.4'] diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_wsdl.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_wsdl.py new file mode 100644 index 000000000..14dd66428 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_wsdl.py @@ -0,0 +1,283 @@ +# -*- coding: utf-8 -*- +# +# Copyright (c) 2017-2021 Felix Fontein +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import sys +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock + +lxmletree = pytest.importorskip("lxml.etree") + +from ansible.module_utils.common.text.converters import to_native + +from ansible_collections.community.dns.plugins.module_utils.wsdl import ( + Parser, + Composer, +) + + +def test_composer_generation(): + composer = Composer(MagicMock(), api='https://example.com/api') + composer.add_simple_command( + 'test', + int_value=42, + str_value='bar', + list_value=[1, 2, 3], + dict_value={ + 'hello': 'world', + 'list': [2, 3, 5, 7], + } + ) + command = to_native(lxmletree.tostring(composer._root, pretty_print=True)).splitlines() + + print(command) + + expected_lines = [ + ' <SOAP-ENV:Header/>', + ' <SOAP-ENV:Body>', + ' <ns0:test xmlns:ns0="https://example.com/api">', + ' <int_value xsi:type="xsd:int">42</int_value>', + ' <str_value xsi:type="xsd:string">bar</str_value>', + ' <list_value xsi:type="SOAP-ENC:Array">', + ' <item xsi:type="xsd:int">1</item>', + ' <item xsi:type="xsd:int">2</item>', + ' <item xsi:type="xsd:int">3</item>', + ' </list_value>', + ' <dict_value xmlns:ns0="http://xml.apache.org/xml-soap" xsi:type="ns0:Map">', + ' <item>', + ' <key xsi:type="xsd:string">hello</key>', + ' <value xsi:type="xsd:string">world</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">list</key>', + ' <value xsi:type="SOAP-ENC:Array">', + ' <item xsi:type="xsd:int">2</item>', + ' <item xsi:type="xsd:int">3</item>', + ' <item xsi:type="xsd:int">5</item>', + ' <item xsi:type="xsd:int">7</item>', + ' </value>', + ' </item>', + ' </dict_value>', + ' </ns0:test>', + ' </SOAP-ENV:Body>', + '</SOAP-ENV:Envelope>', + ] + + if sys.version_info < (3, 7): + assert sorted(command[1:]) == sorted(expected_lines) + else: + assert command[1:] == expected_lines + + for part in [ + '<SOAP-ENV:Envelope', + ' xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"', + ' xmlns:xsd="http://www.w3.org/2001/XMLSchema"', + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', + ' xmlns:ns2="auth"', + ' xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"', + ' SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"', + ]: + assert part in command[0] + + +def test_parsing(): + input = '\n'.join([ + '<?xml version="1.0" encoding="UTF-8"?>', + ''.join([ + '<SOAP-ENV:Envelope', + ' xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"', + ' xmlns:ns1="https://example.com/api"', + ' xmlns:xsd="http://www.w3.org/2001/XMLSchema"', + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"', + ' xmlns:ns2="http://xml.apache.org/xml-soap"', + ' xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"', + ' SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/"', + '>', + ]), + ' <SOAP-ENV:Header>', + ' <ns1:authenticateResponse>', + ' <return xsi:type="xsd:boolean">true</return>', + ' </ns1:authenticateResponse>', + ' </SOAP-ENV:Header>', + ' <SOAP-ENV:Body>', + ' <ns1:getZoneResponse>', + ' <return xsi:type="ns2:Map">', + ' <item>', + ' <key xsi:type="xsd:string">id</key>', + ' <value xsi:type="xsd:int">1</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">user</key>', + ' <value xsi:type="xsd:int">2</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">name</key>', + ' <value xsi:type="xsd:string">example.com</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">email</key>', + ' <value xsi:type="xsd:string">info@example.com</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">ttl</key>', + ' <value xsi:type="xsd:int">10800</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">nameserver</key>', + ' <value xsi:type="xsd:string">ns1.hostserv.eu</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">serial</key>', + ' <value xsi:type="xsd:string">1234567890</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">serialLastUpdate</key>', + ' <value xsi:type="xsd:int">0</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">refresh</key>', + ' <value xsi:type="xsd:int">7200</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">retry</key>', + ' <value xsi:type="xsd:int">120</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">expire</key>', + ' <value xsi:type="xsd:int">1234567</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">template</key>', + ' <value xsi:nil="true"/>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">ns3</key>', + ' <value xsi:type="xsd:int">1</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">records</key>', + ' <value SOAP-ENC:arrayType="ns2:Map[2]" xsi:type="SOAP-ENC:Array">', + ' <item xsi:type="ns2:Map">', + ' <item>', + ' <key xsi:type="xsd:string">id</key>', + ' <value xsi:type="xsd:int">3</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">zone</key>', + ' <value xsi:type="xsd:int">4</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">type</key>', + ' <value xsi:type="xsd:string">A</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">prefix</key>', + ' <value xsi:type="xsd:string"></value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">target</key>', + ' <value xsi:type="xsd:string">1.2.3.4</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">ttl</key>', + ' <value xsi:type="xsd:int">3600</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">comment</key>', + ' <value xsi:nil="true"/>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">priority</key>', + ' <value xsi:nil="true"/>', + ' </item>', + ' </item>', + ' <item xsi:type="ns2:Map">', + ' <item>', + ' <key xsi:type="xsd:string">id</key>', + ' <value xsi:type="xsd:int">5</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">zone</key>', + ' <value xsi:type="xsd:int">4</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">type</key>', + ' <value xsi:type="xsd:string">A</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">prefix</key>', + ' <value xsi:type="xsd:string">*</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">target</key>', + ' <value xsi:type="xsd:string">1.2.3.5</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">ttl</key>', + ' <value xsi:type="xsd:int">3600</value>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">comment</key>', + ' <value xsi:nil="true"/>', + ' </item>', + ' <item>', + ' <key xsi:type="xsd:string">priority</key>', + ' <value xsi:nil="true"/>', + ' </item>', + ' </item>', + ' </value>', + ' </item>', + ' </return>', + ' </ns1:getZoneResponse>', + ' </SOAP-ENV:Body>', + '</SOAP-ENV:Envelope>', + ]).encode('utf-8') + + parser = Parser('https://example.com/api', lxmletree.fromstring(input)) + assert parser.get_header('authenticateResponse') is True + assert len(parser._header) == 1 + assert parser.get_result('getZoneResponse') == { + 'id': 1, + 'user': 2, + 'name': 'example.com', + 'email': 'info@example.com', + 'ttl': 10800, + 'nameserver': 'ns1.hostserv.eu', + 'serial': '1234567890', + 'serialLastUpdate': 0, + 'refresh': 7200, + 'retry': 120, + 'expire': 1234567, + 'template': None, + 'ns3': 1, + 'records': [ + { + 'id': 3, + 'zone': 4, + 'type': 'A', + 'prefix': None, + 'target': '1.2.3.4', + 'ttl': 3600, + 'comment': None, + 'priority': None, + }, + { + 'id': 5, + 'zone': 4, + 'type': 'A', + 'prefix': '*', + 'target': '1.2.3.5', + 'ttl': 3600, + 'comment': None, + 'priority': None, + }, + ], + } + assert len(parser._body) == 1 diff --git a/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_zone.py b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_zone.py new file mode 100644 index 000000000..df16adf2a --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/module_utils/test_zone.py @@ -0,0 +1,60 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +from ansible_collections.community.dns.plugins.module_utils.record import ( + DNSRecord, +) + +from ansible_collections.community.dns.plugins.module_utils.zone import ( + DNSZone, + DNSZoneWithRecords, +) + + +def test_zone_str_repr(): + Z1 = DNSZone('foo') + assert str(Z1) == 'DNSZone(name: foo, info: {})' + assert repr(Z1) == 'DNSZone(name: foo, info: {})' + Z2 = DNSZone('foo') + Z2.id = 42 + Z2.info['foo'] = 'bar' + assert str(Z2) == "DNSZone(id: 42, name: foo, info: {'foo': 'bar'})" + assert repr(Z2) == "DNSZone(id: 42, name: foo, info: {'foo': 'bar'})" + + +def test_zone_with_records_str_repr(): + Z1 = DNSZone('foo') + Z2 = DNSZone('foo') + Z2.id = 42 + A1 = DNSRecord() + A1.prefix = None + A1.type = 'A' + A1.ttl = 300 + A1.target = '1.2.3.4' + A2 = DNSRecord() + A2.id = 23 + A2.prefix = 'bar' + A2.type = 'A' + A2.ttl = 1 + A2.target = '' + A2.extra['foo'] = 23 + ZZ1 = DNSZoneWithRecords(Z1, [A1]) + ZZ2 = DNSZoneWithRecords(Z2, [A1, A2]) + assert str(ZZ1) == '(DNSZone(name: foo, info: {}), [DNSRecord(type: A, prefix: (none), target: "1.2.3.4", ttl: 5m)])' + assert repr(ZZ1) == 'DNSZoneWithRecords(DNSZone(name: foo, info: {}), [DNSRecord(type: A, prefix: (none), target: "1.2.3.4", ttl: 5m)])' + assert str(ZZ2) == ( + '(DNSZone(id: 42, name: foo, info: {}), [DNSRecord(type: A, prefix: (none), target: "1.2.3.4", ttl: 5m),' + ' DNSRecord(id: 23, type: A, prefix: "bar", target: "", ttl: 1s, extra: {\'foo\': 23})])' + ) + assert repr(ZZ2) == ( + 'DNSZoneWithRecords(DNSZone(id: 42, name: foo, info: {}), [DNSRecord(type: A, prefix: (none), target: "1.2.3.4", ttl: 5m),' + ' DNSRecord(id: 23, type: A, prefix: "bar", target: "", ttl: 1s, extra: {\'foo\': 23})])' + ) diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/hetzner.py b/ansible_collections/community/dns/tests/unit/plugins/modules/hetzner.py new file mode 100644 index 000000000..e28edf736 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/hetzner.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +HETZNER_DEFAULT_ZONE = { + 'id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + 'legacy_dns_host': 'string', + 'legacy_ns': ['foo', 'bar'], + 'name': 'example.com', + 'ns': ['string'], + 'owner': 'Example', + 'paused': True, + 'permission': 'string', + 'project': 'string', + 'registrar': 'string', + 'status': 'verified', + 'ttl': 10800, + 'verified': '2021-07-09T11:18:37Z', + 'records_count': 0, + 'is_secondary_dns': True, + 'txt_verification': { + 'name': 'string', + 'token': 'string', + }, +} + +HETZNER_JSON_DEFAULT_ENTRIES = [ + { + 'id': '125', + 'type': 'A', + 'name': '@', + 'value': '1.2.3.4', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '126', + 'type': 'A', + 'name': '*', + 'value': '1.2.3.5', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '127', + 'type': 'AAAA', + 'name': '@', + 'value': '2001:1:2::3', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '128', + 'type': 'AAAA', + 'name': '*', + 'value': '2001:1:2::4', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '129', + 'type': 'MX', + 'name': '@', + 'value': '10 example.com', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '130', + 'type': 'NS', + 'name': '@', + 'value': 'helium.ns.hetzner.de.', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '131', + 'type': 'NS', + 'name': '@', + 'value': 'hydrogen.ns.hetzner.com.', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '132', + 'type': 'NS', + 'name': '@', + 'value': 'oxygen.ns.hetzner.com.', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '200', + 'type': 'SOA', + 'name': '@', + 'value': 'hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + { + 'id': '201', + 'type': 'TXT', + 'name': 'foo', + 'value': u'bär " \\"with quotes\\"" " " "(use \\\\ to escape)"', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, +] + +HETZNER_JSON_ZONE_LIST_RESULT = { + 'zones': [ + HETZNER_DEFAULT_ZONE, + ], +} + +HETZNER_JSON_ZONE_GET_RESULT = { + 'zone': HETZNER_DEFAULT_ZONE, +} + +HETZNER_JSON_ZONE_RECORDS_GET_RESULT = { + 'records': HETZNER_JSON_DEFAULT_ENTRIES, +} diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/hosttech.py b/ansible_collections/community/dns/tests/unit/plugins/modules/hosttech.py new file mode 100644 index 000000000..696cf7d01 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/hosttech.py @@ -0,0 +1,463 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +try: + import lxml.etree +except ImportError: + # should be handled in module importing this one + pass + + +HOSTTECH_WSDL_DEFAULT_ENTRIES = [ + (125, 42, 'A', '', '1.2.3.4', 3600, None, None), + (126, 42, 'A', '*', '1.2.3.5', 3600, None, None), + (127, 42, 'AAAA', '', '2001:1:2::3', 3600, None, None), + (128, 42, 'AAAA', '*', '2001:1:2::4', 3600, None, None), + (129, 42, 'MX', '', 'example.com', 3600, None, '10'), + (130, 42, 'NS', '', 'ns3.hostserv.eu', 10800, None, None), + (131, 42, 'NS', '', 'ns2.hostserv.eu', 10800, None, None), + (132, 42, 'NS', '', 'ns1.hostserv.eu', 10800, None, None), +] + +HOSTTECH_JSON_DEFAULT_ENTRIES = [ + # (125, 42, 'A', '', '1.2.3.4', 3600, None, None), + { + 'id': 125, + 'type': 'A', + 'name': '', + 'ipv4': '1.2.3.4', + 'ttl': 3600, + 'comment': '', + }, + # (126, 42, 'A', '*', '1.2.3.5', 3600, None, None), + { + 'id': 126, + 'type': 'A', + 'name': '*', + 'ipv4': '1.2.3.5', + 'ttl': 3600, + 'comment': '', + }, + # (127, 42, 'AAAA', '', '2001:1:2::3', 3600, None, None), + { + 'id': 127, + 'type': 'AAAA', + 'name': '', + 'ipv6': '2001:1:2::3', + 'ttl': 3600, + 'comment': '', + }, + # (128, 42, 'AAAA', '*', '2001:1:2::4', 3600, None, None), + { + 'id': 128, + 'type': 'AAAA', + 'name': '*', + 'ipv6': '2001:1:2::4', + 'ttl': 3600, + 'comment': '', + }, + # (129, 42, 'MX', '', 'example.com', 3600, None, '10'), + { + 'id': 129, + 'type': 'MX', + 'ownername': '', + 'name': 'example.com', + 'pref': 10, + 'ttl': 3600, + 'comment': '', + }, + # (130, 42, 'NS', '', 'ns3.hostserv.eu', 10800, None, None), + { + 'id': 130, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns3.hostserv.eu', + 'ttl': 10800, + 'comment': '', + }, + # (131, 42, 'NS', '', 'ns2.hostserv.eu', 10800, None, None), + { + 'id': 131, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns2.hostserv.eu', + 'ttl': 10800, + 'comment': '', + }, + # (132, 42, 'NS', '', 'ns1.hostserv.eu', 10800, None, None), + { + 'id': 132, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns1.hostserv.eu', + 'ttl': 10800, + 'comment': '', + }, +] + + +def validate_wsdl_call(conditions): + def predicate(content): + assert content.startswith(b"<?xml version='1.0' encoding='utf-8'?>\n") + + root = lxml.etree.fromstring(content) + header = None + body = None + + for header_ in root.iter(lxml.etree.QName('http://schemas.xmlsoap.org/soap/envelope/', 'Header').text): + header = header_ + for body_ in root.iter(lxml.etree.QName('http://schemas.xmlsoap.org/soap/envelope/', 'Body').text): + body = body_ + + for condition in conditions: + if not condition(content, header, body): + return False + return True + + return predicate + + +def get_wsdl_value(root, name): + for auth in root.iter(name): + return auth + raise Exception('Cannot find child "{0}" in node {1}: {2}'.format(name, root, lxml.etree.tostring(root))) + + +def expect_wsdl_authentication(username, password): + def predicate(content, header, body): + auth = get_wsdl_value(header, lxml.etree.QName('auth', 'authenticate').text) + assert get_wsdl_value(auth, 'UserName').text == username + assert get_wsdl_value(auth, 'Password').text == password + return True + + return predicate + + +def check_wsdl_nil(node): + nil_flag = node.get(lxml.etree.QName('http://www.w3.org/2001/XMLSchema-instance', 'nil')) + if nil_flag != 'true': + print(nil_flag) + assert nil_flag == 'true' + + +def check_wsdl_value(node, value, type=None): + if type is not None: + type_text = node.get(lxml.etree.QName('http://www.w3.org/2001/XMLSchema-instance', 'type')) + assert type_text is not None, 'Cannot find type in {0}: {1}'.format(node, lxml.etree.tostring(node)) + i = type_text.find(':') + if i < 0: + ns = None + else: + ns = node.nsmap.get(type_text[:i]) + type_text = type_text[i + 1:] + if ns != type[0] or type_text != type[1]: + print(ns, type[0], type_text, type[1]) + assert ns == type[0] and type_text == type[1] + if node.text != value: + print(node.text, value) + assert node.text == value + + +def find_xml_map_entry(map_root, key_name, allow_non_existing=False): + for map_entry in map_root.iter('item'): + key = get_wsdl_value(map_entry, 'key') + value = get_wsdl_value(map_entry, 'value') + if key.text == key_name: + check_wsdl_value(key, key_name, type=('http://www.w3.org/2001/XMLSchema', 'string')) + return value + if allow_non_existing: + return None + raise Exception('Cannot find map entry with key "{0}" in node {1}: {2}'.format(key_name, map_root, lxml.etree.tostring(map_root))) + + +def expect_wsdl_value(path, value, type=None): + def predicate(content, header, body): + node = body + for entry in path: + node = get_wsdl_value(node, entry) + check_wsdl_value(node, value, type=type) + return True + + return predicate + + +def add_wsdl_answer_start_lines(lines): + lines.extend([ + '<?xml version="1.0" encoding="UTF-8"?>\n', + '<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"' + ' xmlns:ns1="https://ns1.hosttech.eu/public/api"' + ' xmlns:xsd="http://www.w3.org/2001/XMLSchema"' + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' + ' xmlns:ns2="http://xml.apache.org/xml-soap"' + ' xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"' + ' SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">', + '<SOAP-ENV:Header>', + '<ns1:authenticateResponse>', + '<return xsi:type="xsd:boolean">true</return>', + '</ns1:authenticateResponse>', + '</SOAP-ENV:Header>', + '<SOAP-ENV:Body>', + ]) + + +def add_wsdl_answer_end_lines(lines): + lines.extend([ + '</SOAP-ENV:Body>', + '</SOAP-ENV:Envelope>' + ]) + + +def add_wsdl_dns_record_lines(lines, entry, tag_name): + lines.extend([ + '<{tag_name} xsi:type="ns2:Map">'.format(tag_name=tag_name), + '<item><key xsi:type="xsd:string">id</key><value xsi:type="xsd:int">{value}</value></item>'.format(value=entry[0]), + '<item><key xsi:type="xsd:string">zone</key><value xsi:type="xsd:int">{value}</value></item>'.format(value=entry[1]), + '<item><key xsi:type="xsd:string">type</key><value xsi:type="xsd:string">{value}</value></item>'.format(value=entry[2]), + '<item><key xsi:type="xsd:string">prefix</key><value xsi:type="xsd:string">{value}</value></item>'.format(value=entry[3]), + '<item><key xsi:type="xsd:string">target</key><value xsi:type="xsd:string">{value}</value></item>'.format(value=entry[4]), + '<item><key xsi:type="xsd:string">ttl</key><value xsi:type="xsd:int">{value}</value></item>'.format(value=entry[5]), + ]) + if entry[6] is None: + lines.append('<item><key xsi:type="xsd:string">comment</key><value xsi:nil="true"/></item>') + else: + lines.append('<item><key xsi:type="xsd:string">comment</key><value xsi:type="xsd:string">{value}</value></item>'.format(value=entry[6])) + if entry[7] is None: + lines.append('<item><key xsi:type="xsd:string">priority</key><value xsi:nil="true"/></item>') + else: + lines.append('<item><key xsi:type="xsd:string">priority</key><value xsi:type="xsd:int">{value}</value></item>'.format(value=entry[7])) + lines.append('</{tag_name}>'.format(tag_name=tag_name)) + + +def create_wsdl_zones_answer(zone_id, zone_name, entries): + lines = [] + add_wsdl_answer_start_lines(lines) + lines.extend([ + '<ns1:getZoneResponse>', + '<return xsi:type="ns2:Map">', + '<item><key xsi:type="xsd:string">id</key><value xsi:type="xsd:int">{zone_id}</value></item>'.format(zone_id=zone_id), + '<item><key xsi:type="xsd:string">user</key><value xsi:type="xsd:int">23</value></item>', + '<item><key xsi:type="xsd:string">name</key><value xsi:type="xsd:string">{zone_name}</value></item>'.format(zone_name=zone_name), + '<item><key xsi:type="xsd:string">email</key><value xsi:type="xsd:string">dns@hosttech.eu</value></item>', + '<item><key xsi:type="xsd:string">ttl</key><value xsi:type="xsd:int">10800</value></item>', + '<item><key xsi:type="xsd:string">nameserver</key><value xsi:type="xsd:string">ns1.hostserv.eu</value></item>', + '<item><key xsi:type="xsd:string">serial</key><value xsi:type="xsd:string">12345</value></item>', + '<item><key xsi:type="xsd:string">serialLastUpdate</key><value xsi:type="xsd:int">0</value></item>', + '<item><key xsi:type="xsd:string">refresh</key><value xsi:type="xsd:int">7200</value></item>', + '<item><key xsi:type="xsd:string">retry</key><value xsi:type="xsd:int">120</value></item>', + '<item><key xsi:type="xsd:string">expire</key><value xsi:type="xsd:int">1234567</value></item>', + '<item><key xsi:type="xsd:string">template</key><value xsi:nil="true"/></item>', + '<item><key xsi:type="xsd:string">ns3</key><value xsi:type="xsd:int">1</value></item>', + ]) + lines.append( + '<item><key xsi:type="xsd:string">records</key><value SOAP-ENC:arrayType="ns2:Map[{count}]" xsi:type="SOAP-ENC:Array">'.format( + count=len(entries))) + for entry in entries: + add_wsdl_dns_record_lines(lines, entry, 'item') + lines.extend([ + '</value>', + '</item>', + '</return>', + '</ns1:getZoneResponse>', + ]) + add_wsdl_answer_end_lines(lines) + return ''.join(lines) + + +def create_wsdl_zone_not_found_answer(): + lines = [ + '<?xml version="1.0" encoding="UTF-8"?>\n', + '<SOAP-ENV:Envelope xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/"' + ' xmlns:ns1="https://ns1.hosttech.eu/public/api"' + ' xmlns:xsd="http://www.w3.org/2001/XMLSchema"' + ' xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"' + ' xmlns:ns2="http://xml.apache.org/xml-soap"' + ' xmlns:SOAP-ENC="http://schemas.xmlsoap.org/soap/encoding/"' + ' SOAP-ENV:encodingStyle="http://schemas.xmlsoap.org/soap/encoding/">', + '<SOAP-ENV:Header>', + '<ns1:authenticateResponse>', + '<return xsi:type="xsd:boolean">true</return>', + '</ns1:authenticateResponse>', + '</SOAP-ENV:Header>', + '<SOAP-ENV:Fault>', + '<faultstring>zone not found</faultstring>' + '</SOAP-ENV:Fault>', + '</SOAP-ENV:Envelope>' + ] + return ''.join(lines) + + +def check_wsdl_record(record_data, entry): + check_wsdl_value(find_xml_map_entry(record_data, 'type'), entry[2], type=('http://www.w3.org/2001/XMLSchema', 'string')) + prefix = find_xml_map_entry(record_data, 'prefix') + if entry[3]: + check_wsdl_value(prefix, entry[3], type=('http://www.w3.org/2001/XMLSchema', 'string')) + elif prefix is not None: + check_wsdl_nil(prefix) + check_wsdl_value(find_xml_map_entry(record_data, 'target'), entry[4], type=('http://www.w3.org/2001/XMLSchema', 'string')) + check_wsdl_value(find_xml_map_entry(record_data, 'ttl'), str(entry[5]), type=('http://www.w3.org/2001/XMLSchema', 'int')) + if entry[6] is None: + comment = find_xml_map_entry(record_data, 'comment', allow_non_existing=True) + if comment is not None: + check_wsdl_nil(comment) + else: + check_wsdl_value(find_xml_map_entry(record_data, 'comment'), entry[6], type=('http://www.w3.org/2001/XMLSchema', 'string')) + if entry[7] is None: + check_wsdl_nil(find_xml_map_entry(record_data, 'priority')) + else: + check_wsdl_value(find_xml_map_entry(record_data, 'priority'), entry[7], type=('http://www.w3.org/2001/XMLSchema', 'string')) + + +def validate_wsdl_add_request(zone, entry): + def predicate(content, header, body): + fn_data = get_wsdl_value(body, lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'addRecord').text) + check_wsdl_value(get_wsdl_value(fn_data, 'search'), zone, type=('http://www.w3.org/2001/XMLSchema', 'string')) + check_wsdl_record(get_wsdl_value(fn_data, 'recorddata'), entry) + return True + + return predicate + + +def validate_wsdl_update_request(entry): + def predicate(content, header, body): + fn_data = get_wsdl_value(body, lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'updateRecord').text) + check_wsdl_value(get_wsdl_value(fn_data, 'recordId'), str(entry[0]), type=('http://www.w3.org/2001/XMLSchema', 'int')) + check_wsdl_record(get_wsdl_value(fn_data, 'recorddata'), entry) + return True + + return predicate + + +def validate_wsdl_del_request(entry): + def predicate(content, header, body): + fn_data = get_wsdl_value(body, lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'deleteRecord').text) + check_wsdl_value(get_wsdl_value(fn_data, 'recordId'), str(entry[0]), type=('http://www.w3.org/2001/XMLSchema', 'int')) + return True + + return predicate + + +def create_wsdl_add_result(entry): + lines = [] + add_wsdl_answer_start_lines(lines) + lines.append('<ns1:addRecordResponse>') + add_wsdl_dns_record_lines(lines, entry, 'return') + lines.append('</ns1:addRecordResponse>') + add_wsdl_answer_end_lines(lines) + return ''.join(lines) + + +def create_wsdl_update_result(entry): + lines = [] + add_wsdl_answer_start_lines(lines) + lines.append('<ns1:updateRecordResponse>') + add_wsdl_dns_record_lines(lines, entry, 'return') + lines.append('</ns1:updateRecordResponse>') + add_wsdl_answer_end_lines(lines) + return ''.join(lines) + + +def create_wsdl_del_result(success): + lines = [] + add_wsdl_answer_start_lines(lines) + lines.extend([ + '<ns1:deleteRecordResponse>', + '<return xsi:type="xsd:boolean">{success}</return>'.format(success='true' if success else 'false'), + '</ns1:deleteRecordResponse>', + ]) + add_wsdl_answer_end_lines(lines) + return ''.join(lines) + + +HOSTTECH_WSDL_DEFAULT_ZONE_RESULT = create_wsdl_zones_answer(42, 'example.com', HOSTTECH_WSDL_DEFAULT_ENTRIES) + +HOSTTECH_WSDL_ZONE_NOT_FOUND = create_wsdl_zone_not_found_answer() + +HOSTTECH_JSON_ZONE_LIST_RESULT = { + "data": [ + { + "id": 42, + "name": "example.com", + "email": "test@example.com", + "ttl": 10800, + "nameserver": "ns1.hosttech.ch", + "dnssec": False, + }, + { + "id": 43, + "name": "foo.com", + "email": "test@foo.com", + "ttl": 10800, + "nameserver": "ns1.hosttech.ch", + 'dnssec': True, + 'dnssec_email': 'test@foo.com', + }, + ], +} + +HOSTTECH_JSON_ZONE_GET_RESULT = { + "data": { + "id": 42, + "name": "example.com", + "email": "test@example.com", + "ttl": 10800, + "nameserver": "ns1.hosttech.ch", + "dnssec": False, + "records": HOSTTECH_JSON_DEFAULT_ENTRIES, + } +} + +HOSTTECH_JSON_ZONE_2_GET_RESULT = { + "data": { + "id": 43, + "name": "foo.com", + "email": "test@foo.com", + "ttl": 10800, + "nameserver": "ns1.hosttech.ch", + 'dnssec': True, + 'dnssec_email': 'test@foo.com', + 'ds_records': [ + { + 'key_tag': 12345, + 'algorithm': 8, + 'digest_type': 1, + 'digest': '012356789ABCDEF0123456789ABCDEF012345678', + 'flags': 257, + 'protocol': 3, + 'public_key': + 'MuhdzsQdqEGShwjtJDKZZjdKqUSGluFzTTinpuEeIRzLLcgkwgAPKWFa ' + 'eQntNlmcNDeCziGwpdvhJnvKXEMbFcZwsaDIJuWqERxAQNGABWfPlCLh ' + 'HQPnbpRPNKipSdBaUhuOubvFvjBpFAwiwSAapRDVsAgKvjXucfXpFfYb ' + 'pCundbAXBWhbpHVbqgmGoixXzFSwUsGVYLPpBCiDlLJwzjRKYYaoVYge ' + 'kMtKFYUVnWIKbectWkDFdVqXwkKigCUDiuTTJxOBRJRNzGiDNMWBjYSm ' + 'bBCAHMaMYaghLbYTwyKXltdHTHwBwtswGNfpnEdSpKFzZJonBZArQfHD ' + 'lfceKgmKwEF=', + }, + { + 'key_tag': 12345, + 'algorithm': 8, + 'digest_type': 2, + 'digest': '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF', + 'flags': 257, + 'protocol': 3, + 'public_key': + 'MuhdzsQdqEGShwjtJDKZZjdKqUSGluFzTTinpuEeIRzLLcgkwgAPKWFa ' + 'eQntNlmcNDeCziGwpdvhJnvKXEMbFcZwsaDIJuWqERxAQNGABWfPlCLh ' + 'HQPnbpRPNKipSdBaUhuOubvFvjBpFAwiwSAapRDVsAgKvjXucfXpFfYb ' + 'pCundbAXBWhbpHVbqgmGoixXzFSwUsGVYLPpBCiDlLJwzjRKYYaoVYge ' + 'kMtKFYUVnWIKbectWkDFdVqXwkKigCUDiuTTJxOBRJRNzGiDNMWBjYSm ' + 'bBCAHMaMYaghLbYTwyKXltdHTHwBwtswGNfpnEdSpKFzZJonBZArQfHD ' + 'lfceKgmKwEF=', + } + ], + "records": [], + } +} + +HOSTTECH_JSON_ZONE_RECORDS_GET_RESULT = { + "data": HOSTTECH_JSON_DEFAULT_ENTRIES, +} diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record.py new file mode 100644 index 000000000..f5bb05c7e --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record.py @@ -0,0 +1,835 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hetzner_dns_record + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hetzner import ( + HETZNER_JSON_DEFAULT_ENTRIES, + HETZNER_JSON_ZONE_GET_RESULT, + HETZNER_JSON_ZONE_LIST_RESULT, + HETZNER_JSON_ZONE_RECORDS_GET_RESULT, +) + + +class TestHetznerDNSRecordJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hetzner_dns_record.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '23', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json({'error': {'message': 'zone not found', 'code': 404}}), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id_prefix(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '23', + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '23') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json({'records': [], 'error': {'message': 'zone not found', 'code': 404}}), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_json({'message': 'Invalid authentication credentials'}), + ]) + + assert result['msg'] == ( + 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401): Invalid authentication credentials' + ) + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://dns.hetzner.com/api/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_conversion_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'TXT', + 'ttl': 3600, + 'value': u'"hellö', + 'txt_transformation': 'quoted', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['msg'] == ( + 'Error while converting DNS values: While processing record from the user: Missing double quotation mark at the end of value' + ) + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['diff']['before'] == result['diff']['after'] + + def test_idempotency_absent_value(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['diff']['before'] == {} + assert result['diff']['before'] == {} + + def test_idempotency_absent_value_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_idempotency_absent_type(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_idempotency_absent_record(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_absent_check(self, mocker): + record = HETZNER_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': ((record['name'] + '.') if record['name'] != '@' else '') + 'example.com', + 'type': record['type'], + 'value': record['value'], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_absent(self, mocker): + record = HETZNER_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': ((record['name'] + '.') if record['name'] != '@' else '') + 'example.com', + 'type': record['type'], + 'value': record['value'], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/{0}'.format(record['id'])) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '42', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_check_mode_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '42', + 'prefix': '@', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_diff': True, + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == {} + assert result['diff']['after'] == { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + 'extra': {}, + } + + def test_change_add_one(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org xxx"', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org xxx"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org xxx"', + 'ttl': 3600, + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == {} + assert result['diff']['after'] == { + 'prefix': '', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org xxx"', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + + def test_change_add_one_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_idn_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['value'], '128 issue "letsencrypt.org"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': 'xn--74h', + 'value': '128 issue "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_modify_check(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 300, + 'value': '1.2.3.5', + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_modify(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 300, + 'value': '1.2.3.5', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/126') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'A') + .expect_json_value(['ttl'], 300) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '*') + .expect_json_value(['value'], '1.2.3.5') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '126', + 'type': 'A', + 'name': '*', + 'value': '1.2.3.5', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_create_bad(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 300, + 'value': '1.2.3.5.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 422) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'A') + .expect_json_value(['ttl'], 300) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '*') + .expect_json_value(['value'], '1.2.3.5.6') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '', + 'type': '', + 'name': '', + 'value': '', + 'zone_id': '', + 'created': '', + 'modified': '', + }, + 'error': { + 'message': 'invalid A record', + 'code': 422, + } + }), + ]) + + assert result['msg'] == ( + 'Error: The new A record with value "1.2.3.5.6" and TTL 300 has not been accepted' + ' by the server with error message "invalid A record" (error code 422)' + ) diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_info.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_info.py new file mode 100644 index 000000000..8f0a79b02 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_info.py @@ -0,0 +1,798 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import patch + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hetzner_dns_record_info + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hetzner import ( + HETZNER_JSON_ZONE_GET_RESULT, + HETZNER_JSON_ZONE_LIST_RESULT, + HETZNER_JSON_ZONE_RECORDS_GET_RESULT, +) + + +def mock_sleep(delay): + pass + + +class TestHetznerDNSRecordInfoJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hetzner_dns_record_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message='')), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .result_json(dict(message='')), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://dns.hetzner.com/api/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_too_many_retries(self, mocker): + sleep_values = [5, 10, 1, 1, 1, 60, 10, 1, 10, 3.1415] + + def sleep_check(delay): + expected = sleep_values.pop(0) + assert delay == expected + + with patch('time.sleep', sleep_check): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '-1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '61') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', 'foo') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0.9') + .result_str(''), + FetchUrlCall('GET', 429) + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '3.1415') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '42') + .result_str(''), + ]) + print(sleep_values) + assert result['msg'] == 'Error: Stopping after 10 failed retries with 429 Too Many Attempts' + assert len(sleep_values) == 0 + + def test_conversion_error(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_failed(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'TXT', + 'txt_transformation': 'quoted', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json({'records': [ + { + 'id': '201', + 'type': 'TXT', + 'name': '@', + 'value': u'"hellö', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + ]}), + ]) + + assert result['msg'] == ( + 'Error while converting DNS values: While processing record from API: Missing double quotation mark at the end of value' + ) + + def test_get_single(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert len(result['records']) == 1 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + + def test_get_single_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert len(result['records']) == 1 + assert result['records'][0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + + def test_get_all_for_one_record(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com', + 'record': '*.example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert len(result['records']) == 2 + assert result['records'][0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::4', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + + def test_get_all_for_one_record_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com.', + 'prefix': '@', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert len(result['records']) == 7 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][1] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::3', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': '10 example.com', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': 'helium.ns.hetzner.de.', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': 'hydrogen.ns.hetzner.com.', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': 'oxygen.ns.hetzner.com.', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][6] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': 'hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + + def test_get_all(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foobar', + 'what': 'all_records', + 'zone_id': '42', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foobar') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foobar') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert len(result['records']) == 10 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::3', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][3] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::4', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': '10 example.com', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': 'helium.ns.hetzner.de.', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][6] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': 'hydrogen.ns.hetzner.com.', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][7] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': 'oxygen.ns.hetzner.com.', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][8] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': 'hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + assert result['records'][9] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': u'bär "with quotes" (use \\ to escape)', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + } + + def test_get_single_txt_api(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'txt_transformation': 'api', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['records'] == [{ + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': u'bär " \\"with quotes\\"" " " "(use \\\\ to escape)"', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + }] + + def test_get_single_txt_quoted(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'txt_transformation': 'quoted', + 'txt_character_encoding': 'decimal', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['records'] == [{ + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': u'"b\\195\\164r \\"with quotes\\" (use \\\\ to escape)"', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + }] + + def test_get_single_txt_quoted_octal(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'txt_transformation': 'quoted', + 'txt_character_encoding': 'octal', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['records'] == [{ + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': u'"b\\303\\244r \\"with quotes\\" (use \\\\ to escape)"', + 'extra': { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + }] diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_set.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_set.py new file mode 100644 index 000000000..4bf2d1050 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_set.py @@ -0,0 +1,1901 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hetzner_dns_record_set + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hetzner import ( + HETZNER_JSON_DEFAULT_ENTRIES, + HETZNER_JSON_ZONE_GET_RESULT, + HETZNER_JSON_ZONE_LIST_RESULT, + HETZNER_JSON_ZONE_RECORDS_GET_RESULT, +) + + +class TestHetznerDNSRecordJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hetzner_dns_record_set.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '23', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json({'error': {'message': 'zone not found', 'code': 404}}), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id_prefix(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '23', + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '23') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json({'records': [], 'error': {'message': 'zone not found', 'code': 404}}), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_json({'message': 'Invalid authentication credentials'}), + ]) + + assert result['msg'] == ( + 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401): Invalid authentication credentials' + ) + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://dns.hetzner.com/api/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_conversion_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'TXT', + 'ttl': 3600, + 'value': [ + u'"hellö', + ], + 'txt_transformation': 'quoted', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['msg'] == ( + 'Error while converting DNS values: While processing record from the user: Missing double quotation mark at the end of value' + ) + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': ['10 example.com'], + } + assert result['diff']['before'] == result['diff']['after'] + + def test_idempotency_absent_value(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['diff']['before'] == { + 'record': '*.example.com', + 'prefix': '*', + 'type': 'A', + 'ttl': 3600, + 'value': ['1.2.3.5'], + } + assert result['diff']['before'] == result['diff']['after'] + + def test_idempotency_absent_value_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_idempotency_absent_ttl(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 1800, + 'value': [ + '1.2.3.5', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_idempotency_absent_type(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_idempotency_absent_record(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'warnings' not in result + + def test_idempotency_absent_record_warn(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep_and_warn', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert list(result['warnings']) == ["Record already exists with different value. Set on_existing=replace to remove it"] + + def test_idempotency_absent_record_fail(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep_and_fail', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['msg'] == "Record already exists with different value. Set on_existing=replace to remove it" + + def test_absent(self, mocker): + record = HETZNER_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': ((record['name'] + '.') if record['name'] != '@' else '') + 'example.com', + 'type': record['type'], + 'ttl': record['ttl'], + 'value': [ + record['value'], + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/{0}'.format(record['id'])) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_absent_error(self, mocker): + record = HETZNER_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': ((record['name'] + '.') if record['name'] != '@' else '') + 'example.com', + 'type': record['type'], + 'ttl': record['ttl'], + 'value': [ + record['value'], + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/{0}'.format(record['id'])) + .return_header('Content-Type', 'application/json') + .result_json({'error': {'message': 'Internal Server Error', 'code': 500}}), + ]) + + print(result['msg']) + assert result['msg'] == ( + 'Error: Expected HTTP status 200, 404 for DELETE https://dns.hetzner.com/api/v1/records/125,' + ' but got HTTP status 500 (Internal Server Error) with error message "Internal Server Error" (error code 500)' + ) + + def test_absent_bulk(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'value': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/130') + .result_str(''), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .result_str(''), + # Record 132 has been deleted between querying and we trying to delete it + FetchUrlCall('DELETE', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'record does not exist'}), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_absent_bulk_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'value': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/130') + .result_str(''), + FetchUrlCall('DELETE', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .return_header('Content-Type', 'application/json') + .result_json({'error': {'message': 'Internal Server Error', 'code': 500}}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 200, 404 for DELETE https://dns.hetzner.com/api/v1/records/131,' + ' but got HTTP status 500 (Internal Server Error) with error message "Internal Server Error" (error code 500)' + ) + + def test_absent_other_value(self, mocker): + record = HETZNER_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': ((record['name'] + '.') if record['name'] != '@' else '') + 'example.com', + 'type': record['type'], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/{0}'.format(record['id'])) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '42', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_check_mode_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_id': '42', + 'prefix': '@', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + '_ansible_diff': True, + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == {} + assert result['diff']['after'] == { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': ['0 issue "letsencrypt.org"'], + } + + def test_change_add_one(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org xxx"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org xxx"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_idn_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['value'], '128 issue "letsencrypt.org"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': 'xn--74h', + 'value': '128 issue "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_modify_list_fail(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': None, + 'value': [ + 'helium.ns.hetzner.de.', + 'ytterbium.ns.hetzner.com.', + ], + 'on_existing': 'keep_and_fail', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['msg'] == "Record already exists with different value. Set on_existing=replace to replace it" + + def test_change_modify_list_warn(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'helium.ns.hetzner.de.', + 'ytterbium.ns.hetzner.com.', + ], + 'on_existing': 'keep_and_warn', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': None, + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + } + assert result['diff']['after'] == result['diff']['before'] + assert list(result['warnings']) == ["Record already exists with different value. Set on_existing=replace to replace it"] + + def test_change_modify_list_keep(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': None, + 'value': [ + 'helium.ns.hetzner.de.', + 'ytterbium.ns.hetzner.com.', + ], + 'on_existing': 'keep', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert 'warnings' not in result + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': None, + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + } + assert result['diff']['after'] == result['diff']['before'] + + def test_change_modify_list(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': None, + 'value': [ + 'helium.ns.hetzner.de.', + 'ytterbium.ns.hetzner.com.', + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .result_str(''), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value_absent(['ttl']) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'ytterbium.ns.hetzner.com.') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '132', + 'type': 'NS', + 'name': '@', + 'value': 'ytterbium.ns.hetzner.com.', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': None, + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + } + assert result['diff']['after'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': None, + 'value': ['helium.ns.hetzner.de.', 'ytterbium.ns.hetzner.com.'], + } + + def test_change_modify_txt_unquoted(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'foo.example.com', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär "with quotes" (use \\ to escape)!'], + 'txt_transformation': 'unquoted', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/201') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'TXT') + .expect_json_value_absent(['ttl']) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'foo') + .expect_json_value(['value'], u'"bär \\"with quotes\\" (use \\\\ to escape)!"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '201', + 'type': 'TXT', + 'name': 'foo', + 'value': u'"bär \\"with quotes\\" (use \\\\ to escape)!"', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär "with quotes" (use \\ to escape)'], + } + assert result['diff']['after'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär "with quotes" (use \\ to escape)!'], + } + + def test_change_modify_txt_quoted(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'foo.example.com', + 'type': 'TXT', + 'ttl': None, + 'value': [r'"b\195\164r \"with quotes\" (use \\ to escape)!"'], + 'txt_transformation': 'quoted', + 'txt_character_encoding': 'decimal', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/201') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'TXT') + .expect_json_value_absent(['ttl']) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'foo') + .expect_json_value(['value'], u'"bär \\"with quotes\\" (use \\\\ to escape)!"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '201', + 'type': 'TXT', + 'name': 'foo', + 'value': u'"bär \\"with quotes\\" (use \\\\ to escape)!"', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [r'"b\195\164r \"with quotes\" (use \\ to escape)"'], + } + assert result['diff']['after'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [r'"b\195\164r \"with quotes\" (use \\ to escape)!"'], + } + + def test_change_modify_txt_quoted_octal(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'foo.example.com', + 'type': 'TXT', + 'ttl': None, + 'value': [r'"b\303\244r \"with quotes\" (use \\ to escape)!"'], + 'txt_transformation': 'quoted', + 'txt_character_encoding': 'octal', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/201') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'TXT') + .expect_json_value_absent(['ttl']) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'foo') + .expect_json_value(['value'], u'"bär \\"with quotes\\" (use \\\\ to escape)!"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '201', + 'type': 'TXT', + 'name': 'foo', + 'value': u'"bär \\"with quotes\\" (use \\\\ to escape)!"', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [r'"b\303\244r \"with quotes\" (use \\ to escape)"'], + } + assert result['diff']['after'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [r'"b\303\244r \"with quotes\" (use \\ to escape)!"'], + } + + def test_change_modify_txt_api(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'foo.example.com', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär " " \\"with " " quotes\\" " (use \\\\ to escape)!"'], + 'txt_transformation': 'api', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/201') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'TXT') + .expect_json_value_absent(['ttl']) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'foo') + .expect_json_value(['value'], u'bär " " \\"with " " quotes\\" " (use \\\\ to escape)!"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '201', + 'type': 'TXT', + 'name': 'foo', + 'value': u'bär " " \\"with " " quotes\\" " (use \\\\ to escape)!"', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär " \\"with quotes\\"" " " "(use \\\\ to escape)"'], + } + assert result['diff']['after'] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär " " \\"with " " quotes\\" " (use \\\\ to escape)!"'], + } + + def test_change_modify_bulk(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a1') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '132', + 'type': 'NS', + 'name': '@', + 'value': 'a1', + 'ttl': 10800, + 'zone_id': '42', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a2') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '131', + 'type': 'NS', + 'name': '@', + 'value': 'a2', + 'ttl': 10800, + 'zone_id': '42', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/130') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a3') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '130', + 'type': 'NS', + 'name': '@', + 'value': 'a3', + 'ttl': 10800, + 'zone_id': '42', + }, + }), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/bulk') + .expect_json_value_absent(['records', 0, 'id']) + .expect_json_value(['records', 0, 'type'], 'NS') + .expect_json_value(['records', 0, 'ttl'], 10800) + .expect_json_value(['records', 0, 'zone_id'], '42') + .expect_json_value(['records', 0, 'name'], '@') + .expect_json_value(['records', 0, 'value'], 'a4') + .expect_json_value_absent(['records', 1, 'id']) + .expect_json_value(['records', 1, 'type'], 'NS') + .expect_json_value(['records', 1, 'ttl'], 10800) + .expect_json_value(['records', 1, 'zone_id'], '42') + .expect_json_value(['records', 1, 'name'], '@') + .expect_json_value(['records', 1, 'value'], 'a5') + .expect_json_value_absent(['records', 2, 'id']) + .expect_json_value(['records', 2, 'type'], 'NS') + .expect_json_value(['records', 2, 'ttl'], 10800) + .expect_json_value(['records', 2, 'zone_id'], '42') + .expect_json_value(['records', 2, 'name'], '@') + .expect_json_value(['records', 2, 'value'], 'a6') + .expect_json_value_absent(['records', 3]) + .return_header('Content-Type', 'application/json') + .result_json({ + 'invalid_records': [], + 'valid_records': [], + 'records': [ + { + 'id': '300', + 'type': 'NS', + 'name': '@', + 'value': 'a4', + 'ttl': 10800, + 'zone_id': '42', + }, + { + 'id': '301', + 'type': 'NS', + 'name': '@', + 'value': 'a5', + 'ttl': 10800, + 'zone_id': '42', + }, + { + 'id': '302', + 'type': 'NS', + 'name': '@', + 'value': 'a6', + 'ttl': 10800, + 'zone_id': '42', + }, + ], + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' not in result + + def test_change_modify_bulk_errors(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a1') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'Internal Server Error'}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 200, 422 for PUT https://dns.hetzner.com/api/v1/records/132,' + ' but got HTTP status 500 (Internal Server Error) with message "Internal Server Error"' + ) + + def test_change_modify_bulk_errors_2(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a1') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '132', + 'type': 'NS', + 'name': '@', + 'value': 'a1', + 'ttl': 10800, + 'zone_id': '42', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a2') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '131', + 'type': 'NS', + 'name': '@', + 'value': 'a2', + 'ttl': 10800, + 'zone_id': '42', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/130') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'a3') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '130', + 'type': 'NS', + 'name': '@', + 'value': 'a3', + 'ttl': 10800, + 'zone_id': '42', + }, + }), + FetchUrlCall('POST', 422) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/bulk') + .expect_json_value_absent(['records', 0, 'id']) + .expect_json_value(['records', 0, 'type'], 'NS') + .expect_json_value(['records', 0, 'ttl'], 10800) + .expect_json_value(['records', 0, 'zone_id'], '42') + .expect_json_value(['records', 0, 'name'], '@') + .expect_json_value(['records', 0, 'value'], 'a4') + .expect_json_value_absent(['records', 1, 'id']) + .expect_json_value(['records', 1, 'type'], 'NS') + .expect_json_value(['records', 1, 'ttl'], 10800) + .expect_json_value(['records', 1, 'zone_id'], '42') + .expect_json_value(['records', 1, 'name'], '@') + .expect_json_value(['records', 1, 'value'], 'a5') + .expect_json_value_absent(['records', 2, 'id']) + .expect_json_value(['records', 2, 'type'], 'NS') + .expect_json_value(['records', 2, 'ttl'], 10800) + .expect_json_value(['records', 2, 'zone_id'], '42') + .expect_json_value(['records', 2, 'name'], '@') + .expect_json_value(['records', 2, 'value'], 'a6') + .expect_json_value_absent(['records', 3]) + .return_header('Content-Type', 'application/json') + .result_json({ + 'invalid_records': [ + { + 'type': 'NS', + 'name': '@', + 'value': 'a4', + 'ttl': 10800, + 'zone_id': '42', + }, + { + 'type': 'NS', + 'name': '@', + 'value': 'a5', + 'ttl': 10800, + 'zone_id': '42', + }, + ], + 'valid_records': [ + { + 'type': 'NS', + 'name': '@', + 'value': 'a6', + 'ttl': 10800, + 'zone_id': '42', + }, + ], + 'records': [], + 'error': { + 'message': 'invalid NS record, invalid NS record, ', + 'code': 422, + }, + }), + ]) + + assert result['msg'] == ( + 'Errors: Creating NS record "a4" with TTL 10800 for zone 42 failed with unknown reason;' + ' Creating NS record "a5" with TTL 10800 for zone 42 failed with unknown reason' + ) + + def test_change_change_bad(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set, { + 'hetzner_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.4.5', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('PUT', 422) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/125') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'A') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '1.2.3.4.5') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '', + 'type': '', + 'name': '', + 'value': '', + 'zone_id': '', + 'created': '', + 'modified': '', + }, + 'error': { + 'message': 'invalid A record', + 'code': 422, + } + }), + ]) + + assert result['msg'] == ( + 'Error: The updated A record with value "1.2.3.4.5" and TTL 3600 has not been accepted' + ' by the server with error message "invalid A record" (error code 422)' + ) diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_set_info.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_set_info.py new file mode 100644 index 000000000..a42add09d --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_set_info.py @@ -0,0 +1,696 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import patch + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hetzner_dns_record_set_info + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hetzner import ( + HETZNER_JSON_ZONE_GET_RESULT, + HETZNER_JSON_ZONE_LIST_RESULT, + HETZNER_JSON_ZONE_RECORDS_GET_RESULT, +) + + +def mock_sleep(delay): + pass + + +class TestHetznerDNSRecordSetInfoJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hetzner_dns_record_set_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://dns.hetzner.com/api/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_too_many_retries(self, mocker): + sleep_values = [5, 10, 1, 1, 1, 60, 10, 1, 10, 3.1415] + + def sleep_check(delay): + expected = sleep_values.pop(0) + assert delay == expected + + with patch('time.sleep', sleep_check): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '-1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '61') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', 'foo') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0.9') + .result_str(''), + FetchUrlCall('GET', 429) + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '3.1415') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '42') + .result_str(''), + ]) + print(sleep_values) + assert result['msg'] == 'Error: Stopping after 10 failed retries with 429 Too Many Attempts' + assert len(sleep_values) == 0 + + def test_conversion_error(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_failed(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'TXT', + 'txt_transformation': 'quoted', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json({'records': [ + { + 'id': '201', + 'type': 'TXT', + 'name': '@', + 'value': u'"hellö', + 'zone_id': '42', + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + }, + ]}), + ]) + + assert result['msg'] == ( + 'Error while converting DNS values: While processing record from API: Missing double quotation mark at the end of value' + ) + + def test_get_single(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' in result + assert result['set']['record'] == 'example.com' + assert result['set']['prefix'] == '' + assert result['set']['ttl'] == 3600 + assert result['set']['type'] == 'A' + assert result['set']['value'] == ['1.2.3.4'] + assert 'sets' not in result + + def test_get_single_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' in result + assert result['set']['record'] == '*.example.com' + assert result['set']['prefix'] == '*' + assert result['set']['ttl'] == 3600 + assert result['set']['type'] == 'A' + assert result['set']['value'] == ['1.2.3.5'] + assert 'sets' not in result + + def test_get_all_for_one_record(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com', + 'record': '*.example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 2 + assert sets[0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + } + assert sets[1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + } + + def test_get_all_for_one_record_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com.', + 'prefix': '@', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 5 + assert sets[0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + } + assert sets[1] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + } + assert sets[2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + } + assert sets[3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + } + assert sets[4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + } + + def test_get_all(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'what': 'all_records', + 'zone_id': '42', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 8 + assert sets[0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + } + assert sets[1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + } + assert sets[2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + } + assert sets[3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + } + assert sets[4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + } + assert sets[5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + } + assert sets[6] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + } + assert sets[7] == { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + } + + def test_get_single_txt_api(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'txt_transformation': 'api', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' in result + assert result['set']['record'] == 'foo.example.com' + assert result['set']['prefix'] == 'foo' + assert result['set']['ttl'] is None + assert result['set']['type'] == 'TXT' + assert result['set']['value'] == [u'bär " \\"with quotes\\"" " " "(use \\\\ to escape)"'] + assert 'sets' not in result + + def test_get_single_txt_quoted(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'txt_transformation': 'quoted', + 'txt_character_encoding': 'decimal', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' in result + assert result['set']['record'] == 'foo.example.com' + assert result['set']['prefix'] == 'foo' + assert result['set']['ttl'] is None + assert result['set']['type'] == 'TXT' + assert result['set']['value'] == [u'"b\\195\\164r \\"with quotes\\" (use \\\\ to escape)"'] + assert 'sets' not in result + + def test_get_single_txt_quoted_deprecation(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hetzner_dns_record_set_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prefix': 'foo', + 'type': 'TXT', + 'txt_transformation': 'quoted', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert 'set' in result + assert result['set']['record'] == 'foo.example.com' + assert result['set']['prefix'] == 'foo' + assert result['set']['ttl'] is None + assert result['set']['type'] == 'TXT' + assert result['set']['value'] == [u'"b\\303\\244r \\"with quotes\\" (use \\\\ to escape)"'] + assert 'sets' not in result + assert 'deprecations' in result + found = False + for deprecation in result['deprecations']: + if 'collection_name' in deprecation and deprecation['collection_name'] != 'community.dns': + continue + found = True + assert deprecation['msg'] == ( + 'The default of the txt_character_encoding option will change from "octal" to "decimal" in community.dns 3.0.0.' + ' This potentially affects you since you use txt_transformation=quoted.' + ' You can explicitly set txt_character_encoding to "octal" to keep the current behavior,' + ' or "decimal" to already now switch to the new behavior.' + ' We recommend switching to the new behavior, and using check/diff mode to figure out potential changes' + ) + assert deprecation['version'] == '3.0.0' + assert deprecation.get('date') is None + assert found diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_sets.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_sets.py new file mode 100644 index 000000000..339fb49c5 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_record_sets.py @@ -0,0 +1,1236 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hetzner_dns_record_sets + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hetzner import ( + HETZNER_JSON_ZONE_GET_RESULT, + HETZNER_JSON_ZONE_LIST_RESULT, + HETZNER_JSON_ZONE_RECORDS_GET_RESULT, +) + + +class TestHetznerDNSRecordJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hetzner_dns_record_sets.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_id': 23, + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_id': 23, + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://dns.hetzner.com/api/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_key_collision_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_id': '42', + 'record_sets': [ + { + 'record': 'test.example.com', + 'type': 'A', + 'ignore': True, + }, + { + 'prefix': 'test', + 'type': 'A', + 'value': ['1.2.3.4'], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['msg'] == 'Found multiple sets for record test.example.com and type A: index #0 and #1' + + def test_conversion_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'TXT', + 'ttl': 3600, + 'value': [ + '"hellö', + ], + }, + ], + 'txt_transformation': 'quoted', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['msg'] == ( + 'Error while converting DNS values: While processing record from the user: Missing double quotation mark at the end of value' + ) + + def test_idempotency_empty(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_id': '42', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == '42' + + def test_removal_prune(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'prune': 'true', + 'record_sets': [ + { + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'prefix': '@', + 'ttl': 3600, + 'type': 'AAAA', + 'value': [], + }, + { + 'record': 'example.com', + 'type': 'MX', + 'ignore': True, + }, + { + 'record': 'example.com', + 'type': 'NS', + 'ignore': True, + }, + { + 'record': 'example.com', + 'type': 'SOA', + 'ignore': True, + }, + { + 'record': 'foo.example.com', + 'type': 'TXT', + 'ttl': None, + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/{0}'.format(127)) + .result_str(''), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/{0}'.format(128)) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + }, + { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': None, + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + }, + { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + } + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_id': '42', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_check_mode_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_id': '42', + 'record_sets': [ + { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org xxx"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org xxx"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_idn_prefix(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['value'], '128 issue "letsencrypt.org"') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '133', + 'type': 'CAA', + 'name': 'xn--74h', + 'value': '128 issue "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + + def test_change_add_one_failed(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], '128 issue "letsencrypt.org xxx"') + .return_header('Content-Type', 'application/json') + .result_json({'record': {}, 'error': {'code': 500, 'message': 'Internal Server Error'}}), + ]) + + assert result['msg'] == ( + 'Error: POST https://dns.hetzner.com/api/v1/records resulted in API error 500 (Internal Server Error)' + ' with error message "Internal Server Error" (error code 500)' + ) + + def test_change_add_two_failed(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + '128 issuewild "letsencrypt.org"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('POST', 422) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/bulk') + .expect_json_value_absent(['records', 0, 'id']) + .expect_json_value(['records', 0, 'type'], 'CAA') + .expect_json_value(['records', 0, 'ttl'], 3600) + .expect_json_value(['records', 0, 'zone_id'], '42') + .expect_json_value(['records', 0, 'name'], '@') + .expect_json_value(['records', 0, 'value'], '128 issue "letsencrypt.org xxx"') + .expect_json_value_absent(['records', 1, 'id']) + .expect_json_value(['records', 1, 'type'], 'CAA') + .expect_json_value(['records', 1, 'ttl'], 3600) + .expect_json_value(['records', 1, 'zone_id'], '42') + .expect_json_value(['records', 1, 'name'], '@') + .expect_json_value(['records', 1, 'value'], '128 issuewild "letsencrypt.org"') + .expect_json_value_absent(['records', 2]) + .return_header('Content-Type', 'application/json') + .result_json({ + 'invalid_records': [ + { + 'type': 'CAA', + 'name': '@', + 'value': '128 issue "letsencrypt.org xxx"', + 'ttl': 3600, + 'zone_id': '42', + }, + { + 'type': 'CAA', + 'name': '@', + 'value': '128 issuewild "letsencrypt.org"', + 'ttl': 3600, + 'zone_id': '42', + }, + ], + 'valid_records': [], + 'records': [], + 'error': { + 'message': 'invalid CAA record, invalid CAA record, ', + 'code': 422, + }, + }), + ]) + + assert result['msg'] == ( + 'Errors: Creating CAA record "128 issue "letsencrypt.org xxx"" with TTL 3600 for zone 42 failed with unknown reason;' + ' Creating CAA record "128 issuewild "letsencrypt.org"" with TTL 3600 for zone 42 failed with unknown reason' + ) + + def test_change_modify_list(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'NS', + 'ttl': None, + 'value': [ + 'helium.ns.hetzner.de.', + 'ytterbium.ns.hetzner.com.', + ], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .result_str(''), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value_absent(['ttl']) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'ytterbium.ns.hetzner.com.') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '131', + 'type': 'NS', + 'name': '@', + 'value': 'ytterbium.ns.hetzner.com.', + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + }, + { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': None, + 'value': ['helium.ns.hetzner.de.', 'ytterbium.ns.hetzner.com.'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + }, + { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + } + + def test_change_modify_list_ttl(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_record_sets, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'NS', + 'ttl': 3600, + 'value': [ + 'helium.ns.hetzner.de.', + 'ytterbium.ns.hetzner.com.', + ], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records', without_query=True) + .expect_query_values('zone_id', '42') + .expect_query_values('page', '1') + .expect_query_values('per_page', '100') + .return_header('Content-Type', 'application/json') + .result_json(HETZNER_JSON_ZONE_RECORDS_GET_RESULT), + FetchUrlCall('DELETE', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/130') + .result_str(''), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/132') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'helium.ns.hetzner.de.') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '130', + 'type': 'NS', + 'name': '@', + 'value': 'ytterbium.ns.hetzner.com.', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/records/131') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['zone_id'], '42') + .expect_json_value(['name'], '@') + .expect_json_value(['value'], 'ytterbium.ns.hetzner.com.') + .return_header('Content-Type', 'application/json') + .result_json({ + 'record': { + 'id': '131', + 'type': 'NS', + 'name': '@', + 'value': 'ytterbium.ns.hetzner.com.', + 'ttl': 3600, + 'zone_id': '42', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == '42' + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'NS', + 'value': ['helium.ns.hetzner.de.', 'hydrogen.ns.hetzner.com.', 'oxygen.ns.hetzner.com.'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + }, + { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 3600, + 'value': ['helium.ns.hetzner.de.', 'ytterbium.ns.hetzner.com.'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': None, + 'type': 'SOA', + 'value': ['hydrogen.ns.hetzner.com. dns.hetzner.com. 2021070900 86400 10800 3600000 3600'], + }, + { + 'record': 'foo.example.com', + 'prefix': 'foo', + 'ttl': None, + 'type': 'TXT', + 'value': [u'bär "with quotes" (use \\ to escape)'], + }, + ], + } diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_zone_info.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_zone_info.py new file mode 100644 index 000000000..473d4d1bc --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hetzner_dns_zone_info.py @@ -0,0 +1,192 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hetzner_dns_zone_info + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hetzner import ( + HETZNER_JSON_ZONE_GET_RESULT, + HETZNER_JSON_ZONE_LIST_RESULT, +) + + +class TestHetznerDNSZoneInfoJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hetzner_dns_zone_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .return_header('Content-Type', 'application/json; charset=utf-8') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_id': '23', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .return_header('Content-Type', 'application/json; charset=utf-8') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_id': '23', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://dns.hetzner.com/api/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_get(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_name': 'example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones', without_query=True) + .expect_query_values('name', 'example.com') + .return_header('Content-Type', 'application/json; charset=utf-8') + .result_json(HETZNER_JSON_ZONE_LIST_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['zone_name'] == 'example.com' + assert result['zone_info'] == { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + 'legacy_dns_host': 'string', + 'legacy_ns': ['bar', 'foo'], + 'ns': ['string'], + 'owner': 'Example', + 'paused': True, + 'permission': 'string', + 'project': 'string', + 'registrar': 'string', + 'status': 'verified', + 'ttl': 10800, + 'verified': '2021-07-09T11:18:37Z', + 'records_count': 0, + 'is_secondary_dns': True, + 'txt_verification': { + 'name': 'string', + 'token': 'string', + }, + } + + def test_get_id(self, mocker): + result = self.run_module_success(mocker, hetzner_dns_zone_info, { + 'hetzner_token': 'foo', + 'zone_id': '42', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('auth-api-token', 'foo') + .expect_url('https://dns.hetzner.com/api/v1/zones/42') + .return_header('Content-Type', 'application/json; charset=utf-8') + .result_json(HETZNER_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == '42' + assert result['zone_name'] == 'example.com' + assert result['zone_info'] == { + 'created': '2021-07-09T11:18:37Z', + 'modified': '2021-07-09T11:18:37Z', + 'legacy_dns_host': 'string', + 'legacy_ns': ['bar', 'foo'], + 'ns': ['string'], + 'owner': 'Example', + 'paused': True, + 'permission': 'string', + 'project': 'string', + 'registrar': 'string', + 'status': 'verified', + 'ttl': 10800, + 'verified': '2021-07-09T11:18:37Z', + 'records_count': 0, + 'is_secondary_dns': True, + 'txt_verification': { + 'name': 'string', + 'token': 'string', + }, + } diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record.py new file mode 100644 index 000000000..c72566a2b --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record.py @@ -0,0 +1,1032 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hosttech_dns_record + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hosttech import ( + expect_wsdl_authentication, + expect_wsdl_value, + validate_wsdl_call, + validate_wsdl_add_request, + validate_wsdl_del_request, + create_wsdl_add_result, + create_wsdl_del_result, + HOSTTECH_WSDL_DEFAULT_ENTRIES, + HOSTTECH_WSDL_DEFAULT_ZONE_RESULT, + HOSTTECH_WSDL_ZONE_NOT_FOUND, + HOSTTECH_JSON_DEFAULT_ENTRIES, + HOSTTECH_JSON_ZONE_GET_RESULT, + HOSTTECH_JSON_ZONE_LIST_RESULT, + HOSTTECH_JSON_ZONE_RECORDS_GET_RESULT, +) + +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + + +@pytest.mark.skipif(not HAS_LXML_ETREE, reason="Need lxml.etree for WSDL tests") +class TestHosttechDNSRecordWSDL(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.org', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id_prefix(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_id': 23, + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_value(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_type(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_id': 42, + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '42', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_absent(self, mocker): + record = HOSTTECH_WSDL_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': record[3] + 'example.com', + 'type': record[2], + 'value': record[4], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_del_request(record), + ])) + .result_str(create_wsdl_del_result(True)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one(self, mocker): + new_entry = (131, 42, 'CAA', 'foo', '0 issue "letsencrypt.org"', 3600, None, None) + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'foo.example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_add_request('42', new_entry), + ])) + .result_str(create_wsdl_add_result(new_entry)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + +class TestHosttechDNSRecordJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id_prefix(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 23, + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23/records', without_query=True) + .expect_query_values('type', 'MX') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://api.ns1.hosttech.eu/api/user/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': '10 example.com', + 'extra': { + 'comment': '', + }, + } + assert result['diff']['before'] == result['diff']['after'] + + def test_idempotency_absent_value(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['diff']['before'] == {} + assert result['diff']['before'] == {} + + def test_idempotency_absent_value_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_type(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': '1.2.3.6', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'warnings' not in result + + def test_absent_check(self, mocker): + record = HOSTTECH_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': record['name'] + 'example.com', + 'type': record['type'], + 'value': record['ipv4'], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_absent(self, mocker): + record = HOSTTECH_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': record['name'] + 'example.com', + 'type': record['type'], + 'value': record['ipv4'], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/{0}'.format(record['id'])) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 42, + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 42, + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + '_ansible_diff': True, + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records', without_query=True) + .expect_query_values('type', 'CAA') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == {} + assert result['diff']['after'] == { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': '0 issue "letsencrypt.org"', + 'extra': {}, + } + + def test_change_add_one(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org xxx"', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org xxx') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': '', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org xxx', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == {} + assert result['diff']['after'] == { + 'prefix': '', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org xxx"', + 'extra': { + 'comment': '', + }, + } + + def test_change_add_one_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': '', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_idn_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': '128 issue "letsencrypt.org"', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': 'xn--74h', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_modify_check(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 300, + 'value': '1.2.3.5', + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_modify(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 300, + 'value': '1.2.3.5', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/126') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 300) + .expect_json_value(['name'], '*') + .expect_json_value(['ipv4'], '1.2.3.5') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': '126', + 'type': 'A', + 'name': '*', + 'ipv4': '1.2.3.5', + 'ttl': 300, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_info.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_info.py new file mode 100644 index 000000000..73c9d16a1 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_info.py @@ -0,0 +1,766 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import patch + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hosttech_dns_record_info + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hosttech import ( + expect_wsdl_authentication, + expect_wsdl_value, + validate_wsdl_call, + HOSTTECH_WSDL_DEFAULT_ZONE_RESULT, + HOSTTECH_WSDL_ZONE_NOT_FOUND, + HOSTTECH_JSON_ZONE_GET_RESULT, + HOSTTECH_JSON_ZONE_LIST_RESULT, +) + +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + + +def mock_sleep(delay): + pass + + +@pytest.mark.skipif(not HAS_LXML_ETREE, reason="Need lxml.etree for WSDL tests") +class TestHosttechDNSRecordInfoWSDL(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.org', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_get_single(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 1 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'comment': '', + }, + } + + def test_get_all_for_one_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'what': 'all_types_for_record', + 'zone_id': 42, + 'record': '*.example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '42', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 2 + assert result['records'][0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'comment': '', + }, + } + assert result['records'][1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::4', + 'extra': { + 'comment': '', + }, + } + + def test_get_all(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'what': 'all_records', + 'zone_name': 'example.com.', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 8 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'comment': '', + }, + } + assert result['records'][1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'comment': '', + }, + } + assert result['records'][2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::3', + 'extra': { + 'comment': '', + }, + } + assert result['records'][3] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::4', + 'extra': { + 'comment': '', + }, + } + assert result['records'][4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': '10 example.com', + 'extra': { + 'comment': '', + }, + } + assert result['records'][5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns3.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + assert result['records'][6] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns2.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + assert result['records'][7] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns1.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + + +class TestHosttechDNSRecordInfoJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://api.ns1.hosttech.eu/api/user/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_too_many_retries(self, mocker): + sleep_values = [5, 10, 1, 1, 1, 60, 10, 1, 10, 3.1415] + + def sleep_check(delay): + expected = sleep_values.pop(0) + assert delay == expected + + with patch('time.sleep', sleep_check): + result = self.run_module_failed(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '-1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '61') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', 'foo') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0.9') + .result_str(''), + FetchUrlCall('GET', 429) + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '3.1415') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '42') + .result_str(''), + ]) + print(sleep_values) + assert result['msg'] == 'Error: Stopping after 10 failed retries with 429 Too Many Attempts' + assert len(sleep_values) == 0 + + def test_get_single(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 1 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'comment': '', + }, + } + + def test_get_single_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 1 + assert result['records'][0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'comment': '', + }, + } + + def test_get_all_for_one_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com', + 'record': '*.example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 2 + assert result['records'][0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'comment': '', + }, + } + assert result['records'][1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::4', + 'extra': { + 'comment': '', + }, + } + + def test_get_all_for_one_record_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com.', + 'prefix': '', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 6 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'comment': '', + }, + } + assert result['records'][1] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::3', + 'extra': { + 'comment': '', + }, + } + assert result['records'][2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': '10 example.com', + 'extra': { + 'comment': '', + }, + } + assert result['records'][3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns3.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + assert result['records'][4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns2.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + assert result['records'][5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns1.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + + def test_get_all(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_info, { + 'hosttech_token': 'foo', + 'what': 'all_records', + 'zone_id': 42, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert len(result['records']) == 8 + assert result['records'][0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.4', + 'extra': { + 'comment': '', + }, + } + assert result['records'][1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': '1.2.3.5', + 'extra': { + 'comment': '', + }, + } + assert result['records'][2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::3', + 'extra': { + 'comment': '', + }, + } + assert result['records'][3] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': '2001:1:2::4', + 'extra': { + 'comment': '', + }, + } + assert result['records'][4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': '10 example.com', + 'extra': { + 'comment': '', + }, + } + assert result['records'][5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns3.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + assert result['records'][6] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns2.hostserv.eu', + 'extra': { + 'comment': '', + }, + } + assert result['records'][7] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': 'ns1.hostserv.eu', + 'extra': { + 'comment': '', + }, + } diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_set.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_set.py new file mode 100644 index 000000000..579d58091 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_set.py @@ -0,0 +1,1899 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hosttech_dns_record_set + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hosttech import ( + expect_wsdl_authentication, + expect_wsdl_value, + validate_wsdl_call, + validate_wsdl_add_request, + validate_wsdl_update_request, + validate_wsdl_del_request, + create_wsdl_add_result, + create_wsdl_update_result, + create_wsdl_del_result, + HOSTTECH_WSDL_DEFAULT_ENTRIES, + HOSTTECH_WSDL_DEFAULT_ZONE_RESULT, + HOSTTECH_WSDL_ZONE_NOT_FOUND, + HOSTTECH_JSON_DEFAULT_ENTRIES, + HOSTTECH_JSON_ZONE_GET_RESULT, + HOSTTECH_JSON_ZONE_LIST_RESULT, + HOSTTECH_JSON_ZONE_RECORDS_GET_RESULT, +) + +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + + +@pytest.mark.skipif(not HAS_LXML_ETREE, reason="Need lxml.etree for WSDL tests") +class TestHosttechDNSRecordWSDL(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_set.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.org', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id_prefix(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_id': 23, + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_value(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_ttl(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 1800, + 'value': [ + '1.2.3.5', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_type(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_id': 42, + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '42', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_absent(self, mocker): + record = HOSTTECH_WSDL_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': record[3] + 'example.com', + 'type': record[2], + 'ttl': record[5], + 'value': [ + record[4], + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_del_request(record), + ])) + .result_str(create_wsdl_del_result(True)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one(self, mocker): + new_entry = (131, 42, 'CAA', 'foo', '0 issue "letsencrypt.org"', 3600, None, None) + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'foo.example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_add_request('42', new_entry), + ])) + .result_str(create_wsdl_add_result(new_entry)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_modify_list_fail(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + 'on_existing': 'keep_and_fail', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['msg'] == "Record already exists with different value. Set on_existing=replace to replace it" + + def test_change_modify_list(self, mocker): + del_entry = (130, 42, 'NS', '', 'ns3.hostserv.eu', 10800, None, None) + update_entry = (131, 42, 'NS', '', 'ns4.hostserv.eu', 10800, None, None) + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_del_request(del_entry), + ])) + .result_str(create_wsdl_del_result(True)), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_update_request(update_entry), + ])) + .result_str(create_wsdl_update_result(update_entry)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns2.hostserv.eu', 'ns3.hostserv.eu'], + } + assert result['diff']['after'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns4.hostserv.eu'], + } + + +class TestHosttechDNSRecordJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_set.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id_prefix(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 23, + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23/records', without_query=True) + .expect_query_values('type', 'MX') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://api.ns1.hosttech.eu/api/user/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'MX', + 'ttl': 3600, + 'value': ['10 example.com'], + } + assert result['diff']['before'] == result['diff']['after'] + + def test_idempotency_absent_value(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['diff']['before'] == { + 'record': '*.example.com', + 'prefix': '*', + 'type': 'A', + 'ttl': 3600, + 'value': ['1.2.3.5'], + } + assert result['diff']['before'] == result['diff']['after'] + + def test_idempotency_absent_value_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_ttl(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': '*.example.com', + 'type': 'A', + 'ttl': 1800, + 'value': [ + '1.2.3.5', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_type(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_absent_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'warnings' not in result + + def test_idempotency_absent_record_warn(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep_and_warn', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert list(result['warnings']) == ["Record already exists with different value. Set on_existing=replace to remove it"] + + def test_idempotency_absent_record_fail(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com.', + 'record': 'somewhere.example.com.', + 'type': 'A', + 'ttl': 3600, + 'value': [ + '1.2.3.6', + ], + 'on_existing': 'keep_and_fail', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['msg'] == "Record already exists with different value. Set on_existing=replace to remove it" + + def test_absent(self, mocker): + record = HOSTTECH_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': record['name'] + 'example.com', + 'type': record['type'], + 'ttl': record['ttl'], + 'value': [ + record['ipv4'], + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/{0}'.format(record['id'])) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_absent_bulk(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'value': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .result_str(''), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .result_str(''), + # Record 132 has been deleted between querying and we trying to delete it + FetchUrlCall('DELETE', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/132') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'record does not exist'}), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_absent_bulk_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'value': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .result_str(''), + FetchUrlCall('DELETE', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'Internal Server Error'}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 204, 404 for DELETE https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131,' + ' but got HTTP status 500 (Internal Server Error) with message "Internal Server Error"' + ) + + def test_absent_other_value(self, mocker): + record = HOSTTECH_JSON_DEFAULT_ENTRIES[0] + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'absent', + 'zone_name': 'example.com', + 'record': record['name'] + 'example.com', + 'type': record['type'], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/{0}'.format(record['id'])) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 42, + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_id': 42, + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + '_ansible_diff': True, + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records', without_query=True) + .expect_query_values('type', 'CAA') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_RECORDS_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == {} + assert result['diff']['after'] == { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': ['0 issue "letsencrypt.org"'], + } + + def test_change_add_one(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org xxx') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': '', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org xxx', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': '', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_idn_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': 'xn--74h', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_fail(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'Internal Server Error'}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 201 for POST https://api.ns1.hosttech.eu/api/user/v1/zones/42/records,' + ' but got HTTP status 500 (Internal Server Error) with message "Internal Server Error"' + ) + + def test_change_modify_list_fail(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + 'on_existing': 'keep_and_fail', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['msg'] == "Record already exists with different value. Set on_existing=replace to replace it" + + def test_change_modify_list_warn(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + 'on_existing': 'keep_and_warn', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns2.hostserv.eu', 'ns3.hostserv.eu'], + } + assert result['diff']['after'] == result['diff']['before'] + assert list(result['warnings']) == ["Record already exists with different value. Set on_existing=replace to replace it"] + + def test_change_modify_list_keep(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + 'on_existing': 'keep', + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert 'warnings' not in result + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns2.hostserv.eu', 'ns3.hostserv.eu'], + } + assert result['diff']['after'] == result['diff']['before'] + + def test_change_modify_list(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .result_str(''), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'ns4.hostserv.eu') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 131, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns4.hostserv.eu', + 'ttl': 10800, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns2.hostserv.eu', 'ns3.hostserv.eu'], + } + assert result['diff']['after'] == { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns4.hostserv.eu'], + } + + def test_change_modify_bulk(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/132') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a1') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 132, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a1', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a2') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 131, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a2', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a3') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 130, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a3', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a4') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 300, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a4', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a5') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 301, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a5', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a6') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 302, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a6', + 'ttl': 10800, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' not in result + + def test_change_modify_bulk_errors_update(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('PUT', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/132') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a1') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'Internal Server Error'}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 200 for PUT https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/132,' + ' but got HTTP status 500 (Internal Server Error) with message "Internal Server Error"' + ) + + def test_change_modify_bulk_errors_create(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set, { + 'hosttech_token': 'foo', + 'state': 'present', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'a1', + 'a2', + 'a3', + 'a4', + 'a5', + 'a6', + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/132') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a1') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 132, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a1', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a2') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 131, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a2', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a3') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 130, + 'type': 'NS', + 'ownername': '', + 'targetname': 'a3', + 'ttl': 10800, + 'comment': '', + }, + }), + FetchUrlCall('POST', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'NS') + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'a4') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'Internal Server Error'}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 201 for POST https://api.ns1.hosttech.eu/api/user/v1/zones/42/records,' + ' but got HTTP status 500 (Internal Server Error) with message "Internal Server Error"' + ) diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_set_info.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_set_info.py new file mode 100644 index 000000000..35fd53730 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_set_info.py @@ -0,0 +1,649 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import patch + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hosttech_dns_record_set_info + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hosttech import ( + expect_wsdl_authentication, + expect_wsdl_value, + validate_wsdl_call, + HOSTTECH_WSDL_DEFAULT_ZONE_RESULT, + HOSTTECH_WSDL_ZONE_NOT_FOUND, + HOSTTECH_JSON_ZONE_GET_RESULT, + HOSTTECH_JSON_ZONE_LIST_RESULT, +) + +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + + +def mock_sleep(delay): + pass + + +@pytest.mark.skipif(not HAS_LXML_ETREE, reason="Need lxml.etree for WSDL tests") +class TestHosttechDNSRecordSetInfoWSDL(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_set_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.org', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_get_single(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' in result + assert result['set']['record'] == 'example.com' + assert result['set']['prefix'] == '' + assert result['set']['ttl'] == 3600 + assert result['set']['type'] == 'A' + assert result['set']['value'] == ['1.2.3.4'] + assert 'sets' not in result + + def test_get_all_for_one_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'what': 'all_types_for_record', + 'zone_id': 42, + 'record': '*.example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '42', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 2 + assert sets[0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + } + assert sets[1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + } + + def test_get_all(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'what': 'all_records', + 'zone_name': 'example.com.', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 6 + assert sets[0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + } + assert sets[1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + } + assert sets[2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + } + assert sets[3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + } + assert sets[4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + } + assert sets[5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + } + + +class TestHosttechDNSRecordSetInfoJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_set_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_id': 23, + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record': 'example.org', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://api.ns1.hosttech.eu/api/user/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_too_many_retries(self, mocker): + sleep_values = [5, 10, 1, 1, 1, 60, 10, 1, 10, 3.1415] + + def sleep_check(delay): + expected = sleep_values.pop(0) + assert delay == expected + + with patch('time.sleep', sleep_check): + result = self.run_module_failed(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '-1') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '61') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', 'foo') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '0.9') + .result_str(''), + FetchUrlCall('GET', 429) + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '3.1415') + .result_str(''), + FetchUrlCall('GET', 429) + .return_header('Retry-After', '42') + .result_str(''), + ]) + print(sleep_values) + assert result['msg'] == 'Error: Stopping after 10 failed retries with 429 Too Many Attempts' + assert len(sleep_values) == 0 + + def test_get_single(self, mocker): + with patch('time.sleep', mock_sleep): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record': 'example.com', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '5') + .result_str(''), + FetchUrlCall('GET', 429) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Retry-After', '10') + .result_str(''), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' in result + assert result['set']['record'] == 'example.com' + assert result['set']['prefix'] == '' + assert result['set']['ttl'] == 3600 + assert result['set']['type'] == 'A' + assert result['set']['value'] == ['1.2.3.4'] + assert 'sets' not in result + + def test_get_single_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'prefix': '*', + 'type': 'A', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' in result + assert result['set']['record'] == '*.example.com' + assert result['set']['prefix'] == '*' + assert result['set']['ttl'] == 3600 + assert result['set']['type'] == 'A' + assert result['set']['value'] == ['1.2.3.5'] + assert 'sets' not in result + + def test_get_all_for_one_record(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com', + 'record': '*.example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 2 + assert sets[0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + } + assert sets[1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + } + + def test_get_all_for_one_record_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'what': 'all_types_for_record', + 'zone_name': 'example.com.', + 'prefix': '', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 4 + assert sets[0] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + } + assert sets[1] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + } + assert sets[2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + } + assert sets[3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + } + + def test_get_all(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_set_info, { + 'hosttech_token': 'foo', + 'what': 'all_records', + 'zone_id': 42, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert 'set' not in result + assert 'sets' in result + sets = result['sets'] + assert len(sets) == 6 + assert sets[0] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + } + assert sets[1] == { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + } + assert sets[2] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + } + assert sets[3] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + } + assert sets[4] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + } + assert sets[5] == { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + } diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_sets.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_sets.py new file mode 100644 index 000000000..8c609b3af --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_record_sets.py @@ -0,0 +1,1429 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hosttech_dns_record_sets + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hosttech import ( + expect_wsdl_authentication, + expect_wsdl_value, + validate_wsdl_call, + validate_wsdl_add_request, + validate_wsdl_update_request, + validate_wsdl_del_request, + create_wsdl_add_result, + create_wsdl_update_result, + create_wsdl_del_result, + HOSTTECH_WSDL_DEFAULT_ZONE_RESULT, + HOSTTECH_WSDL_ZONE_NOT_FOUND, + HOSTTECH_JSON_ZONE_GET_RESULT, + HOSTTECH_JSON_ZONE_LIST_RESULT, +) + +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + + +@pytest.mark.skipif(not HAS_LXML_ETREE, reason="Need lxml.etree for WSDL tests") +class TestHosttechDNSRecordWSDL(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_sets.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.org', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_id': 23, + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'prefix': '', + 'type': 'A', + 'value': '1.2.3.4', + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + } + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one(self, mocker): + new_entry = (131, 42, 'CAA', 'foo', '0 issue "letsencrypt.org"', 3600, None, None) + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'foo.example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + } + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_add_request('42', new_entry), + ])) + .result_str(create_wsdl_add_result(new_entry)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_modify_list(self, mocker): + del_entry = (130, 42, 'NS', '', 'ns3.hostserv.eu', 10800, None, None) + update_entry = (131, 42, 'NS', '', 'ns4.hostserv.eu', 10800, None, None) + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_del_request(del_entry), + ])) + .result_str(create_wsdl_del_result(True)), + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + validate_wsdl_update_request(update_entry), + ])) + .result_str(create_wsdl_update_result(update_entry)), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns4.hostserv.eu'], + }, + ], + } + + +class TestHosttechDNSRecordJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_record_sets.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_id': 23, + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_id': 23, + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://api.ns1.hosttech.eu/api/user/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_key_collision_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_id': 42, + 'record_sets': [ + { + 'record': 'test.example.com', + 'type': 'A', + 'ignore': True, + }, + { + 'prefix': 'test', + 'type': 'A', + 'value': ['1.2.3.4'], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['msg'] == 'Found multiple sets for record test.example.com and type A: index #0 and #1' + + def test_idempotency_empty(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_id': 42, + 'record_sets': [], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_idempotency_present(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'MX', + 'ttl': 3600, + 'value': [ + '10 example.com', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + + def test_removal_prune(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'prune': 'true', + 'record_sets': [ + { + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': [], + }, + { + 'record': 'example.com', + 'type': 'MX', + 'ignore': True, + }, + { + 'record': 'example.com', + 'type': 'NS', + 'ignore': True, + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/{0}'.format(127)) + .result_str(''), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/{0}'.format(128)) + .result_str(''), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + + def test_change_add_one_check_mode(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_id': 42, + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_check_mode_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_id': 42, + 'record_sets': [ + { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '0 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_check_mode': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_failed(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org xxx') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'Internal Server Error'}), + ]) + + assert result['msg'] == ( + 'Error: Expected HTTP status 201 for POST https://api.ns1.hosttech.eu/api/user/v1/zones/42/records,' + ' but got HTTP status 500 (Internal Server Error) with message "Internal Server Error"' + ) + + def test_change_add_one(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org xxx"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org xxx') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': '', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org xxx', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'prefix': '', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], '') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': '', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_add_one_idn_prefix(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'prefix': '☺', + 'type': 'CAA', + 'ttl': 3600, + 'value': [ + '128 issue "letsencrypt.org"', + ], + }, + ], + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('POST', 201) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records') + .expect_json_value_absent(['id']) + .expect_json_value(['type'], 'CAA') + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['name'], 'xn--74h') + .expect_json_value(['flag'], '128') + .expect_json_value(['tag'], 'issue') + .expect_json_value(['value'], 'letsencrypt.org') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 133, + 'type': 'CAA', + 'name': 'xn--74h', + 'flag': '128', + 'tag': 'issue', + 'value': 'letsencrypt.org', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + + def test_change_modify_list(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .result_str(''), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 10800) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'ns4.hostserv.eu') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 131, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns4.hostserv.eu', + 'ttl': 10800, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns1.hostserv.eu', 'ns4.hostserv.eu'], + }, + ], + } + + def test_change_modify_list_nodelete(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'NS', + 'ttl': 10800, + 'value': [ + 'ns1.hostserv.eu', + 'ns2.hostserv.eu', + ], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .return_header('Content-Type', 'application/json') + .result_json({'message': 'record does not exist'}), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 10800, + 'value': ['ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + + def test_change_modify_list_ttl(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_record_sets, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + 'record_sets': [ + { + 'record': 'example.com', + 'type': 'NS', + 'ttl': 3600, + 'value': [ + 'ns1.hostserv.eu', + 'ns4.hostserv.eu', + ], + }, + ], + '_ansible_diff': True, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + FetchUrlCall('DELETE', 204) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/130') + .result_str(''), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/132') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'ns1.hostserv.eu') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 130, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns4.hostserv.eu', + 'ttl': 3600, + 'comment': '', + }, + }), + FetchUrlCall('PUT', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42/records/131') + .expect_json_value_absent(['id']) + .expect_json_value_absent(['type']) + .expect_json_value(['ttl'], 3600) + .expect_json_value(['comment'], '') + .expect_json_value(['ownername'], '') + .expect_json_value(['targetname'], 'ns4.hostserv.eu') + .return_header('Content-Type', 'application/json') + .result_json({ + 'data': { + 'id': 131, + 'type': 'NS', + 'ownername': '', + 'targetname': 'ns4.hostserv.eu', + 'ttl': 3600, + 'comment': '', + }, + }), + ]) + + assert result['changed'] is True + assert result['zone_id'] == 42 + assert 'diff' in result + assert 'before' in result['diff'] + assert 'after' in result['diff'] + assert result['diff']['before'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 10800, + 'type': 'NS', + 'value': ['ns3.hostserv.eu', 'ns2.hostserv.eu', 'ns1.hostserv.eu'], + }, + ], + } + assert result['diff']['after'] == { + 'record_sets': [ + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.5'], + }, + { + 'record': '*.example.com', + 'prefix': '*', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'A', + 'value': ['1.2.3.4'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'AAAA', + 'value': ['2001:1:2::3'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'ttl': 3600, + 'type': 'MX', + 'value': ['10 example.com'], + }, + { + 'record': 'example.com', + 'prefix': '', + 'type': 'NS', + 'ttl': 3600, + 'value': ['ns1.hostserv.eu', 'ns4.hostserv.eu'], + }, + ], + } diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_zone_info.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_zone_info.py new file mode 100644 index 000000000..e95aebe3a --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_hosttech_dns_zone_info.py @@ -0,0 +1,351 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021 Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.utils.fetch_url_module_framework import ( + BaseTestModule, + FetchUrlCall, +) + +from ansible_collections.community.dns.plugins.modules import hosttech_dns_zone_info + +# These imports are needed so patching below works +import ansible_collections.community.dns.plugins.module_utils.http # noqa: F401, pylint: disable=unused-import + +from .hosttech import ( + expect_wsdl_authentication, + expect_wsdl_value, + validate_wsdl_call, + HOSTTECH_WSDL_DEFAULT_ZONE_RESULT, + HOSTTECH_WSDL_ZONE_NOT_FOUND, + HOSTTECH_JSON_ZONE_GET_RESULT, + HOSTTECH_JSON_ZONE_2_GET_RESULT, + HOSTTECH_JSON_ZONE_LIST_RESULT, +) + +try: + import lxml.etree + HAS_LXML_ETREE = True +except ImportError: + HAS_LXML_ETREE = False + + +@pytest.mark.skipif(not HAS_LXML_ETREE, reason="Need lxml.etree for WSDL tests") +class TestHosttechDNSZoneInfoWSDL(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_zone_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.org', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_id': 23, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '23', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_ZONE_NOT_FOUND), + ]) + + assert result['msg'] == 'Zone not found' + + def test_get(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_zone_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_name': 'example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + 'example.com', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['zone_name'] == 'example.com' + + def test_get_id(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_zone_info, { + 'hosttech_username': 'foo', + 'hosttech_password': 'bar', + 'zone_id': '42', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('POST', 200) + .expect_content_predicate(validate_wsdl_call([ + expect_wsdl_authentication('foo', 'bar'), + expect_wsdl_value( + [lxml.etree.QName('https://ns1.hosttech.eu/public/api', 'getZone').text, 'sZoneName'], + '42', + ('http://www.w3.org/2001/XMLSchema', 'string') + ), + ])) + .result_str(HOSTTECH_WSDL_DEFAULT_ZONE_RESULT), + ]) + + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['zone_name'] == 'example.com' + assert result['zone_info'] == { + 'email': 'dns@hosttech.eu', + 'ttl': 10800, + } + + +class TestHosttechDNSZoneInfoJSON(BaseTestModule): + MOCK_ANSIBLE_MODULEUTILS_BASIC_ANSIBLEMODULE = 'ansible_collections.community.dns.plugins.modules.hosttech_dns_zone_info.AnsibleModule' + MOCK_ANSIBLE_MODULEUTILS_URLS_FETCH_URL = 'ansible_collections.community.dns.plugins.module_utils.http.fetch_url' + + def test_unknown_zone(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + ]) + + assert result['msg'] == 'Zone not found' + + def test_unknown_zone_id(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_id': 23, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 404) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .return_header('Content-Type', 'application/json') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Zone not found' + + def test_auth_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 401) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'] == 'Cannot authenticate: Unauthorized: the authentication parameters are incorrect (HTTP status 401)' + + def test_auth_error_forbidden(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_id': 23, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 403) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/23') + .result_json(dict(message="")), + ]) + + assert result['msg'] == 'Cannot authenticate: Forbidden: you do not have access to this resource (HTTP status 403)' + + def test_other_error(self, mocker): + result = self.run_module_failed(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.org', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 500) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.org') + .result_str(''), + ]) + + assert result['msg'].startswith('Error: GET https://api.ns1.hosttech.eu/api/user/v1/zones?') + assert 'did not yield JSON data, but HTTP status code 500 with Content-Type' in result['msg'] + + def test_get(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_name': 'example.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'example.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['zone_name'] == 'example.com' + assert result['zone_info'] == { + 'email': 'test@example.com', + 'ttl': 10800, + 'dnssec': False, + 'dnssec_email': None, + 'ds_records': None, + } + + def test_get_id(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_id': 42, + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/42') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 42 + assert result['zone_name'] == 'example.com' + assert result['zone_info'] == { + 'email': 'test@example.com', + 'ttl': 10800, + 'dnssec': False, + 'dnssec_email': None, + 'ds_records': None, + } + + def test_get_dnssec(self, mocker): + result = self.run_module_success(mocker, hosttech_dns_zone_info, { + 'hosttech_token': 'foo', + 'zone_name': 'foo.com', + '_ansible_remote_tmp': '/tmp/tmp', + '_ansible_keep_remote_files': True, + }, [ + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones', without_query=True) + .expect_query_values('query', 'foo.com') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_LIST_RESULT), + FetchUrlCall('GET', 200) + .expect_header('accept', 'application/json') + .expect_header('authorization', 'Bearer foo') + .expect_url('https://api.ns1.hosttech.eu/api/user/v1/zones/43') + .return_header('Content-Type', 'application/json') + .result_json(HOSTTECH_JSON_ZONE_2_GET_RESULT), + ]) + assert result['changed'] is False + assert result['zone_id'] == 43 + assert result['zone_name'] == 'foo.com' + assert result['zone_info'] == { + 'email': 'test@foo.com', + 'ttl': 10800, + 'dnssec': True, + 'dnssec_email': 'test@foo.com', + 'ds_records': [ + { + 'key_tag': 12345, + 'algorithm': 8, + 'digest_type': 1, + 'digest': '012356789ABCDEF0123456789ABCDEF012345678', + 'flags': 257, + 'protocol': 3, + 'public_key': + 'MuhdzsQdqEGShwjtJDKZZjdKqUSGluFzTTinpuEeIRzLLcgkwgAPKWFa ' + 'eQntNlmcNDeCziGwpdvhJnvKXEMbFcZwsaDIJuWqERxAQNGABWfPlCLh ' + 'HQPnbpRPNKipSdBaUhuOubvFvjBpFAwiwSAapRDVsAgKvjXucfXpFfYb ' + 'pCundbAXBWhbpHVbqgmGoixXzFSwUsGVYLPpBCiDlLJwzjRKYYaoVYge ' + 'kMtKFYUVnWIKbectWkDFdVqXwkKigCUDiuTTJxOBRJRNzGiDNMWBjYSm ' + 'bBCAHMaMYaghLbYTwyKXltdHTHwBwtswGNfpnEdSpKFzZJonBZArQfHD ' + 'lfceKgmKwEF=', + }, + { + 'key_tag': 12345, + 'algorithm': 8, + 'digest_type': 2, + 'digest': '0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF', + 'flags': 257, + 'protocol': 3, + 'public_key': + 'MuhdzsQdqEGShwjtJDKZZjdKqUSGluFzTTinpuEeIRzLLcgkwgAPKWFa ' + 'eQntNlmcNDeCziGwpdvhJnvKXEMbFcZwsaDIJuWqERxAQNGABWfPlCLh ' + 'HQPnbpRPNKipSdBaUhuOubvFvjBpFAwiwSAapRDVsAgKvjXucfXpFfYb ' + 'pCundbAXBWhbpHVbqgmGoixXzFSwUsGVYLPpBCiDlLJwzjRKYYaoVYge ' + 'kMtKFYUVnWIKbectWkDFdVqXwkKigCUDiuTTJxOBRJRNzGiDNMWBjYSm ' + 'bBCAHMaMYaghLbYTwyKXltdHTHwBwtswGNfpnEdSpKFzZJonBZArQfHD ' + 'lfceKgmKwEF=', + } + ], + } diff --git a/ansible_collections/community/dns/tests/unit/plugins/modules/test_wait_for_txt.py b/ansible_collections/community/dns/tests/unit/plugins/modules/test_wait_for_txt.py new file mode 100644 index 000000000..829d6465e --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/modules/test_wait_for_txt.py @@ -0,0 +1,1425 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.internal_test_tools.tests.unit.compat.mock import MagicMock, patch + +from ansible_collections.community.internal_test_tools.tests.unit.plugins.modules.utils import ( + set_module_args, + ModuleTestCase, + AnsibleExitJson, + AnsibleFailJson, +) + +from ansible_collections.community.dns.plugins.modules import wait_for_txt + +from ..module_utils.resolver_helper import ( + mock_resolver, + mock_query_udp, + create_mock_answer, + create_mock_response, +) + +# We need dnspython +dns = pytest.importorskip('dns') + + +def mock_sleep(delay): + pass + + +def mock_monotonic(call_sequence): + def f(): + assert len(call_sequence) > 0, 'monotonic() was called more often than expected' + value = call_sequence[0] + del call_sequence[0] + return value + + return f + + +class TestWaitForTXT(ModuleTestCase): + def test_single(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.AAAA, '1:2::3'), + )), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '4.4.4.4'), + )), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('1:2::3', '3.3.3.3'): [ + { + 'target': dns.name.from_unicode(u'example.org'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf'), + )), + }, + ], + ('4.4.4.4', ): [ + { + 'target': dns.name.from_unicode(u'example.org'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + ), dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'example.org') + )]), + }, + { + 'query_target': dns.name.from_unicode(u'org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.org'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.org'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + ] + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 1 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['asdf'], + 'ns.example.org': ['asdf'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 1 + + def test_double(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'fdsa'), + )), + }, + { + 'target': dns.name.from_unicode(u'mail.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'mail.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"any bar"'), + )), + }, + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'fdsa'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf'), + )), + }, + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'mail.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'mail.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + ], + 'mode': 'equals', + }, + { + 'name': 'mail.example.com', + 'values': [ + 'foo bar', + 'any bar', + ], + 'mode': 'superset', + }, + ], + 'timeout': 10, + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 2 + assert len(exc.value.args[0]['records']) == 2 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['asdf'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 3 + assert exc.value.args[0]['records'][1]['name'] == 'mail.example.com' + assert exc.value.args[0]['records'][1]['done'] is True + assert exc.value.args[0]['records'][1]['values'] == { + 'ns.example.com': ['any bar'], + } + assert exc.value.args[0]['records'][1]['check_count'] == 1 + + def test_subset(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'as df'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"another one"'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"foo bar"'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"another one"'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"foo bar"'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"another one"'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'as df'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'example.com', + 'values': [ + 'asdf', + 'asdf', + 'foo bar', + ], + 'mode': 'subset', + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 1 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['foo bar', 'another one', 'asdf'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 3 + + def test_superset(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + )), + }, + { + 'target': dns.name.from_unicode(u'mail.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(), + }, + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'fdsa'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf'), + )), + }, + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf ""'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'bee'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'mail.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'mail.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + 'bee', + ], + 'mode': 'superset', + }, + { + 'name': 'mail.example.com', + 'values': [ + 'foo bar', + 'any bar', + ], + 'mode': 'superset', + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 2 + assert len(exc.value.args[0]['records']) == 2 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['asdf', 'bee'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 3 + assert exc.value.args[0]['records'][1]['name'] == 'mail.example.com' + assert exc.value.args[0]['records'][1]['done'] is True + assert exc.value.args[0]['records'][1]['values'] == { + 'ns.example.com': [], + } + assert exc.value.args[0]['records'][1]['check_count'] == 1 + + def test_superset_not_empty(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'bumble'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'bee'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'wizard'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'bumble'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'bee'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'example.com', + 'values': [ + 'bumble', + 'bee', + ], + 'mode': 'superset_not_empty', + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 1 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['bumble', 'bee'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 4 + + def test_equals(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'bumble bee'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'wizard'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'wizard'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'foo'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'example.com', + 'values': [ + 'foo', + 'bumble bee', + 'wizard', + ], + 'mode': 'equals', + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 1 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['bumble bee', 'wizard', 'foo'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 4 + + def test_equals_ordered(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'wizard'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'foo'), + )), + }, + { + 'target': dns.name.from_unicode(u'example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'foo'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"bumble bee"'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'wizard'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleExitJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'example.com', + 'values': [ + 'foo', + 'bumble bee', + 'wizard', + ], + 'mode': 'equals_ordered', + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['changed'] is False + assert exc.value.args[0]['completed'] == 1 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'example.com' + assert exc.value.args[0]['records'][0]['done'] is True + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['foo', 'bumble bee', 'wizard'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 4 + + def test_timeout(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + ('3.3.3.3', ): [ + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'fdsa'), + )), + }, + { + 'target': dns.name.from_unicode(u'mail.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'mail.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, '"any bar"'), + )), + }, + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'fdsa'), + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdf'), + )), + }, + { + 'target': dns.name.from_unicode(u'www.example.com'), + 'rdtype': dns.rdatatype.TXT, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'www.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.TXT, 'asdfasdf'), + )), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'mail.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, authority=[dns.rrset.from_rdata( + 'mail.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with patch('ansible_collections.community.dns.plugins.modules.wait_for_txt.monotonic', + mock_monotonic([0, 0.01, 1.2, 6.013, 7.41, 12.021])): + with pytest.raises(AnsibleFailJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + ], + 'mode': 'equals', + }, + { + 'name': 'mail.example.com', + 'values': [ + 'foo bar', + 'any bar', + ], + 'mode': 'superset', + }, + ], + 'timeout': 12, + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['msg'] == 'Timeout (1 out of 2 check(s) passed).' + assert exc.value.args[0]['completed'] == 1 + assert len(exc.value.args[0]['records']) == 2 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is False + assert exc.value.args[0]['records'][0]['values'] == { + 'ns.example.com': ['asdfasdf'], + } + assert exc.value.args[0]['records'][0]['check_count'] == 3 + assert exc.value.args[0]['records'][1]['name'] == 'mail.example.com' + assert exc.value.args[0]['records'][1]['done'] is True + assert exc.value.args[0]['records'][1]['values'] == { + 'ns.example.com': ['any bar'], + } + assert exc.value.args[0]['records'][1]['check_count'] == 1 + + def test_nxdomain(self): + resolver = mock_resolver(['1.1.1.1'], {}) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NXDOMAIN), + }, + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NXDOMAIN), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with patch('ansible_collections.community.dns.plugins.modules.wait_for_txt.monotonic', + mock_monotonic([0, 0.01, 1.2, 6.013])): + with pytest.raises(AnsibleFailJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + ], + }, + ], + 'timeout': 2, + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['msg'] == 'Timeout (0 out of 1 check(s) passed).' + assert exc.value.args[0]['completed'] == 0 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is False + assert exc.value.args[0]['records'][0]['values'] == {} + assert exc.value.args[0]['records'][0]['check_count'] == 2 + + def test_servfail(self): + resolver = mock_resolver(['1.1.1.1'], {}) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.SERVFAIL), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleFailJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + ], + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['msg'] == 'Unexpected resolving error: Error SERVFAIL while querying 1.1.1.1 with query get NS for "com."' + assert exc.value.args[0]['completed'] == 0 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is False + assert 'values' not in exc.value.args[0]['records'][0] + assert exc.value.args[0]['records'][0]['check_count'] == 0 + + def test_cname_loop(self): + fake_query = MagicMock() + fake_query.question = 'Doctor Who?' + resolver = mock_resolver(['1.1.1.1'], { + ('1.1.1.1', ): [ + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.com', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '3.3.3.3'), + )), + }, + { + 'target': 'ns.example.com', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.A, + 'lifetime': 10, + 'result': create_mock_answer(dns.rrset.from_rdata( + 'ns.example.org', + 300, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.A, '4.4.4.4'), + )), + }, + { + 'target': 'ns.example.org', + 'rdtype': dns.rdatatype.AAAA, + 'lifetime': 10, + 'raise': dns.resolver.NoAnswer(response=fake_query), + }, + ], + }) + udp_sequence = [ + { + 'query_target': dns.name.from_unicode(u'com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.com'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'www.example.com'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.SOA, 'ns.example.com. ns.example.com. 12345 7200 120 2419200 10800'), + ), dns.rrset.from_rdata( + 'www.example.com', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'example.org') + )]), + }, + { + 'query_target': dns.name.from_unicode(u'org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.org'), + )]), + }, + { + 'query_target': dns.name.from_unicode(u'example.org'), + 'query_type': dns.rdatatype.NS, + 'nameserver': '1.1.1.1', + 'kwargs': { + 'timeout': 10, + }, + 'result': create_mock_response(dns.rcode.NOERROR, answer=[dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.NS, 'ns.example.org'), + ), dns.rrset.from_rdata( + 'example.org', + 3600, + dns.rdata.from_text(dns.rdataclass.IN, dns.rdatatype.CNAME, 'www.example.com') + )]), + }, + ] + with patch('dns.resolver.get_default_resolver', resolver): + with patch('dns.resolver.Resolver', resolver): + with patch('dns.query.udp', mock_query_udp(udp_sequence)): + with patch('time.sleep', mock_sleep): + with pytest.raises(AnsibleFailJson) as exc: + set_module_args({ + 'records': [ + { + 'name': 'www.example.com', + 'values': [ + 'asdf', + ], + }, + ], + }) + wait_for_txt.main() + + print(exc.value.args[0]) + assert exc.value.args[0]['msg'] == 'Unexpected resolving error: Found CNAME loop starting at www.example.com' + assert exc.value.args[0]['completed'] == 0 + assert len(exc.value.args[0]['records']) == 1 + assert exc.value.args[0]['records'][0]['name'] == 'www.example.com' + assert exc.value.args[0]['records'][0]['done'] is False + assert 'values' not in exc.value.args[0]['records'][0] + assert exc.value.args[0]['records'][0]['check_count'] == 0 diff --git a/ansible_collections/community/dns/tests/unit/plugins/plugin_utils/test_public_suffix.py b/ansible_collections/community/dns/tests/unit/plugins/plugin_utils/test_public_suffix.py new file mode 100644 index 000000000..984fd7600 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/plugins/plugin_utils/test_public_suffix.py @@ -0,0 +1,191 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2021, Felix Fontein <felix@fontein.de> +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +# Note that this file contains some public domain test data from +# https://raw.githubusercontent.com/publicsuffix/list/master/tests/test_psl.txt +# The data is marked and documented as public domain appropriately. + +# Make coding more python3-ish +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + + +import pytest + +from ansible_collections.community.dns.plugins.plugin_utils.public_suffix import ( + PublicSuffixList, + PUBLIC_SUFFIX_LIST, +) + + +TEST_GET_SUFFIX = [ + ('', {}, {}, '', ''), + ('.', {}, {}, '', ''), + ('foo.com', {}, {}, 'com', 'foo.com'), + ('bar.foo.com.', {}, {}, 'com.', 'foo.com.'), + ('BaR.fOo.CoM.', {'normalize_result': True}, {}, 'com.', 'foo.com.'), + ('BaR.fOo.CoM.', {}, {}, 'CoM.', 'fOo.CoM.'), + ('com', {}, {}, 'com', ''), + ('com', {}, {'only_if_registerable': False}, 'com', 'com'), + ('com', {'keep_unknown_suffix': False}, {}, 'com', ''), + ('foo.com', {}, {}, 'com', 'foo.com'), + ('foo.com', {'keep_unknown_suffix': False}, {}, 'com', 'foo.com'), + ('foobarbaz', {}, {}, 'foobarbaz', ''), + ('foobarbaz', {}, {'only_if_registerable': False}, 'foobarbaz', 'foobarbaz'), + ('foobarbaz', {'keep_unknown_suffix': False}, {}, '', ''), + ('foo.foobarbaz', {}, {}, 'foobarbaz', 'foo.foobarbaz'), + ('foo.foobarbaz', {'keep_unknown_suffix': False}, {}, '', ''), + ('-a.com', {}, {}, '', ''), # invalid domain name (leading dash in label) + ('a-.com', {}, {}, '', ''), # invalid domain name (trailing dash in label) + ('-.com', {}, {}, '', ''), # invalid domain name (leading and trailing dash in label) + ('.com', {}, {}, '', ''), # invalid domain name (empty label) + ('test.cloudfront.net', {}, {}, 'cloudfront.net', 'test.cloudfront.net'), # private rule + ('test.cloudfront.net', {'icann_only': True}, {}, 'net', 'cloudfront.net'), +] + + +@pytest.mark.parametrize("domain, kwargs, reg_extra_kwargs, suffix, reg_domain", TEST_GET_SUFFIX) +def test_get_suffix(domain, kwargs, reg_extra_kwargs, suffix, reg_domain): + assert PUBLIC_SUFFIX_LIST.get_suffix(domain, **kwargs) == suffix + kwargs.update(reg_extra_kwargs) + assert PUBLIC_SUFFIX_LIST.get_registrable_domain(domain, **kwargs) == reg_domain + + +# ------------------------------------------------------------------------------------------------- +# The following list is taken from https://raw.githubusercontent.com/publicsuffix/list/master/tests/test_psl.txt +# Any copyright for this list is dedicated to the Public Domain. (https://creativecommons.org/publicdomain/zero/1.0/) +# This list has been provided by Rob Stradling of Comodo (see last section on https://publicsuffix.org/list/). +TEST_SUFFIX_OFFICIAL_TESTS = [ + # '' input. + ('', '', {}), + # Mixed case. + ('COM', '', {}), + ('example.COM', 'example.com', {'normalize_result': True}), + ('WwW.example.COM', 'example.com', {'normalize_result': True}), + ('example.COM', 'example.COM', {}), + ('WwW.example.COM', 'example.COM', {}), + # Leading dot. + ('.com', '', {}), + ('.example', '', {}), + ('.example.com', '', {}), + ('.example.example', '', {}), + # Unlisted TLD. + ('example', '', {}), + ('example.example', 'example.example', {}), + ('b.example.example', 'example.example', {}), + ('a.b.example.example', 'example.example', {}), + # Listed, but non-Internet, TLD. + # ('local', '', {}), + # ('example.local', '', {}), + # ('b.example.local', '', {}), + # ('a.b.example.local', '', {}), + # TLD with only 1 rule. + ('biz', '', {}), + ('domain.biz', 'domain.biz', {}), + ('b.domain.biz', 'domain.biz', {}), + ('a.b.domain.biz', 'domain.biz', {}), + # TLD with some 2-level rules. + ('com', '', {}), + ('example.com', 'example.com', {}), + ('b.example.com', 'example.com', {}), + ('a.b.example.com', 'example.com', {}), + ('uk.com', '', {}), + ('example.uk.com', 'example.uk.com', {}), + ('b.example.uk.com', 'example.uk.com', {}), + ('a.b.example.uk.com', 'example.uk.com', {}), + ('test.ac', 'test.ac', {}), + # TLD with only 1 (wildcard) rule. + ('mm', '', {}), + ('c.mm', '', {}), + ('b.c.mm', 'b.c.mm', {}), + ('a.b.c.mm', 'b.c.mm', {}), + # More complex TLD. + ('jp', '', {}), + ('test.jp', 'test.jp', {}), + ('www.test.jp', 'test.jp', {}), + ('ac.jp', '', {}), + ('test.ac.jp', 'test.ac.jp', {}), + ('www.test.ac.jp', 'test.ac.jp', {}), + ('kyoto.jp', '', {}), + ('test.kyoto.jp', 'test.kyoto.jp', {}), + ('ide.kyoto.jp', '', {}), + ('b.ide.kyoto.jp', 'b.ide.kyoto.jp', {}), + ('a.b.ide.kyoto.jp', 'b.ide.kyoto.jp', {}), + ('c.kobe.jp', '', {}), + ('b.c.kobe.jp', 'b.c.kobe.jp', {}), + ('a.b.c.kobe.jp', 'b.c.kobe.jp', {}), + ('city.kobe.jp', 'city.kobe.jp', {}), + ('www.city.kobe.jp', 'city.kobe.jp', {}), + # TLD with a wildcard rule and exceptions. + ('ck', '', {}), + ('test.ck', '', {}), + ('b.test.ck', 'b.test.ck', {}), + ('a.b.test.ck', 'b.test.ck', {}), + ('www.ck', 'www.ck', {}), + ('www.www.ck', 'www.ck', {}), + # US K12. + ('us', '', {}), + ('test.us', 'test.us', {}), + ('www.test.us', 'test.us', {}), + ('ak.us', '', {}), + ('test.ak.us', 'test.ak.us', {}), + ('www.test.ak.us', 'test.ak.us', {}), + ('k12.ak.us', '', {}), + ('test.k12.ak.us', 'test.k12.ak.us', {}), + ('www.test.k12.ak.us', 'test.k12.ak.us', {}), + # IDN labels. + (u'食狮.com.cn', u'食狮.com.cn', {}), + (u'食狮.公司.cn', u'食狮.公司.cn', {}), + (u'www.食狮.公司.cn', u'食狮.公司.cn', {}), + (u'shishi.公司.cn', u'shishi.公司.cn', {}), + (u'公司.cn', u'', {}), + (u'食狮.中国', u'食狮.中国', {}), + (u'www.食狮.中国', u'食狮.中国', {}), + (u'shishi.中国', u'shishi.中国', {}), + (u'中国', u'', {}), + # Same as above, but punycoded. (TODO: punycode not supported yet!) + ('xn--85x722f.com.cn', 'xn--85x722f.com.cn', {}), + ('xn--85x722f.xn--55qx5d.cn', 'xn--85x722f.xn--55qx5d.cn', {}), + ('www.xn--85x722f.xn--55qx5d.cn', 'xn--85x722f.xn--55qx5d.cn', {}), + ('shishi.xn--55qx5d.cn', 'shishi.xn--55qx5d.cn', {}), + ('xn--55qx5d.cn', '', {}), + ('xn--85x722f.xn--fiqs8s', 'xn--85x722f.xn--fiqs8s', {}), + ('www.xn--85x722f.xn--fiqs8s', 'xn--85x722f.xn--fiqs8s', {}), + ('shishi.xn--fiqs8s', 'shishi.xn--fiqs8s', {}), + ('xn--fiqs8s', '', {}), +] +# End of public domain test data +# ------------------------------------------------------------------------------------------------- + + +@pytest.mark.parametrize("domain, registrable_domain, kwargs", TEST_SUFFIX_OFFICIAL_TESTS) +def test_get_suffix_official(domain, registrable_domain, kwargs): + reg_domain = PUBLIC_SUFFIX_LIST.get_registrable_domain(domain, **kwargs) + assert reg_domain == registrable_domain + + +def test_load_psl_dot(tmpdir): + fn = tmpdir / 'psl.dat' + fn.write('''// ===BEGIN BLA BLA DOMAINS=== +.com. +// ===END BLA BLA DOMAINS==='''.encode('utf-8')) + psl = PublicSuffixList.load(str(fn)) + assert len(psl._rules) == 1 + rule = psl._rules[0] + assert rule.labels == ('com', ) + assert rule.exception_rule is False + assert rule.part == 'bla bla' + + +def test_load_psl_no_part(tmpdir): + fn = tmpdir / 'psl.dat' + fn.write('''// ===BEGIN BLA BLA DOMAINS=== +com +// ===END BLA BLA DOMAINS=== +net'''.encode('utf-8')) + with pytest.raises(Exception) as excinfo: + PublicSuffixList.load(str(fn)) + assert str(excinfo.value) == 'Internal error: found PSL entry with no part!' diff --git a/ansible_collections/community/dns/tests/unit/requirements.txt b/ansible_collections/community/dns/tests/unit/requirements.txt new file mode 100644 index 000000000..8b7c7cd61 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/requirements.txt @@ -0,0 +1,11 @@ +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +unittest2 ; python_version < '2.7' +importlib ; python_version < '2.7' + +dnspython + +lxml < 4.3.0 ; python_version < '2.7' # lxml 4.3.0 and later require python 2.7 or later +lxml ; python_version >= '2.7' diff --git a/ansible_collections/community/dns/tests/unit/requirements.yml b/ansible_collections/community/dns/tests/unit/requirements.yml new file mode 100644 index 000000000..586a6a1b3 --- /dev/null +++ b/ansible_collections/community/dns/tests/unit/requirements.yml @@ -0,0 +1,7 @@ +--- +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +collections: +- community.internal_test_tools diff --git a/ansible_collections/community/dns/update-docs-fragments.py b/ansible_collections/community/dns/update-docs-fragments.py new file mode 100755 index 000000000..9ceba70f1 --- /dev/null +++ b/ansible_collections/community/dns/update-docs-fragments.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +from __future__ import (absolute_import, division, print_function) +__metaclass__ = type + + +import importlib +import os +import re +import sys +import textwrap + +import yaml + + +PROVIDERS = ['hetzner', 'hosttech'] + +DEPENDENT_FRAGMENTS = [ + ('RECORD_TYPE_CHOICES', [ + ('record_type', 'options.type', [ + 'module_record', + 'module_record_info', + 'module_record_set', + 'module_record_set_info', + ]), + ]), + ('RECORD_DEFAULT_TTL', [ + ('record_default_ttl', 'options.ttl', [ + 'module_record', + 'module_record_set', + ]), + ]), + ('RECORD_TYPE_CHOICES_RECORD_SETS_MODULE', [ + ('record_type', 'options.record_sets.suboptions.type', [ + 'module_record_sets', + ]), + ('record_default_ttl', 'options.record_sets.suboptions.ttl', [ + 'module_record_sets', + ]), + ]), + ('RECORD_TYPE_CHOICES_RECORDS_INVENTORY', [ + ('record_type', 'options.filters.suboptions.type', [ + 'inventory_records', + ]), + ]), + ('ZONE_ID_TYPE', [ + ('zone_id_type', 'options.zone_id', [ + 'module_record', + 'module_record_info', + 'module_record_set', + 'module_record_set_info', + 'module_record_sets', + 'module_zone_info', + 'inventory_records', + ]), + ]), +] + + +def get_provider_informations(providers): + files_to_remove = [] + + def add_init_py(path): + path = os.path.join(path, '__init__.py') + if os.path.exists(path): + return + with open(path, 'wb') as f: + f.write(b'') + files_to_remove.append(path) + + try: + sys.path.append(os.path.join('..', '..', '..')) + + add_init_py(os.path.join('..', '..')) + add_init_py(os.path.join('..')) + add_init_py(os.path.join('.')) + add_init_py(os.path.join('plugins')) + add_init_py(os.path.join('plugins', 'module_utils')) + + provider_infos = {} + errors = [] + + for provider in providers: + add_init_py(os.path.join('plugins', 'module_utils', provider)) + full_py_path = 'ansible_collections.community.dns.plugins.module_utils.{0}.api'.format(provider) + full_pathname = os.path.join('plugins', 'module_utils', provider, 'api.py') + try: + loader = importlib.machinery.SourceFileLoader(full_py_path, full_pathname) + spec = importlib.util.spec_from_loader(full_py_path, loader) + the_module = importlib.util.module_from_spec(spec) + loader.exec_module(the_module) + except Exception as e: + errors.append('{0}: Error while importing module {1}: {2}'.format(full_pathname, full_py_path, e)) + continue + + create_provider_info_fn_name = 'create_{0}_provider_information'.format(provider) + try: + create_provider_info_fn = provider_information = the_module.__dict__[create_provider_info_fn_name] + provider_infos[provider] = create_provider_info_fn() + except KeyError as e: + errors.append('{0}: Cannot find function {1}'.format(full_pathname, create_provider_info_fn)) + except Exception as e: + errors.append('{0}: Error while invoking function {1}: {2}'.format(full_pathname, create_provider_info_fn, e)) + + return provider_infos, errors + finally: + for path in files_to_remove: + os.remove(path) + + +class DocFragmentParseError(Exception): + def __init__(self, path, error_message): + self.path = path + self.error_message = error_message + super(DocFragmentParseError, self).__init__('Error while parsing {0}: {1}'.format(path, error_message)) + + +DOC_FRAGMENT_START_MATCHER = re.compile(r"^ ([A-Z_]+) = r'''$") + + +class Dumper(yaml.SafeDumper): + def ignore_aliases(self, *args): + return True + + def increase_indent(self, flow=False, *args, **kwargs): + self.best_indent = kwargs.pop('ident_override', 4) + return super().increase_indent(*args, **kwargs) + + def expect_block_sequence(self): + self.increase_indent(flow=False, indentless=False, ident_override=2) + self.state = self.expect_first_block_sequence_item + + +class DocFragment: + def __init__(self, path, prefix_lines, name, lines): + self.prefix_lines = prefix_lines + self.name = name + self.lines = lines + + try: + self.data = yaml.safe_load('\n'.join(self.lines)) + except Exception as e: + raise DocFragmentParseError(path, 'Error while parsing part {0}: {1}'.format(name, e)) + + def recreate_lines(self): + data = yaml.dump(self.data, default_flow_style=False, indent=4, Dumper=Dumper, sort_keys=False) + self.lines = data.splitlines() + + def serialize_lines(self): + return self.prefix_lines + [" {0} = r'''".format(self.name)] + self.lines + ["'''"] + + +class DocFragmentFile: + def __init__(self, path): + with open(path, 'r', encoding='utf-8') as f: + lines = f.read().splitlines() + + self.prefix = [] + self.fragments = [] + self.fragments_by_name = {} + + where = 'prefix' + for line in lines: + if where == 'prefix': + self.prefix.append(line) + if line == 'class ModuleDocFragment(object):': + where = 'body' + body_prefix = [] + body_name = None + body_lines = [] + elif where == 'body': + if body_name is None: + m = DOC_FRAGMENT_START_MATCHER.match(line) + if m: + body_name = m.group(1) + else: + body_prefix.append(line) + elif line == "'''": + fragment = DocFragment(path, body_prefix, body_name, body_lines) + self.fragments.append(fragment) + self.fragments_by_name[body_name] = fragment + body_prefix = [] + body_name = None + body_lines = [] + else: + body_lines.append(line) + + if where == 'prefix': + raise DocFragmentParseError(path, 'Cannot find body') + + def serialize_to_string(self): + lines = [] + lines.extend(self.prefix) + for fragment in self.fragments: + lines.extend(fragment.serialize_lines()) + lines.append('') + return '\n'.join(lines) + + +def doc_fragment_fn(name): + return os.path.join('plugins', 'doc_fragments', '{0}.py'.format(name)) + + +def load_doc_fragment(name): + fn = doc_fragment_fn(name) + return DocFragmentFile(fn) + + +def load_single_doc_fragment(name): + fragment = 'DOCUMENTATION' + if '.' in name: + name, fragment = name.split('.', 1) + fragment = fragment.upper() + doc_fragment = load_doc_fragment(name) + return doc_fragment.fragments_by_name[fragment] + + +def write_doc_fragment(name, doc_fragment): + fn = doc_fragment_fn(name) + data = doc_fragment.serialize_to_string() + with open(fn, 'w', encoding='utf-8') as f: + f.write(data) + + +def compare_doc_fragment(name, doc_fragment): + fn = doc_fragment_fn(name) + data = doc_fragment.serialize_to_string() + with open(fn, 'r', encoding='utf-8') as f: + compare_data = f.read() + return data == compare_data + + +def augment_fragment(provider_fragment, provider_info): + data = { + 'record_type': {'choices': sorted(provider_info.get_supported_record_types())}, + 'zone_id_type': {'type': provider_info.get_zone_id_type()}, + 'record_id_type': {'type': provider_info.get_record_id_type()}, + 'record_default_ttl': {'default': provider_info.get_record_default_ttl()} + } + + for fragment_name, fragment_insertion_data in DEPENDENT_FRAGMENTS: + insertion_fragment = provider_fragment.fragments_by_name.get(fragment_name) + if insertion_fragment is None: + insertion_fragment = DocFragment('', [], fragment_name, []) + provider_fragment.fragments.append(insertion_fragment) + provider_fragment.fragments_by_name[fragment_name] = insertion_fragment + + insertion_fragment.data = {} + all_doc_fragment_names = set() + for what, insertion_point, doc_fragment_names in fragment_insertion_data: + all_doc_fragment_names.update(doc_fragment_names) + doc_fragments = [load_single_doc_fragment(doc_fragment) for doc_fragment in doc_fragment_names] + insertion_point = insertion_point.split('.') + + insertion_pos = insertion_fragment.data + original_pos = doc_fragments[0].data # FIXME + for depth, part in enumerate(insertion_point): + if part not in insertion_pos: + insertion_pos[part] = {} + insertion_pos = insertion_pos[part] + original_pos = original_pos[part] + if depth >= 2: + for x in original_pos: + if x not in insertion_pos: + insertion_pos[x] = original_pos[x] + insertion_pos.update(data[what]) + + insertion_fragment.prefix_lines = [ + '', + ' # WARNING: This section is automatically generated by update-docs-fragments.py.', + ] + insertion_fragment.prefix_lines.extend([ + ' # {0}'.format(line) + for line in textwrap.wrap( + 'It is used to augment the docs fragment{0} {1}.'.format( + 's' if len(all_doc_fragment_names) != 1 else '', + ', '.join(sorted(all_doc_fragment_names))), + width=80) + ]) + insertion_fragment.prefix_lines.append(' # DO NOT EDIT MANUALLY!') + insertion_fragment.recreate_lines() + + +def main(program, arguments): + lint = '--lint' in arguments + provider_infos, errors = get_provider_informations(PROVIDERS) + try: + for provider, provider_info in sorted(provider_infos.items()): + try: + doc_fragment = load_doc_fragment(provider) + + augment_fragment(doc_fragment, provider_info) + + if not compare_doc_fragment(provider, doc_fragment): + path = doc_fragment_fn(provider) + if lint: + errors.append('{0}: Needs to be updated by update-docs-fragments.py'.format(path)) + else: + print('Writing {0}...'.format(path)) + write_doc_fragment(provider, doc_fragment) + + except DocFragmentParseError as e: + errors.append('{0}: Error while parsing docs fragment: {1}'.format(e.path, e.error_message)) + + except Exception as e: + errors.append('{0}: Unexpected error: {1}'.format(program, e)) + + for error in errors: + print(error) + return 5 if errors else 0 + + +if __name__ == '__main__': + sys.exit(main(sys.argv[0], sys.argv[1:])) diff --git a/ansible_collections/community/dns/update-psl.sh b/ansible_collections/community/dns/update-psl.sh new file mode 100755 index 000000000..a9c23ccb5 --- /dev/null +++ b/ansible_collections/community/dns/update-psl.sh @@ -0,0 +1,23 @@ +#!/bin/sh +# Copyright (c) Ansible Project +# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt) +# SPDX-License-Identifier: GPL-3.0-or-later + +set +eux + +# Sometimes the version on publicsuffix.org differs depending on from where you request it over many hours, +# so for now let's directly fetch it from GitHub. + +# curl https://publicsuffix.org/list/public_suffix_list.dat --output plugins/public_suffix_list.dat +curl https://raw.githubusercontent.com/publicsuffix/list/master/public_suffix_list.dat --output plugins/public_suffix_list.dat + +git status plugins/public_suffix_list.dat + +if [ -n "$(git status --porcelain=v1 plugins/public_suffix_list.dat)" ]; then + git diff + if [ ! -e changelogs/fragments/update-psl.yml ]; then + echo "bugfixes:" > changelogs/fragments/update-psl.yml + echo ' - "Update Public Suffix List."' >> changelogs/fragments/update-psl.yml + fi + exit 1 +fi |