| # 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. |
| |
| from __future__ import with_statement |
| |
| import copy |
| |
| from libcloud.dns.types import Provider, RecordType |
| from libcloud.dns.types import RecordError |
| from libcloud.dns.types import ZoneDoesNotExistError, \ |
| RecordDoesNotExistError, ZoneAlreadyExistsError, RecordAlreadyExistsError |
| from libcloud.dns.base import DNSDriver, Zone, Record |
| from libcloud.common.gandi_live import ResourceNotFoundError, \ |
| ResourceConflictError, GandiLiveResponse, GandiLiveConnection, \ |
| BaseGandiLiveDriver |
| |
| |
| __all__ = [ |
| 'GandiLiveDNSDriver', |
| ] |
| |
| |
| TTL_MIN = 300 |
| TTL_MAX = 2592000 # 30 days |
| API_BASE = '/api/v5' |
| |
| |
| class GandiLiveDNSResponse(GandiLiveResponse): |
| pass |
| |
| |
| class GandiLiveDNSConnection(GandiLiveConnection): |
| responseCls = GandiLiveDNSResponse |
| |
| |
| class GandiLiveDNSDriver(BaseGandiLiveDriver, DNSDriver): |
| """ |
| API reference can be found at: |
| |
| https://doc.livedns.gandi.net/ |
| |
| Please note that the Libcloud paradigm of one zone per domain does not |
| match exactly with Gandi LiveDNS. For Gandi, a "zone" can apply to |
| multiple domains. This driver behaves as if the domain is a zone, but be |
| warned that modifying a domain means modifying the zone. Iif you have a |
| zone associated with mutiple domains, all of those domains will be |
| modified as well. |
| """ |
| |
| type = Provider.GANDI |
| name = 'Gandi LiveDNS' |
| website = 'http://www.gandi.net/domain' |
| |
| connectionCls = GandiLiveDNSConnection |
| |
| # also supports CAA, CDS |
| RECORD_TYPE_MAP = { |
| RecordType.A: 'A', |
| RecordType.AAAA: 'AAAA', |
| RecordType.ALIAS: 'ALIAS', |
| RecordType.CNAME: 'CNAME', |
| RecordType.DNAME: 'DNAME', |
| RecordType.DS: 'DS', |
| RecordType.KEY: 'KEY', |
| RecordType.LOC: 'LOC', |
| RecordType.MX: 'MX', |
| RecordType.NS: 'NS', |
| RecordType.PTR: 'PTR', |
| RecordType.SPF: 'SPF', |
| RecordType.SRV: 'SRV', |
| RecordType.SSHFP: 'SSHFP', |
| RecordType.TLSA: 'TLSA', |
| RecordType.TXT: 'TXT', |
| RecordType.WKS: 'WKS', |
| RecordType.CAA: 'CAA', |
| } |
| |
| def list_zones(self): |
| zones = self.connection.request(action='%s/domains' % API_BASE, |
| method='GET') |
| return self._to_zones(zones.object) |
| |
| def get_zone(self, zone_id): |
| action = '%s/domains/%s' % (API_BASE, zone_id) |
| try: |
| zone = self.connection.request(action=action, method='GET') |
| except ResourceNotFoundError: |
| raise ZoneDoesNotExistError(value='', |
| driver=self.connection.driver, |
| zone_id=zone_id) |
| return self._to_zone(zone.object) |
| |
| """ |
| :param extra: (optional) Extra attribute ('name'); if not provided, name |
| is based on domain. |
| |
| :return: :class:`Zone` with attribute zone_uuid set in extra ``dict`` |
| """ |
| def create_zone(self, domain, type='master', ttl=None, extra=None): |
| if extra and 'name' in extra: |
| zone_name = extra['name'] |
| else: |
| zone_name = '%s zone' % domain |
| zone_data = { |
| 'name': zone_name, |
| } |
| |
| try: |
| new_zone = self.connection.request(action='%s/zones' % API_BASE, |
| method='POST', |
| data=zone_data) |
| except ResourceConflictError: |
| raise ZoneAlreadyExistsError(value='', |
| driver=self.connection.driver, |
| zone_id=zone_name) |
| new_zone_uuid = new_zone.headers['location'].split('/')[-1] |
| |
| self.ex_switch_domain_gandi_zone(domain, new_zone_uuid) |
| |
| return self._to_zone({'fqdn': domain, 'zone_uuid': new_zone_uuid}) |
| |
| def list_records(self, zone): |
| action = '%s/domains/%s/records' % (API_BASE, zone.id) |
| records = self.connection.request(action=action, method='GET') |
| return self._to_records(records.object, zone) |
| |
| """ |
| :return: :class:`Record` with the extra ``dict`` containing attribute |
| other_values ``list`` of ``str`` for other values; the first |
| value is returned through Record.data. |
| """ |
| def get_record(self, zone_id, record_id): |
| record_type, name = record_id.split(':', 1) |
| action = '%s/domains/%s/records/%s/%s' % (API_BASE, |
| zone_id, |
| name, |
| record_type) |
| try: |
| record = self.connection.request(action=action, method='GET') |
| except ResourceNotFoundError: |
| raise RecordDoesNotExistError(value='', |
| driver=self.connection.driver, |
| record_id=record_id) |
| return self._to_record(record.object, self.get_zone(zone_id))[0] |
| |
| def create_record(self, name, zone, type, data, extra=None): |
| self._validate_record(None, name, type, data, extra) |
| |
| action = '%s/domains/%s/records' % (API_BASE, zone.id) |
| |
| if type == 'MX': |
| data = '%s %s' % (extra['priority'], data) |
| |
| record_data = { |
| 'rrset_name': name, |
| 'rrset_type': self.RECORD_TYPE_MAP[type], |
| 'rrset_values': [data], |
| } |
| |
| if extra is not None and 'ttl' in extra: |
| record_data['rrset_ttl'] = extra['ttl'] |
| |
| try: |
| self.connection.request(action=action, method='POST', |
| data=record_data) |
| except ResourceConflictError: |
| raise RecordAlreadyExistsError(value='', |
| driver=self.connection.driver, |
| record_id='%s:%s' % ( |
| self.RECORD_TYPE_MAP[type], |
| name)) |
| |
| return self._to_record_sub(record_data, zone, data) |
| |
| """ |
| Ignores name and type, not allowed in an update call to the service. |
| |
| The Gandi service requires all values for a record when doing an update. |
| Not providing all values during an update means the service will interpret |
| it as replacing all values with the one data value. The easiest way to |
| accomplish this is to make sure the value of a get_record is used as the |
| value of the record parameter. |
| |
| This method will change the value when only one exists. When more than |
| one exists, it will combine the data parameter value with the extra dict |
| values contained in the list extra['_other_records']. This method should |
| only be used to make single value updates. |
| |
| To change the number of values in the value set or to change several at |
| once, delete and recreate, potentially using ex_create_multi_value_record. |
| """ |
| def update_record(self, record, name, type, data, extra): |
| self._validate_record(record.id, record.name, record.type, data, extra) |
| |
| action = '%s/domains/%s/records/%s/%s' % ( |
| API_BASE, |
| record.zone.id, |
| record.name, |
| self.RECORD_TYPE_MAP[record.type] |
| ) |
| |
| multiple_value_record = record.extra.get('_multi_value', False) |
| other_records = record.extra.get('_other_records', []) |
| |
| if record.type == RecordType.MX: |
| data = '%s %s' % (extra['priority'], data) |
| |
| if multiple_value_record and len(other_records) > 0: |
| rvalue = [data] |
| for other_record in other_records: |
| if record.type == RecordType.MX: |
| rvalue.append('%s %s' % |
| (other_record['extra']['priority'], |
| other_record['data'])) |
| else: |
| rvalue.append(other_record['data']) |
| else: |
| rvalue = [data] |
| |
| record_data = { |
| 'rrset_values': rvalue |
| } |
| |
| if extra is not None and 'ttl' in extra: |
| record_data['rrset_ttl'] = extra['ttl'] |
| |
| try: |
| self.connection.request(action=action, method='PUT', |
| data=record_data) |
| except ResourceNotFoundError: |
| raise RecordDoesNotExistError(value='', |
| driver=self.connection.driver, |
| record_id=record.id) |
| |
| record_data['rrset_name'] = record.name |
| record_data['rrset_type'] = self.RECORD_TYPE_MAP[record.type] |
| return self._to_record(record_data, record.zone)[0] |
| |
| """ |
| The Gandi service considers all values for a name-type combination to be |
| one record. Deleting that name-type record means deleting all values for |
| it. |
| """ |
| def delete_record(self, record): |
| action = '%s/domains/%s/records/%s/%s' % ( |
| API_BASE, |
| record.zone.id, |
| record.name, |
| self.RECORD_TYPE_MAP[record.type] |
| ) |
| try: |
| self.connection.request(action=action, method='DELETE') |
| except ResourceNotFoundError: |
| raise RecordDoesNotExistError(value='', |
| driver=self.connection.driver, |
| record_id=record.id) |
| # Originally checked for success here, but it should never reach |
| # this point with anything other than HTTP 200 |
| return True |
| |
| def export_zone_to_bind_format(self, zone): |
| action = '%s/domains/%s/records' % (API_BASE, zone.id) |
| headers = { |
| 'Accept': 'text/plain' |
| } |
| resp = self.connection.request(action=action, method='GET', |
| headers=headers, raw=True) |
| return resp.body |
| |
| # There is nothing you can update about a domain; you can update zones' |
| # names and which zone a domain is associated with, but the domain itself |
| # is basically immutable. Instead, some ex_ methods for dealing with |
| # Gandi zones. |
| |
| """ |
| Update the name of a Gandi zone. |
| |
| Note that a Gandi zone is not the same as a Libcloud zone. A Gandi zone |
| is a separate object type from a Gandi domain; a Gandi zone can be reused |
| by multiple Gandi domains, and the actual records are associated with the |
| zone directly. This is mostly masked in this driver to make it look like |
| records are associated with domains. If you need to step out of that |
| masking, use these extension methods. |
| |
| :param zone_uuid: Identifier for the Gandi zone. |
| :type zone_uuid: ``str`` |
| |
| :param name: New name for the Gandi zone. |
| :type name: ``str`` |
| |
| :return: ``bool`` |
| """ |
| def ex_update_gandi_zone_name(self, zone_uuid, name): |
| action = '%s/zones/%s' % (API_BASE, zone_uuid) |
| data = { |
| 'name': name, |
| } |
| self.connection.request(action=action, method='PATCH', |
| data=data) |
| return True |
| |
| # There is no concept of deleting domains in this API, not even to |
| # disassociate a domain from a zone. You can delete a zone, though. |
| """ |
| Delete a Gandi zone. This may raise a ResourceConflictError if you |
| try to delete a zone that has domains still using it. |
| |
| :param zone_uuid: Identifier for the Gandi zone |
| :type zone_uuid: ``str`` |
| |
| :return: ``bool`` |
| """ |
| def ex_delete_gandi_zone(self, zone_uuid): |
| self.connection.request(action='%s/zones/%s' % (API_BASE, zone_uuid), |
| method='DELETE') |
| return True |
| |
| """ |
| Change the Gandi zone a domain is asociated with. |
| |
| :param domain: Domain name to switch zones. |
| :type domain: ``str`` |
| |
| :param zone_uuid: Identifier for the new Gandi zone to switch to. |
| :type zone_uuid: ``str`` |
| |
| :return: ``bool`` |
| """ |
| def ex_switch_domain_gandi_zone(self, domain, zone_uuid): |
| domain_data = { |
| 'zone_uuid': zone_uuid, |
| } |
| self.connection.request(action='%s/domains/%s' % (API_BASE, domain), |
| method='PATCH', |
| data=domain_data) |
| return True |
| |
| """ |
| Create a new record with multiple values. |
| |
| :param data: Record values (depends on the record type) |
| :type data: ``list`` (of ``str``) |
| |
| :return: ``list`` of :class:`Record`s |
| """ |
| def ex_create_multi_value_record(self, name, zone, type, data, extra=None): |
| self._validate_record(None, name, type, data, extra) |
| |
| action = '%s/domains/%s/records' % (API_BASE, zone.id) |
| |
| record_data = { |
| 'rrset_name': name, |
| 'rrset_type': self.RECORD_TYPE_MAP[type], |
| 'rrset_values': data, |
| } |
| |
| if extra is not None and 'ttl' in extra: |
| record_data['rrset_ttl'] = extra['ttl'] |
| |
| try: |
| self.connection.request(action=action, method='POST', |
| data=record_data) |
| except ResourceConflictError: |
| raise RecordAlreadyExistsError(value='', |
| driver=self.connection.driver, |
| record_id='%s:%s' % ( |
| self.RECORD_TYPE_MAP[type], |
| name)) |
| return self._to_record(record_data, zone) |
| |
| def _to_record(self, data, zone): |
| records = [] |
| rrset_values = data['rrset_values'] |
| multiple_value_record = len(rrset_values) > 1 |
| |
| for index, rrset_value in enumerate(rrset_values): |
| record = self._to_record_sub(data, zone, rrset_value) |
| record.extra['_multi_value'] = multiple_value_record |
| if multiple_value_record: |
| record.extra['_other_records'] = [] |
| records.append(record) |
| |
| if multiple_value_record: |
| for index in range(0, len(records)): |
| record = records[index] |
| for other_index, other_record in enumerate(records): |
| if index == other_index: |
| continue |
| |
| extra = copy.deepcopy(other_record.extra) |
| extra.pop('_multi_value') |
| extra.pop('_other_records') |
| |
| item = { |
| 'name': other_record.name, |
| 'data': other_record.data, |
| 'type': other_record.type, |
| 'extra': extra |
| } |
| record.extra['_other_records'].append(item) |
| return records |
| |
| def _to_record_sub(self, data, zone, value): |
| extra = {} |
| ttl = data.get('rrset_ttl', None) |
| if ttl is not None: |
| extra['ttl'] = int(ttl) |
| if data['rrset_type'] == 'MX': |
| priority, value = value.split() |
| extra['priority'] = priority |
| return Record( |
| id='%s:%s' % (data['rrset_type'], data['rrset_name']), |
| name=data['rrset_name'], |
| type=self._string_to_record_type(data['rrset_type']), |
| data=value, |
| zone=zone, |
| driver=self, |
| ttl=ttl, |
| extra=extra) |
| |
| def _to_records(self, data, zone): |
| records = [] |
| for r in data: |
| records += self._to_record(r, zone) |
| return records |
| |
| def _to_zone(self, zone): |
| extra = {} |
| if 'zone_uuid' in zone: |
| extra = { |
| 'zone_uuid': zone['zone_uuid'] |
| } |
| return Zone( |
| id=str(zone['fqdn']), |
| domain=zone['fqdn'], |
| type='master', |
| ttl=0, |
| driver=self, |
| extra=extra, |
| ) |
| |
| def _to_zones(self, zones): |
| ret = [] |
| for z in zones: |
| ret.append(self._to_zone(z)) |
| return ret |
| |
| def _validate_record(self, record_id, name, record_type, data, extra): |
| if len(data) > 1024: |
| raise RecordError('Record data must be <= 1024 characters', |
| driver=self, record_id=record_id) |
| if type == 'MX' or type == RecordType.MX: |
| if extra is None or 'priority' not in extra: |
| raise RecordError('MX record must have a priority', |
| driver=self, record_id=record_id) |
| if extra is not None and '_other_records' in extra: |
| for other_value in extra.get('_other_records', []): |
| if len(other_value['data']) > 1024: |
| raise RecordError('Record data must be <= 1024 characters', |
| driver=self, record_id=record_id) |
| if type == 'MX' or type == RecordType.MX: |
| if (other_value['extra'] is None |
| or 'priority' not in other_value['extra']): |
| raise RecordError('MX record must have a priority', |
| driver=self, record_id=record_id) |
| if extra is not None and 'ttl' in extra: |
| if extra['ttl'] < TTL_MIN: |
| raise RecordError('TTL must be at least 300 seconds', |
| driver=self, record_id=record_id) |
| if extra['ttl'] > TTL_MAX: |
| raise RecordError('TTL must not exceed 30 days', |
| driver=self, record_id=record_id) |