Support secondary zones

Change-Id: If9fd6351087fcfd1873cb9adb2bcf753ce42700d
This commit is contained in:
Endre Karlson 2014-11-03 14:31:30 +01:00
parent 963e4d0151
commit d801098b0f
23 changed files with 1373 additions and 79 deletions

View File

@ -62,6 +62,10 @@ def create_domain():
domain_schema.validate(values)
central_api = central_rpcapi.CentralAPI.get_instance()
# A V1 zone only supports being a primary (No notion of a type)
values['type'] = 'PRIMARY'
domain = central_api.create_domain(context, objects.Domain(**values))
response = flask.jsonify(domain_schema.filter(domain))
@ -76,7 +80,8 @@ def get_domains():
context = flask.request.environ.get('context')
central_api = central_rpcapi.CentralAPI.get_instance()
domains = central_api.find_domains(context)
domains = central_api.find_domains(context, criterion={"type": "PRIMARY"})
return flask.jsonify(domains_schema.filter({'domains': domains}))
@ -86,7 +91,9 @@ def get_domain(domain_id):
context = flask.request.environ.get('context')
central_api = central_rpcapi.CentralAPI.get_instance()
domain = central_api.get_domain(context, domain_id)
criterion = {"id": domain_id, "type": "PRIMARY"}
domain = central_api.find_domain(context, criterion=criterion)
return flask.jsonify(domain_schema.filter(domain))
@ -99,7 +106,8 @@ def update_domain(domain_id):
central_api = central_rpcapi.CentralAPI.get_instance()
# Fetch the existing resource
domain = central_api.get_domain(context, domain_id)
criterion = {"id": domain_id, "type": "PRIMARY"}
domain = central_api.find_domain(context, criterion=criterion)
# Prepare a dict of fields for validation
domain_data = domain_schema.filter(domain)
@ -120,6 +128,11 @@ def delete_domain(domain_id):
context = flask.request.environ.get('context')
central_api = central_rpcapi.CentralAPI.get_instance()
# TODO(ekarlso): Fix this to something better.
criterion = {"id": domain_id, "type": "PRIMARY"}
central_api.find_domain(context, criterion=criterion)
central_api.delete_domain(context, domain_id)
return flask.Response(status=200)
@ -131,6 +144,10 @@ def get_domain_servers(domain_id):
central_api = central_rpcapi.CentralAPI.get_instance()
# TODO(ekarlso): Fix this to something better.
criterion = {"id": domain_id, "type": "PRIMARY"}
central_api.find_domain(context, criterion=criterion)
nameservers = central_api.get_domain_servers(context, domain_id)
servers = objects.ServerList()

View File

@ -40,6 +40,11 @@ def _find_recordset(context, domain_id, name, type):
def _find_or_create_recordset(context, domain_id, name, type, ttl):
central_api = central_rpcapi.CentralAPI.get_instance()
criterion = {"id": domain_id, "type": "PRIMARY"}
central_api.find_domain(context, criterion=criterion)
try:
recordset = _find_recordset(context, domain_id, name, type)
except exceptions.RecordSetNotFound:
@ -49,7 +54,6 @@ def _find_or_create_recordset(context, domain_id, name, type, ttl):
'type': type,
'ttl': ttl,
}
central_api = central_rpcapi.CentralAPI.get_instance()
recordset = central_api.create_recordset(
context, domain_id, objects.RecordSet(**values))
@ -193,7 +197,8 @@ def update_record(domain_id, record_id):
# NOTE: We need to ensure the domain actually exists, otherwise we may
# return a record not found instead of a domain not found
central_api.get_domain(context, domain_id)
criterion = {"id": domain_id, "type": "PRIMARY"}
central_api.find_domain(context, criterion)
# Fetch the existing resource
# NOTE(kiall): We use "find_record" rather than "get_record" as we do not
@ -251,7 +256,8 @@ def delete_record(domain_id, record_id):
# NOTE: We need to ensure the domain actually exists, otherwise we may
# return a record not found instead of a domain not found
central_api.get_domain(context, domain_id)
criterion = {"id": domain_id, "type": "PRIMARY"}
central_api.find_domain(context, criterion=criterion)
# Find the record
criterion = {'domain_id': domain_id, 'id': record_id}

View File

@ -16,6 +16,7 @@
import pecan
from dns import zone as dnszone
from dns import exception as dnsexception
from oslo.config import cfg
from designate import exceptions
from designate import utils
@ -26,7 +27,10 @@ from designate.api.v2.controllers import nameservers
from designate.api.v2.controllers import recordsets
from designate.api.v2.controllers.zones import tasks
from designate.api.v2.views import zones as zones_view
from designate.objects import Domain
from designate import objects
CONF = cfg.CONF
class ZonesController(rest.RestController):
@ -134,14 +138,30 @@ class ZonesController(rest.RestController):
"""'Normal' zone creation"""
body = request.body_dict
# We need to check the zone type before validating the schema since if
# it's the type is SECONDARY we need to set the email to the mgmt email
zone = body.get('zone')
if isinstance(zone, dict):
if 'type' not in zone:
zone['type'] = 'PRIMARY'
if zone['type'] == 'SECONDARY':
mgmt_email = CONF['service:central'].managed_resource_email
body['zone']['email'] = mgmt_email
# Validate the request conforms to the schema
self._resource_schema.validate(body)
# Convert from APIv2 -> Central format
values = self._view.load(context, request, body)
# TODO(ekarlso): Fix this once setter or so works.
masters = values.pop('masters', [])
zone = objects.Domain.from_dict(values)
zone.set_masters(masters)
# Create the zone
zone = self.central_api.create_domain(context, Domain(**values))
zone = self.central_api.create_domain(context, zone)
# Prepare the response headers
# If the zone has been created asynchronously
@ -168,7 +188,9 @@ class ZonesController(rest.RestController):
# records are taken care of in _create_zone).
check_origin=False)
domain = dnsutils.from_dnspython_zone(dnspython_zone)
for rrset in domain.recordsets:
domain.type = 'PRIMARY'
for rrset in list(domain.recordsets):
if rrset.type in ('NS', 'SOA'):
domain.recordsets.remove(rrset)
@ -229,9 +251,26 @@ class ZonesController(rest.RestController):
# Validate the new set of data
self._resource_schema.validate(zone_data)
# Unpack the values
values = self._view.load(context, request, body)
zone.set_masters(values.pop('masters', []))
# If masters are specified then we set zone.transferred_at to None
# which will cause a new transfer
if 'attributes' in zone.obj_what_changed():
zone.transferred_at = None
# Update and persist the resource
zone.update(self._view.load(context, request, body))
zone = self.central_api.update_domain(context, zone)
zone.update(values)
if zone.type == 'SECONDARY' and 'email' in zone.obj_what_changed():
msg = "Changed email is not allowed."
raise exceptions.InvalidObject(msg)
increment_serial = zone.type == 'PRIMARY'
zone = self.central_api.update_domain(
context, zone, increment_serial=increment_serial)
if zone.status == 'PENDING':
response.status_int = 202

View File

