| # Licensed to the Apache Software Foundation (ASF) under one or more |
| # contributor license agreements. See the NOTICE file distributed with |
| # this work for additional information regarding copyright ownership. |
| # The ASF licenses this file to You under the Apache License, Version 2.0 |
| # (the "License"); you may not use this file except in compliance with |
| # the License. You may obtain a copy of the License at |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, |
| # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| # See the License for the specific language governing permissions and |
| # limitations under the License. |
| |
| __all__ = ["ZerigoDNSDriver"] |
| |
| |
| import copy |
| import base64 |
| |
| from libcloud.utils.py3 import httplib |
| from libcloud.utils.py3 import b |
| |
| from libcloud.utils.py3 import ET |
| from libcloud.utils.misc import merge_valid_keys, get_new_obj |
| from libcloud.utils.xml import findtext, findall |
| from libcloud.common.base import XmlResponse, ConnectionUserAndKey |
| from libcloud.common.types import InvalidCredsError, LibcloudError |
| from libcloud.common.types import MalformedResponseError |
| from libcloud.dns.types import Provider, RecordType |
| from libcloud.dns.types import ZoneDoesNotExistError, RecordDoesNotExistError |
| from libcloud.dns.base import DNSDriver, Zone, Record |
| |
| API_HOST = "ns.zerigo.com" |
| API_VERSION = "1.1" |
| API_ROOT = "/api/%s/" % (API_VERSION) |
| |
| VALID_ZONE_EXTRA_PARAMS = ["notes", "tag-list", "ns1", "slave-nameservers"] |
| VALID_RECORD_EXTRA_PARAMS = ["notes", "ttl", "priority"] |
| |
| # Number of items per page (maximum limit is 1000) |
| ITEMS_PER_PAGE = 100 |
| |
| |
| class ZerigoError(LibcloudError): |
| def __init__(self, code, errors): |
| self.code = code |
| self.errors = errors or [] |
| |
| def __str__(self): |
| return "Errors: %s" % (", ".join(self.errors)) |
| |
| def __repr__(self): |
| return "<ZerigoError response code=%s, errors count=%s>" % ( |
| self.code, |
| len(self.errors), |
| ) |
| |
| |
| class ZerigoDNSResponse(XmlResponse): |
| def success(self): |
| return self.status in [httplib.OK, httplib.CREATED, httplib.ACCEPTED] |
| |
| def parse_error(self): |
| status = int(self.status) |
| |
| if status == 401: |
| if not self.body: |
| raise InvalidCredsError(str(self.status) + ": " + self.error) |
| else: |
| raise InvalidCredsError(self.body) |
| elif status == 404: |
| context = self.connection.context |
| if context["resource"] == "zone": |
| raise ZoneDoesNotExistError( |
| value="", driver=self, zone_id=context["id"] |
| ) |
| elif context["resource"] == "record": |
| raise RecordDoesNotExistError( |
| value="", driver=self, record_id=context["id"] |
| ) |
| elif status != 503: |
| try: |
| body = ET.XML(self.body) |
| except Exception: |
| raise MalformedResponseError("Failed to parse XML", body=self.body) |
| |
| errors = [] |
| for error in findall(element=body, xpath="error"): |
| errors.append(error.text) |
| |
| raise ZerigoError(code=status, errors=errors) |
| |
| return self.body |
| |
| |
| class ZerigoDNSConnection(ConnectionUserAndKey): |
| host = API_HOST |
| secure = True |
| responseCls = ZerigoDNSResponse |
| |
| def add_default_headers(self, headers): |
| auth_b64 = base64.b64encode(b("%s:%s" % (self.user_id, self.key))) |
| headers["Authorization"] = "Basic %s" % (auth_b64.decode("utf-8")) |
| return headers |
| |
| def request(self, action, params=None, data="", headers=None, method="GET"): |
| if not headers: |
| headers = {} |
| if not params: |
| params = {} |
| |
| if method in ("POST", "PUT"): |
| headers = {"Content-Type": "application/xml; charset=UTF-8"} |
| return super(ZerigoDNSConnection, self).request( |
| action=action, params=params, data=data, method=method, headers=headers |
| ) |
| |
| |
| class ZerigoDNSDriver(DNSDriver): |
| type = Provider.ZERIGO |
| name = "Zerigo DNS" |
| website = "http://www.zerigo.com/" |
| connectionCls = ZerigoDNSConnection |
| |
| RECORD_TYPE_MAP = { |
| RecordType.A: "A", |
| RecordType.AAAA: "AAAA", |
| RecordType.CNAME: "CNAME", |
| RecordType.GEO: "GEO", |
| RecordType.MX: "MX", |
| RecordType.NAPTR: "NAPTR", |
| RecordType.NS: "NS", |
| RecordType.PTR: "PTR", |
| RecordType.REDIRECT: "REDIRECT", |
| RecordType.SPF: "SPF", |
| RecordType.SRV: "SRV", |
| RecordType.TXT: "TXT", |
| RecordType.URL: "URL", |
| } |
| |
| def iterate_zones(self): |
| return self._get_more("zones") |
| |
| def iterate_records(self, zone): |
| return self._get_more("records", zone=zone) |
| |
| def get_zone(self, zone_id): |
| path = API_ROOT + "zones/%s.xml" % (zone_id) |
| self.connection.set_context({"resource": "zone", "id": zone_id}) |
| data = self.connection.request(path).object |
| zone = self._to_zone(elem=data) |
| return zone |
| |
| def get_record(self, zone_id, record_id): |
| zone = self.get_zone(zone_id=zone_id) |
| self.connection.set_context({"resource": "record", "id": record_id}) |
| path = API_ROOT + "hosts/%s.xml" % (record_id) |
| data = self.connection.request(path).object |
| record = self._to_record(elem=data, zone=zone) |
| return record |
| |
| def create_zone(self, domain, type="master", ttl=None, extra=None): |
| """ |
| Create a new zone. |
| |
| Provider API docs: |
| https://www.zerigo.com/docs/apis/dns/1.1/zones/create |
| |
| @inherits: :class:`DNSDriver.create_zone` |
| """ |
| path = API_ROOT + "zones.xml" |
| zone_elem = self._to_zone_elem(domain=domain, type=type, ttl=ttl, extra=extra) |
| data = self.connection.request( |
| action=path, data=ET.tostring(zone_elem), method="POST" |
| ).object |
| zone = self._to_zone(elem=data) |
| return zone |
| |
| def update_zone(self, zone, domain=None, type=None, ttl=None, extra=None): |
| """ |
| Update an existing zone. |
| |
| Provider API docs: |
| https://www.zerigo.com/docs/apis/dns/1.1/zones/update |
| |
| @inherits: :class:`DNSDriver.update_zone` |
| """ |
| if domain: |
| raise LibcloudError("Domain cannot be changed", driver=self) |
| |
| path = API_ROOT + "zones/%s.xml" % (zone.id) |
| zone_elem = self._to_zone_elem(domain=domain, type=type, ttl=ttl, extra=extra) |
| response = self.connection.request( |
| action=path, data=ET.tostring(zone_elem), method="PUT" |
| ) |
| assert response.status == httplib.OK |
| |
| merged = merge_valid_keys( |
| params=copy.deepcopy(zone.extra), |
| valid_keys=VALID_ZONE_EXTRA_PARAMS, |
| extra=extra, |
| ) |
| updated_zone = get_new_obj( |
| obj=zone, klass=Zone, attributes={"type": type, "ttl": ttl, "extra": merged} |
| ) |
| return updated_zone |
| |
| def create_record(self, name, zone, type, data, extra=None): |
| """ |
| Create a new record. |
| |
| Provider API docs: |
| https://www.zerigo.com/docs/apis/dns/1.1/hosts/create |
| |
| @inherits: :class:`DNSDriver.create_record` |
| """ |
| path = API_ROOT + "zones/%s/hosts.xml" % (zone.id) |
| record_elem = self._to_record_elem(name=name, type=type, data=data, extra=extra) |
| response = self.connection.request( |
| action=path, data=ET.tostring(record_elem), method="POST" |
| ) |
| assert response.status == httplib.CREATED |
| record = self._to_record(elem=response.object, zone=zone) |
| return record |
| |
| def update_record(self, record, name=None, type=None, data=None, extra=None): |
| path = API_ROOT + "hosts/%s.xml" % (record.id) |
| record_elem = self._to_record_elem(name=name, type=type, data=data, extra=extra) |
| response = self.connection.request( |
| action=path, data=ET.tostring(record_elem), method="PUT" |
| ) |
| assert response.status == httplib.OK |
| |
| merged = merge_valid_keys( |
| params=copy.deepcopy(record.extra), |
| valid_keys=VALID_RECORD_EXTRA_PARAMS, |
| extra=extra, |
| ) |
| updated_record = get_new_obj( |
| obj=record, |
| klass=Record, |
| attributes={"type": type, "data": data, "extra": merged}, |
| ) |
| return updated_record |
| |
| def delete_zone(self, zone): |
| path = API_ROOT + "zones/%s.xml" % (zone.id) |
| self.connection.set_context({"resource": "zone", "id": zone.id}) |
| response = self.connection.request(action=path, method="DELETE") |
| return response.status == httplib.OK |
| |
| def delete_record(self, record): |
| path = API_ROOT + "hosts/%s.xml" % (record.id) |
| self.connection.set_context({"resource": "record", "id": record.id}) |
| response = self.connection.request(action=path, method="DELETE") |
| return response.status == httplib.OK |
| |
| def ex_get_zone_by_domain(self, domain): |
| """ |
| Retrieve a zone object by the domain name. |
| |
| :param domain: The domain which should be used |
| :type domain: ``str`` |
| |
| :rtype: :class:`Zone` |
| """ |
| path = API_ROOT + "zones/%s.xml" % (domain) |
| self.connection.set_context({"resource": "zone", "id": domain}) |
| data = self.connection.request(path).object |
| zone = self._to_zone(elem=data) |
| return zone |
| |
| def ex_force_slave_axfr(self, zone): |
| """ |
| Force a zone transfer. |
| |
| :param zone: Zone which should be used. |
| :type zone: :class:`Zone` |
| |
| :rtype: :class:`Zone` |
| """ |
| path = API_ROOT + "zones/%s/force_slave_axfr.xml" % (zone.id) |
| self.connection.set_context({"resource": "zone", "id": zone.id}) |
| response = self.connection.request(path, method="POST") |
| assert response.status == httplib.ACCEPTED |
| return zone |
| |
| def _to_zone_elem(self, domain=None, type=None, ttl=None, extra=None): |
| zone_elem = ET.Element("zone", {}) |
| |
| if domain: |
| domain_elem = ET.SubElement(zone_elem, "domain") |
| domain_elem.text = domain |
| |
| if type: |
| ns_type_elem = ET.SubElement(zone_elem, "ns-type") |
| |
| if type == "master": |
| ns_type_elem.text = "pri_sec" |
| elif type == "slave": |
| if not extra or "ns1" not in extra: |
| raise LibcloudError( |
| "ns1 extra attribute is required " + "when zone type is slave", |
| driver=self, |
| ) |
| |
| ns_type_elem.text = "sec" |
| ns1_elem = ET.SubElement(zone_elem, "ns1") |
| ns1_elem.text = extra["ns1"] |
| elif type == "std_master": |
| # TODO: Each driver should provide supported zone types |
| # Slave name servers are elsewhere |
| if not extra or "slave-nameservers" not in extra: |
| raise LibcloudError( |
| "slave-nameservers extra " |
| + "attribute is required whenzone " |
| + "type is std_master", |
| driver=self, |
| ) |
| |
| ns_type_elem.text = "pri" |
| slave_nameservers_elem = ET.SubElement(zone_elem, "slave-nameservers") |
| slave_nameservers_elem.text = extra["slave-nameservers"] |
| |
| if ttl: |
| default_ttl_elem = ET.SubElement(zone_elem, "default-ttl") |
| default_ttl_elem.text = str(ttl) |
| |
| if extra and "tag-list" in extra: |
| tags = extra["tag-list"] |
| |
| tags_elem = ET.SubElement(zone_elem, "tag-list") |
| tags_elem.text = " ".join(tags) |
| |
| return zone_elem |
| |
| def _to_record_elem(self, name=None, type=None, data=None, extra=None): |
| record_elem = ET.Element("host", {}) |
| |
| if name: |
| name_elem = ET.SubElement(record_elem, "hostname") |
| name_elem.text = name |
| |
| if type is not None: |
| type_elem = ET.SubElement(record_elem, "host-type") |
| type_elem.text = self.RECORD_TYPE_MAP[type] |
| |
| if data: |
| data_elem = ET.SubElement(record_elem, "data") |
| data_elem.text = data |
| |
| if extra: |
| if "ttl" in extra: |
| ttl_elem = ET.SubElement(record_elem, "ttl", {"type": "integer"}) |
| ttl_elem.text = str(extra["ttl"]) |
| |
| if "priority" in extra: |
| # Only MX and SRV records support priority |
| priority_elem = ET.SubElement( |
| record_elem, "priority", {"type": "integer"} |
| ) |
| |
| priority_elem.text = str(extra["priority"]) |
| |
| if "notes" in extra: |
| notes_elem = ET.SubElement(record_elem, "notes") |
| notes_elem.text = extra["notes"] |
| |
| return record_elem |
| |
| def _to_zones(self, elem): |
| zones = [] |
| |
| for item in findall(element=elem, xpath="zone"): |
| zone = self._to_zone(elem=item) |
| zones.append(zone) |
| |
| return zones |
| |
| def _to_zone(self, elem): |
| id = findtext(element=elem, xpath="id") |
| domain = findtext(element=elem, xpath="domain") |
| type = findtext(element=elem, xpath="ns-type") |
| type = "master" if type.find("pri") == 0 else "slave" |
| ttl = findtext(element=elem, xpath="default-ttl") |
| |
| hostmaster = findtext(element=elem, xpath="hostmaster") |
| custom_ns = findtext(element=elem, xpath="custom-ns") |
| custom_nameservers = findtext(element=elem, xpath="custom-nameservers") |
| notes = findtext(element=elem, xpath="notes") |
| nx_ttl = findtext(element=elem, xpath="nx-ttl") |
| slave_nameservers = findtext(element=elem, xpath="slave-nameservers") |
| tags = findtext(element=elem, xpath="tag-list") |
| tags = tags.split(" ") if tags else [] |
| |
| extra = { |
| "hostmaster": hostmaster, |
| "custom-ns": custom_ns, |
| "custom-nameservers": custom_nameservers, |
| "notes": notes, |
| "nx-ttl": nx_ttl, |
| "slave-nameservers": slave_nameservers, |
| "tags": tags, |
| } |
| zone = Zone( |
| id=str(id), domain=domain, type=type, ttl=int(ttl), driver=self, extra=extra |
| ) |
| return zone |
| |
| def _to_records(self, elem, zone): |
| records = [] |
| |
| for item in findall(element=elem, xpath="host"): |
| record = self._to_record(elem=item, zone=zone) |
| records.append(record) |
| |
| return records |
| |
| def _to_record(self, elem, zone): |
| id = findtext(element=elem, xpath="id") |
| name = findtext(element=elem, xpath="hostname") |
| type = findtext(element=elem, xpath="host-type") |
| type = self._string_to_record_type(type) |
| data = findtext(element=elem, xpath="data") |
| |
| notes = findtext(element=elem, xpath="notes", no_text_value=None) |
| state = findtext(element=elem, xpath="state", no_text_value=None) |
| fqdn = findtext(element=elem, xpath="fqdn", no_text_value=None) |
| priority = findtext(element=elem, xpath="priority", no_text_value=None) |
| ttl = findtext(element=elem, xpath="ttl", no_text_value=None) |
| |
| if not name: |
| name = None |
| |
| if ttl: |
| ttl = int(ttl) |
| |
| extra = { |
| "notes": notes, |
| "state": state, |
| "fqdn": fqdn, |
| "priority": priority, |
| "ttl": ttl, |
| } |
| |
| record = Record( |
| id=id, |
| name=name, |
| type=type, |
| data=data, |
| zone=zone, |
| driver=self, |
| ttl=ttl, |
| extra=extra, |
| ) |
| return record |
| |
| def _get_more(self, rtype, **kwargs): |
| exhausted = False |
| last_key = None |
| |
| while not exhausted: |
| items, last_key, exhausted = self._get_data(rtype, last_key, **kwargs) |
| |
| for item in items: |
| yield item |
| |
| def _get_data(self, rtype, last_key, **kwargs): |
| # Note: last_key in this case really is a "last_page". |
| # TODO: Update base driver and change last_key to something more |
| # generic - e.g. marker |
| params = {} |
| params["per_page"] = ITEMS_PER_PAGE |
| params["page"] = last_key + 1 if last_key else 1 |
| |
| if rtype == "zones": |
| path = API_ROOT + "zones.xml" |
| response = self.connection.request(path) |
| transform_func = self._to_zones |
| elif rtype == "records": |
| zone = kwargs["zone"] |
| path = API_ROOT + "zones/%s/hosts.xml" % (zone.id) |
| self.connection.set_context({"resource": "zone", "id": zone.id}) |
| response = self.connection.request(path, params=params) |
| transform_func = self._to_records |
| |
| exhausted = False |
| result_count = int(response.headers.get("x-query-count", 0)) |
| |
| if (params["page"] * ITEMS_PER_PAGE) >= result_count: |
| exhausted = True |
| |
| if response.status == httplib.OK: |
| items = transform_func(elem=response.object, **kwargs) |
| return items, params["page"], exhausted |
| else: |
| return [], None, True |