diff --git a/docs/api.clients.zones.rst b/docs/api.clients.zones.rst new file mode 100644 index 00000000..b8809675 --- /dev/null +++ b/docs/api.clients.zones.rst @@ -0,0 +1,30 @@ +ZonesClient +================== + + +.. autoclass:: hcloud.zones.client.ZonesClient + :members: + +.. autoclass:: hcloud.zones.client.BoundZone + :members: + +.. autoclass:: hcloud.zones.client.BoundZoneRRSet + :members: + +.. autoclass:: hcloud.zones.domain.Zone + :members: + +.. autoclass:: hcloud.zones.domain.ZoneAuthoritativeNameservers + :members: + +.. autoclass:: hcloud.zones.domain.ZonePrimaryNameserver + :members: + +.. autoclass:: hcloud.zones.domain.ZoneRecord + :members: + +.. autoclass:: hcloud.zones.domain.ZoneRRSet + :members: + +.. autoclass:: hcloud.zones.domain.CreateZoneResponse + :members: diff --git a/hcloud/_client.py b/hcloud/_client.py index 0b67a4fe..4c499585 100644 --- a/hcloud/_client.py +++ b/hcloud/_client.py @@ -26,6 +26,7 @@ from .servers import ServersClient from .ssh_keys import SSHKeysClient from .volumes import VolumesClient +from .zones import ZonesClient class BackoffFunction(Protocol): @@ -254,6 +255,12 @@ def __init__( :type: :class:`PlacementGroupsClient ` """ + self.zones = ZonesClient(self) + """ZonesClient Instance + + :type: :class:`ZonesClient ` + """ + def request( # type: ignore[no-untyped-def] self, method: str, diff --git a/hcloud/zones/__init__.py b/hcloud/zones/__init__.py new file mode 100644 index 00000000..7871e100 --- /dev/null +++ b/hcloud/zones/__init__.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from .client import ( + BoundZone, + BoundZoneRRSet, + ZonesClient, + ZonesPageResult, +) +from .domain import ( + CreateZoneResponse, + Zone, + ZoneAuthoritativeNameservers, + ZonePrimaryNameserver, + ZoneRecord, + ZoneRRSet, +) + +__all__ = [ + "BoundZone", + "BoundZoneRRSet", + "CreateZoneResponse", + "Zone", + "ZoneAuthoritativeNameservers", + "ZonePrimaryNameserver", + "ZoneRecord", + "ZoneRRSet", + "ZonesClient", + "ZonesPageResult", +] diff --git a/hcloud/zones/client.py b/hcloud/zones/client.py new file mode 100644 index 00000000..09fbd277 --- /dev/null +++ b/hcloud/zones/client.py @@ -0,0 +1,1428 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, NamedTuple + +from ..actions import ActionsPageResult, BoundAction, ResourceActionsClient +from ..core import BoundModelBase, Meta, ResourceClientBase +from .domain import ( + CreateZoneResponse, + CreateZoneRRSetResponse, + DeleteZoneResponse, + DeleteZoneRRSetResponse, + ExportZonefileResponse, + Zone, + ZoneAuthoritativeNameservers, + ZoneMode, + ZonePrimaryNameserver, + ZoneRecord, + ZoneRRSet, + ZoneRRSetType, +) + +if TYPE_CHECKING: + from .._client import Client + + +class BoundZone(BoundModelBase, Zone): + _client: ZonesClient + + model = Zone + + def __init__( + self, + client: ZonesClient, + data: dict[str, Any], + complete: bool = True, + ): + raw = data.get("primary_nameservers") + if raw is not None: + data["primary_nameservers"] = [ + ZonePrimaryNameserver.from_dict(o) for o in raw + ] + + raw = data.get("authoritative_nameservers") + if raw: + data["authoritative_nameservers"] = ZoneAuthoritativeNameservers.from_dict( + raw + ) + + super().__init__(client, data, complete) + + def update( + self, + *, + labels: dict[str, str] | None = None, + ) -> BoundZone: + """ + Updates the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-update-a-zone + + :param labels: User-defined labels (key/value pairs) for the Resource. + + 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. + """ + return self._client.update(self, labels=labels) + + def delete(self) -> DeleteZoneResponse: + """ + Deletes the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-delete-a-zone + + 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. + """ + return self._client.delete(self) + + def export_zonefile(self) -> ExportZonefileResponse: + """ + Returns a generated Zone file in BIND (RFC 1034/1035) format. + + See https://docs.hetzner.cloud/reference/cloud#zones-export-a-zone-file + + 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. + """ + return self._client.export_zonefile(self) + + def get_actions_list( + self, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns all Actions for the Zone for a specific page. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + 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. + """ + return self._client.get_actions_list( + self, + status=status, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_actions( + self, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + + 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. + """ + return self._client.get_actions( + self, + status=status, + sort=sort, + ) + + def import_zonefile( + self, + zonefile: str, + ) -> BoundAction: + """ + Imports a zone file, replacing all resource record sets (ZoneRRSet). + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-import-a-zone-file + + :param zonefile: Zone file to import. + + 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. + """ + return self._client.import_zonefile(self, zonefile=zonefile) + + def change_protection( + self, + *, + delete: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-protection + + :param delete: Prevents the Zone from being deleted. + + 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. + """ + return self._client.change_protection(self, delete=delete) + + def change_ttl( + self, + ttl: int, + ) -> BoundAction: + """ + Changes the TTL of the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-default-ttl + + :param ttl: Default Time To Live (TTL) of the Zone. + + 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. + """ + return self._client.change_ttl(self, ttl=ttl) + + def change_primary_nameservers( + self, + primary_nameservers: list[ZonePrimaryNameserver], + ) -> BoundAction: + """ + Changes the primary nameservers of the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-primary-nameservers + + :param primary_nameservers: Primary nameservers of the Zone. + + 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. + """ + return self._client.change_primary_nameservers( + self, + primary_nameservers=primary_nameservers, + ) + + def get_rrset( + self, + name: str, + type: ZoneRRSetType, + ) -> BoundZoneRRSet: + """ + Returns a single ZoneRRSet from the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-get-an-rrset + + :param name: Name of the RRSet. + :param type: Type of the RRSet. + + 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. + """ + return self._client.get_rrset(self, name=name, type=type) + + def get_rrset_list( + self, + *, + name: str | None = None, + type: list[ZoneRRSetType] | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ZoneRRSetsPageResult: + """ + Returns all ZoneRRSet in the Zone for a specific page. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-list-rrsets + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + 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. + """ + return self._client.get_rrset_list( + self, + name=name, + type=type, + label_selector=label_selector, + sort=sort, + page=page, + per_page=per_page, + ) + + def get_rrset_all( + self, + *, + name: str | None = None, + type: list[ZoneRRSetType] | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundZoneRRSet]: + """ + Returns all ZoneRRSet in the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-list-rrsets + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + 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. + """ + return self._client.get_rrset_all( + self, + name=name, + type=type, + label_selector=label_selector, + sort=sort, + ) + + def create_rrset( + self, + *, + name: str, + type: ZoneRRSetType, + ttl: int | None = None, + labels: dict[str, str] | None = None, + records: list[ZoneRecord] | None = None, + ) -> CreateZoneRRSetResponse: + """ + Creates a ZoneRRSet in the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-create-an-rrset + + :param name: Name of the RRSet. + :param type: Type of the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + :param labels: User-defined labels (key/value pairs) for the Resource. + :param records: Records of the RRSet. + + 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. + """ + return self._client.create_rrset( + self, + name=name, + type=type, + ttl=ttl, + labels=labels, + records=records, + ) + + def update_rrset( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + *, + labels: dict[str, str] | None = None, + ) -> BoundZoneRRSet: + """ + Updates a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-update-an-rrset + + :param rrset: RRSet to update. + :param labels: User-defined labels (key/value pairs) for the Resource. + + 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. + """ + return self._client.update_rrset(rrset, labels=labels) + + def delete_rrset( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + ) -> DeleteZoneRRSetResponse: + """ + Deletes a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-delete-an-rrset + + :param rrset: RRSet to delete. + + 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. + """ + return self._client.delete_rrset(rrset) + + def change_rrset_protection( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + *, + change: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-protection + + :param rrset: RRSet to update. + :param change: Prevent the Zone from being changed (deletion and updates). + + 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. + """ + return self._client.change_rrset_protection(rrset, change=change) + + def change_rrset_ttl( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + ttl: int | None, + ) -> BoundAction: + """ + Changes the TTL of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-ttl + + :param rrset: RRSet to update. + :param change: Time To Live (TTL) of the RRSet. + + 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. + """ + return self._client.change_rrset_ttl(rrset, ttl=ttl) + + def add_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ttl: int | None = None, + ) -> BoundAction: + """ + Adds records to a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-add-records-to-an-rrset + + :param rrset: RRSet to update. + :param records: Records to add to the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + + 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. + """ + return self._client.add_rrset_records(rrset, records=records, ttl=ttl) + + def remove_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Removes records from a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-remove-records-from-an-rrset + + :param rrset: RRSet to update. + :param records: Records to remove from the RRSet. + + 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. + """ + return self._client.remove_rrset_records(rrset, records=records) + + def set_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Sets the records of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-set-records-of-an-rrset + + :param rrset: RRSet to update. + :param records: Records to set in the RRSet. + + 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. + """ + return self._client.set_rrset_records(rrset, records=records) + + +class BoundZoneRRSet(BoundModelBase, ZoneRRSet): + _client: ZonesClient + + model = ZoneRRSet + + def __init__(self, client: ZonesClient, data: dict, complete: bool = True): + raw = data.get("zone") + if raw is not None: + data["zone"] = BoundZone(client, data={"id": raw}, complete=False) + + raw = data.get("records") + if raw is not None: + data["records"] = [ZoneRecord.from_dict(o) for o in raw] + + super().__init__(client, data, complete) + + def update_rrset( + self, + *, + labels: dict[str, str] | None = None, + ) -> BoundZoneRRSet: + """ + Updates the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-update-an-rrset + + :param labels: User-defined labels (key/value pairs) for the Resource. + + 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. + """ + return self._client.update_rrset(self, labels=labels) + + def delete_rrset( + self, + ) -> DeleteZoneRRSetResponse: + """ + Deletes the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-delete-an-rrset + + 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. + """ + return self._client.delete_rrset(self) + + def change_rrset_protection( + self, + *, + change: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-protection + + :param change: Prevent the Zone from being changed (deletion and updates). + + 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. + """ + return self._client.change_rrset_protection(self, change=change) + + def change_rrset_ttl( + self, + ttl: int | None, + ) -> BoundAction: + """ + Changes the TTL of the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-ttl + + :param change: Time To Live (TTL) of the RRSet. + + 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. + """ + return self._client.change_rrset_ttl(self, ttl=ttl) + + def add_rrset_records( + self, + records: list[ZoneRecord], + ttl: int | None = None, + ) -> BoundAction: + """ + Adds records to the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-add-records-to-an-rrset + + :param records: Records to add to the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + + 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. + """ + return self._client.add_rrset_records(self, records=records, ttl=ttl) + + def remove_rrset_records( + self, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Removes records from the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-remove-records-from-an-rrset + + :param records: Records to remove from the RRSet. + + 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. + """ + return self._client.remove_rrset_records(self, records=records) + + def set_rrset_records( + self, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Sets the records of the ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-set-records-of-an-rrset + + :param records: Records to set in the RRSet. + + 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. + """ + return self._client.set_rrset_records(self, records=records) + + +class ZonesPageResult(NamedTuple): + zones: list[BoundZone] + meta: Meta + + +class ZoneRRSetsPageResult(NamedTuple): + rrsets: list[BoundZoneRRSet] + meta: Meta + + +class ZonesClient(ResourceClientBase): + """ + ZoneClient is a client for the Zone (DNS) API. + + See https://docs.hetzner.cloud/reference/cloud#zones and https://docs.hetzner.cloud/reference/cloud#zone-rrsets. + + 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. + """ + + _base_url = "/zones" + + actions: ResourceActionsClient + """Zones scoped actions client + + :type: :class:`ResourceActionsClient ` + """ + + def __init__(self, client: Client): + super().__init__(client) + self.actions = ResourceActionsClient(client, self._base_url) + + def get(self, id_or_name: int | str) -> BoundZone: + """ + Returns a single Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-get-a-zone + + :param id_or_name: ID or Name of the Zone. + + 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. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{id_or_name}", + ) + return BoundZone(self, response["zone"]) + + def get_list( + self, + *, + name: str | None = None, + mode: ZoneMode | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ZonesPageResult: + """ + Returns a list of Zone for a specific page. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param mode: Filter resources by their mode. The response will only contain the resources matching exactly the specified mode. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + 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. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if mode is not None: + params["mode"] = mode + if label_selector is not None: + params["label_selector"] = label_selector + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + method="GET", + url=f"{self._base_url}", + params=params, + ) + return ZonesPageResult( + zones=[BoundZone(self, item) for item in response["zones"]], + meta=Meta.parse_meta(response), + ) + + def get_all( + self, + *, + name: str | None = None, + mode: ZoneMode | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundZone]: + """ + Returns a list of all Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param mode: Filter resources by their mode. The response will only contain the resources matching exactly the specified mode. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + 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. + """ + return self._iter_pages( + self.get_list, + name=name, + mode=mode, + label_selector=label_selector, + sort=sort, + ) + + def create( + self, + *, + name: str, + mode: ZoneMode, + ttl: int | None = None, + labels: dict[str, str] | None = None, + primary_nameservers: list[ZonePrimaryNameserver] | None = None, + rrsets: list[ZoneRRSet] | None = None, + zonefile: str | None = None, + ) -> CreateZoneResponse: + """ + Creates a Zone. + + A default SOA and three NS resource records with the assigned Hetzner nameservers are created automatically. + + See https://docs.hetzner.cloud/reference/cloud#zones-create-a-zone + + :param name: Name of the Zone. + :param mode: Mode of the Zone. + :param ttl: Default Time To Live (TTL) of the Zone. + :param labels: User-defined labels (key/value pairs) for the Resource. + :param primary_nameservers: Primary nameservers of the Zone. + :param rrsets: RRSets to be added to the Zone. + :param zonefile: Zone file to import. + + 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. + """ + data: dict[str, Any] = { + "name": name, + "mode": mode, + } + if ttl is not None: + data["ttl"] = ttl + if labels is not None: + data["labels"] = labels + if primary_nameservers is not None: + data["primary_nameservers"] = [o.to_payload() for o in primary_nameservers] + if rrsets is not None: + data["rrsets"] = [o.to_payload() for o in rrsets] + if zonefile is not None: + data["zonefile"] = zonefile + + response = self._client.request( + method="POST", + url=f"{self._base_url}", + json=data, + ) + + return CreateZoneResponse( + zone=BoundZone(self, response["zone"]), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update( + self, + zone: Zone | BoundZone, + *, + labels: dict[str, str] | None = None, + ) -> BoundZone: + """ + Updates a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-update-a-zone + + :param zone: Zone to update. + :param labels: User-defined labels (key/value pairs) for the Resource. + + 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. + """ + data: dict[str, Any] = {} + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{zone.id_or_name}", + json=data, + ) + return BoundZone(self, response["zone"]) + + def delete( + self, + zone: Zone | BoundZone, + ) -> DeleteZoneResponse: + """ + Deletes a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-delete-a-zone + + :param zone: Zone to delete. + + 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. + """ + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{zone.id_or_name}", + ) + + return DeleteZoneResponse( + action=BoundAction(self._parent.actions, response["action"]), + ) + + def export_zonefile( + self, + zone: Zone | BoundZone, + ) -> ExportZonefileResponse: + """ + Returns a generated Zone file in BIND (RFC 1034/1035) format. + + See https://docs.hetzner.cloud/reference/cloud#zones-export-a-zone-file + + :param zone: Zone to export the zone file from. + + 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. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{zone.id_or_name}/zonefile", + ) + return ExportZonefileResponse(response["zonefile"]) + + def get_actions_list( + self, + zone: Zone | BoundZone, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ActionsPageResult: + """ + Returns all Actions for a Zone for a specific page. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param zone: Zone to fetch the Actions from. + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + 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. + """ + params: dict[str, Any] = {} + if status is not None: + params["status"] = status + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{zone.id_or_name}/actions", + params=params, + ) + return ActionsPageResult( + actions=[BoundAction(self._parent.actions, o) for o in response["actions"]], + meta=Meta.parse_meta(response), + ) + + def get_actions( + self, + zone: Zone | BoundZone, + *, + status: list[str] | None = None, + sort: list[str] | None = None, + ) -> list[BoundAction]: + """ + Returns all Actions for a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zones-list-zones + + :param zone: Zone to fetch the Actions from. + :param status: Filter the actions by status. The response will only contain actions matching the specified statuses. + :param sort: Sort resources by field and direction. + + 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. + """ + return self._iter_pages( + self.get_actions_list, + zone, + status=status, + sort=sort, + ) + + def import_zonefile( + self, + zone: Zone | BoundZone, + zonefile: str, + ) -> BoundAction: + """ + Imports a zone file, replacing all resource record sets (ZoneRRSet). + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-import-a-zone-file + + :param zone: Zone to import the zone file into. + :param zonefile: Zone file to import. + + 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. + """ + data: dict[str, Any] = { + "zonefile": zonefile, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/actions/import_zonefile", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_protection( + self, + zone: Zone | BoundZone, + *, + delete: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-protection + + :param zone: Zone to update. + :param delete: Prevents the Zone from being deleted. + + 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. + """ + data: dict[str, Any] = {} + if delete is not None: + data["delete"] = delete + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/actions/change_protection", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_ttl( + self, + zone: Zone | BoundZone, + ttl: int, + ) -> BoundAction: + """ + Changes the TTL of a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-default-ttl + + :param zone: Zone to update. + :param ttl: Default Time To Live (TTL) of the Zone. + + 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. + """ + data: dict[str, Any] = { + "ttl": ttl, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/actions/change_ttl", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_primary_nameservers( + self, + zone: Zone | BoundZone, + primary_nameservers: list[ZonePrimaryNameserver], + ) -> BoundAction: + """ + Changes the primary nameservers of a Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-actions-change-a-zones-primary-nameservers + + :param zone: Zone to update. + :param primary_nameservers: Primary nameservers of the Zone. + + 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. + """ + data: dict[str, Any] = { + "primary_nameservers": [o.to_payload() for o in primary_nameservers], + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/actions/change_primary_nameservers", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def get_rrset( + self, + zone: Zone | BoundZone, + name: str, + type: ZoneRRSetType, + ) -> BoundZoneRRSet: + """ + Returns a single ZoneRRSet from the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-get-an-rrset + + :param zone: Zone to fetch the RRSet from. + :param name: Name of the RRSet. + :param type: Type of the RRSet. + + 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. + """ + response = self._client.request( + method="GET", + url=f"{self._base_url}/{zone.id_or_name}/rrsets/{name}/{type}", + ) + return BoundZoneRRSet(self, response["rrset"]) + + def get_rrset_list( + self, + zone: Zone | BoundZone, + *, + name: str | None = None, + type: list[ZoneRRSetType] | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + page: int | None = None, + per_page: int | None = None, + ) -> ZoneRRSetsPageResult: + """ + Returns all ZoneRRSet in the Zone for a specific page. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-list-rrsets + + :param zone: Zone to fetch the RRSets from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + :param page: Page number to return. + :param per_page: Maximum number of entries returned per page. + + 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. + """ + params: dict[str, Any] = {} + if name is not None: + params["name"] = name + if type is not None: + params["type"] = type + if label_selector is not None: + params["label_selector"] = label_selector + if sort is not None: + params["sort"] = sort + if page is not None: + params["page"] = page + if per_page is not None: + params["per_page"] = per_page + + response = self._client.request( + method="GET", + url=f"{self._base_url}/{zone.id_or_name}/rrsets", + params=params, + ) + return ZoneRRSetsPageResult( + rrsets=[BoundZoneRRSet(self, item) for item in response["rrsets"]], + meta=Meta.parse_meta(response), + ) + + def get_rrset_all( + self, + zone: Zone | BoundZone, + *, + name: str | None = None, + type: list[ZoneRRSetType] | None = None, + label_selector: str | None = None, + sort: list[str] | None = None, + ) -> list[BoundZoneRRSet]: + """ + Returns all ZoneRRSet in the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-list-rrsets + + :param zone: Zone to fetch the RRSets from. + :param name: Filter resources by their name. The response will only contain the resources matching exactly the specified name. + :param type: Filter resources by their type. The response will only contain the resources matching exactly the specified type. + :param label_selector: Filter resources by labels. The response will only contain resources matching the label selector. + :param sort: Sort resources by field and direction. + + 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. + """ + return self._iter_pages( + self.get_rrset_list, + zone, + name=name, + type=type, + label_selector=label_selector, + sort=sort, + ) + + def create_rrset( + self, + zone: Zone | BoundZone, + *, + name: str, + type: ZoneRRSetType, + ttl: int | None = None, + labels: dict[str, str] | None = None, + records: list[ZoneRecord] | None = None, + ) -> CreateZoneRRSetResponse: + """ + Creates a ZoneRRSet in the Zone. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-create-an-rrset + + :param zone: Zone to create the RRSets in. + :param name: Name of the RRSet. + :param type: Type of the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + :param labels: User-defined labels (key/value pairs) for the Resource. + :param records: Records of the RRSet. + + 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. + """ + data: dict[str, Any] = { + "name": name, + "type": type, + } + if ttl is not None: + data["ttl"] = ttl + if labels is not None: + data["labels"] = labels + if records is not None: + data["records"] = [o.to_payload() for o in records] + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{zone.id_or_name}/rrsets", + json=data, + ) + return CreateZoneRRSetResponse( + rrset=BoundZoneRRSet(self, response["rrset"]), + action=BoundAction(self._parent.actions, response["action"]), + ) + + def update_rrset( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + *, + labels: dict[str, str] | None = None, + ) -> BoundZoneRRSet: + """ + Updates a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-update-an-rrset + + :param rrset: RRSet to update. + :param labels: User-defined labels (key/value pairs) for the Resource. + + 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. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = {} + if labels is not None: + data["labels"] = labels + + response = self._client.request( + method="PUT", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}", + json=data, + ) + return BoundZoneRRSet(self, response["rrset"]) + + def delete_rrset( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + ) -> DeleteZoneRRSetResponse: + """ + Deletes a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets-delete-an-rrset + + :param rrset: RRSet to delete. + + 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. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + response = self._client.request( + method="DELETE", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}", + ) + return DeleteZoneRRSetResponse( + action=BoundAction(self._parent.actions, response["action"]), + ) + + def change_rrset_protection( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + *, + change: bool | None = None, + ) -> BoundAction: + """ + Changes the protection of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-protection + + :param rrset: RRSet to update. + :param change: Prevent the Zone from being changed (deletion and updates). + + 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. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = {} + if change is not None: + data["change"] = change + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/change_protection", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def change_rrset_ttl( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + ttl: int | None, + ) -> BoundAction: + """ + Changes the TTL of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-change-an-rrsets-ttl + + :param rrset: RRSet to update. + :param change: Time To Live (TTL) of the RRSet. + + 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. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "ttl": ttl, + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/change_ttl", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def add_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ttl: int | None = None, + ) -> BoundAction: + """ + Adds records to a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-add-records-to-an-rrset + + :param rrset: RRSet to update. + :param records: Records to add to the RRSet. + :param ttl: Time To Live (TTL) of the RRSet. + + 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. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "records": [o.to_payload() for o in records], + } + if ttl is not None: + data["ttl"] = ttl + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/add_records", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def remove_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Removes records from a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-remove-records-from-an-rrset + + :param rrset: RRSet to update. + :param records: Records to remove from the RRSet. + + 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. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "records": [o.to_payload() for o in records], + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/remove_records", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) + + def set_rrset_records( + self, + rrset: ZoneRRSet | BoundZoneRRSet, + records: list[ZoneRecord], + ) -> BoundAction: + """ + Sets the records of a ZoneRRSet. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrset-actions-set-records-of-an-rrset + + :param rrset: RRSet to update. + :param records: Records to set in the RRSet. + + 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. + """ + if rrset.zone is None: + raise ValueError("rrset zone property is none") + + data: dict[str, Any] = { + "records": [o.to_payload() for o in records], + } + + response = self._client.request( + method="POST", + url=f"{self._base_url}/{rrset.zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/set_records", + json=data, + ) + return BoundAction(self._parent.actions, response["action"]) diff --git a/hcloud/zones/domain.py b/hcloud/zones/domain.py new file mode 100644 index 00000000..cb80ef5c --- /dev/null +++ b/hcloud/zones/domain.py @@ -0,0 +1,430 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Any, Literal, TypedDict + +from dateutil.parser import isoparse + +from ..core import BaseDomain, DomainIdentityMixin + +if TYPE_CHECKING: + from ..actions import BoundAction + from .client import BoundZone, BoundZoneRRSet + + +ZoneMode = Literal["primary", "secondary"] +ZoneStatus = Literal["ok", "updating", "error"] +ZoneRegistrar = Literal["hetzner", "other", "unknown"] + + +class Zone(BaseDomain, DomainIdentityMixin): + """ + Zone Domain. + + See https://docs.hetzner.cloud/reference/cloud#zones. + """ + + MODE_PRIMARY = "primary" + """ + Zone in primary mode, resource record sets (RRSets) and resource records (RRs) are + managed via the Cloud API or Cloud Console. + """ + MODE_SECONDARY = "secondary" + """ + Zone in secondary mode, Hetzner's nameservers query RRSets and RRs from given + primary nameservers via AXFR. + """ + + STATUS_OK = "ok" + """The Zone is pushed to the authoritative nameservers.""" + STATUS_UPDATING = "updating" + """The Zone is currently being published to the authoritative nameservers.""" + STATUS_ERROR = "error" + """The Zone could not be published to the authoritative nameservers.""" + + REGISTRAR_HETZNER = "hetzner" + REGISTRAR_OTHER = "other" + REGISTRAR_UNKNOWN = "unknown" + + __api_properties__ = ( + "id", + "name", + "created", + "mode", + "ttl", + "labels", + "protection", + "status", + "record_count", + "registrar", + "primary_nameservers", + "authoritative_nameservers", + ) + __slots__ = __api_properties__ + + def __init__( + self, + id: int | None = None, + name: str | None = None, + created: str | None = None, + mode: ZoneMode | None = None, + ttl: int | None = None, + labels: dict[str, str] | None = None, + protection: ZoneProtection | None = None, + status: ZoneStatus | None = None, + record_count: int | None = None, + registrar: ZoneRegistrar | None = None, + primary_nameservers: list[ZonePrimaryNameserver] | None = None, + authoritative_nameservers: ZoneAuthoritativeNameservers | None = None, + ): + self.id = id + self.name = name + self.created = isoparse(created) if created else None + self.mode = mode + self.ttl = ttl + self.labels = labels + self.protection = protection + self.status = status + self.record_count = record_count + self.registrar = registrar + self.primary_nameservers = primary_nameservers + self.authoritative_nameservers = authoritative_nameservers + + +ZonePrimaryNameserverTSIGAlgorithm = Literal[ + "hmac-md5", + "hmac-sha1", + "hmac-sha256", +] + + +class ZonePrimaryNameserver(BaseDomain): + """ + Zone Primary Nameserver Domain. + """ + + TSIG_ALGORITHM_HMAC_MD5 = "hmac-md5" + """Transaction signature (TSIG) algorithm used to generate the TSIG key.""" + TSIG_ALGORITHM_HMAC_SHA1 = "hmac-sha1" + """Transaction signature (TSIG) algorithm used to generate the TSIG key.""" + TSIG_ALGORITHM_HMAC_SHA256 = "hmac-sha256" + """Transaction signature (TSIG) algorithm used to generate the TSIG key.""" + + __api_properties__ = ( + "address", + "port", + "tsig_algorithm", + "tsig_key", + ) + __slots__ = __api_properties__ + + def __init__( + self, + address: str, + port: int | None = None, + tsig_algorithm: ZonePrimaryNameserverTSIGAlgorithm | None = None, + tsig_key: str | None = None, + ): + self.address = address + self.port = port + self.tsig_algorithm = tsig_algorithm + self.tsig_key = tsig_key + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = { + "address": self.address, + } + if self.port is not None: + payload["port"] = self.port + if self.tsig_algorithm is not None: + payload["tsig_algorithm"] = self.tsig_algorithm + if self.tsig_key is not None: + payload["tsig_key"] = self.tsig_key + + return payload + + +ZoneAuthoritativeNameserversDelegationStatus = Literal[ + "valid", + "partially-valid", + "invalid", + "lame", + "unregistered", + "unknown", +] + + +class ZoneAuthoritativeNameservers(BaseDomain): + """ + Zone Authoritative Nameservers Domain. + """ + + DELEGATION_STATUS_VALID = "valid" + DELEGATION_STATUS_PARTIALLY_VALID = "partially-valid" + DELEGATION_STATUS_INVALID = "invalid" + DELEGATION_STATUS_LAME = "lame" + DELEGATION_STATUS_UNREGISTERED = "unregistered" + DELEGATION_STATUS_UNKNOWN = "unknown" + + __api_properties__ = ( + "assigned", + "delegated", + "delegation_last_check", + "delegation_status", + ) + __slots__ = __api_properties__ + + def __init__( + self, + assigned: list[str] | None = None, + delegated: list[str] | None = None, + delegation_last_check: str | None = None, + delegation_status: ZoneAuthoritativeNameserversDelegationStatus | None = None, + ): + self.assigned = assigned + self.delegated = delegated + self.delegation_last_check = ( + isoparse(delegation_last_check) + if delegation_last_check is not None + else None + ) + self.delegation_status = delegation_status + + +class ZoneProtection(TypedDict): + """ + Zone Protection. + """ + + delete: bool + + +class CreateZoneResponse(BaseDomain): + """ + Create Zone Response Domain. + """ + + __api_properties__ = ("zone", "action") + __slots__ = __api_properties__ + + def __init__( + self, + zone: BoundZone, + action: BoundAction, + ): + self.zone = zone + self.action = action + + +class DeleteZoneResponse(BaseDomain): + """ + Delete Zone Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action + + +class ExportZonefileResponse(BaseDomain): + """ + Export Zonefile Response Domain. + """ + + __api_properties__ = ("zonefile",) + __slots__ = __api_properties__ + + def __init__( + self, + zonefile: str, + ): + self.zonefile = zonefile + + +ZoneRRSetType = Literal[ + "A", + "AAAA", + "CAA", + "CNAME", + "DS", + "HINFO", + "HTTPS", + "MX", + "NS", + "PTR", + "RP", + "SOA", + "SRV", + "SVCB", + "TLSA", + "TXT", +] + + +class ZoneRRSet(BaseDomain): + """ + Zone RRSet Domain. + + See https://docs.hetzner.cloud/reference/cloud#zone-rrsets + """ + + TYPE_A = "A" + TYPE_AAAA = "AAAA" + TYPE_CAA = "CAA" + TYPE_CNAME = "CNAME" + TYPE_DS = "DS" + TYPE_HINFO = "HINFO" + TYPE_HTTPS = "HTTPS" + TYPE_MX = "MX" + TYPE_NS = "NS" + TYPE_PTR = "PTR" + TYPE_RP = "RP" + TYPE_SOA = "SOA" + TYPE_SRV = "SRV" + TYPE_SVCB = "SVCB" + TYPE_TLSA = "TLSA" + TYPE_TXT = "TXT" + + __api_properties__ = ( + "name", + "type", + "ttl", + "labels", + "protection", + "records", + "id", + "zone", + ) + __slots__ = __api_properties__ + + def __init__( + self, + name: str | None = None, + type: ZoneRRSetType | None = None, + ttl: int | None = None, + labels: dict[str, str] | None = None, + protection: ZoneRRSetProtection | None = None, + records: list[ZoneRecord] | None = None, + id: str | None = None, + zone: BoundZone | Zone | None = None, + ): + # Ensure that 'id', 'name' and 'type' are always populated. + if name is not None and type is not None: + if id is None: + id = f"{name}/{type}" + else: + if id is not None: + name, _, type = id.partition("/") # type: ignore[assignment] + else: + raise ValueError("id or name and type must be set") + + self.name = name + self.type = type + self.ttl = ttl + self.labels = labels + self.protection = protection + self.records = records + + self.id = id + self.zone = zone + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = { + "name": self.name, + "type": self.type, + } + if self.ttl is not None: + payload["ttl"] = self.ttl + if self.labels is not None: + payload["labels"] = self.labels + if self.protection is not None: + payload["protection"] = self.protection + if self.records is not None: + payload["records"] = [o.to_payload() for o in self.records] + + return payload + + +class ZoneRRSetProtection(TypedDict): + """ + Zone RRSet Protection. + """ + + change: bool + + +class ZoneRecord(BaseDomain): + """ + Zone Record Domain. + """ + + __api_properties__ = ( + "value", + "comment", + ) + __slots__ = __api_properties__ + + def __init__( + self, + value: str, + comment: str | None = None, + ): + self.value = value + self.comment = comment + + def to_payload(self) -> dict[str, Any]: + """ + Generates the request payload from this domain object. + """ + payload: dict[str, Any] = { + "value": self.value, + } + if self.comment is not None: + payload["comment"] = self.comment + + return payload + + +class CreateZoneRRSetResponse(BaseDomain): + """ + Create Zone RRSet Response Domain. + """ + + __api_properties__ = ( + "rrset", + "action", + ) + __slots__ = __api_properties__ + + def __init__( + self, + rrset: BoundZoneRRSet, + action: BoundAction, + ): + self.rrset = rrset + self.action = action + + +class DeleteZoneRRSetResponse(BaseDomain): + """ + Delete Zone RRSet Response Domain. + """ + + __api_properties__ = ("action",) + __slots__ = __api_properties__ + + def __init__( + self, + action: BoundAction, + ): + self.action = action diff --git a/tests/unit/actions/test_client.py b/tests/unit/actions/test_client.py index e3c266b2..6c09f7dd 100644 --- a/tests/unit/actions/test_client.py +++ b/tests/unit/actions/test_client.py @@ -23,6 +23,7 @@ from hcloud.primary_ips import BoundPrimaryIP, PrimaryIPsClient from hcloud.servers import BoundServer, ServersClient from hcloud.volumes import BoundVolume, VolumesClient +from hcloud.zones import BoundZone, ZonesClient from ..conftest import assert_bound_action1, assert_bound_action2 @@ -36,6 +37,7 @@ "primary_ips": (PrimaryIPsClient, BoundPrimaryIP), "servers": (ServersClient, BoundServer), "volumes": (VolumesClient, BoundVolume), + "zones": (ZonesClient, BoundZone), } @@ -241,6 +243,12 @@ def resource(self, request): def resource_client(self, client: Client, resource: str) -> ResourceClientBase: return getattr(client, resource) + @pytest.fixture() + def bound_model(self, client: Client, resource: str) -> BoundModelBase: + _, bound_model_class = resources_with_actions[resource] + resource_client = getattr(client, resource) + return bound_model_class(resource_client, data={"id": 1}) + @pytest.mark.parametrize( "params", [ @@ -253,12 +261,13 @@ def test_get_actions_list( request_mock: mock.MagicMock, resource_client: ResourceClientBase, resource: str, + bound_model: BoundModelBase, action_list_response, params, ): request_mock.return_value = action_list_response - result = resource_client.get_actions_list(mock.MagicMock(id=1), **params) + result = resource_client.get_actions_list(bound_model, **params) request_mock.assert_called_with( method="GET", @@ -285,12 +294,13 @@ def test_get_actions( request_mock: mock.MagicMock, resource_client: ResourceClientBase, resource: str, + bound_model: BoundModelBase, action_list_response, params, ): request_mock.return_value = action_list_response - actions = resource_client.get_actions(mock.MagicMock(id=1), **params) + actions = resource_client.get_actions(bound_model, **params) request_mock.assert_called_with( method="GET", diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py index 1e9a75b3..a340a6e3 100644 --- a/tests/unit/conftest.py +++ b/tests/unit/conftest.py @@ -2,7 +2,9 @@ from __future__ import annotations +from collections.abc import Generator from unittest import mock +from warnings import warn import pytest @@ -130,3 +132,13 @@ def action_list_response(action1_running, action2_running): action2_running, ], } + + +@pytest.fixture() +def hetzner_client() -> Generator[Client]: + warn("DEPRECATED") + client = Client(token="token") + patcher = mock.patch.object(client, "request") + patcher.start() + yield client + patcher.stop() diff --git a/tests/unit/zones/__init__.py b/tests/unit/zones/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit/zones/conftest.py b/tests/unit/zones/conftest.py new file mode 100644 index 00000000..4984cf3b --- /dev/null +++ b/tests/unit/zones/conftest.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import pytest + + +@pytest.fixture() +def zone1(): + return { + "id": 42, + "name": "example1.com", + "created": "2016-01-30T23:55:00+00:00", + "mode": "primary", + "ttl": 10800, + "protection": { + "delete": False, + }, + "labels": { + "key": "value", + }, + "primary_nameservers": [ + {"address": "198.51.100.1", "port": 53}, + {"address": "203.0.113.1", "port": 53}, + ], + "record_count": 0, + "status": "ok", + "registrar": "hetzner", + "authoritative_nameservers": { + "assigned": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ], + "delegated": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ], + "delegation_last_check": "2016-01-30T23:55:00+00:00", + "delegation_status": "valid", + }, + } + + +@pytest.fixture() +def zone2(): + return { + "id": 43, + "name": "example2.com", + "created": "2016-01-30T23:55:00+00:00", + "mode": "secondary", + "ttl": 10800, + "protection": { + "delete": False, + }, + "labels": { + "key": "value", + }, + "primary_nameservers": [ + {"address": "198.51.100.1", "port": 53}, + {"address": "203.0.113.1", "port": 53}, + ], + "record_count": 0, + "status": "ok", + "registrar": "hetzner", + "authoritative_nameservers": { + "assigned": [], + "delegated": [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ], + "delegation_last_check": "2016-01-30T23:55:00+00:00", + "delegation_status": "valid", + }, + } + + +@pytest.fixture() +def zone_rrset1(): + return { + "zone": 42, + "id": "www/A", + "name": "www", + "type": "A", + "ttl": 3600, + "labels": {"key": "value"}, + "protection": {"change": False}, + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + ], + } + + +@pytest.fixture() +def zone_rrset2(): + return { + "zone": 42, + "id": "blog/A", + "name": "blog", + "type": "A", + "ttl": 3600, + "labels": {"key": "value"}, + "protection": {"change": False}, + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + ], + } + + +@pytest.fixture() +def zone_response(zone1): + return {"zone": zone1} + + +@pytest.fixture() +def zone_list_response(zone1, zone2): + return { + "zones": [zone1, zone2], + } + + +@pytest.fixture() +def zone_create_response(zone1, action1_running): + return { + "zone": zone1, + "action": action1_running, + } + + +@pytest.fixture() +def zone_rrset_response(zone_rrset1): + return { + "rrset": zone_rrset1, + } + + +@pytest.fixture() +def zone_rrset_list_response(zone_rrset1, zone_rrset2): + return { + "rrsets": [zone_rrset1, zone_rrset2], + } + + +@pytest.fixture() +def zone_rrset_create_response(zone_rrset1, action1_running): + return { + "rrset": zone_rrset1, + "action": action1_running, + } diff --git a/tests/unit/zones/test_client.py b/tests/unit/zones/test_client.py new file mode 100644 index 00000000..91165ad1 --- /dev/null +++ b/tests/unit/zones/test_client.py @@ -0,0 +1,928 @@ +# pylint: disable=protected-access + +from __future__ import annotations + +from unittest import mock + +import pytest +from dateutil.parser import isoparse + +from hcloud import Client +from hcloud.zones import ( + BoundZone, + BoundZoneRRSet, + Zone, + ZoneAuthoritativeNameservers, + ZonePrimaryNameserver, + ZoneRecord, + ZoneRRSet, + ZonesClient, +) + +from ..conftest import assert_bound_action1 + + +def assert_bound_zone1(o: BoundZone, client: ZonesClient): + assert isinstance(o, BoundZone) + assert o._client is client + assert o.id == 42 + assert o.name == "example1.com" + + +def assert_bound_zone2(o: BoundZone, client: ZonesClient): + assert isinstance(o, BoundZone) + assert o._client is client + assert o.id == 43 + assert o.name == "example2.com" + + +def assert_bound_zone_rrset1(o: BoundZoneRRSet, client: ZonesClient): + assert isinstance(o, BoundZoneRRSet) + assert o._client is client + assert o.name == "www" + assert o.type == "A" + assert o.id == "www/A" + + +def assert_bound_zone_rrset2(o: BoundZoneRRSet, client: ZonesClient): + assert isinstance(o, BoundZoneRRSet) + assert o._client is client + assert o.name == "blog" + assert o.type == "A" + assert o.id == "blog/A" + + +class TestZonesClient: + @pytest.fixture() + def resource_client(self, client: Client) -> ZonesClient: + return client.zones + + def test_get_using_id( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_response, + ): + request_mock.return_value = zone_response + + result = resource_client.get(42) + + request_mock.assert_called_with( + method="GET", + url="/zones/42", + ) + + assert_bound_zone1(result, resource_client) + assert result.created == isoparse("2016-01-30T23:55:00+00:00") + assert result.mode == "primary" + assert result.ttl == 10800 + assert result.protection == {"delete": False} + assert result.labels == {"key": "value"} + assert result.primary_nameservers[0].address == "198.51.100.1" + assert result.primary_nameservers[0].port == 53 + assert result.primary_nameservers[1].address == "203.0.113.1" + assert result.primary_nameservers[1].port == 53 + + assert ( + result.authoritative_nameservers.assigned[0] == "hydrogen.ns.hetzner.com." + ) + assert ( + result.authoritative_nameservers.delegated[0] == "hydrogen.ns.hetzner.com." + ) + assert result.authoritative_nameservers.delegation_last_check == isoparse( + "2016-01-30T23:55:00+00:00" + ) + assert result.authoritative_nameservers.delegation_status == "valid" + + assert result.record_count == 0 + assert result.status == "ok" + assert result.registrar == "hetzner" + + def test_get_using_name( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_response, + ): + request_mock.return_value = zone_response + + result = resource_client.get("example.com") + + request_mock.assert_called_with( + method="GET", + url="/zones/example.com", + ) + + assert_bound_zone1(result, resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"mode": "primary"}, + {"label_selector": "key=value", "page": 2, "per_page": 10, "sort": "id"}, + {"name": "example.com"}, + {}, + ], + ) + def test_get_list( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_list_response, + params, + ): + request_mock.return_value = zone_list_response + + resp = resource_client.get_list(**params) + + request_mock.assert_called_with( + method="GET", + url="/zones", + params=params, + ) + + assert resp.meta is not None + + assert len(resp.zones) == 2 + assert_bound_zone1(resp.zones[0], resource_client) + assert_bound_zone2(resp.zones[1], resource_client) + + @pytest.mark.parametrize( + "params", + [ + {"label_selector": "key=value"}, + {}, + ], + ) + def test_get_all( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_list_response, + params, + ): + request_mock.return_value = zone_list_response + + result = resource_client.get_all(**params) + + request_mock.assert_called_with( + method="GET", + url="/zones", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(result) == 2 + assert_bound_zone1(result[0], resource_client) + assert_bound_zone2(result[1], resource_client) + + def test_create( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone_create_response, + ): + request_mock.return_value = zone_create_response + + resp = resource_client.create( + name="example.com", + mode="primary", + ttl=3600, + labels={"key": "value"}, + primary_nameservers=[ + ZonePrimaryNameserver(address="198.51.100.1", port=53), + ZonePrimaryNameserver(address="203.0.113.1"), + ], + rrsets=[ZoneRRSet(name="www", type="A", records=[ZoneRecord("127.0.0.1")])], + ) + + request_mock.assert_called_with( + url="/zones", + method="POST", + json={ + "name": "example.com", + "mode": "primary", + "ttl": 3600, + "labels": {"key": "value"}, + "primary_nameservers": [ + {"address": "198.51.100.1", "port": 53}, + {"address": "203.0.113.1"}, + ], + "rrsets": [ + {"name": "www", "type": "A", "records": [{"value": "127.0.0.1"}]} + ], + }, + ) + + assert_bound_zone1(resp.zone, resource_client) + assert_bound_action1(resp.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_update( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_response, + ): + request_mock.return_value = zone_response + + result = resource_client.update(zone, labels={"key": "new value"}) + + request_mock.assert_called_with( + method="PUT", + url=f"/zones/{zone.id_or_name}", + json={"labels": {"key": "new value"}}, + ) + + assert_bound_zone1(result, resource_client) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_delete( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + resp = resource_client.delete(zone) + + request_mock.assert_called_with( + method="DELETE", + url=f"/zones/{zone.id_or_name}", + ) + + assert_bound_action1(resp.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_export_zonefile( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + ): + request_mock.return_value = {"zonefile": "content"} + + resp = resource_client.export_zonefile(zone) + + request_mock.assert_called_with( + method="GET", + url=f"/zones/{zone.id_or_name}/zonefile", + ) + + assert resp.zonefile == "content" + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_import_zonefile( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.import_zonefile(zone, "content") + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/actions/import_zonefile", + json={"zonefile": "content"}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_change_protection( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_protection(zone, delete=True) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/actions/change_protection", + json={"delete": True}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_change_primary_nameservers( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_primary_nameservers( + zone, + primary_nameservers=[ + ZonePrimaryNameserver(address="198.51.100.1", port=53), + ZonePrimaryNameserver(address="203.0.113.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/actions/change_primary_nameservers", + json={ + "primary_nameservers": [ + {"address": "198.51.100.1", "port": 53}, + {"address": "203.0.113.1"}, + ] + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_change_ttl( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + action_response, + ): + request_mock.return_value = action_response + + action = resource_client.change_ttl(zone, 3600) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/actions/change_ttl", + json={"ttl": 3600}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + # ============ RRSETS ============ + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_get_rrset( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_rrset_response, + ): + request_mock.return_value = zone_rrset_response + + result = resource_client.get_rrset(zone, "www", "A") + + request_mock.assert_called_with( + method="GET", + url=f"/zones/{zone.id_or_name}/rrsets/www/A", + ) + + assert_bound_zone_rrset1(result, resource_client) + assert result.ttl == 3600 + assert result.protection == {"change": False} + assert result.labels == {"key": "value"} + assert result.records[0].value == "198.51.100.1" + assert result.records[0].comment == "web server" + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "params", + [ + {"type": ["A"]}, + {"label_selector": "key=value", "page": 2, "per_page": 10, "sort": "id"}, + {"name": "www"}, + {}, + ], + ) + def test_get_rrset_list( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_rrset_list_response, + params, + ): + request_mock.return_value = zone_rrset_list_response + + resp = resource_client.get_rrset_list(zone, **params) + + request_mock.assert_called_with( + method="GET", + url=f"/zones/{zone.id_or_name}/rrsets", + params=params, + ) + + assert resp.meta is not None + + assert len(resp.rrsets) == 2 + assert_bound_zone_rrset1(resp.rrsets[0], resource_client) + assert_bound_zone_rrset2(resp.rrsets[1], resource_client) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "params", + [ + {"label_selector": "key=value"}, + {}, + ], + ) + def test_get_rrset_all( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_rrset_list_response, + params, + ): + request_mock.return_value = zone_rrset_list_response + + result = resource_client.get_rrset_all(zone, **params) + + request_mock.assert_called_with( + method="GET", + url=f"/zones/{zone.id_or_name}/rrsets", + params={**params, "page": 1, "per_page": 50}, + ) + + assert len(result) == 2 + assert_bound_zone_rrset1(result[0], resource_client) + assert_bound_zone_rrset2(result[1], resource_client) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + def test_create_rrset( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + zone_rrset_create_response, + ): + request_mock.return_value = zone_rrset_create_response + + resp = resource_client.create_rrset( + zone, + name="www", + type="A", + ttl=3600, + labels={"key": "value"}, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("127.0.0.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets", + json={ + "name": "www", + "type": "A", + "ttl": 3600, + "labels": {"key": "value"}, + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "127.0.0.1"}, + ], + }, + ) + + assert_bound_zone_rrset1(resp.rrset, resource_client) + assert_bound_action1(resp.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_update_rrset( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + zone_rrset_response, + ): + rrset.zone = zone + + request_mock.return_value = zone_rrset_response + + result = resource_client.update_rrset(rrset, labels={"key": "new value"}) + + request_mock.assert_called_with( + method="PUT", + url=f"/zones/{zone.id_or_name}/rrsets/www/A", + json={"labels": {"key": "new value"}}, + ) + + assert_bound_zone_rrset1(result, resource_client) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_delete_rrset( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + resp = resource_client.delete_rrset(rrset) + + request_mock.assert_called_with( + method="DELETE", + url=f"/zones/{zone.id_or_name}/rrsets/www/A", + ) + + assert_bound_action1(resp.action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_change_rrset_protection( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.change_rrset_protection(rrset, change=True) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/change_protection", + json={"change": True}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_change_rrset_ttl( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.change_rrset_ttl(rrset, ttl=3600) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/change_ttl", + json={"ttl": 3600}, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_add_rrset_records( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.add_rrset_records( + rrset, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("127.0.0.1"), + ], + ttl=300, + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/add_records", + json={ + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "127.0.0.1"}, + ], + "ttl": 300, + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_remove_rrset_records( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.remove_rrset_records( + rrset, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("127.0.0.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/remove_records", + json={ + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "127.0.0.1"}, + ] + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + @pytest.mark.parametrize( + "zone", + [ + Zone(name="example.com"), + BoundZone(client=mock.MagicMock(), data={"id": 42}), + ], + ) + @pytest.mark.parametrize( + "rrset", + [ + ZoneRRSet(name="www", type="A"), + BoundZoneRRSet(client=mock.MagicMock(), data={"id": "www/A"}), + ], + ) + def test_set_rrset_records( + self, + request_mock: mock.MagicMock, + resource_client: ZonesClient, + zone: Zone, + rrset: ZoneRRSet, + action_response, + ): + rrset.zone = zone + + request_mock.return_value = action_response + + action = resource_client.set_rrset_records( + rrset, + records=[ + ZoneRecord("198.51.100.1", "web server"), + ZoneRecord("127.0.0.1"), + ], + ) + + request_mock.assert_called_with( + method="POST", + url=f"/zones/{zone.id_or_name}/rrsets/{rrset.name}/{rrset.type}/actions/set_records", + json={ + "records": [ + {"value": "198.51.100.1", "comment": "web server"}, + {"value": "127.0.0.1"}, + ] + }, + ) + + assert_bound_action1(action, resource_client._parent.actions) + + +class TestBoundZone: + @pytest.fixture() + def resource_client(self, client: Client): + return client.zones + + @pytest.fixture() + def bound_model(self, resource_client, zone1): + return BoundZone(resource_client, data=zone1) + + def test_init(self, resource_client: ZonesClient, bound_model: BoundZone): + o = bound_model + + assert_bound_zone1(o, resource_client) + + assert o.id == 42 + assert o.name == "example1.com" + assert o.created == isoparse("2016-01-30T23:55:00+00:00") + assert o.mode == "primary" + assert o.ttl == 10800 + assert o.protection == {"delete": False} + assert o.labels == {"key": "value"} + assert len(o.primary_nameservers) == 2 + + assert isinstance(o.primary_nameservers[0], ZonePrimaryNameserver) + assert o.primary_nameservers[0].address == "198.51.100.1" + assert o.primary_nameservers[0].port == 53 + assert isinstance(o.primary_nameservers[1], ZonePrimaryNameserver) + assert o.primary_nameservers[1].address == "203.0.113.1" + assert o.primary_nameservers[1].port == 53 + + assert isinstance(o.authoritative_nameservers, ZoneAuthoritativeNameservers) + assert o.authoritative_nameservers.assigned == [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ] + assert o.authoritative_nameservers.delegated == [ + "hydrogen.ns.hetzner.com.", + "oxygen.ns.hetzner.com.", + "helium.ns.hetzner.de.", + ] + assert o.authoritative_nameservers.delegation_last_check == isoparse( + "2016-01-30T23:55:00+00:00" + ) + assert o.authoritative_nameservers.delegation_status == "valid" + + assert o.record_count == 0 + assert o.status == "ok" + assert o.registrar == "hetzner" + + +class TestBoundZoneRRSet: + @pytest.fixture() + def resource_client(self, client: Client): + return client.zones + + @pytest.fixture() + def bound_model(self, resource_client, zone_rrset1): + return BoundZoneRRSet(resource_client, data=zone_rrset1) + + def test_init(self, resource_client: ZonesClient, bound_model: BoundZoneRRSet): + o = bound_model + + assert_bound_zone_rrset1(o, resource_client) + + assert o.id == "www/A" + assert o.name == "www" + assert o.type == "A" + assert o.ttl == 3600 + assert o.labels == {"key": "value"} + assert o.protection == {"change": False} + assert len(o.records) == 1 + + assert isinstance(o.records[0], ZoneRecord) + assert o.records[0].value == "198.51.100.1" + assert o.records[0].comment == "web server" + + assert isinstance(o.zone, BoundZone) + assert o.zone.id == 42