@ -29,11 +29,12 @@ class ZonesView(base_view.BaseView):
def show_basic(self, context, request, zone):
"""Basic view of a zone"""
return {
values = {
"id": zone['id'],
"pool_id": zone['pool_id'],
"project_id": zone['tenant_id'],
"name": zone['name'],
"type": zone['type'],
"email": zone['email'],
"description": zone['description'],
"ttl": zone['ttl'],
@ -43,10 +44,14 @@ class ZonesView(base_view.BaseView):
"version": zone['version'],
"created_at": zone['created_at'],
"updated_at": zone['updated_at'],
"transferred_at": zone['transferred_at'],
"masters": zone.masters,
"links": self._get_resource_links(request, zone)
}
return values
def load(self, context, request, body):
"""Extract a "central" compatible dict from an API call"""
valid_keys = ('name', 'email', 'description', 'ttl')
return self._load(context, request, body, valid_keys)
zone_keys = ('name', 'description', 'type', 'email', 'masters', 'ttl')
return self._load(context, request, body, zone_keys)

View File

@ -482,6 +482,7 @@ class Service(service.RPCService, service.Service):
zone['minimum'])
def _create_soa(self, context, zone):
# Need elevated context to get the servers
elevated_context = context.elevated()
elevated_context.all_tenants = True
@ -505,6 +506,9 @@ class Service(service.RPCService, service.Service):
return soa
def _update_soa(self, context, zone):
# NOTE: We should not be updating SOA records when a zone is SECONDARY.
if zone.type != 'PRIMARY':
return
nameservers = self.get_domain_servers(context, zone['id'])
@ -519,6 +523,10 @@ class Service(service.RPCService, service.Service):
# NS Recordset Methods
def _create_ns(self, context, zone, nameservers):
# NOTE: We should not be creating NS records when a zone is SECONDARY.
if zone.type != 'PRIMARY':
return
# Create an NS record for each server
ns_values = []
for s in nameservers:
@ -537,6 +545,10 @@ class Service(service.RPCService, service.Service):
return ns
def _update_ns(self, context, zone, orig_name, new_name):
# NOTE: We should not be updating NS records when a zone is SECONDARY.
if zone.type != 'PRIMARY':
return
# Get the zone's NS recordset
ns = self.find_recordset(context,
criterion={'domain_id': zone['id'],
@ -826,6 +838,9 @@ class Service(service.RPCService, service.Service):
'Please create at least one nameserver'))
raise exceptions.NoServersConfigured()
if domain.type == 'SECONDARY' and domain.serial is None:
domain.serial = 1
domain = self._create_domain_in_storage(context, domain)
self.pool_manager_api.create_domain(context, domain)
@ -847,9 +862,6 @@ class Service(service.RPCService, service.Service):
domain.action = 'CREATE'
domain.status = 'PENDING'
# Set the serial number
domain.serial = utils.increment_serial()
domain = self.storage.create_domain(context, domain)
nameservers = self.get_domain_servers(context, domain['id'])
@ -1079,6 +1091,7 @@ class Service(service.RPCService, service.Service):
target = {
'domain_id': domain_id,
'domain_name': domain.name,
'domain_type': domain.type,
'recordset_name': recordset.name,
'tenant_id': domain.tenant_id,
}
@ -1190,6 +1203,7 @@ class Service(service.RPCService, service.Service):
target = {
'domain_id': recordset.obj_get_original_value('domain_id'),
'domain_type': domain.type,
'recordset_id': recordset.obj_get_original_value('id'),
'domain_name': domain.name,
'tenant_id': domain.tenant_id
@ -1253,6 +1267,7 @@ class Service(service.RPCService, service.Service):
target = {
'domain_id': domain_id,
'domain_name': domain.name,
'domain_type': domain.type,
'recordset_id': recordset.id,
'tenant_id': domain.tenant_id
}
@ -1305,11 +1320,13 @@ class Service(service.RPCService, service.Service):
def create_record(self, context, domain_id, recordset_id, record,
increment_serial=True):
domain = self.storage.get_domain(context, domain_id)
recordset = self.storage.get_recordset(context, recordset_id)
target = {
'domain_id': domain_id,
'domain_name': domain.name,
'domain_type': domain.type,
'recordset_id': recordset_id,
'recordset_name': recordset.name,
'tenant_id': domain.tenant_id
@ -1413,6 +1430,7 @@ class Service(service.RPCService, service.Service):
target = {
'domain_id': record.obj_get_original_value('domain_id'),
'domain_name': domain.name,
'domain_type': domain.type,
'recordset_id': record.obj_get_original_value('recordset_id'),
'recordset_name': recordset.name,
'record_id': record.obj_get_original_value('id'),
@ -1451,6 +1469,7 @@ class Service(service.RPCService, service.Service):
def delete_record(self, context, domain_id, recordset_id, record_id,
increment_serial=True):
domain = self.storage.get_domain(context, domain_id)
recordset = self.storage.get_recordset(context, recordset_id)
record = self.storage.get_record(context, record_id)
@ -1465,6 +1484,7 @@ class Service(service.RPCService, service.Service):
target = {
'domain_id': domain_id,
'domain_name': domain.name,
'domain_type': domain.type,
'recordset_id': recordset_id,
'recordset_name': recordset.name,
'record_id': record.id,
@ -1778,6 +1798,7 @@ class Service(service.RPCService, service.Service):
tenant_id = cfg.CONF['service:central'].managed_resource_tenant_id
zone_values = {
'type': 'PRIMARY',
'name': zone_name,
'email': email,
'tenant_id': tenant_id

View File

@ -244,6 +244,10 @@ class DuplicatePoolAttribute(Duplicate):
error_type = 'duplicate_pool_attribute'
class DuplicateDomainAttribute(Duplicate):
error_type = 'duplicate_domain_attribute'
class MethodNotAllowed(Base):
expected = True
error_code = 405
@ -284,6 +288,10 @@ class DomainNotFound(NotFound):
error_type = 'domain_not_found'
class DomainAttributeNotFound(NotFound):
error_type = 'domain_attribute_not_found'
class TldNotFound(NotFound):
error_type = 'tld_not_found'

View File

@ -21,6 +21,7 @@ from designate.objects.base import PagedListObjectMixin # noqa
from designate.objects.backend_option import BackendOption, BackendOptionList # noqa
from designate.objects.blacklist import Blacklist, BlacklistList # noqa
from designate.objects.domain import Domain, DomainList # noqa
from designate.objects.domain_attribute import DomainAttribute, DomainAttributeList # noqa
from designate.objects.pool_manager_status import PoolManagerStatus, PoolManagerStatusList # noqa
from designate.objects.pool_server import PoolServer, PoolServerList # noqa
from designate.objects.pool import Pool, PoolList # noqa

View File

@ -13,6 +13,8 @@
# License for the specific language governing permissions and limitations
# under the License.
from designate.objects import base
from designate.objects.domain_attribute import DomainAttribute
from designate.objects.domain_attribute import DomainAttributeList
class Domain(base.DictObjectMixin, base.SoftDeleteObjectMixin,
@ -36,8 +38,30 @@ class Domain(base.DictObjectMixin, base.SoftDeleteObjectMixin,
'relation': True,
'relation_cls': 'RecordSetList'
},
'attributes': {
'relation': True,
'relation_cls': 'DomainAttributeList'
},
'type': {},
'transferred_at': {},
}
@property
def masters(self):
if self.obj_attr_is_set('attributes'):
return [i.value for i in self.attributes if i.key == 'master']
else:
return None
# TODO(ekarlso): Make this a property sette rpr Kiall's comments later.
def set_masters(self, masters):
attributes = DomainAttributeList()
for m in masters:
obj = DomainAttribute(key='master', value=m)
attributes.append(obj)
self.attributes = attributes
class DomainList(base.ListObjectMixin, base.DesignateObject,
base.PagedListObjectMixin):

View File

@ -0,0 +1,29 @@
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# Author: Endre Karlson <endre.karlson@hp.com>
#
# Licensed 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 designate.objects import base
class DomainAttribute(base.DictObjectMixin, base.PersistentObjectMixin,
base.DesignateObject):
FIELDS = {
'domain_id': {},
'key': {},
'value': {}
}
class DomainAttributeList(base.ListObjectMixin, base.DesignateObject):
LIST_ITEM_TYPE = DomainAttribute

View File

@ -1,3 +1,4 @@
{
"$schema": "http://json-schema.org/draft-04/hyper-schema",
@ -13,7 +14,7 @@
"zone": {
"type": "object",
"additionalProperties": false,
"required": ["name", "email"],
"required": ["name"],
"properties": {
"id": {
@ -41,6 +42,19 @@
"maxLength": 255,
"immutable": true
},
"type": {
"type": "string",
"description": "Zone Type",
"enum": ["PRIMARY", "SECONDARY"]
},
"masters": {
"type": ["array", "null"],
"items": {
"type": "string"
},
"description": "Masters for this Zone",
"uniqueItems": true
},
"email": {
"type": "string",
"description": "Hostmaster email address",
@ -73,7 +87,7 @@
"serial": {
"type": "integer",
"description": "Zone serial number",
"minimum": 1,
"minimum": 0,
"maximum": 4294967295,
"readOnly": true
},
@ -94,6 +108,12 @@
"format": "date-time",
"readOnly": true
},
"transferred_at": {
"type": ["string", "null"],
"description": "Date and time of last successful transfer",
"format": "date-time",
"readOnly": true
},
"links": {
"type": "object",
"additionalProperties": false,
@ -105,7 +125,43 @@
}
}
}
}
},
"oneOf": [
{
"description": "Primary zone",
"required": ["email"],
"properties": {
"type": {
"type": "string",
"enum": ["PRIMARY"]
},
"masters": {
"type": ["null", "array"],
"maxItems": 0
}
}
},
{
"description": "Secondary zone",
"required": ["type", "masters"],
"properties": {
"type": {
"type": "string",
"enum": ["SECONDARY"]
},
"masters": {
"type": "array",
"items": {
"type": "string",
"format": "ipandport"
},
"description": "Masters for this Zone",
"uniqueItems": true,
"minItems": 1
}
}
}
]
}
}
}

View File

@ -33,6 +33,12 @@ RE_TLDNAME = r'^(?!.{255,})(?:(?!\-)[A-Za-z0-9_\-]{1,63}(?<!\-))' \
RE_UUID = r'^(?:[0-9a-fA-F]){8}-(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){4}-' \
r'(?:[0-9a-fA-F]){4}-(?:[0-9a-fA-F]){12}$'
RE_IP_AND_PORT = r'^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}' \
r'(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)' \
r'(?::(6553[0-5]|655[0-2]\d|65[0-4]\d\d|6[0-4]\d{3}' \
r'|[1-5]\d{4}|[1-9]\d{0,3}|0))?$'
draft3_format_checker = jsonschema.draft3_format_checker
draft4_format_checker = jsonschema.draft4_format_checker
@ -135,3 +141,15 @@ def is_uuid(instance):
return False
return True
@draft3_format_checker.checks("ip-and-port")
@draft4_format_checker.checks("ipandport")
def is_ip_and_port(instance):
if not isinstance(instance, compat.str_types):
return True
if not re.match(RE_IP_AND_PORT, instance):
return False
return True

View File

@ -230,8 +230,18 @@ class SQLAlchemyStorage(sqlalchemy_base.SQLAlchemy, storage_base.Storage):
exceptions.DomainNotFound, criterion, one, marker, limit,
sort_key, sort_dir)
if not one:
def _load_relations(domain):
if domain.type == 'SECONDARY':
domain.attributes = self._find_domain_attributes(
context, {'domain_id': domain.id})
domain.obj_reset_changes(['attributes'])
if one:
_load_relations(domains)
else:
domains.total_count = self.count_domains(context, criterion)
for d in domains:
_load_relations(d)
return domains
@ -240,32 +250,85 @@ class SQLAlchemyStorage(sqlalchemy_base.SQLAlchemy, storage_base.Storage):
extra_values = {"reverse_name": domain.name[::-1]}
# Don't handle recordsets for now
return self._create(
tables.domains, domain, exceptions.DuplicateDomain, ['recordsets'],
domain = self._create(
tables.domains, domain, exceptions.DuplicateDomain,
['attributes', 'recordsets'],
extra_values=extra_values)
if domain.obj_attr_is_set('attributes'):
for attrib in domain.attributes:
self.create_domain_attribute(context, domain.id, attrib)
else:
domain.attributes = objects.DomainAttributeList()
domain.obj_reset_changes('attributes')
return domain
def get_domain(self, context, domain_id):
return self._find_domains(context, {'id': domain_id}, one=True)
domain = self._find_domains(context, {'id': domain_id}, one=True)
return domain
def find_domains(self, context, criterion=None, marker=None, limit=None,
sort_key=None, sort_dir=None):
return self._find_domains(context, criterion, marker=marker,
limit=limit, sort_key=sort_key,
sort_dir=sort_dir)
domains = self._find_domains(context, criterion, marker=marker,
limit=limit, sort_key=sort_key,
sort_dir=sort_dir)
return domains
def find_domain(self, context, criterion):
return self._find_domains(context, criterion, one=True)
domain = self._find_domains(context, criterion, one=True)
return domain
def update_domain(self, context, domain):
# Don't handle recordsets for now
tenant_id_changed = False
if 'tenant_id' in domain.obj_what_changed():
tenant_id_changed = True
# Don't handle recordsets for now
updated_domain = self._update(
context, tables.domains, domain, exceptions.DuplicateDomain,
exceptions.DomainNotFound, ['recordsets'])
exceptions.DomainNotFound,
['attributes', 'recordsets'])
if domain.obj_attr_is_set('attributes'):
# Gather the Attribute ID's we have
have = set([r.id for r in self._find_domain_attributes(
context, {'domain_id': domain.id})])
# Prep some lists of changes
keep = set([])
create = []
update = []
# Determine what to change
for i in domain.attributes:
keep.add(i.id)
try:
i.obj_get_original_value('id')
except KeyError:
create.append(i)
else:
update.append(i)
# NOTE: Since we're dealing with mutable objects, the return value
# of create/update/delete attribute is not needed.
# The original item will be mutated in place on the input
# "domain.attributes" list.
# Delete Attributes
for i_id in have - keep:
attr = self._find_domain_attributes(
context, {'id': i_id}, one=True)
self.delete_domain_attribute(context, attr.id)
# Update Attributes
for i in update:
self.update_domain_attribute(context, i)
# Create Attributes
for attr in create:
attr.domain_id = domain.id
self.create_domain_attribute(context, domain.id, attr)
if tenant_id_changed:
recordsets_query = tables.recordsets.update().\
@ -301,6 +364,48 @@ class SQLAlchemyStorage(sqlalchemy_base.SQLAlchemy, storage_base.Storage):
return result[0]
# Domain attribute methods
def _find_domain_attributes(self, context, criterion, one=False,
marker=None, limit=None, sort_key=None,
sort_dir=None):
return self._find(context, tables.domain_attributes,
objects.DomainAttribute, objects.DomainAttributeList,
exceptions.DomainAttributeNotFound, criterion, one,
marker, limit, sort_key, sort_dir)
def create_domain_attribute(self, context, domain_id, domain_attribute):
domain_attribute.domain_id = domain_id
return self._create(tables.domain_attributes, domain_attribute,
exceptions.DuplicateDomainAttribute)
def get_domain_attributes(self, context, domain_attribute_id):
return self._find_domain_attributes(
context, {'id': domain_attribute_id}, one=True)
def find_domain_attributes(self, context, criterion=None, marker=None,
limit=None, sort_key=None, sort_dir=None):
return self._find_domain_attributes(context, criterion, marker=marker,
limit=limit, sort_key=sort_key,
sort_dir=sort_dir)
def find_domain_attribute(self, context, criterion):
return self._find_domain_attributes(context, criterion, one=True)
def update_domain_attribute(self, context, domain_attribute):
return self._update(context, tables.domain_attributes,
domain_attribute,
exceptions.DuplicateDomainAttribute,
exceptions.DomainAttributeNotFound)
def delete_domain_attribute(self, context, domain_attribute_id):
domain_attribute = self._find_domain_attributes(
context, {'id': domain_attribute_id}, one=True)
deleted_domain_attribute = self._delete(
context, tables.domain_attributes, domain_attribute,
exceptions.DomainAttributeNotFound)
return deleted_domain_attribute
# RecordSet Methods
def _find_recordsets(self, context, criterion, one=False, marker=None,
limit=None, sort_key=None, sort_dir=None):

