From adddef5fc07d13fca0b7ff8cf883d84850d9dc14 Mon Sep 17 00:00:00 2001 From: "Jonas L." Date: Tue, 7 Oct 2025 11:04:00 +0200 Subject: [PATCH] feat: support the new DNS API (#703) Add support for the new [DNS API](https://docs.hetzner.cloud/reference/cloud#dns). The DNS API is currently in **beta**. See the [DNS API beta changelog](https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta) for more details. --- changelogs/fragments/new-dns-api.yml | 44 ++ meta/runtime.yml | 12 + plugins/module_utils/experimental.py | 7 + plugins/modules/zone.py | 505 ++++++++++++++++++ plugins/modules/zone_info.py | 267 +++++++++ plugins/modules/zone_rrset.py | 389 ++++++++++++++ plugins/modules/zone_rrset_info.py | 240 +++++++++ tests/integration/targets/zone/aliases | 3 + .../targets/zone/defaults/main/common.yml | 29 + .../targets/zone/defaults/main/main.yml | 4 + .../targets/zone/tasks/cleanup.yml | 5 + tests/integration/targets/zone/tasks/main.yml | 31 ++ tests/integration/targets/zone/tasks/test.yml | 317 +++++++++++ tests/integration/targets/zone_info/aliases | 3 + .../zone_info/defaults/main/common.yml | 29 + .../targets/zone_info/defaults/main/main.yml | 4 + .../targets/zone_info/tasks/cleanup.yml | 5 + .../targets/zone_info/tasks/main.yml | 31 ++ .../targets/zone_info/tasks/prepare.yml | 9 + .../targets/zone_info/tasks/test.yml | 77 +++ tests/integration/targets/zone_rrset/aliases | 3 + .../zone_rrset/defaults/main/common.yml | 29 + .../targets/zone_rrset/defaults/main/main.yml | 4 + .../targets/zone_rrset/tasks/cleanup.yml | 5 + .../targets/zone_rrset/tasks/main.yml | 31 ++ .../targets/zone_rrset/tasks/prepare.yml | 9 + .../targets/zone_rrset/tasks/test.yml | 186 +++++++ .../targets/zone_rrset_info/aliases | 3 + .../zone_rrset_info/defaults/main/common.yml | 29 + .../zone_rrset_info/defaults/main/main.yml | 4 + .../targets/zone_rrset_info/tasks/cleanup.yml | 5 + .../targets/zone_rrset_info/tasks/main.yml | 31 ++ .../targets/zone_rrset_info/tasks/prepare.yml | 36 ++ .../targets/zone_rrset_info/tasks/test.yml | 96 ++++ 34 files changed, 2482 insertions(+) create mode 100644 changelogs/fragments/new-dns-api.yml create mode 100644 plugins/modules/zone.py create mode 100644 plugins/modules/zone_info.py create mode 100644 plugins/modules/zone_rrset.py create mode 100644 plugins/modules/zone_rrset_info.py create mode 100644 tests/integration/targets/zone/aliases create mode 100644 tests/integration/targets/zone/defaults/main/common.yml create mode 100644 tests/integration/targets/zone/defaults/main/main.yml create mode 100644 tests/integration/targets/zone/tasks/cleanup.yml create mode 100644 tests/integration/targets/zone/tasks/main.yml create mode 100644 tests/integration/targets/zone/tasks/test.yml create mode 100644 tests/integration/targets/zone_info/aliases create mode 100644 tests/integration/targets/zone_info/defaults/main/common.yml create mode 100644 tests/integration/targets/zone_info/defaults/main/main.yml create mode 100644 tests/integration/targets/zone_info/tasks/cleanup.yml create mode 100644 tests/integration/targets/zone_info/tasks/main.yml create mode 100644 tests/integration/targets/zone_info/tasks/prepare.yml create mode 100644 tests/integration/targets/zone_info/tasks/test.yml create mode 100644 tests/integration/targets/zone_rrset/aliases create mode 100644 tests/integration/targets/zone_rrset/defaults/main/common.yml create mode 100644 tests/integration/targets/zone_rrset/defaults/main/main.yml create mode 100644 tests/integration/targets/zone_rrset/tasks/cleanup.yml create mode 100644 tests/integration/targets/zone_rrset/tasks/main.yml create mode 100644 tests/integration/targets/zone_rrset/tasks/prepare.yml create mode 100644 tests/integration/targets/zone_rrset/tasks/test.yml create mode 100644 tests/integration/targets/zone_rrset_info/aliases create mode 100644 tests/integration/targets/zone_rrset_info/defaults/main/common.yml create mode 100644 tests/integration/targets/zone_rrset_info/defaults/main/main.yml create mode 100644 tests/integration/targets/zone_rrset_info/tasks/cleanup.yml create mode 100644 tests/integration/targets/zone_rrset_info/tasks/main.yml create mode 100644 tests/integration/targets/zone_rrset_info/tasks/prepare.yml create mode 100644 tests/integration/targets/zone_rrset_info/tasks/test.yml diff --git a/changelogs/fragments/new-dns-api.yml b/changelogs/fragments/new-dns-api.yml new file mode 100644 index 0000000..d96f70d --- /dev/null +++ b/changelogs/fragments/new-dns-api.yml @@ -0,0 +1,44 @@ +release_summary: | + This release adds support for the new `DNS API`_. + + The DNS API is currently in **beta**, which will likely end on 10 + November 2025. After the beta ended, it will no longer be possible to + create new zones in the old DNS system. See the `DNS Beta FAQ`_ for more + details. + + Future minor releases of this project may include breaking changes for + features that are related to the DNS API. + + See the `DNS API Beta changelog`_ for more details. + + **Examples** + + .. code:: yaml + + - name: Create a primary Zone + hetzner.hcloud.zone: + name: example.com + mode: primary + labels: + key: value + state: present + + - name: Create a Zone RRSet + hetzner.hcloud.zone_rrset: + zone: example.com + name: "@" + type: A + records: + - comment: server1 + value: 201.118.10.2 + state: present + + .. _DNS Beta FAQ: https://docs.hetzner.com/networking/dns/faq/beta + .. _DNS API: https://docs.hetzner.cloud/reference/cloud#dns + .. _DNS API Beta changelog: https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta + +minor_changes: + - zone - New module to manage DNS Zones in Hetzner Cloud. + - zone_info - New module to fetch DNS Zones details. + - zone_rrset - New module to manage DNS Zone RRSets in the Hetzner Cloud. + - zone_rrset_info - New module to fetch DNS RRSets details. diff --git a/meta/runtime.yml b/meta/runtime.yml index a5ccd33..72a24e7 100644 --- a/meta/runtime.yml +++ b/meta/runtime.yml @@ -36,6 +36,10 @@ action_groups: - volume - volume_info - volume_attachment + - zone + - zone_info + - zone_rrset + - zone_rrset_info plugin_routing: modules: @@ -107,3 +111,11 @@ plugin_routing: redirect: hetzner.hcloud.volume hcloud_volume_attachment: redirect: hetzner.hcloud.volume_attachment + hcloud_zone: + redirect: hetzner.hcloud.zone + hcloud_zone_info: + redirect: hetzner.hcloud.zone_info + hcloud_zone_rrset: + redirect: hetzner.hcloud.zone_rrset + hcloud_zone_rrset_info: + redirect: hetzner.hcloud.zone_rrset_info diff --git a/plugins/module_utils/experimental.py b/plugins/module_utils/experimental.py index e253e87..9bc27b0 100644 --- a/plugins/module_utils/experimental.py +++ b/plugins/module_utils/experimental.py @@ -32,3 +32,10 @@ def experimental_warning_function(product: str, maturity: str, url: str): module.warn(message) return fn + + +dns_experimental_warning = experimental_warning_function( + "DNS API", + "in beta", + "https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta", +) diff --git a/plugins/modules/zone.py b/plugins/modules/zone.py new file mode 100644 index 0000000..96dafc0 --- /dev/null +++ b/plugins/modules/zone.py @@ -0,0 +1,505 @@ +#!/usr/bin/python + +# Copyright: (c) 2025, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +from __future__ import annotations + +DOCUMENTATION = """ +--- +module: zone + +short_description: Create and manage DNS Zone on the Hetzner Cloud. + +description: + - Create, update and delete DNS Zone on the Hetzner Cloud. + - See the L(Zones API documentation,https://docs.hetzner.cloud/reference/cloud#zones) for more details. + - B(Experimental:) DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + +author: + - Jonas Lammler (@jooola) + +options: + id: + description: + - ID of the Zone to manage. + - Only required if no Zone O(name) is given. + type: int + name: + description: + - Name of the Zone to manage. + - Only required if no Zone O(id) is given or the Zone does not exist. + - All names with well-known public suffixes (e.g. .de, .com, .co.uk) are supported. Subdomains are not supported. + - The name must be in lower case and must not end with a dot. + - Internationalized domain names must be transcribed to Punycode representation with ACE prefix, e.g. xn--mnchen-3ya.de (münchen.de). + type: str + mode: + description: + - Mode of the Zone. + - Required if the Zone does not exist. + type: str + choices: [primary, secondary] + ttl: + description: + - TTL of the Zone. + type: int + labels: + description: + - User-defined key-value pairs. + type: dict + delete_protection: + description: + - Protect the Zone from deletion. + type: bool + primary_nameservers: + description: + - Primary nameservers of the Zone. + - Only applicable for Zones with O(mode=secondary). + type: list + elements: dict + suboptions: + address: + description: + - Public IPv4 or IPv6 address of the primary nameserver. + type: str + port: + description: + - Port of the primary nameserver. + type: int + tsig_algorithm: + description: + - Transaction signature (TSIG) algorithm used to generate the TSIG key. + type: str + tsig_key: + description: + - Transaction signature (TSIG) key. + type: str + zonefile: + description: + - Zone file to import. + - Optional if O(state=present) and the Zone does not exist, ignored otherwise. + - Required if O(state=import). + type: str + state: + description: + - State of the Zone. + - C(import) is not idempotent. + default: present + choices: [absent, present, import] + type: str + +extends_documentation_fragment: + - hetzner.hcloud.hcloud +""" + +EXAMPLES = """ +- name: Create a primary Zone + hetzner.hcloud.zone: + name: example.com + mode: primary + ttl: 10800 + labels: + key: value + state: present + +- name: Create a primary Zone using a zonefile + hetzner.hcloud.zone: + name: example.com + mode: primary + zonefile: | + $ORIGIN example.com. + $TTL 3600 + + @ 300 IN CAA 0 issue "letsencrypt.org" + + @ 600 IN A 192.168.254.2 + @ 600 IN A 192.168.254.3 + + @ IN AAAA fdd0:367a:0cb7::2 + @ IN AAAA fdd0:367a:0cb7::3 + + www IN CNAME example.com. + blog IN CNAME example.com. + + anything IN TXT "some value" + state: present + +- name: Create a primary Zone with Internationalized Domain Name (IDN) + hetzner.hcloud.zone: + # Leverage Python's encoding.idna module https://docs.python.org/3/library/codecs.html#module-encodings.idna + name: "{{ 'këks-🍪-example.com'.encode('idna') }}" + mode: primary + state: present + +- name: Create a secondary Zone + hetzner.hcloud.zone: + name: example.com + mode: secondary + primary_nameservers: + - address: 203.0.113.1 + port: 53 + labels: + key: value + state: present + +- name: Delete a Zone + hetzner.hcloud.zone: + name: example.com + state: absent +""" + +RETURN = """ +hcloud_zone: + description: Zone instance. + returned: always + type: dict + contains: + id: + description: ID of the Zone. + returned: always + type: int + sample: 12345 + name: + description: Name of the Zone. + returned: always + type: str + sample: example.com + mode: + description: Mode of the Zone. + returned: always + type: str + sample: primary + ttl: + description: TTL of the Zone. + returned: always + type: int + sample: 10800 + labels: + description: User-defined labels (key-value pairs) + returned: always + type: dict + sample: + key: value + delete_protection: + description: Protect the Zone from deletion. + returned: always + type: bool + sample: false + primary_nameservers: + description: Primary nameservers of the Zone. + returned: always + type: list + elements: dict + contains: + address: + description: Public IPv4 or IPv6 address of the primary nameserver. + returned: always + type: str + sample: 203.0.113.1 + port: + description: Port of the primary nameserver. + returned: always + type: int + sample: 53 + tsig_algorithm: + description: Transaction signature (TSIG) algorithm used to generate the TSIG key. + returned: always + type: str + sample: hmac-sha256 + tsig_key: + description: Transaction signature (TSIG) key. + returned: always + type: str + status: + description: Status of the Zone. + returned: always + type: str + sample: ok + registrar: + description: Registrar of the Zone. + returned: always + type: str + sample: hetzner + authoritative_nameservers: + description: Authoritative nameservers of the Zone. + returned: always + type: dict + contains: + assigned: + description: Authoritative Hetzner nameservers assigned to the Zone. + returned: always + type: list + elements: str + sample: ["hydrogen.ns.hetzner.com.", "oxygen.ns.hetzner.com.", "helium.ns.hetzner.de."] + delegated: + description: Authoritative nameservers delegated to the parent DNS zone. + returned: always + type: list + elements: str + sample: ["hydrogen.ns.hetzner.com.", "oxygen.ns.hetzner.com.", "helium.ns.hetzner.de."] + delegation_last_check: + description: Point in time when the DNS zone delegation was last checked (in ISO-8601 format). + returned: always + type: str + sample: "2023-11-06T13:36:56+00:00" + delegation_status: + description: Status of the delegation. + returned: always + type: str + sample: valid + record_count: + description: Number of Resource Records (RR) within the Zone. + returned: always + type: int + sample: 4 +""" + + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.experimental import dns_experimental_warning +from ..module_utils.hcloud import AnsibleHCloud +from ..module_utils.vendor.hcloud import APIException, HCloudException +from ..module_utils.vendor.hcloud.actions import BoundAction +from ..module_utils.vendor.hcloud.zones import BoundZone, ZonePrimaryNameserver + + +class AnsibleHCloudZone(AnsibleHCloud): + represent = "hcloud_zone" + + hcloud_zone: BoundZone | None = None + + def __init__(self, module: AnsibleModule): + dns_experimental_warning(module) + super().__init__(module) + + def _prepare_result(self): + return { + "id": self.hcloud_zone.id, + "name": self.hcloud_zone.name, + "mode": self.hcloud_zone.mode, + "labels": self.hcloud_zone.labels, + "ttl": self.hcloud_zone.ttl, + "primary_nameservers": [ + self._prepare_result_primary_nameserver(o) for o in self.hcloud_zone.primary_nameservers + ], + "delete_protection": self.hcloud_zone.protection["delete"], + "status": self.hcloud_zone.status, + "registrar": self.hcloud_zone.registrar, + "authoritative_nameservers": { + "assigned": self.hcloud_zone.authoritative_nameservers.assigned, + "delegated": self.hcloud_zone.authoritative_nameservers.delegated, + "delegation_last_check": ( + self.hcloud_zone.authoritative_nameservers.delegation_last_check.isoformat() + if self.hcloud_zone.authoritative_nameservers.delegation_last_check is not None + else None + ), + "delegation_status": self.hcloud_zone.authoritative_nameservers.delegation_status, + }, + "record_count": self.hcloud_zone.record_count, + } + + def _prepare_result_primary_nameserver(self, o: ZonePrimaryNameserver): + return { + "address": o.address, + "port": o.port, + "tsig_algorithm": o.tsig_algorithm, + "tsig_key": o.tsig_key, + } + + def _get(self): + try: + if self.module.params.get("id") is not None: + self.hcloud_zone = self.client.zones.get(self.module.params.get("id")) + else: + try: + self.hcloud_zone = self.client.zones.get(self.module.params.get("name")) + except APIException as api_exc: + if api_exc.code != "not_found": + raise + except HCloudException as exception: + self.fail_json_hcloud(exception) + + def _create(self): + self.module.fail_on_missing_params(required_params=["name", "mode"]) + params = { + "name": self.module.params.get("name"), + "mode": self.module.params.get("mode"), + } + + if self.module.params.get("ttl") is not None: + params["ttl"] = self.module.params.get("ttl") + + if self.module.params.get("labels") is not None: + params["labels"] = self.module.params.get("labels") + + if self.module.params.get("primary_nameservers") is not None: + params["primary_nameservers"] = [ + ZonePrimaryNameserver( + address=o["address"], + port=o["port"], + ) + for o in self.module.params.get("primary_nameservers") + ] + + if self.module.params.get("zonefile") is not None: + params["zonefile"] = self.module.params.get("zonefile") + + if not self.module.check_mode: + try: + resp = self.client.zones.create(**params) + resp.action.wait_until_finished() + + self.hcloud_zone = resp.zone + + if self.module.params.get("delete_protection") is not None: + action = self.hcloud_zone.change_protection( + delete=self.module.params.get("delete_protection"), + ) + action.wait_until_finished() + + except HCloudException as exception: + self.fail_json_hcloud(exception) + + self._mark_as_changed() + self._get() + + def _update(self): + try: + actions: list[BoundAction] = [] + delete_protection = self.module.params.get("delete_protection") + if delete_protection is not None and delete_protection != self.hcloud_zone.protection["delete"]: + if not self.module.check_mode: + action = self.hcloud_zone.change_protection(delete=delete_protection) + actions.append(action) + self._mark_as_changed() + + ttl = self.module.params.get("ttl") + if ttl is not None and ttl != self.hcloud_zone.ttl: + if not self.module.check_mode: + action = self.hcloud_zone.change_ttl(ttl=ttl) + actions.append(action) + self._mark_as_changed() + + primary_nameservers = self.module.params.get("primary_nameservers") + if primary_nameservers is not None and self._diff_primary_nameservers(): + if not self.module.check_mode: + action = self.hcloud_zone.change_primary_nameservers( + primary_nameservers=[ZonePrimaryNameserver.from_dict(o) for o in primary_nameservers] + ) + actions.append(action) + self._mark_as_changed() + + for action in actions: + action.wait_until_finished() + + params = {} + + labels = self.module.params.get("labels") + if labels is not None and labels != self.hcloud_zone.labels: + params["labels"] = labels + self._mark_as_changed() + + if not self.module.check_mode: + self.hcloud_zone = self.hcloud_zone.update(**params) + + except HCloudException as exception: + self.fail_json_hcloud(exception) + + def _diff_primary_nameservers(self) -> bool: + current = [self._prepare_result_primary_nameserver(o) for o in self.hcloud_zone.primary_nameservers] + wanted = [ + self._prepare_result_primary_nameserver(ZonePrimaryNameserver.from_dict(o)) + for o in self.module.params.get("primary_nameservers") + ] + + return current != wanted + + def present(self): + self._get() + if self.hcloud_zone is None: + self._create() + else: + self._update() + + def absent(self): + try: + self._get() + if self.hcloud_zone is not None: + if not self.module.check_mode: + resp = self.hcloud_zone.delete() + resp.action.wait_until_finished() + self._mark_as_changed() + + self.hcloud_zone = None + except HCloudException as exception: + self.fail_json_hcloud(exception) + + def import_(self): + self._get() + if self.hcloud_zone is None: + self._create() + else: + try: + if not self.module.check_mode: + action = self.hcloud_zone.import_zonefile(self.module.params.get("zonefile")) + action.wait_until_finished() + + self._mark_as_changed() + self._get() + + except HCloudException as exception: + self.fail_json_hcloud(exception) + + @classmethod + def define_module(cls): + return AnsibleModule( + argument_spec=dict( + id={"type": "int"}, + name={"type": "str"}, + mode={"type": "str", "choices": ["primary", "secondary"]}, + primary_nameservers={ + "type": "list", + "elements": "dict", + "options": dict( + address={"type": "str"}, + port={"type": "int"}, + tsig_algorithm={"type": "str", "default": None}, + tsig_key={"type": "str", "default": None, "no_log": True}, + ), + }, + ttl={"type": "int"}, + labels={"type": "dict"}, + delete_protection={"type": "bool"}, + zonefile={"type": "str"}, + state={ + "choices": ["absent", "present", "import"], + "default": "present", + }, + **super().base_module_arguments(), + ), + required_one_of=[["id", "name"]], + required_if=[["state", "import", ["zonefile"]]], + supports_check_mode=True, + ) + + +def main(): + module = AnsibleHCloudZone.define_module() + + hcloud = AnsibleHCloudZone(module) + state = module.params.get("state") + if state == "absent": + hcloud.absent() + elif state == "import": + hcloud.import_() + else: + hcloud.present() + + module.exit_json(**hcloud.get_result()) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/zone_info.py b/plugins/modules/zone_info.py new file mode 100644 index 0000000..fc18175 --- /dev/null +++ b/plugins/modules/zone_info.py @@ -0,0 +1,267 @@ +#!/usr/bin/python + +# Copyright: (c) 2025, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +from __future__ import annotations + +DOCUMENTATION = """ +--- +module: zone_info + +short_description: Gather infos about your Hetzner Cloud Zones. + +description: + - Gather infos about your Hetzner Cloud Zones. + - See the L(Zones API documentation,https://docs.hetzner.cloud/reference/cloud#zones) for more details. + - B(Experimental:) DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + +author: + - Jonas Lammler (@jooola) + +options: + id: + description: + - ID of the Zone you want to get. + - The module will fail if the provided ID is invalid. + type: int + name: + description: + - Name of the Zone you want to get. + type: str + label_selector: + description: + - Label selector to filter the Zones you want to get. + type: str + +extends_documentation_fragment: + - hetzner.hcloud.hcloud +""" + +EXAMPLES = """ +- name: Gather Zone infos + hetzner.hcloud.zone_info: + register: output +- name: Print the gathered infos + debug: + var: output.hcloud_zone_info +""" + +RETURN = """ +hcloud_zone_info: + description: Zone infos as list. + returned: always + type: list + elements: dict + contains: + id: + description: ID of the Zone. + returned: always + type: int + sample: 12345 + name: + description: Name of the Zone. + returned: always + type: str + sample: example.com + mode: + description: Mode of the Zone. + returned: always + type: str + sample: primary + ttl: + description: TTL of the Zone. + returned: always + type: int + sample: 10800 + labels: + description: User-defined labels (key-value pairs) + returned: always + type: dict + sample: + key: value + delete_protection: + description: Protect the Zone from deletion. + returned: always + type: bool + sample: false + primary_nameservers: + description: Primary nameservers of the Zone. + returned: always + type: list + elements: dict + contains: + address: + description: Public IPv4 or IPv6 address of the primary nameserver. + returned: always + type: str + sample: 203.0.113.1 + port: + description: Port of the primary nameserver. + returned: always + type: int + sample: 53 + tsig_algorithm: + description: Transaction signature (TSIG) algorithm used to generate the TSIG key. + returned: always + type: str + sample: hmac-sha256 + tsig_key: + description: Transaction signature (TSIG) key. + returned: always + type: str + status: + description: Status of the Zone. + returned: always + type: str + sample: ok + registrar: + description: Registrar of the Zone. + returned: always + type: str + sample: hetzner + authoritative_nameservers: + description: Authoritative nameservers of the Zone. + returned: always + type: dict + contains: + assigned: + description: Authoritative Hetzner nameservers assigned to the Zone. + returned: always + type: list + elements: str + sample: ["hydrogen.ns.hetzner.com.", "oxygen.ns.hetzner.com.", "helium.ns.hetzner.de."] + delegated: + description: Authoritative nameservers delegated to the parent DNS zone. + returned: always + type: list + elements: str + sample: ["hydrogen.ns.hetzner.com.", "oxygen.ns.hetzner.com.", "helium.ns.hetzner.de."] + delegation_last_check: + description: Point in time when the DNS zone delegation was last checked (in ISO-8601 format). + returned: always + type: str + sample: "2023-11-06T13:36:56+00:00" + delegation_status: + description: Status of the delegation. + returned: always + type: str + sample: valid + record_count: + description: Number of Resource Records (RR) within the Zone. + returned: always + type: int + sample: 4 +""" + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.experimental import dns_experimental_warning +from ..module_utils.hcloud import AnsibleHCloud +from ..module_utils.vendor.hcloud import APIException, HCloudException +from ..module_utils.vendor.hcloud.zones import BoundZone, ZonePrimaryNameserver + + +class AnsibleHCloudZoneInfo(AnsibleHCloud): + represent = "hcloud_zone_info" + + hcloud_zone_info: list[BoundZone] | None = None + + def __init__(self, module: AnsibleModule): + dns_experimental_warning(module) + super().__init__(module) + + def _prepare_result(self): + tmp = [] + + for zone in self.hcloud_zone_info: + if zone is None: + continue + + tmp.append( + { + "id": zone.id, + "name": zone.name, + "mode": zone.mode, + "labels": zone.labels, + "ttl": zone.ttl, + "primary_nameservers": [ + self._prepare_result_primary_nameserver(o) for o in zone.primary_nameservers + ], + "delete_protection": zone.protection["delete"], + "status": zone.status, + "registrar": zone.registrar, + "authoritative_nameservers": { + "assigned": zone.authoritative_nameservers.assigned, + "delegated": zone.authoritative_nameservers.delegated, + "delegation_last_check": ( + zone.authoritative_nameservers.delegation_last_check.isoformat() + if zone.authoritative_nameservers.delegation_last_check is not None + else None + ), + "delegation_status": zone.authoritative_nameservers.delegation_status, + }, + "record_count": zone.record_count, + } + ) + + return tmp + + def _prepare_result_primary_nameserver(self, o: ZonePrimaryNameserver): + return { + "address": o.address, + "port": o.port, + "tsig_algorithm": o.tsig_algorithm, + "tsig_key": o.tsig_key, + } + + def get_zones(self): + try: + self.hcloud_zone_info = [] + + if self.module.params.get("id") is not None: + self.hcloud_zone_info = [self.client.zones.get(self.module.params.get("id"))] + elif self.module.params.get("name") is not None: + try: + self.hcloud_zone_info = [self.client.zones.get(self.module.params.get("name"))] + except APIException as api_exc: + if api_exc.code != "not_found": + raise + elif self.module.params.get("label_selector") is not None: + self.hcloud_zone_info = self.client.zones.get_all( + label_selector=self.module.params.get("label_selector") + ) + else: + self.hcloud_zone_info = self.client.zones.get_all() + + except HCloudException as exception: + self.fail_json_hcloud(exception) + + @classmethod + def define_module(cls): + return AnsibleModule( + argument_spec=dict( + id={"type": "int"}, + name={"type": "str"}, + label_selector={"type": "str"}, + **super().base_module_arguments(), + ), + supports_check_mode=True, + ) + + +def main(): + module = AnsibleHCloudZoneInfo.define_module() + hcloud = AnsibleHCloudZoneInfo(module) + + hcloud.get_zones() + result = hcloud.get_result() + + ansible_info = {"hcloud_zone_info": result["hcloud_zone_info"]} + module.exit_json(**ansible_info) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/zone_rrset.py b/plugins/modules/zone_rrset.py new file mode 100644 index 0000000..895315f --- /dev/null +++ b/plugins/modules/zone_rrset.py @@ -0,0 +1,389 @@ +#!/usr/bin/python + +# Copyright: (c) 2025, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +from __future__ import annotations + +DOCUMENTATION = """ +--- +module: zone_rrset + +short_description: Create and manage Zone RRSets on the Hetzner Cloud. + +description: + - Create, update and delete Zone RRSets on the Hetzner Cloud. + - See the L(Zone RRSets API documentation,https://docs.hetzner.cloud/reference/cloud#zone-rrsets) for more details. + - B(Experimental:) DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + +author: + - Jonas Lammler (@jooola) + +options: + zone: + description: + - Name or ID of the parent Zone. + type: str + required: true + id: + description: + - ID of the Zone RRSet to manage. + - Only required if no Zone RRSet O(name) and O(type) are given. + type: int + name: + description: + - Name of the Zone RRSet to manage. + - Only required if no Zone RRSet O(id) is given or the Zone RRSet does not exist. + type: str + type: + description: + - Type of the Zone RRSet to manage. + - Only required if no Zone RRSet O(id) is given or the Zone RRSet does not exist. + type: str + ttl: + description: + - TTL of the Zone RRSet. + type: int + records: + description: + - Records of the Zone RRSet. + type: list + elements: dict + suboptions: + value: + description: + - Value of the record. + type: str + comment: + description: + - Comment of the record. + type: str + change_protection: + description: + - Protect the Zone RRSet from changes (deletion and updates). + type: bool + labels: + description: + - User-defined key-value pairs. + type: dict + state: + description: + - State of the Zone RRSet. + default: present + choices: [absent, present] + type: str + +extends_documentation_fragment: + - hetzner.hcloud.hcloud +""" + +EXAMPLES = """ +- name: Create a Zone RRSet + hetzner.hcloud.zone_rrset: + zone: example.com + name: www + type: A + ttl: 300 + records: + - value: 201.118.10.2 + comment: web server 1 + - value: 201.118.10.3 + comment: web server 2 + state: present + +- name: Delete a Zone RRSet + hetzner.hcloud.zone_rrset: + zone: 42 + name: www + type: A + state: absent +""" + +RETURN = """ +hcloud_zone_rrset: + description: Zone RRSet instance. + returned: always + type: dict + contains: + zone: + description: ID of the parent Zone. + type: int + returned: always + sample: 42 + id: + description: ID of the Zone RRSet. + type: str + returned: always + sample: www/A + name: + description: Name of the Zone RRSet. + type: str + returned: always + sample: my-zone + type: + description: Type of the Zone RRSet. + type: str + returned: always + sample: A + ttl: + description: TTL of the Zone RRSet. + type: int + returned: always + sample: 3600 + labels: + description: User-defined labels (key-value pairs) + type: dict + returned: always + sample: + key: value + change_protection: + description: Protect the Zone RRSet from changes (deletion and updates). + type: bool + returned: always + sample: false + records: + description: Records of the Zone RRSet. + returned: always + type: list + elements: dict + contains: + value: + description: Value of the Record. + returned: always + type: str + sample: 203.0.113.1 + comment: + description: Comment of the Record. + returned: always + type: str + sample: webserver 1 +""" + +from typing import Literal + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.experimental import dns_experimental_warning +from ..module_utils.hcloud import AnsibleHCloud +from ..module_utils.vendor.hcloud import APIException, HCloudException +from ..module_utils.vendor.hcloud.actions import BoundAction +from ..module_utils.vendor.hcloud.zones import BoundZoneRRSet, Zone, ZoneRecord + + +class AnsibleHCloudZoneRRSet(AnsibleHCloud): + represent = "hcloud_zone_rrset" + + hcloud_zone_rrset: BoundZoneRRSet | None = None + + def __init__(self, module: AnsibleModule): + dns_experimental_warning(module) + super().__init__(module) + + def _prepare_result(self): + return { + # Do not use the zone name to prevent a request to the API. + "zone": self.hcloud_zone_rrset.zone.id, + "id": self.hcloud_zone_rrset.id, + "name": self.hcloud_zone_rrset.name, + "type": self.hcloud_zone_rrset.type, + "ttl": self.hcloud_zone_rrset.ttl, + "labels": self.hcloud_zone_rrset.labels, + "change_protection": self.hcloud_zone_rrset.protection["change"], + "records": [self._prepare_result_record(o) for o in self.hcloud_zone_rrset.records or []], + } + + def _prepare_result_record(self, record: ZoneRecord): + return { + "value": record.value, + "comment": record.comment, + } + + def _get(self): + try: + if self.module.params.get("id") is not None: + # pylint: disable=disallowed-name + rrset_name, _, rrset_type = self.module.params.get("id").partition("/") + else: + rrset_name, rrset_type = self.module.params.get("name"), self.module.params.get("type") + + try: + self.hcloud_zone_rrset = self.client.zones.get_rrset( + # zone name and id are interchangeable + zone=Zone(self.module.params.get("zone")), + name=rrset_name, + type=rrset_type, + ) + except APIException as api_exception: + if api_exception.code != "not_found": + raise + except HCloudException as exception: + self.fail_json_hcloud(exception) + + def _create(self): + self.module.fail_on_missing_params(required_params=["name", "type"]) + params = { + "name": self.module.params.get("name"), + "type": self.module.params.get("type"), + } + + if self.module.params.get("ttl") is not None: + params["ttl"] = self.module.params.get("ttl") + + if self.module.params.get("labels") is not None: + params["labels"] = self.module.params.get("labels") + + if self.module.params.get("records") is not None: + params["records"] = [ZoneRecord.from_dict(o) for o in self.module.params.get("records")] + + if not self.module.check_mode: + try: + resp = self.client.zones.create_rrset( + # zone name and id are interchangeable + zone=Zone(self.module.params.get("zone")), + **params, + ) + resp.action.wait_until_finished() + + self.hcloud_zone_rrset = resp.rrset + + if self.module.params.get("change_protection") is not None: + action = self.hcloud_zone_rrset.change_rrset_protection( + change=self.module.params.get("change_protection"), + ) + action.wait_until_finished() + + except HCloudException as exception: + self.fail_json_hcloud(exception) + + self._mark_as_changed() + self._get() + + def _update(self): + try: + # The "change" protection prevents us from updating the rrset. To reach the + # state the user provided, we must update the "change" protection: + # - before other updates if the current change protection is enabled, + # - after other updates if the current change protection is disabled. + update_protection_when: Literal["after", "before"] | None = None + + change_protection = self.module.params.get("change_protection") + if change_protection is not None and change_protection != self.hcloud_zone_rrset.protection["change"]: + update_protection_when = "before" if self.hcloud_zone_rrset.protection["change"] else "after" + + if update_protection_when == "before": + if not self.module.check_mode: + action = self.hcloud_zone_rrset.change_rrset_protection(change=change_protection) + action.wait_until_finished() + self._mark_as_changed() + + actions: list[BoundAction] = [] + + ttl = self.module.params.get("ttl") + if ttl is not None and ttl != self.hcloud_zone_rrset.ttl: + if not self.module.check_mode: + action = self.hcloud_zone_rrset.change_rrset_ttl(ttl=ttl) + actions.append(action) + self._mark_as_changed() + + records = self.module.params.get("records") + if records is not None and self._diff_records(): + if not self.module.check_mode: + action = self.hcloud_zone_rrset.set_rrset_records( + records=[ZoneRecord.from_dict(o) for o in records] + ) + actions.append(action) + self._mark_as_changed() + + for action in actions: + action.wait_until_finished() + + labels = self.module.params.get("labels") + if labels is not None and labels != self.hcloud_zone_rrset.labels: + if not self.module.check_mode: + self.hcloud_zone_rrset.update_rrset(labels=labels) + self._mark_as_changed() + + if update_protection_when == "after": + if not self.module.check_mode: + action = self.hcloud_zone_rrset.change_rrset_protection(change=change_protection) + action.wait_until_finished() + self._mark_as_changed() + + self._get() + except HCloudException as exception: + self.fail_json_hcloud(exception) + + def _diff_records(self) -> bool: + current = [self._prepare_result_record(o) for o in self.hcloud_zone_rrset.records] + wanted = [self._prepare_result_record(ZoneRecord.from_dict(o)) for o in self.module.params.get("records")] + + return current != wanted + + def present(self): + self._get() + if self.hcloud_zone_rrset is None: + self._create() + else: + self._update() + + def absent(self): + try: + self._get() + if self.hcloud_zone_rrset is not None: + if not self.module.check_mode: + resp = self.hcloud_zone_rrset.delete_rrset() + resp.action.wait_until_finished() + self._mark_as_changed() + + self.hcloud_zone_rrset = None + except HCloudException as exception: + self.fail_json_hcloud(exception) + + @classmethod + def define_module(cls): + return AnsibleModule( + argument_spec=dict( + zone={"type": "str", "required": True}, + id={"type": "int"}, + name={"type": "str"}, + type={"type": "str"}, + ttl={"type": "int"}, + labels={"type": "dict"}, + records={ + "type": "list", + "elements": "dict", + "options": dict( + value={"type": "str"}, + comment={"type": "str"}, + ), + }, + change_protection={"type": "bool"}, + state={ + "choices": ["absent", "present"], + "default": "present", + }, + **super().base_module_arguments(), + ), + required_one_of=[["id", "name"]], + required_together=[["name", "type"]], + supports_check_mode=True, + ) + + +def main(): + module = AnsibleHCloudZoneRRSet.define_module() + + hcloud = AnsibleHCloudZoneRRSet(module) + state = module.params.get("state") + if state == "absent": + hcloud.absent() + else: + hcloud.present() + + module.exit_json(**hcloud.get_result()) + + +if __name__ == "__main__": + main() diff --git a/plugins/modules/zone_rrset_info.py b/plugins/modules/zone_rrset_info.py new file mode 100644 index 0000000..722a682 --- /dev/null +++ b/plugins/modules/zone_rrset_info.py @@ -0,0 +1,240 @@ +#!/usr/bin/python + +# Copyright: (c) 2025, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) + + +from __future__ import annotations + +DOCUMENTATION = """ +--- +module: zone_rrset_info + +short_description: Gather infos about your Hetzner Cloud Zone RRSets. + +description: + - Gather infos about your Hetzner Cloud Zone RRSets. + - See the L(Zone RRSets API documentation,https://docs.hetzner.cloud/reference/cloud#zone-rrsets) for more details. + - B(Experimental:) DNS API is in beta, breaking changes may occur within minor releases. + See https://docs.hetzner.cloud/changelog#2025-10-07-dns-beta for more details. + +author: + - Jonas Lammler (@jooola) + +options: + zone: + description: + - Name or ID of the parent Zone. + type: str + required: true + id: + description: + - ID of the Zone RRSet you want to get. + type: str + name: + description: + - Name of the Zone RRSets you want to get. + type: str + type: + description: + - Type of the Zone RRSets you want to get. + type: str + label_selector: + description: + - Label selector to filter the Zone RRSets you want to get. + type: str + +extends_documentation_fragment: + - hetzner.hcloud.hcloud +""" + +EXAMPLES = """ +- name: Gather Zone RRSet infos + hetzner.hcloud.zone_rrset_info: + register: output +- name: Print the gathered infos + debug: + var: output.hcloud_zone_rrset_info +""" + +RETURN = """ +hcloud_zone_rrset_info: + description: Zone RRSet infos as list. + returned: always + type: list + elements: dict + contains: + zone: + description: ID of the parent Zone. + type: int + returned: always + sample: 42 + id: + description: ID of the Zone RRSet. + returned: always + type: str + sample: www/A + name: + description: Name of the Zone RRSet. + returned: always + type: str + sample: www + type: + description: Mode of the Zone RRSet. + returned: always + type: str + sample: A + ttl: + description: TTL of the Zone RRSet. + returned: always + type: int + sample: 10800 + labels: + description: User-defined labels (key-value pairs) + returned: always + type: dict + sample: + key: value + change_protection: + description: Protect the Zone RRSet from changes (deletion and updates). + returned: always + type: bool + sample: false + records: + description: Record of the Zone RRSet. + returned: always + type: list + elements: dict + contains: + value: + description: Value of the Record. + returned: always + type: str + sample: 203.0.113.1 + comment: + description: Comment of the Record. + returned: always + type: str + sample: webserver 1 +""" + +from ansible.module_utils.basic import AnsibleModule + +from ..module_utils.experimental import dns_experimental_warning +from ..module_utils.hcloud import AnsibleHCloud +from ..module_utils.vendor.hcloud import APIException, HCloudException +from ..module_utils.vendor.hcloud.zones import BoundZoneRRSet, Zone, ZoneRecord + + +class AnsibleHCloudZoneRRSetInfo(AnsibleHCloud): + represent = "hcloud_zone_rrset_info" + + hcloud_zone_rrset_info: list[BoundZoneRRSet] | None = None + + def __init__(self, module: AnsibleModule): + dns_experimental_warning(module) + super().__init__(module) + + def _prepare_result(self): + tmp = [] + + for zone_rrset in self.hcloud_zone_rrset_info: + if zone_rrset is None: + continue + + tmp.append( + { + # Do not use the zone name to prevent a request to the API. + "zone": zone_rrset.zone.id, + "id": zone_rrset.id, + "name": zone_rrset.name, + "type": zone_rrset.type, + "ttl": zone_rrset.ttl, + "labels": zone_rrset.labels, + "change_protection": zone_rrset.protection["change"], + "records": [self._prepare_result_record(o) for o in zone_rrset.records or []], + } + ) + + return tmp + + def _prepare_result_record(self, record: ZoneRecord): + return { + "value": record.value, + "comment": record.comment, + } + + def get_zone_rrsets(self): + try: + self.hcloud_zone_rrset_info = [] + + # zone name and id are interchangeable + zone = Zone(self.module.params.get("zone")) + + if self.module.params.get("id") is not None: + # pylint: disable=disallowed-name + rrset_name, _, rrset_type = self.module.params.get("id").partition("/") + try: + self.hcloud_zone_rrset_info = [ + self.client.zones.get_rrset( + zone, + name=rrset_name, + type=rrset_type, + ) + ] + except APIException as api_exception: + if api_exception.code != "not_found": + raise + elif self.module.params.get("name") is not None: + rrset_name, rrset_type = self.module.params.get("name"), self.module.params.get("type") + try: + self.hcloud_zone_rrset_info = [ + self.client.zones.get_rrset( + zone, + name=rrset_name, + type=rrset_type, + ) + ] + except APIException as api_exception: + if api_exception.code != "not_found": + raise + elif self.module.params.get("label_selector") is not None: + self.hcloud_zone_rrset_info = self.client.zones.get_rrset_all( + zone, + label_selector=self.module.params.get("label_selector"), + ) + else: + self.hcloud_zone_rrset_info = self.client.zones.get_rrset_all(zone) + + except HCloudException as exception: + self.fail_json_hcloud(exception) + + @classmethod + def define_module(cls): + return AnsibleModule( + argument_spec=dict( + zone={"type": "str", "required": True}, + id={"type": "str"}, + name={"type": "str"}, + type={"type": "str"}, + label_selector={"type": "str"}, + **super().base_module_arguments(), + ), + required_together=[("name", "type")], + supports_check_mode=True, + ) + + +def main(): + module = AnsibleHCloudZoneRRSetInfo.define_module() + hcloud = AnsibleHCloudZoneRRSetInfo(module) + + hcloud.get_zone_rrsets() + result = hcloud.get_result() + + ansible_info = {"hcloud_zone_rrset_info": result["hcloud_zone_rrset_info"]} + module.exit_json(**ansible_info) + + +if __name__ == "__main__": + main() diff --git a/tests/integration/targets/zone/aliases b/tests/integration/targets/zone/aliases new file mode 100644 index 0000000..18b1111 --- /dev/null +++ b/tests/integration/targets/zone/aliases @@ -0,0 +1,3 @@ +cloud/hcloud +gather_facts/no +azp/group2 diff --git a/tests/integration/targets/zone/defaults/main/common.yml b/tests/integration/targets/zone/defaults/main/common.yml new file mode 100644 index 0000000..0b15142 --- /dev/null +++ b/tests/integration/targets/zone/defaults/main/common.yml @@ -0,0 +1,29 @@ +# +# DO NOT EDIT THIS FILE! Please edit the files in tests/integration/common instead. +# +--- +# Azure Pipelines will configure this value to something similar to +# "azp-84824-1-hetzner-2-13-test-2-13-hcloud-3-9-1-default-i" +hcloud_prefix: "tests" + +# Used to namespace resources created by concurrent test pipelines/targets +hcloud_run_ns: "{{ hcloud_prefix | md5 }}" +hcloud_role_ns: "{{ role_name | split('_') | map('batch', 2) | map('first') | flatten() | join() }}" +hcloud_ns: "ansible-{{ hcloud_run_ns }}-{{ hcloud_role_ns }}" + +# Used to easily update the server types and images across all our tests. +hcloud_server_type_name: cax11 +hcloud_server_type_id: 45 + +hcloud_server_type_upgrade_name: cax21 +hcloud_server_type_upgrade_id: 93 + +hcloud_image_name: debian-12 +hcloud_image_id: 114690389 # architecture=arm + +hcloud_location_name: hel1 +hcloud_location_id: 3 +hcloud_datacenter_name: hel1-dc2 +hcloud_datacenter_id: 3 + +hcloud_network_zone_name: eu-central diff --git a/tests/integration/targets/zone/defaults/main/main.yml b/tests/integration/targets/zone/defaults/main/main.yml new file mode 100644 index 0000000..b130236 --- /dev/null +++ b/tests/integration/targets/zone/defaults/main/main.yml @@ -0,0 +1,4 @@ +# Copyright: (c) 2019, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +hcloud_zone_name: "{{ hcloud_ns }}.de" diff --git a/tests/integration/targets/zone/tasks/cleanup.yml b/tests/integration/targets/zone/tasks/cleanup.yml new file mode 100644 index 0000000..7619fa5 --- /dev/null +++ b/tests/integration/targets/zone/tasks/cleanup.yml @@ -0,0 +1,5 @@ +--- +- name: Cleanup test_zone + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + state: absent diff --git a/tests/integration/targets/zone/tasks/main.yml b/tests/integration/targets/zone/tasks/main.yml new file mode 100644 index 0000000..767fc46 --- /dev/null +++ b/tests/integration/targets/zone/tasks/main.yml @@ -0,0 +1,31 @@ +# +# DO NOT EDIT THIS FILE! Please edit the files in tests/integration/common instead. +# +--- +- name: Check if cleanup.yml exists + ansible.builtin.stat: + path: "{{ role_path }}/tasks/cleanup.yml" + register: cleanup_file + +- name: Check if prepare.yml exists + ansible.builtin.stat: + path: "{{ role_path }}/tasks/prepare.yml" + register: prepare_file + +- name: Include cleanup tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/cleanup.yml" + when: cleanup_file.stat.exists + +- name: Include prepare tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/prepare.yml" + when: prepare_file.stat.exists + +- name: Run tests + block: + - name: Include test tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/test.yml" + + always: + - name: Include cleanup tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/cleanup.yml" + when: cleanup_file.stat.exists diff --git a/tests/integration/targets/zone/tasks/test.yml b/tests/integration/targets/zone/tasks/test.yml new file mode 100644 index 0000000..052ce3c --- /dev/null +++ b/tests/integration/targets/zone/tasks/test.yml @@ -0,0 +1,317 @@ +--- +- name: Test missing required parameters + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + state: present + ignore_errors: true + register: result +- name: Verify missing required parameters + ansible.builtin.assert: + that: + - result is failed + - 'result.msg == "missing required arguments: mode"' + +- name: Test create with check mode + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + mode: primary + ttl: 10800 + labels: + key: value + check_mode: true + register: result +- name: Verify create with check mode + ansible.builtin.assert: + that: + - result is changed + +- name: Test create primary + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + mode: primary + ttl: 10800 + labels: + key: value + register: result +- name: Verify create primary + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_zone.id is not none + - result.hcloud_zone.name == hcloud_zone_name + - result.hcloud_zone.mode == "primary" + - result.hcloud_zone.primary_nameservers | count == 0 + - result.hcloud_zone.ttl == 10800 + - result.hcloud_zone.labels.key == "value" + - result.hcloud_zone.delete_protection == false + - result.hcloud_zone.status == "ok" + - result.hcloud_zone.registrar == "other" + - result.hcloud_zone.authoritative_nameservers.assigned == ["hydrogen.ns.hetzner.com.", "oxygen.ns.hetzner.com.", "helium.ns.hetzner.de."] + - result.hcloud_zone.authoritative_nameservers.delegated == [] + - result.hcloud_zone.authoritative_nameservers.delegation_last_check is none + - result.hcloud_zone.authoritative_nameservers.delegation_status == "unknown" + +- name: Test create primary idempotency + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + mode: primary + ttl: 10800 + labels: + key: value + register: result +- name: Verify create primary idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test update primary + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + ttl: 3600 + labels: + key: changed + new: value + delete_protection: true + register: result +- name: Verify update primary + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_zone.name == hcloud_zone_name + - result.hcloud_zone.mode == "primary" + - result.hcloud_zone.ttl == 3600 + - result.hcloud_zone.labels.key == "changed" + - result.hcloud_zone.labels.new == "value" + - result.hcloud_zone.delete_protection == true + +- name: Test update primary idempotency + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + ttl: 3600 + labels: + key: changed + new: value + delete_protection: true + register: result +- name: Verify update primary idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test delete primary with delete protection + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + state: absent + ignore_errors: true + register: result +- name: Verify delete primary with delete protection + ansible.builtin.assert: + that: + - result is failed + - result.failure.code == "protected" + +- name: Test update primary delete protection + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + delete_protection: false + register: result +- name: Verify update primary delete protection + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_zone.delete_protection == false + +- name: Test delete primary + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + state: absent + register: result +- name: Verify delete primary + ansible.builtin.assert: + that: + - result is changed + +- name: Test create secondary + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + mode: secondary + primary_nameservers: + - address: 203.0.113.1 + port: 53 + register: result +- name: Verify create secondary + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_zone.id is not none + - result.hcloud_zone.name == hcloud_zone_name + - result.hcloud_zone.mode == "secondary" + - result.hcloud_zone.primary_nameservers | count == 1 + - result.hcloud_zone.primary_nameservers[0].address == "203.0.113.1" + - result.hcloud_zone.primary_nameservers[0].port == 53 + - result.hcloud_zone.ttl == 3600 + +- name: Test create secondary idempotency + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + mode: secondary + primary_nameservers: + - address: 203.0.113.1 + port: 53 + register: result +- name: Verify create secondary idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test update secondary + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + primary_nameservers: + - address: 203.0.113.1 + port: 5353 + register: result +- name: Verify update secondary + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_zone.name == hcloud_zone_name + - result.hcloud_zone.mode == "secondary" + - result.hcloud_zone.primary_nameservers | count == 1 + - result.hcloud_zone.primary_nameservers[0].address == "203.0.113.1" + - result.hcloud_zone.primary_nameservers[0].port == 5353 + +- name: Test update secondary idempotency + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + primary_nameservers: + - address: 203.0.113.1 + port: 5353 + register: result +- name: Verify update secondary idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test delete secondary + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + state: absent + register: result +- name: Verify delete secondary + ansible.builtin.assert: + that: + - result is changed + +- name: Test create primary from zonefile + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + mode: primary + zonefile: | + $ORIGIN {{ hcloud_zone_name }}. + $TTL 3600 + + @ 300 IN CAA 0 issue "letsencrypt.org" + + @ 600 IN A 192.168.254.2 + @ 600 IN A 192.168.254.3 + + @ IN AAAA fdd0:367a:0cb7::2 + @ IN AAAA fdd0:367a:0cb7::3 + + www IN CNAME {{ hcloud_zone_name }}. + blog IN CNAME {{ hcloud_zone_name }}. + + anything IN TXT "some value" + register: result +- name: Verify create primary from zonefile + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_zone.id is not none + - result.hcloud_zone.name == hcloud_zone_name + - result.hcloud_zone.mode == "primary" + - result.hcloud_zone.primary_nameservers | count == 0 + - result.hcloud_zone.ttl == 3600 + - result.hcloud_zone.labels | count == 0 + - result.hcloud_zone.delete_protection == false + - result.hcloud_zone.status == "ok" + - result.hcloud_zone.registrar == "other" + +- name: Test create primary from zonefile idempotency + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + mode: primary + zonefile: | + $ORIGIN {{ hcloud_zone_name }}. + $TTL 3600 + + @ 300 IN CAA 0 issue "letsencrypt.org" + + @ 600 IN A 192.168.254.2 + @ 600 IN A 192.168.254.3 + + @ IN AAAA fdd0:367a:0cb7::2 + @ IN AAAA fdd0:367a:0cb7::3 + + www IN CNAME {{ hcloud_zone_name }}. + blog IN CNAME {{ hcloud_zone_name }}. + + anything IN TXT "some value" + register: result +- name: Verify create primary from zonefile idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test import from zonefile + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + mode: primary + zonefile: | + @ SOA hydrogen.ns.hetzner.com. dns.hetzner.com. 2025032100 86400 10800 3600000 3600 + + @ NS hydrogen.ns.hetzner.com. + @ NS oxygen.ns.hetzner.com. + @ NS helium.ns.hetzner.de. + + $ORIGIN {{ hcloud_zone_name }}. + $TTL 3600 + + @ 300 IN CAA 0 issue "letsencrypt.org" + + @ 600 IN A 192.168.254.2 + @ 600 IN A 192.168.254.3 + + @ IN AAAA fdd0:367a:0cb7::2 + @ IN AAAA fdd0:367a:0cb7::3 + + www IN CNAME {{ hcloud_zone_name }}. + blog IN CNAME {{ hcloud_zone_name }}. + + anything IN TXT "some value" + state: import + register: result +- name: Verify import from zonefile + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_zone.id is not none + - result.hcloud_zone.name == hcloud_zone_name + - result.hcloud_zone.mode == "primary" + - result.hcloud_zone.primary_nameservers | count == 0 + - result.hcloud_zone.ttl == 3600 + - result.hcloud_zone.labels | count == 0 + - result.hcloud_zone.delete_protection == false + - result.hcloud_zone.status == "ok" + - result.hcloud_zone.registrar == "other" + +- name: Test delete primary from zonefile + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + state: absent + register: result +- name: Verify delete primary from zonefile + ansible.builtin.assert: + that: + - result is changed diff --git a/tests/integration/targets/zone_info/aliases b/tests/integration/targets/zone_info/aliases new file mode 100644 index 0000000..18b1111 --- /dev/null +++ b/tests/integration/targets/zone_info/aliases @@ -0,0 +1,3 @@ +cloud/hcloud +gather_facts/no +azp/group2 diff --git a/tests/integration/targets/zone_info/defaults/main/common.yml b/tests/integration/targets/zone_info/defaults/main/common.yml new file mode 100644 index 0000000..0b15142 --- /dev/null +++ b/tests/integration/targets/zone_info/defaults/main/common.yml @@ -0,0 +1,29 @@ +# +# DO NOT EDIT THIS FILE! Please edit the files in tests/integration/common instead. +# +--- +# Azure Pipelines will configure this value to something similar to +# "azp-84824-1-hetzner-2-13-test-2-13-hcloud-3-9-1-default-i" +hcloud_prefix: "tests" + +# Used to namespace resources created by concurrent test pipelines/targets +hcloud_run_ns: "{{ hcloud_prefix | md5 }}" +hcloud_role_ns: "{{ role_name | split('_') | map('batch', 2) | map('first') | flatten() | join() }}" +hcloud_ns: "ansible-{{ hcloud_run_ns }}-{{ hcloud_role_ns }}" + +# Used to easily update the server types and images across all our tests. +hcloud_server_type_name: cax11 +hcloud_server_type_id: 45 + +hcloud_server_type_upgrade_name: cax21 +hcloud_server_type_upgrade_id: 93 + +hcloud_image_name: debian-12 +hcloud_image_id: 114690389 # architecture=arm + +hcloud_location_name: hel1 +hcloud_location_id: 3 +hcloud_datacenter_name: hel1-dc2 +hcloud_datacenter_id: 3 + +hcloud_network_zone_name: eu-central diff --git a/tests/integration/targets/zone_info/defaults/main/main.yml b/tests/integration/targets/zone_info/defaults/main/main.yml new file mode 100644 index 0000000..b130236 --- /dev/null +++ b/tests/integration/targets/zone_info/defaults/main/main.yml @@ -0,0 +1,4 @@ +# Copyright: (c) 2019, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +hcloud_zone_name: "{{ hcloud_ns }}.de" diff --git a/tests/integration/targets/zone_info/tasks/cleanup.yml b/tests/integration/targets/zone_info/tasks/cleanup.yml new file mode 100644 index 0000000..7619fa5 --- /dev/null +++ b/tests/integration/targets/zone_info/tasks/cleanup.yml @@ -0,0 +1,5 @@ +--- +- name: Cleanup test_zone + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + state: absent diff --git a/tests/integration/targets/zone_info/tasks/main.yml b/tests/integration/targets/zone_info/tasks/main.yml new file mode 100644 index 0000000..767fc46 --- /dev/null +++ b/tests/integration/targets/zone_info/tasks/main.yml @@ -0,0 +1,31 @@ +# +# DO NOT EDIT THIS FILE! Please edit the files in tests/integration/common instead. +# +--- +- name: Check if cleanup.yml exists + ansible.builtin.stat: + path: "{{ role_path }}/tasks/cleanup.yml" + register: cleanup_file + +- name: Check if prepare.yml exists + ansible.builtin.stat: + path: "{{ role_path }}/tasks/prepare.yml" + register: prepare_file + +- name: Include cleanup tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/cleanup.yml" + when: cleanup_file.stat.exists + +- name: Include prepare tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/prepare.yml" + when: prepare_file.stat.exists + +- name: Run tests + block: + - name: Include test tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/test.yml" + + always: + - name: Include cleanup tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/cleanup.yml" + when: cleanup_file.stat.exists diff --git a/tests/integration/targets/zone_info/tasks/prepare.yml b/tests/integration/targets/zone_info/tasks/prepare.yml new file mode 100644 index 0000000..5ef9fb0 --- /dev/null +++ b/tests/integration/targets/zone_info/tasks/prepare.yml @@ -0,0 +1,9 @@ +--- +- name: Create test_zone + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + mode: primary + ttl: 3600 + labels: + key: value + register: test_zone diff --git a/tests/integration/targets/zone_info/tasks/test.yml b/tests/integration/targets/zone_info/tasks/test.yml new file mode 100644 index 0000000..1d49f1e --- /dev/null +++ b/tests/integration/targets/zone_info/tasks/test.yml @@ -0,0 +1,77 @@ +# Copyright: (c) 2025, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: Gather hcloud_zone_info + hetzner.hcloud.zone_info: + register: result +- name: Verify hcloud_zone_info + ansible.builtin.assert: + that: + - result.hcloud_zone_info | list | count >= 1 + +- name: Gather hcloud_zone_info in check mode + hetzner.hcloud.zone_info: + check_mode: true + register: result +- name: Verify hcloud_zone_info in check mode + ansible.builtin.assert: + that: + - result.hcloud_zone_info | list | count >= 1 + +- name: Gather hcloud_zone_info with correct id + hetzner.hcloud.zone_info: + id: "{{ test_zone.hcloud_zone.id }}" + register: result +- name: Verify hcloud_zone_info with correct id + ansible.builtin.assert: + that: + - result.hcloud_zone_info | list | count == 1 + +- name: Gather hcloud_zone_info with wrong id + hetzner.hcloud.zone_info: + id: "{{ test_zone.hcloud_zone.id }}4321" + ignore_errors: true + register: result +- name: Verify hcloud_zone_info with wrong id + ansible.builtin.assert: + that: + - result is failed + +- name: Gather hcloud_zone_info with correct name + hetzner.hcloud.zone_info: + name: "{{ hcloud_zone_name }}" + register: result +- name: Verify hcloud_zone_info with correct name + ansible.builtin.assert: + that: + - result.hcloud_zone_info | list | count == 1 + +- name: Gather hcloud_zone_info with wrong name + hetzner.hcloud.zone_info: + name: "invalid-{{ hcloud_zone_name }}" + register: result +- name: Verify hcloud_zone_info with wrong name + ansible.builtin.assert: + that: + - result.hcloud_zone_info | list | count == 0 + +- name: Gather hcloud_zone_info with correct label selector + hetzner.hcloud.zone_info: + label_selector: "key=value" + register: result +- name: Verify hcloud_zone_info with correct label selector + ansible.builtin.assert: + that: + - > + result.hcloud_zone_info + | selectattr('name', 'equalto', hcloud_zone_name) + | list | count == 1 + +- name: Gather hcloud_zone_info with wrong label selector + hetzner.hcloud.zone_info: + label_selector: "key!=value" + register: result +- name: Verify hcloud_zone_info with wrong label selector + ansible.builtin.assert: + that: + - result.hcloud_zone_info | list | count == 0 diff --git a/tests/integration/targets/zone_rrset/aliases b/tests/integration/targets/zone_rrset/aliases new file mode 100644 index 0000000..18b1111 --- /dev/null +++ b/tests/integration/targets/zone_rrset/aliases @@ -0,0 +1,3 @@ +cloud/hcloud +gather_facts/no +azp/group2 diff --git a/tests/integration/targets/zone_rrset/defaults/main/common.yml b/tests/integration/targets/zone_rrset/defaults/main/common.yml new file mode 100644 index 0000000..0b15142 --- /dev/null +++ b/tests/integration/targets/zone_rrset/defaults/main/common.yml @@ -0,0 +1,29 @@ +# +# DO NOT EDIT THIS FILE! Please edit the files in tests/integration/common instead. +# +--- +# Azure Pipelines will configure this value to something similar to +# "azp-84824-1-hetzner-2-13-test-2-13-hcloud-3-9-1-default-i" +hcloud_prefix: "tests" + +# Used to namespace resources created by concurrent test pipelines/targets +hcloud_run_ns: "{{ hcloud_prefix | md5 }}" +hcloud_role_ns: "{{ role_name | split('_') | map('batch', 2) | map('first') | flatten() | join() }}" +hcloud_ns: "ansible-{{ hcloud_run_ns }}-{{ hcloud_role_ns }}" + +# Used to easily update the server types and images across all our tests. +hcloud_server_type_name: cax11 +hcloud_server_type_id: 45 + +hcloud_server_type_upgrade_name: cax21 +hcloud_server_type_upgrade_id: 93 + +hcloud_image_name: debian-12 +hcloud_image_id: 114690389 # architecture=arm + +hcloud_location_name: hel1 +hcloud_location_id: 3 +hcloud_datacenter_name: hel1-dc2 +hcloud_datacenter_id: 3 + +hcloud_network_zone_name: eu-central diff --git a/tests/integration/targets/zone_rrset/defaults/main/main.yml b/tests/integration/targets/zone_rrset/defaults/main/main.yml new file mode 100644 index 0000000..b130236 --- /dev/null +++ b/tests/integration/targets/zone_rrset/defaults/main/main.yml @@ -0,0 +1,4 @@ +# Copyright: (c) 2019, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +hcloud_zone_name: "{{ hcloud_ns }}.de" diff --git a/tests/integration/targets/zone_rrset/tasks/cleanup.yml b/tests/integration/targets/zone_rrset/tasks/cleanup.yml new file mode 100644 index 0000000..7619fa5 --- /dev/null +++ b/tests/integration/targets/zone_rrset/tasks/cleanup.yml @@ -0,0 +1,5 @@ +--- +- name: Cleanup test_zone + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + state: absent diff --git a/tests/integration/targets/zone_rrset/tasks/main.yml b/tests/integration/targets/zone_rrset/tasks/main.yml new file mode 100644 index 0000000..767fc46 --- /dev/null +++ b/tests/integration/targets/zone_rrset/tasks/main.yml @@ -0,0 +1,31 @@ +# +# DO NOT EDIT THIS FILE! Please edit the files in tests/integration/common instead. +# +--- +- name: Check if cleanup.yml exists + ansible.builtin.stat: + path: "{{ role_path }}/tasks/cleanup.yml" + register: cleanup_file + +- name: Check if prepare.yml exists + ansible.builtin.stat: + path: "{{ role_path }}/tasks/prepare.yml" + register: prepare_file + +- name: Include cleanup tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/cleanup.yml" + when: cleanup_file.stat.exists + +- name: Include prepare tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/prepare.yml" + when: prepare_file.stat.exists + +- name: Run tests + block: + - name: Include test tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/test.yml" + + always: + - name: Include cleanup tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/cleanup.yml" + when: cleanup_file.stat.exists diff --git a/tests/integration/targets/zone_rrset/tasks/prepare.yml b/tests/integration/targets/zone_rrset/tasks/prepare.yml new file mode 100644 index 0000000..943411c --- /dev/null +++ b/tests/integration/targets/zone_rrset/tasks/prepare.yml @@ -0,0 +1,9 @@ +--- +- name: Create test_zone + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + mode: primary + ttl: 10800 + labels: + key: value + register: test_zone diff --git a/tests/integration/targets/zone_rrset/tasks/test.yml b/tests/integration/targets/zone_rrset/tasks/test.yml new file mode 100644 index 0000000..38d1ae2 --- /dev/null +++ b/tests/integration/targets/zone_rrset/tasks/test.yml @@ -0,0 +1,186 @@ +--- +- name: Test missing required parameters # noqa: args[module] + hetzner.hcloud.zone_rrset: + zone: "{{ hcloud_zone_name }}" + state: present + ignore_errors: true + register: result +- name: Verify missing required parameters + ansible.builtin.assert: + that: + - result is failed + - 'result.msg == "one of the following is required: id, name"' + +- name: Test create with check mode + hetzner.hcloud.zone_rrset: + zone: "{{ hcloud_zone_name }}" + name: www + type: A + ttl: 300 + labels: + key: value + records: + - value: 201.118.10.11 + comment: web server 1 + - value: 201.118.10.12 + comment: web server 2 + check_mode: true + register: result +- name: Verify create with check mode + ansible.builtin.assert: + that: + - result is changed + +- name: Test create + hetzner.hcloud.zone_rrset: + zone: "{{ hcloud_zone_name }}" + name: www + type: A + ttl: 300 + labels: + key: value + records: + - value: 201.118.10.11 + comment: web server 1 + - value: 201.118.10.12 + comment: web server 2 + register: result +- name: Verify create + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_zone_rrset.zone == test_zone.hcloud_zone.id + - result.hcloud_zone_rrset.id == "www/A" + - result.hcloud_zone_rrset.name == "www" + - result.hcloud_zone_rrset.type == "A" + - result.hcloud_zone_rrset.ttl == 300 + - result.hcloud_zone_rrset.labels.key == "value" + - result.hcloud_zone_rrset.change_protection == false + - result.hcloud_zone_rrset.records | count == 2 + - result.hcloud_zone_rrset.records[0].value == "201.118.10.11" + - result.hcloud_zone_rrset.records[0].comment == "web server 1" + - result.hcloud_zone_rrset.records[1].value == "201.118.10.12" + - result.hcloud_zone_rrset.records[1].comment == "web server 2" + +- name: Test create idempotency + hetzner.hcloud.zone_rrset: + zone: "{{ hcloud_zone_name }}" + name: www + type: A + ttl: 300 + labels: + key: value + records: + - value: 201.118.10.11 + comment: web server 1 + - value: 201.118.10.12 + comment: web server 2 + register: result +- name: Verify create idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test update + hetzner.hcloud.zone_rrset: + zone: "{{ hcloud_zone_name }}" + name: www + type: A + ttl: 60 + labels: + key: changed + new: value + records: + - value: 201.118.10.11 + comment: web server 1 + - value: 201.118.10.13 + comment: web server 3 + change_protection: true + register: result +- name: Verify update + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_zone_rrset.zone == test_zone.hcloud_zone.id + - result.hcloud_zone_rrset.id == "www/A" + - result.hcloud_zone_rrset.ttl == 60 + - result.hcloud_zone_rrset.labels.key == "changed" + - result.hcloud_zone_rrset.labels.new == "value" + - result.hcloud_zone_rrset.change_protection == true + - result.hcloud_zone_rrset.records | count == 2 + - result.hcloud_zone_rrset.records[0].value == "201.118.10.11" + - result.hcloud_zone_rrset.records[0].comment == "web server 1" + - result.hcloud_zone_rrset.records[1].value == "201.118.10.13" + - result.hcloud_zone_rrset.records[1].comment == "web server 3" + +- name: Test update idempotency + hetzner.hcloud.zone_rrset: + zone: "{{ hcloud_zone_name }}" + name: www + type: A + ttl: 60 + labels: + key: changed + new: value + records: + - value: 201.118.10.11 + comment: web server 1 + - value: 201.118.10.13 + comment: web server 3 + change_protection: true + register: result +- name: Verify update idempotency + ansible.builtin.assert: + that: + - result is not changed + +- name: Test delete with change protection + hetzner.hcloud.zone_rrset: + zone: "{{ hcloud_zone_name }}" + name: www + type: A + state: absent + ignore_errors: true + register: result +- name: Verify delete with change protection + ansible.builtin.assert: + that: + - result is failed + - result.failure.code == "protected" + +- name: Test delete zone with change protection + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + state: absent + ignore_errors: true + register: result +- name: Verify delete zone with change protection + ansible.builtin.assert: + that: + - result is failed + - result.failure.code == "protected" + +- name: Test update change protection + hetzner.hcloud.zone_rrset: + zone: "{{ hcloud_zone_name }}" + name: www + type: A + change_protection: false + register: result +- name: Verify update delete protection + ansible.builtin.assert: + that: + - result is changed + - result.hcloud_zone_rrset.change_protection == false + +- name: Test delete + hetzner.hcloud.zone_rrset: + zone: "{{ hcloud_zone_name }}" + name: www + type: A + state: absent + register: result +- name: Verify delete + ansible.builtin.assert: + that: + - result is changed diff --git a/tests/integration/targets/zone_rrset_info/aliases b/tests/integration/targets/zone_rrset_info/aliases new file mode 100644 index 0000000..18b1111 --- /dev/null +++ b/tests/integration/targets/zone_rrset_info/aliases @@ -0,0 +1,3 @@ +cloud/hcloud +gather_facts/no +azp/group2 diff --git a/tests/integration/targets/zone_rrset_info/defaults/main/common.yml b/tests/integration/targets/zone_rrset_info/defaults/main/common.yml new file mode 100644 index 0000000..0b15142 --- /dev/null +++ b/tests/integration/targets/zone_rrset_info/defaults/main/common.yml @@ -0,0 +1,29 @@ +# +# DO NOT EDIT THIS FILE! Please edit the files in tests/integration/common instead. +# +--- +# Azure Pipelines will configure this value to something similar to +# "azp-84824-1-hetzner-2-13-test-2-13-hcloud-3-9-1-default-i" +hcloud_prefix: "tests" + +# Used to namespace resources created by concurrent test pipelines/targets +hcloud_run_ns: "{{ hcloud_prefix | md5 }}" +hcloud_role_ns: "{{ role_name | split('_') | map('batch', 2) | map('first') | flatten() | join() }}" +hcloud_ns: "ansible-{{ hcloud_run_ns }}-{{ hcloud_role_ns }}" + +# Used to easily update the server types and images across all our tests. +hcloud_server_type_name: cax11 +hcloud_server_type_id: 45 + +hcloud_server_type_upgrade_name: cax21 +hcloud_server_type_upgrade_id: 93 + +hcloud_image_name: debian-12 +hcloud_image_id: 114690389 # architecture=arm + +hcloud_location_name: hel1 +hcloud_location_id: 3 +hcloud_datacenter_name: hel1-dc2 +hcloud_datacenter_id: 3 + +hcloud_network_zone_name: eu-central diff --git a/tests/integration/targets/zone_rrset_info/defaults/main/main.yml b/tests/integration/targets/zone_rrset_info/defaults/main/main.yml new file mode 100644 index 0000000..b130236 --- /dev/null +++ b/tests/integration/targets/zone_rrset_info/defaults/main/main.yml @@ -0,0 +1,4 @@ +# Copyright: (c) 2019, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +hcloud_zone_name: "{{ hcloud_ns }}.de" diff --git a/tests/integration/targets/zone_rrset_info/tasks/cleanup.yml b/tests/integration/targets/zone_rrset_info/tasks/cleanup.yml new file mode 100644 index 0000000..7619fa5 --- /dev/null +++ b/tests/integration/targets/zone_rrset_info/tasks/cleanup.yml @@ -0,0 +1,5 @@ +--- +- name: Cleanup test_zone + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + state: absent diff --git a/tests/integration/targets/zone_rrset_info/tasks/main.yml b/tests/integration/targets/zone_rrset_info/tasks/main.yml new file mode 100644 index 0000000..767fc46 --- /dev/null +++ b/tests/integration/targets/zone_rrset_info/tasks/main.yml @@ -0,0 +1,31 @@ +# +# DO NOT EDIT THIS FILE! Please edit the files in tests/integration/common instead. +# +--- +- name: Check if cleanup.yml exists + ansible.builtin.stat: + path: "{{ role_path }}/tasks/cleanup.yml" + register: cleanup_file + +- name: Check if prepare.yml exists + ansible.builtin.stat: + path: "{{ role_path }}/tasks/prepare.yml" + register: prepare_file + +- name: Include cleanup tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/cleanup.yml" + when: cleanup_file.stat.exists + +- name: Include prepare tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/prepare.yml" + when: prepare_file.stat.exists + +- name: Run tests + block: + - name: Include test tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/test.yml" + + always: + - name: Include cleanup tasks + ansible.builtin.include_tasks: "{{ role_path }}/tasks/cleanup.yml" + when: cleanup_file.stat.exists diff --git a/tests/integration/targets/zone_rrset_info/tasks/prepare.yml b/tests/integration/targets/zone_rrset_info/tasks/prepare.yml new file mode 100644 index 0000000..3d74044 --- /dev/null +++ b/tests/integration/targets/zone_rrset_info/tasks/prepare.yml @@ -0,0 +1,36 @@ +--- +- name: Create test_zone + hetzner.hcloud.zone: + name: "{{ hcloud_zone_name }}" + mode: primary + ttl: 3600 + labels: + key: value + register: test_zone + +- name: Create test_zone_rrset1 + hetzner.hcloud.zone_rrset: + zone: "{{ hcloud_zone_name }}" + name: www + type: A + ttl: 600 + labels: + key: value + records: + - comment: webserver 1 + value: 201.34.52.45 + - comment: webserver 2 + value: 201.34.52.46 + register: test_zone_rrset1 + +- name: Create test_zone_rrset2 + hetzner.hcloud.zone_rrset: + zone: "{{ hcloud_zone_name }}" + name: blog + type: A + records: + - comment: webserver 3 + value: 201.34.52.47 + - comment: webserver 4 + value: 201.34.52.48 + register: test_zone_rrset2 diff --git a/tests/integration/targets/zone_rrset_info/tasks/test.yml b/tests/integration/targets/zone_rrset_info/tasks/test.yml new file mode 100644 index 0000000..5c10843 --- /dev/null +++ b/tests/integration/targets/zone_rrset_info/tasks/test.yml @@ -0,0 +1,96 @@ +# Copyright: (c) 2025, Hetzner Cloud GmbH +# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt) +--- +- name: Gather hcloud_zone_rrset_info + hetzner.hcloud.zone_rrset_info: + zone: "{{ hcloud_zone_name }}" + register: result +- name: Verify hcloud_zone_rrset_info + ansible.builtin.assert: + that: + - result.hcloud_zone_rrset_info | list | count >= 1 + +- name: Gather hcloud_zone_rrset_info in check mode + hetzner.hcloud.zone_rrset_info: + zone: "{{ hcloud_zone_name }}" + check_mode: true + register: result +- name: Verify hcloud_zone_rrset_info in check mode + ansible.builtin.assert: + that: + - result.hcloud_zone_rrset_info | list | count >= 1 + +- name: Gather hcloud_zone_rrset_info with wrong zone + hetzner.hcloud.zone_rrset_info: + zone: invalid.de + ignore_errors: true + register: result +- name: Verify hcloud_zone_rrset_info with wrong zone + ansible.builtin.assert: + that: + - result is failed + +- name: Gather hcloud_zone_rrset_info with correct id + hetzner.hcloud.zone_rrset_info: + zone: "{{ hcloud_zone_name }}" + id: www/A + register: result +- name: Verify hcloud_zone_rrset_info with correct id + ansible.builtin.assert: + that: + - result.hcloud_zone_rrset_info | list | count == 1 + +- name: Gather hcloud_zone_rrset_info with wrong id + hetzner.hcloud.zone_rrset_info: + zone: "{{ hcloud_zone_name }}" + id: invalid/A + register: result +- name: Verify hcloud_zone_rrset_info with wrong id + ansible.builtin.assert: + that: + - result.hcloud_zone_rrset_info | list | count == 0 + +- name: Gather hcloud_zone_rrset_info with correct name + hetzner.hcloud.zone_rrset_info: + zone: "{{ hcloud_zone_name }}" + name: www + type: A + register: result +- name: Verify hcloud_zone_rrset_info with correct name + ansible.builtin.assert: + that: + - result.hcloud_zone_rrset_info | list | count == 1 + +- name: Gather hcloud_zone_rrset_info with wrong name + hetzner.hcloud.zone_rrset_info: + zone: "{{ hcloud_zone_name }}" + name: invalid + type: A + register: result +- name: Verify hcloud_zone_rrset_info with wrong name + ansible.builtin.assert: + that: + - result.hcloud_zone_rrset_info | list | count == 0 + +- name: Gather hcloud_zone_rrset_info with correct label selector + hetzner.hcloud.zone_rrset_info: + zone: "{{ hcloud_zone_name }}" + label_selector: "key=value" + register: result +- name: Verify hcloud_zone_rrset_info with correct label selector + ansible.builtin.assert: + that: + - > + result.hcloud_zone_rrset_info + | selectattr('id', 'equalto', "www/A") + | list | count == 1 + +- name: Gather hcloud_zone_rrset_info with wrong label selector + hetzner.hcloud.zone_rrset_info: + zone: "{{ hcloud_zone_name }}" + label_selector: "key!=value" + register: result +- name: Verify hcloud_zone_rrset_info with wrong label selector + ansible.builtin.assert: + that: + - result.hcloud_zone_rrset_info | list | count == 0