485 lines
18 KiB
Python
485 lines
18 KiB
Python
# 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)
|