View File

@ -0,0 +1,105 @@
# Copyright 2014 Hewlett-Packard Development Company, L.P.
#
# Author: Endre Karlson <endre.karlson@hp.com>
#
# Licensed 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 oslo.utils import timeutils
from sqlalchemy import DateTime, Enum, Integer, String, ForeignKeyConstraint
from sqlalchemy.schema import Column, MetaData, Table
from sqlalchemy.sql import select
from migrate.changeset.constraint import UniqueConstraint
from designate import utils
from designate.sqlalchemy.types import UUID
meta = MetaData()
ZONE_ATTRIBUTE_KEYS = ('master',)
ZONE_TYPES = ('PRIMARY', 'SECONDARY')
meta = MetaData()
domain_attributes = Table(
'domain_attributes', meta,
Column('id', UUID(), default=utils.generate_uuid, primary_key=True),
Column('version', Integer(), default=1, nullable=False),
Column('created_at', DateTime, default=lambda: timeutils.utcnow()),
Column('updated_at', DateTime, onupdate=lambda: timeutils.utcnow()),
Column('key', Enum(name='key', *ZONE_ATTRIBUTE_KEYS)),
Column('value', String(255), nullable=False),
Column('domain_id', UUID(), nullable=False),
UniqueConstraint('key', 'value', 'domain_id', name='unique_attributes'),
ForeignKeyConstraint(['domain_id'], ['domains.id'], ondelete='CASCADE'),
mysql_engine='INNODB',
mysql_charset='utf8'
)
def upgrade(migrate_engine):
meta.bind = migrate_engine
domains_table = Table('domains', meta, autoload=True)
# Add type and transferred_at to domains
type_ = Column('type', Enum(name='type', *ZONE_TYPES), default='PRIMARY',
server_default='PRIMARY')
transferred_at = Column('transferred_at', DateTime, default=None)
type_.create(domains_table, populate_default=True)
transferred_at.create(domains_table, populate_default=True)
domain_attributes.create()
dialect = migrate_engine.url.get_dialect().name
if dialect.startswith('sqlite'):
constraint = UniqueConstraint(
'name', 'deleted', name='unique_domain_name', table=domains_table)
# Add missing unique index
constraint.create()
def downgrade(migrate_engine):
meta.bind = migrate_engine
domains_table = Table('domains', meta, autoload=True)
domains = select(columns=[domains_table.c.id, domains_table.c.type])\
.where(domains_table.c.type == 'SECONDARY')\
.execute().fetchall()
for dom in domains:
delete = domains_table.delete()\
.where(domains_table.id == dom.id)
delete.execute()
domains_table.c.type.drop()
domains_table.c.transferred_at.drop()
domain_attributes.drop()
dialect = migrate_engine.url.get_dialect().name
if dialect.startswith('sqlite'):
constraint = UniqueConstraint(
'name', 'deleted', name='unique_domain_name', table=domains_table)
# Add missing unique index
constraint.create()

View File

@ -36,6 +36,11 @@ TSIG_SCOPES = ['POOL', 'ZONE']
POOL_PROVISIONERS = ['UNMANAGED']
ACTIONS = ['CREATE', 'DELETE', 'UPDATE', 'NONE']
ZONE_ATTRIBUTE_KEYS = ('master',)
ZONE_TYPES = ('PRIMARY', 'SECONDARY',)
metadata = MetaData()
quotas = Table('quotas', metadata,
@ -79,6 +84,8 @@ domains = Table('domains', metadata,
Column('name', String(255), nullable=False),
Column('email', String(255), nullable=False),
Column('description', Unicode(160), nullable=True),
Column("type", Enum(name='type', *ZONE_TYPES), nullable=False),
Column('transferred_at', DateTime, default=None),
Column('ttl', Integer, default=CONF.default_ttl, nullable=False),
Column('serial', Integer, default=timeutils.utcnow_ts, nullable=False),
Column('refresh', Integer, default=CONF.default_soa_refresh,
@ -104,6 +111,23 @@ domains = Table('domains', metadata,
mysql_charset='utf8',
)
domain_attributes = Table('domain_attributes', metadata,
Column('id', UUID(), default=utils.generate_uuid, primary_key=True),
Column('version', Integer(), default=1, nullable=False),
Column('created_at', DateTime, default=lambda: timeutils.utcnow()),
Column('updated_at', DateTime, onupdate=lambda: timeutils.utcnow()),
Column('key', Enum(name='key', *ZONE_ATTRIBUTE_KEYS)),
Column('value', String(255), nullable=False),
Column('domain_id', UUID(), nullable=False),
UniqueConstraint('key', 'value', 'domain_id', name='unique_attributes'),
ForeignKeyConstraint(['domain_id'], ['domains.id'], ondelete='CASCADE'),
mysql_engine='INNODB',
mysql_charset='utf8'
)
recordsets = Table('recordsets', metadata,
Column('id', UUID, default=utils.generate_uuid, primary_key=True),
Column('version', Integer(), default=1, nullable=False),

View File

@ -101,19 +101,43 @@ class TestCase(base.BaseTestCase):
}]
# The last domain is invalid
domain_fixtures = [{
'name': 'example.com.',
'email': 'example@example.com',
}, {
'name': 'example.net.',
'email': 'example@example.net',
}, {
'name': 'example.org.',
'email': 'example@example.org',
}, {
'name': 'invalid.com.....',
'email': 'example@invalid.com',
}]
domain_fixtures = {
'PRIMARY': [
{
'name': 'example.com.',
'type': 'PRIMARY',
'email': 'example@example.com',
}, {
'name': 'example.net.',
'type': 'PRIMARY',
'email': 'example@example.net',
}, {
'name': 'example.org.',
'type': 'PRIMARY',
'email': 'example@example.org',
}, {
'name': 'invalid.com.....',
'type': 'PRIMARY',
'email': 'example@invalid.com',
}
],
'SECONDARY': [
{
'name': 'example.com.',
'type': 'SECONDARY',
}, {
'name': 'example.net.',
'type': 'SECONDARY',
}, {
'name': 'example.org.',
'type': 'SECONDARY',
}, {
'name': 'invalid.com.....',
'type': 'SECONDARY',
}
]
}
recordset_fixtures = {
'A': [
@ -381,10 +405,12 @@ class TestCase(base.BaseTestCase):
_values.update(values)
return _values
def get_domain_fixture(self, fixture=0, values=None):
def get_domain_fixture(self, domain_type=None, fixture=0, values=None):
domain_type = domain_type or 'PRIMARY'
values = values or {}
_values = copy.copy(self.domain_fixtures[fixture])
_values = copy.copy(self.domain_fixtures[domain_type][fixture])
_values.update(values)
return _values
@ -552,6 +578,7 @@ class TestCase(base.BaseTestCase):
def create_domain(self, **kwargs):
context = kwargs.pop('context', self.admin_context)
fixture = kwargs.pop('fixture', 0)
domain_type = kwargs.pop('type', None)
try:
# We always need a server to create a server
@ -565,7 +592,8 @@ class TestCase(base.BaseTestCase):
except exceptions.DuplicatePoolAttribute:
pass
values = self.get_domain_fixture(fixture=fixture, values=kwargs)
values = self.get_domain_fixture(domain_type=domain_type,
fixture=fixture, values=kwargs)
if 'tenant_id' not in values:
values['tenant_id'] = context.tenant

View File

@ -18,6 +18,7 @@ import datetime
from mock import patch
from oslo import messaging
from oslo.config import cfg
from oslo_log import log as logging
from designate import exceptions
@ -36,6 +37,9 @@ class ApiV1DomainsTest(ApiV1Test):
# Create a domain
fixture = self.get_domain_fixture(0)
# V1 doesn't have these
del fixture['type']
response = self.post('domains', data=fixture)
self.assertIn('id', response.json)
@ -59,6 +63,9 @@ class ApiV1DomainsTest(ApiV1Test):
# Create a domain
fixture = self.get_domain_fixture(0)
# V1 doesn't have these
del fixture['type']
self.post('domains', data=fixture, status_code=500)
@patch.object(central_service.Service, 'create_domain',
@ -67,6 +74,9 @@ class ApiV1DomainsTest(ApiV1Test):
# Create a domain
fixture = self.get_domain_fixture(0)
# V1 doesn't have these
del fixture['type']
self.post('domains', data=fixture, status_code=504)
@patch.object(central_service.Service, 'create_domain',
@ -74,6 +84,10 @@ class ApiV1DomainsTest(ApiV1Test):
def test_create_domain_duplicate(self, _):
# Create a domain
fixture = self.get_domain_fixture(0)
# V1 doesn't have these
del fixture['type']
self.post('domains', data=fixture, status_code=409)
def test_create_domain_null_ttl(self):
@ -101,6 +115,9 @@ class ApiV1DomainsTest(ApiV1Test):
# Create a domain
fixture = self.get_domain_fixture(0)
# V1 doesn't have type
del fixture['type']
# Give it a UTF-8 filled description
fixture['description'] = "utf-8:2H₂+O₂⇌2H₂O,R=4.7kΩ,⌀200mm∮E⋅da=Q,n" \
",∑f(i)=∏g(i),∀x∈:⌈x⌉"
@ -218,7 +235,7 @@ class ApiV1DomainsTest(ApiV1Test):
self.assertIn('id', response.json)
self.assertEqual(response.json['id'], domain['id'])
@patch.object(central_service.Service, 'get_domain',
@patch.object(central_service.Service, 'find_domain',
side_effect=messaging.MessagingTimeout())
def test_get_domain_timeout(self, _):
# Create a domain
@ -353,3 +370,33 @@ class ApiV1DomainsTest(ApiV1Test):
self.delete('domains/2fdadfb1cf964259ac6bbb7b6d2ff980',
status_code=404)
def test_get_secondary_missing(self):
fixture = self.get_domain_fixture('SECONDARY', 0)
fixture['email'] = cfg.CONF['service:central'].managed_resource_email
domain = self.create_domain(**fixture)
self.get('domains/%s' % domain.id, status_code=404)
def test_update_secondary_missing(self):
fixture = self.get_domain_fixture('SECONDARY', 0)
fixture['email'] = cfg.CONF['service:central'].managed_resource_email
domain = self.create_domain(**fixture)
self.put('domains/%s' % domain.id, {}, status_code=404)
def test_delete_secondary_missing(self):
fixture = self.get_domain_fixture('SECONDARY', 0)
fixture['email'] = cfg.CONF['service:central'].managed_resource_email
domain = self.create_domain(**fixture)
self.delete('domains/%s' % domain.id, status_code=404)
def test_get_domain_servers_from_secondary(self):
fixture = self.get_domain_fixture('SECONDARY', 0)
fixture['email'] = cfg.CONF['service:central'].managed_resource_email
domain = self.create_domain(**fixture)
self.get('domains/%s/servers' % domain.id, status_code=404)

View File

@ -456,7 +456,7 @@ class ApiV1RecordsTest(ApiV1Test):
self.put('domains/%s/records/%s' % (self.domain['id'], record['id']),
data=data, status_code=400)
@patch.object(central_service.Service, 'get_domain',
@patch.object(central_service.Service, 'find_domain',
side_effect=messaging.MessagingTimeout())
def test_update_record_timeout(self, _):
# Create a record
@ -517,7 +517,7 @@ class ApiV1RecordsTest(ApiV1Test):
record['id']),
status_code=404)
@patch.object(central_service.Service, 'get_domain',
@patch.object(central_service.Service, 'find_domain',
side_effect=messaging.MessagingTimeout())
def test_delete_record_timeout(self, _):
# Create a record
@ -546,3 +546,51 @@ class ApiV1RecordsTest(ApiV1Test):
self.delete('domains/%s/records/2fdadfb1-cf96-4259-ac6b-'
'bb7b6d2ff980GH' % self.domain['id'],
status_code=404)
def test_get_record_in_secondary(self):
fixture = self.get_domain_fixture('SECONDARY', 1)
fixture['email'] = "root@example.com"
domain = self.create_domain(**fixture)
record = self.create_record(domain, self.recordset)
url = 'domains/%s/records/%s' % (domain.id, record.id)
self.get(url, status_code=404)
def test_create_record_in_secondary(self):
fixture = self.get_domain_fixture('SECONDARY', 1)
fixture['email'] = "root@example.com"
domain = self.create_domain(**fixture)
record = {
"name": "foo.%s" % domain.name,
"type": "A",
"data": "10.0.0.1"
}
url = 'domains/%s/records' % domain.id
self.post(url, record, status_code=404)
def test_update_record_in_secondary(self):
fixture = self.get_domain_fixture('SECONDARY', 1)
fixture['email'] = "root@example.com"
domain = self.create_domain(**fixture)
record = self.create_record(domain, self.recordset)
url = 'domains/%s/records/%s' % (domain.id, record.id)
self.put(url, {"data": "10.0.0.1"}, status_code=404)
def test_delete_record_in_secondary(self):
fixture = self.get_domain_fixture('SECONDARY', 1)
fixture['email'] = "root@example.com"
domain = self.create_domain(**fixture)
record = self.create_record(domain, self.recordset)
url = 'domains/%s/records/%s' % (domain.id, record.id)
self.delete(url, status_code=404)

View File

@ -645,3 +645,102 @@ class ApiV2RecordSetsTest(ApiV2TestCase):
# But there should be four in total (NS/SOA + the created)
self.assertEqual(4, response.json['metadata']['total_count'])
# Secondary Zones specific tests
def test_get_secondary_zone_recordset(self):
fixture = self.get_domain_fixture('SECONDARY', 1)
fixture['email'] = 'root@example.com'
secondary = self.create_domain(**fixture)
# Create a recordset
recordset = self.create_recordset(secondary)
url = '/zones/%s/recordsets/%s' % (secondary['id'], recordset['id'])
response = self.client.get(url)
# Check the headers are what we expect
self.assertEqual(200, response.status_int)
self.assertEqual('application/json', response.content_type)
# Check the body structure is what we expect
self.assertIn('recordset', response.json)
self.assertIn('links', response.json['recordset'])
self.assertIn('self', response.json['recordset']['links'])
# Check the values returned are what we expect
self.assertIn('id', response.json['recordset'])
self.assertIn('created_at', response.json['recordset'])
self.assertIsNone(response.json['recordset']['updated_at'])
self.assertEqual(recordset['name'], response.json['recordset']['name'])
self.assertEqual(recordset['type'], response.json['recordset']['type'])
def test_get_secondary_zone_recordsets(self):
fixture = self.get_domain_fixture('SECONDARY', 1)
fixture['email'] = 'foo@bar.io'
secondary = self.create_domain(**fixture)
url = '/zones/%s/recordsets' % secondary['id']
response = self.client.get(url)
# Check the headers are what we expect
self.assertEqual(200, response.status_int)
self.assertEqual('application/json', response.content_type)
# Check the body structure is what we expect
self.assertIn('recordsets', response.json)
self.assertIn('links', response.json)
self.assertIn('self', response.json['links'])
# We should start with 2 recordsets for SOA & NS
self.assertEqual(1, len(response.json['recordsets']))
soa = self.central_service.find_recordset(
self.admin_context, criterion={'domain_id': secondary['id'],
'type': 'SOA'})
data = [self.create_recordset(secondary,
name='x-%s.%s' % (i, secondary['name']))
for i in xrange(0, 10)]
data.insert(0, soa)
self._assert_paging(data, url, key='recordsets')
self._assert_invalid_paging(data, url, key='recordsets')
def test_create_secondary_zone_recordset(self):
fixture = self.get_domain_fixture('SECONDARY', 1)
fixture['email'] = 'foo@bar.io'
secondary = self.create_domain(**fixture)
fixture = self.get_recordset_fixture(secondary['name'], fixture=0)
url = '/zones/%s/recordsets' % secondary['id']
self._assert_exception('forbidden', 403, self.client.post_json, url,
{'recordset': fixture})
def test_update_secondary_zone_recordset(self):
fixture = self.get_domain_fixture('SECONDARY', 1)
fixture['email'] = 'foo@bar.io'
secondary = self.create_domain(**fixture)
# Set the context so that we can create a RRSet
recordset = self.create_recordset(secondary)
url = '/zones/%s/recordsets/%s' % (recordset['domain_id'],
recordset['id'])
self._assert_exception('forbidden', 403, self.client.put_json, url,
{'recordset': {'ttl': 100}})
def test_delete_secondary_zone_recordset(self):
fixture = self.get_domain_fixture('SECONDARY', 1)
fixture['email'] = 'foo@bar.io'
secondary = self.create_domain(**fixture)
# Set the context so that we can create a RRSet
recordset = self.create_recordset(secondary)
url = '/zones/%s/recordsets/%s' % (recordset['domain_id'],
recordset['id'])
self._assert_exception('forbidden', 403, self.client.delete, url)

View File

@ -15,6 +15,7 @@
# under the License.
from dns import zone as dnszone
from mock import patch
from oslo.config import cfg
from oslo import messaging
from oslo_log import log as logging
@ -38,7 +39,8 @@ class ApiV2ZonesTest(ApiV2TestCase):
def test_create_zone(self):
# Create a zone
fixture = self.get_domain_fixture(0)
fixture = self.get_domain_fixture(fixture=0)
response = self.client.post_json('/zones/', {'zone': fixture})
# Check the headers are what we expect
@ -54,6 +56,35 @@ class ApiV2ZonesTest(ApiV2TestCase):
self.assertIn('id', response.json['zone'])
self.assertIn('created_at', response.json['zone'])
self.assertEqual('PENDING', response.json['zone']['status'])
self.assertEqual('PRIMARY', response.json['zone']['type'])
self.assertEqual([], response.json['zone']['masters'])
self.assertIsNone(response.json['zone']['updated_at'])
for k in fixture:
self.assertEqual(fixture[k], response.json['zone'][k])
def test_create_zone_no_type(self):
# Create a zone
fixture = self.get_domain_fixture(fixture=0)
del fixture['type']
response = self.client.post_json('/zones/', {'zone': fixture})
# Check the headers are what we expect
self.assertEqual(202, response.status_int)
self.assertEqual('application/json', response.content_type)
# Check the body structure is what we expect
self.assertIn('zone', response.json)
self.assertIn('links', response.json['zone'])
self.assertIn('self', response.json['zone']['links'])
# Check the values returned are what we expect
self.assertIn('id', response.json['zone'])
self.assertIn('created_at', response.json['zone'])
self.assertEqual('PENDING', response.json['zone']['status'])
self.assertEqual('PRIMARY', response.json['zone']['type'])
self.assertEqual([], response.json['zone']['masters'])
self.assertIsNone(response.json['zone']['updated_at'])
for k in fixture:
@ -63,7 +94,7 @@ class ApiV2ZonesTest(ApiV2TestCase):
# NOTE: The schemas should be tested separately to the API. So we
# don't need to test every variation via the API itself.
# Fetch a fixture
fixture = self.get_domain_fixture(0)
fixture = self.get_domain_fixture(fixture=0)
# Add a junk field to the wrapper
body = {'zone': fixture, 'junk': 'Junk Field'}
@ -82,7 +113,7 @@ class ApiV2ZonesTest(ApiV2TestCase):
'/zones', body)
def test_create_zone_body_validation(self):
fixture = self.get_domain_fixture(0)
fixture = self.get_domain_fixture(fixture=0)
# Add id to the body
fixture['id'] = '2fdadfb1-cf96-4259-ac6b-bb7b6d2ff980'
# Ensure it fails with a 400
@ -90,7 +121,7 @@ class ApiV2ZonesTest(ApiV2TestCase):
self._assert_exception('invalid_object', 400, self.client.post_json,
'/zones', body)
fixture = self.get_domain_fixture(0)
fixture = self.get_domain_fixture(fixture=0)
# Add created_at to the body
fixture['created_at'] = '2014-03-12T19:07:53.000000'
# Ensure it fails with a 400
@ -100,7 +131,7 @@ class ApiV2ZonesTest(ApiV2TestCase):
def test_create_zone_invalid_name(self):
# Try to create a zone with an invalid name
fixture = self.get_domain_fixture(-1)
fixture = self.get_domain_fixture(fixture=-1)
# Ensure it fails with a 400
self._assert_exception('invalid_object', 400, self.client.post_json,
@ -109,7 +140,7 @@ class ApiV2ZonesTest(ApiV2TestCase):
@patch.object(central_service.Service, 'create_domain',
side_effect=messaging.MessagingTimeout())
def test_create_zone_timeout(self, _):
fixture = self.get_domain_fixture(0)
fixture = self.get_domain_fixture(fixture=0)
body = {'zone': fixture}
@ -119,7 +150,7 @@ class ApiV2ZonesTest(ApiV2TestCase):
@patch.object(central_service.Service, 'create_domain',
side_effect=exceptions.DuplicateDomain())
def test_create_zone_duplicate(self, _):
fixture = self.get_domain_fixture(0)
fixture = self.get_domain_fixture(fixture=0)
body = {'zone': fixture}
@ -389,6 +420,91 @@ class ApiV2ZonesTest(ApiV2TestCase):
url = '/zones/%s/tasks' % zone.id
self._assert_exception('not_found', 404, self.client.get, url)
def test_create_secondary(self):
# Create a zone
fixture = self.get_domain_fixture('SECONDARY', 0)
fixture['masters'] = ["10.0.0.1"]
response = self.client.post_json('/zones/', {'zone': fixture})
# Check the headers are what we expect
self.assertEqual(202, response.status_int)
self.assertEqual('application/json', response.content_type)
# Check the body structure is what we expect
self.assertIn('zone', response.json)
self.assertIn('links', response.json['zone'])
self.assertIn('self', response.json['zone']['links'])
# Check the values returned are what we expect
self.assertIn('id', response.json['zone'])
self.assertIn('created_at', response.json['zone'])
self.assertEqual('PENDING', response.json['zone']['status'])
self.assertEqual(cfg.CONF['service:central'].managed_resource_email,
response.json['zone']['email'])
self.assertIsNone(response.json['zone']['updated_at'])
# Zone is not transferred yet
self.assertIsNone(response.json['zone']['transferred_at'])
# Serial defaults to 1
self.assertEqual(response.json['zone']['serial'], 1)
for k in fixture:
self.assertEqual(fixture[k], response.json['zone'][k])
def test_create_secondary_no_masters(self):
# Create a zone
fixture = self.get_domain_fixture('SECONDARY', 0)
self._assert_exception('invalid_object', 400, self.client.post_json,
'/zones/', {'zone': fixture})
def test_update_secondary(self):
# Create a zone
fixture = self.get_domain_fixture('SECONDARY', 0)
fixture['email'] = cfg.CONF['service:central'].managed_resource_email
fixture['attributes'] = [{"key": "master", "value": "10.0.0.10"}]
# Create a zone
zone = self.create_domain(**fixture)
masters = ['10.0.0.1', '10.0.0.2']
# Prepare an update body
body = {'zone': {'masters': masters}}
response = self.client.patch_json('/zones/%s' % zone['id'], body,
status=202)
# Check the headers are what we expect
self.assertEqual(202, response.status_int)
self.assertEqual('application/json', response.content_type)
# Check the body structure is what we expect
self.assertIn('zone', response.json)
self.assertIn('links', response.json['zone'])
self.assertIn('self', response.json['zone']['links'])
self.assertIn('status', response.json['zone'])
# Check the values returned are what we expect
self.assertIn('id', response.json['zone'])
self.assertIsNotNone(response.json['zone']['updated_at'])
self.assertEqual(masters, response.json['zone']['masters'])
self.assertEqual(1, response.json['zone']['serial'])
def test_update_secondary_email_invalid_object(self):
# Create a zone
fixture = self.get_domain_fixture('SECONDARY', 0)
fixture['email'] = cfg.CONF['service:central'].managed_resource_email
# Create a zone
zone = self.create_domain(**fixture)
body = {'zone': {'email': 'foo@bar.io'}}
self._assert_exception('invalid_object', 400, self.client.patch_json,
'/zones/%s' % zone['id'], body)
# Zone import/export
def test_missing_origin(self):
fixture = self.get_zonefile_fixture(variant='noorigin')
@ -417,6 +533,7 @@ class ApiV2ZonesTest(ApiV2TestCase):
get_response = self.client.get('/zones/%s' %
post_response.json['zone']['id'],
headers={'Accept': 'text/dns'})
exported_zonefile = get_response.body
imported = dnszone.from_text(self.get_zonefile_fixture())
exported = dnszone.from_text(exported_zonefile)
@ -450,7 +567,7 @@ class ApiV2ZonesTest(ApiV2TestCase):
self.assertEqual(0, response.json['metadata']['total_count'])
# Create a zone
fixture = self.get_domain_fixture(0)
fixture = self.get_domain_fixture(fixture=0)
response = self.client.post_json('/zones/', {'zone': fixture})
response = self.client.get('/zones/')
@ -460,10 +577,10 @@ class ApiV2ZonesTest(ApiV2TestCase):
def test_total_count_pagination(self):
# Create two zones
fixture = self.get_domain_fixture(0)
fixture = self.get_domain_fixture(fixture=0)
response = self.client.post_json('/zones/', {'zone': fixture})
fixture = self.get_domain_fixture(1)
fixture = self.get_domain_fixture(fixture=1)
response = self.client.post_json('/zones/', {'zone': fixture})
# Paginate so that there is only one zone returned

View File

@ -214,7 +214,7 @@ class CentralServiceTest(CentralTestCase):
group='service:central')
context = self.get_context()
values = self.get_domain_fixture(1)
values = self.get_domain_fixture(fixture=1)
values['ttl'] = 0
with testtools.ExpectedException(exceptions.InvalidTTL):
@ -443,7 +443,8 @@ class CentralServiceTest(CentralTestCase):
def test_create_domain_over_tld(self):
values = dict(
name='example.com.',
email='info@example.com'
email='info@example.com',
type='PRIMARY'
)
self._test_create_domain(values)
@ -459,7 +460,8 @@ class CentralServiceTest(CentralTestCase):
# Test creation of a domain in 한국 (kr)
values = dict(
name='example.xn--3e0b707e.',
email='info@example.xn--3e0b707e'
email='info@example.xn--3e0b707e',
type='PRIMARY'
)
self._test_create_domain(values)
@ -476,7 +478,7 @@ class CentralServiceTest(CentralTestCase):
parent_domain = self.create_domain(fixture=0)
# Prepare values for the subdomain using fixture 1 as a base
values = self.get_domain_fixture(1)
values = self.get_domain_fixture(fixture=1)
values['name'] = 'www.%s' % parent_domain['name']
# Create the subdomain
@ -490,7 +492,7 @@ class CentralServiceTest(CentralTestCase):
def test_create_superdomain(self):
# Prepare values for the domain and subdomain
# using fixture 1 as a base
domain_values = self.get_domain_fixture(1)
domain_values = self.get_domain_fixture(fixture=1)
subdomain_values = copy.deepcopy(domain_values)
subdomain_values['name'] = 'www.%s' % domain_values['name']
@ -529,7 +531,7 @@ class CentralServiceTest(CentralTestCase):
context.tenant = '2'
# Prepare values for the subdomain using fixture 1 as a base
values = self.get_domain_fixture(1)
values = self.get_domain_fixture(fixture=1)
values['name'] = 'www.%s' % parent_domain['name']
# Attempt to create the subdomain
@ -544,7 +546,7 @@ class CentralServiceTest(CentralTestCase):
context.tenant = '1'
# Set up domain and subdomain values
domain_values = self.get_domain_fixture(1)
domain_values = self.get_domain_fixture(fixture=1)
domain_name = domain_values['name']
subdomain_values = copy.deepcopy(domain_values)
@ -644,7 +646,7 @@ class CentralServiceTest(CentralTestCase):
group='service:central')
context = self.get_context()
values = self.get_domain_fixture(1)
values = self.get_domain_fixture(fixture=1)
values['ttl'] = 0
# Create a server
@ -658,7 +660,7 @@ class CentralServiceTest(CentralTestCase):
self.policy({'use_low_ttl': '!'})
self.config(min_ttl="None",
group='service:central')
values = self.get_domain_fixture(1)
values = self.get_domain_fixture(fixture=1)
values['ttl'] = -100
# Create a server

View File

@ -0,0 +1,462 @@
..
Copyright 2014 Hewlett-Packard Development Company, L.P.
Author: Endre Karlson <endre.karlson@hp.com>
Licensed 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.
Secondary Zones
===============
The Designate v2 API introduced functionality that allows Designate to act as a
DNS slave, rather than a master for a zone. This is accomplished by completing
a zone transfer (AXFR) from a DNS server managed outside of Designate.
RecordSets / Records
--------------------
Changes to secondary zones are managed outside of Designate. Users must make
the changes they wish, and prompt a fresh zone transfer (AXFR) into Designate
to make those changes live on any DNS servers Designate manages.
Setup
-----
To add a secondary zone to Designate, there must be a DNS master for the zone,
to which Designate can act as a slave. For this guide, we assume that you have
already set this up.
The remaining Designate set up will be similar to a non-secondary zone setup.
You'll need a primary DNS server for Designate to manage and transfer secondary
zones to.
In our examples we'll use the following values:
*Name* - example.com.
*Masters* - 192.168.27.100
Setup - example NSD4
^^^^^^^^^^^^^^^^^^^^
Skip this section if you have a master already to use.
.. note::
For this it is assumed that you are running on Ubuntu.
Install
^^^^^^^
For some reason there's a bug with the nsd package so it doesn't create the user that it needs for the installation. So we'll create that before installing the package.
.. code-block:: bash
$ sudo apt-get install nsd
Configure
^^^^^^^^^
.. code-block:: bash
$ sudo zcat /usr/share/doc/nsd/examples/nsd.conf.sample.gz >/tmp/nsd.conf
$ sudo mv /tmp/nsd.conf /etc/nsd/nsd.conf
Add the following to /etc/nsd/nsd.conf
.. note::
If you're wondering why we set notify to `192.168.27.100`:`5354` it's because MDNS runs on 5354 by default.
.. code-block:: bash
$ sudo vi /etc/nsd/nsd.conf
Add the contents:
.. code-block:: yaml
pattern:
name: "mdns"
zonefile: "%s.zone"
notify: 192.168.27.100@5354 NOKEY
provide-xfr: 192.168.27.100 NOKEY
allow-axfr-fallback: yes
Add a zone file
^^^^^^^^^^^^^^^
Create a new *Zone* in NSD called *example.com.*
**/etc/nsd/example.com.zone**
.. code-block:: bash
$ sudo vi /etc/nsd/example.com.zone
And add the contents:
::
$TTL 1800 ;minimum ttl
example.com. IN SOA ns1.example.com. admin.example.net. (
2014111301 ;serial
3600 ;refresh
600 ;retry
180000 ;expire
600 ;negative ttl
)
TXT "v=spf1 +a +mx ~all"
SPF "v=spf1 +a +mx ~all"
NS ns1.example.com.
NS ns2.example.com.
NS ns3.example.com.
MX 0 mail1.example.com.
MX 5 mail2.example.com.
MX 10 mail3.example.com.
A 10.0.0.1
A 10.0.0.2
A 10.0.0.3
ns1 A 172.16.28.100
ns2 A 172.16.28.101
ns3 A 172.16.28.103
mail1 A 10.0.10.1
mail2 A 10.0.10.2
mail3 A 10.0.10.3
google CNAME google.com.
Restart NSD
^^^^^^^^^^^
.. code-block:: bash
$ sudo service nsd restart
Check that it's working
.. code-block:: bash
$ sudo nsd-control status
Activate the zone in NSD
.. code-block:: bash
$ sudo nsd-control addzone example.com mdns
Creating the Zone
-----------------
When you create a domain in Designate there are two possible initial actions:
- Domain is created but transfer fails if it's not available yet in master,
then typically the initial transfer will be done once the master sends first
NOTIFY.
- Domain is created and transfers straight away.
In both cases the interaction between your master and Designate is handled by
the MDNS instance at the Designate side.
Defintion of values:
- *email* set to the value of the *managed_resource_email* option in the
*central* section of the Designate configuration.
- *transferred_at* is **null** and *version* is **1** since the zone has not
transferred yet.
- *serial* gets set automatically by the system initially and to the value of the master's serial post initial AXFR.
.. http:post:: /zones
Creates a new zone.
**Example request:**
.. sourcecode:: http
POST /v2/zones HTTP/1.1
Host: 127.0.0.1:9001
Accept: application/json
Content-Type: application/json
{
"zone": {
"name": "example.com.",
"type": "SECONDARY",
"masters": ["192.168.27.100"],
"description": "This is a slave for example.com."
}
}
**Example response:**
.. sourcecode:: http
HTTP/1.1 201 Created
Content-Type: application/json
{
"zone": {
"id": "a86dba58-0043-4cc6-a1bb-69d5e86f3ca3",
"pool_id": "572ba08c-d929-4c70-8e42-03824bb24ca2",
"project_id": "4335d1f0-f793-11e2-b778-0800200c9a66",
"name": "example.com.",
"email": "email@example.io",
"ttl": 3600,
"serial": 1404757531,
"status": "ACTIVE",
"description": "This is a slave for example.com."
"masters": ["192.168.27.100"],
"type": "SECONDARY",
"transferred_at": null,
"version": 1,
"created_at": "2014-07-07T18:25:31.275934",
"updated_at": null,
"links": {
"self": "https://127.0.0.1:9001/v2/zones/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3"
}
}
}
Get Zone
--------
.. http:get:: /zones/(uuid:id)
Retrieves a secondary zone with the specified zone ID.
**Example request:**
.. sourcecode:: http
GET /v2/zones/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3 HTTP/1.1
Host: 127.0.0.1:9001
Accept: application/json
Content-Type: application/json
**Example response:**
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"zone": {
"id": "a86dba58-0043-4cc6-a1bb-69d5e86f3ca3",
"pool_id": "572ba08c-d929-4c70-8e42-03824bb24ca2",
"project_id": "4335d1f0-f793-11e2-b778-0800200c9a66",
"name": "example.com.",
"email": "email@example.io",
"ttl": 3600,
"serial": 1404757531,
"status": "ACTIVE",
"description": "This is a slave for example.com."
"masters": ["192.168.27.100"],
"type": "SECONDARY",
"transferred_at": null,
"version": 1,
"created_at": "2014-07-07T18:25:31.275934",
"updated_at": null,
"links": {
"self": "https://127.0.0.1:9001/v2/zones/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3"
}
}
}
:statuscode 200: Success
:statuscode 401: Access Denied
List Secondary Zones
--------------------
.. http:get:: /zones
Lists all zones.
**Example Request:**
.. sourcecode:: http
GET /v2/zones?type=SECONDARY HTTP/1.1
Host: 127.0.0.1:9001
Accept: application/json
Content-Type: application/json
**Example Response:**
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"zones": [{
"id": "a86dba58-0043-4cc6-a1bb-69d5e86f3ca3",
"pool_id": "572ba08c-d929-4c70-8e42-03824bb24ca2",
"project_id": "4335d1f0-f793-11e2-b778-0800200c9a66",
"name": "example.com.",
"email": "email@example.io",
"ttl": 3600,
"serial": 1404757531,
"status": "ACTIVE",
"description": "This is a slave for example.com."
"masters": ["192.168.27.100"],
"type": "SECONDARY",
"transferred_at": null,
"version": 1,
"created_at": "2014-07-07T18:25:31.275934",
"updated_at": null,
"links": {
"self": "https://127.0.0.1:9001/v2/zones/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3"
}
}
}, {
"id": "a86dba58-0043-4cc6-a1bb-69d5e86f3ca4",
"pool_id": "572ba08c-d929-4c70-8e42-03824bb24ca2",
"project_id": "4335d1f0-f793-11e2-b778-0800200c9a66",
"name": "bar.io.",
"email": "email@example.io",
"ttl": 3600,
"serial": 10,
"status": "ACTIVE",
"description": "This is a slave for bar.io."
"masters": ["192.168.27.100"],
"type": "SECONDARY",
"transferred_at": 2014-07-07T18:25:35.275934,
"version": 2,
"created_at": "2014-07-07T18:25:31.275934",
"updated_at": null,
"links": {
"self": "https://127.0.0.1:9001/v2/zones/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3"
}
}],
"links": {
"self": "https://127.0.0.1:9001/v2/zones"
}
}
:statuscode 200: Success
:statuscode 401: Access Denied
Update Zone
-----------
.. http:patch:: /zones/(uuid:id)
Changes the specified attribute(s) for an existing zone.
In the example below, we update the TTL to 3600.
**Request:**
.. sourcecode:: http
PATCH /v2/zones/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3 HTTP/1.1
Host: 127.0.0.1:9001
Accept: application/json
Content-Type: application/json
{
"zone": {
"masters": ["192.168.27.101"]
}
}
**Response:**
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"zone": {
"id": "a86dba58-0043-4cc6-a1bb-69d5e86f3ca3",
"pool_id": "572ba08c-d929-4c70-8e42-03824bb24ca2",
"project_id": "4335d1f0-f793-11e2-b778-0800200c9a66",
"name": "example.com.",
"email": "email@example.io",
"ttl": 3600,
"serial": 1404757531,
"status": "ACTIVE",
"description": "This is a slave for example.com."
"masters": ["192.168.27.101"],
"type": "SECONDARY",
"transferred_at": null,
"version": 2,
"created_at": "2014-07-07T18:25:31.275934",
"updated_at": 2014-07-07T18:25:34.275934,
"links": {
"self": "https://127.0.0.1:9001/v2/zones/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3"
}
}
}
:form description: UTF-8 text field.
:form name: Valid zone name (Immutable).
:form type: Enum PRIMARY/SECONDARY, default PRIMARY (Immutable).
:form email: email address, required for type PRIMARY, NULL for SECONDARY.
:form ttl: time-to-live numeric value in seconds, NULL for SECONDARY
:form masters: Array of master nameservers. (NULL for type PRIMARY, required for SECONDARY otherwise zone will not be transferred before set.)
:statuscode 200: Success
:statuscode 202: Accepted
:statuscode 401: Access Denied
Delete Zone
-----------
.. http:delete:: zones/(uuid:id)
Deletes a zone with the specified zone ID.
**Example Request:**
.. sourcecode:: http
DELETE /v2/zones/a86dba58-0043-4cc6-a1bb-69d5e86f3ca3 HTTP/1.1
Host: 127.0.0.1:9001
Accept: application/json
Content-Type: application/json
**Example Response:**
.. sourcecode:: http
HTTP/1.1 204 No Content
:statuscode 204: No content

View File

@ -63,6 +63,9 @@ Create Zone
"serial": 1404757531,
"status": "ACTIVE",
"description": "This is an example zone.",
"masters": [],
"type": "PRIMARY",
"transferred_at": null,
"version": 1,
"created_at": "2014-07-07T18:25:31.275934",
"updated_at": null,
@ -72,10 +75,12 @@ Create Zone
}
}
:form description: UTF-8 text field
:form email: email address
:form name: zone name
:form ttl: time-to-live numeric value in seconds
:form description: UTF-8 text field.
:form name: Valid zone name (Immutable).
:form type: Enum PRIMARY/SECONDARY, default PRIMARY (Immutable).
:form email: email address, required for type PRIMARY, NULL for SECONDARY.
:form ttl: time-to-live numeric value in seconds, NULL for SECONDARY.
:form masters: Array of master nameservers. (NULL for type PRIMARY, required for SECONDARY otherwise zone will not be transferred before set).
:statuscode 201: Created
:statuscode 202: Accepted
@ -117,6 +122,9 @@ Get Zone
"serial": 1404757531,
"status": "ACTIVE",
"description": "This is an example zone.",
"masters": [],
"type": "PRIMARY",
"transferred_at": null,
"version": 1,
"created_at": "2014-07-07T18:25:31.275934",
"updated_at": null,
@ -165,6 +173,9 @@ List Zones
"serial": 1404757531,
"status": "ACTIVE",
"description": "This is an example zone.",
"masters": [],
"type": "PRIMARY",
"transferred_at": null,
"version": 1,
"created_at": "2014-07-07T18:25:31.275934",
"updated_at": null,
@ -181,6 +192,9 @@ List Zones
"serial": 1404756682,
"status": "ACTIVE",
"description": "This is another example zone.",
"masters": [],
"type": "PRIMARY",
"transferred_at": null,
"version": 1,
"created_at": "2014-07-07T18:22:08.287743",
"updated_at": null,
@ -238,6 +252,9 @@ Update Zone
"serial": 1404760160,
"status": "ACTIVE",
"description": "This is an example zone.",
"masters": [],
"type": "PRIMARY",
"transferred_at": null,
"version": 1,
"created_at": "2014-07-07T18:25:31.275934",
"updated_at": "2014-07-07T19:09:20.876366",
@ -247,10 +264,12 @@ Update Zone
}
}
:form description: UTF-8 text field
:form email: email address
:form name: zone name
:form ttl: time-to-live numeric value in seconds
:form description: UTF-8 text field.
:form name: Valid zone name (Immutable).
:form type: Enum PRIMARY/SECONDARY, default PRIMARY (Immutable).
:form email: email address, required for type PRIMARY, NULL for SECONDARY.
:form ttl: time-to-live numeric value in seconds, NULL for SECONDARY
:form masters: Array of master nameservers. (NULL for type PRIMARY, required for SECONDARY otherwise zone will not be transferred before set.)
:statuscode 200: Success
:statuscode 202: Accepted
@ -328,7 +347,10 @@ Import Zone
"ttl": "42",
"created_at": "2014-07-07T18:25:31.275934",
"updated_at": null,
"version": 1
"version": 1,
"masters": [],
"type": "PRIMARY",
"transferred_at": null
}
}

View File

@ -1,5 +1,7 @@
{
"admin": "role:admin or is_admin:True",
"primary_zone": "target.domain_type:SECONDARY",
"owner": "tenant:%(tenant_id)s",
"admin_or_owner": "rule:admin or rule:owner",
"target": "tenant:%(target_tenant_id)s",
@ -46,6 +48,15 @@
"count_domains": "rule:admin_or_owner",
"touch_domain": "rule:admin_or_owner",
"create_recordset": "('PRIMARY':%(domain_type)s and rule:admin_or_owner) OR ('SECONDARY':%(domain_type)s AND is_admin:True)",
"get_recordsets": "rule:admin_or_owner",
"get_recordset": "rule:admin_or_owner",
"find_recordsets": "rule:admin_or_owner",
"find_recordset": "rule:admin_or_owner",
"update_recordset": "('PRIMARY':%(domain_type)s and rule:admin_or_owner) OR ('SECONDARY':%(domain_type)s AND is_admin:True)",
"delete_recordset": "('PRIMARY':%(domain_type)s and rule:admin_or_owner) OR ('SECONDARY':%(domain_type)s AND is_admin:True)",
"count_recordset": "rule:admin_or_owner",
"create_record": "rule:admin_or_owner",
"get_records": "rule:admin_or_owner",
"get_record": "rule:admin_or_owner",