From 0c34c75dc44876245709987a18ea004607fca815 Mon Sep 17 00:00:00 2001 From: Sudarshan Acharya Date: Fri, 18 May 2012 15:49:12 -0500 Subject: [PATCH] DNS Support for Instance IP. Can be turned off using a flag. Provides a Rackspace DNS driver. Provides an interface to write drivers for other DNS engines. --- etc/reddwarf/reddwarf.conf.sample | 3 + reddwarf/common/exception.py | 5 + reddwarf/common/remote.py | 5 + reddwarf/db/sqlalchemy/mappers.py | 2 + .../migrate_repo/versions/001_base_schema.py | 1 + .../versions/005_rsdns_records.py | 44 ++++ reddwarf/db/sqlalchemy/session.py | 2 + reddwarf/dns/__init__.py | 18 ++ reddwarf/dns/driver.py | 117 ++++++++++ reddwarf/dns/manager.py | 85 +++++++ reddwarf/dns/rsdns/__init__.py | 15 ++ reddwarf/dns/rsdns/driver.py | 221 ++++++++++++++++++ reddwarf/dns/rsdns/models.py | 80 +++++++ reddwarf/instance/models.py | 23 ++ reddwarf/instance/views.py | 1 + rsdns/__init__.py | 22 ++ rsdns/client/__init__.py | 24 ++ rsdns/client/dns_client.py | 122 ++++++++++ rsdns/client/domains.py | 89 +++++++ rsdns/client/exceptions.py | 53 +++++ rsdns/client/future.py | 54 +++++ rsdns/client/records.py | 146 ++++++++++++ 22 files changed, 1132 insertions(+) create mode 100644 reddwarf/db/sqlalchemy/migrate_repo/versions/005_rsdns_records.py create mode 100644 reddwarf/dns/__init__.py create mode 100644 reddwarf/dns/driver.py create mode 100644 reddwarf/dns/manager.py create mode 100644 reddwarf/dns/rsdns/__init__.py create mode 100644 reddwarf/dns/rsdns/driver.py create mode 100644 reddwarf/dns/rsdns/models.py create mode 100644 rsdns/__init__.py create mode 100644 rsdns/client/__init__.py create mode 100644 rsdns/client/dns_client.py create mode 100644 rsdns/client/domains.py create mode 100644 rsdns/client/exceptions.py create mode 100644 rsdns/client/future.py create mode 100644 rsdns/client/records.py diff --git a/etc/reddwarf/reddwarf.conf.sample b/etc/reddwarf/reddwarf.conf.sample index 607bd20f08..cdc30086d7 100644 --- a/etc/reddwarf/reddwarf.conf.sample +++ b/etc/reddwarf/reddwarf.conf.sample @@ -49,6 +49,9 @@ reddwarf_volume_support = True device_path = /dev/vdb mount_point = /var/lib/mysql +# Reddwarf DNS +reddwarf_dns_support = False + # ============ notifer queue kombu connection options ======================== notifier_queue_hostname = localhost diff --git a/reddwarf/common/exception.py b/reddwarf/common/exception.py index c3885f282d..edd8733744 100644 --- a/reddwarf/common/exception.py +++ b/reddwarf/common/exception.py @@ -70,6 +70,11 @@ class ComputeInstanceNotFound(NotFound): message = _("Resource %(instance_id)s can not be retrieved.") + +class DnsRecordNotFound(NotFound): + + message = _("DnsRecord with name= %(name)s not found.") + class OverLimit(ReddwarfError): diff --git a/reddwarf/common/remote.py b/reddwarf/common/remote.py index 8dfa9425ab..70ba0dd0c3 100644 --- a/reddwarf/common/remote.py +++ b/reddwarf/common/remote.py @@ -22,6 +22,11 @@ from novaclient.v1_1.client import Client CONFIG = config.Config +def create_dns_client(context): + from reddwarf.dns.manager import DnsManager + return DnsManager() + + def create_guest_client(context, id): from reddwarf.guestagent.api import API return API(context, id) diff --git a/reddwarf/db/sqlalchemy/mappers.py b/reddwarf/db/sqlalchemy/mappers.py index 3b2511bd6a..62ba76e4a3 100644 --- a/reddwarf/db/sqlalchemy/mappers.py +++ b/reddwarf/db/sqlalchemy/mappers.py @@ -34,6 +34,8 @@ def map(engine, models): Table('service_images', meta, autoload=True)) orm.mapper(models['service_statuses'], Table('service_statuses', meta, autoload=True)) + orm.mapper(models['rsdns_records'], + Table('rsdns_records', meta, autoload=True)) def mapping_exists(model): diff --git a/reddwarf/db/sqlalchemy/migrate_repo/versions/001_base_schema.py b/reddwarf/db/sqlalchemy/migrate_repo/versions/001_base_schema.py index 087ad91908..38c58cfd9c 100644 --- a/reddwarf/db/sqlalchemy/migrate_repo/versions/001_base_schema.py +++ b/reddwarf/db/sqlalchemy/migrate_repo/versions/001_base_schema.py @@ -37,6 +37,7 @@ instances = Table('instances', meta, Column('created', DateTime()), Column('updated', DateTime()), Column('name', String(255)), + Column('hostname', String(255)), Column('compute_instance_id', String(36)), Column('task_id', Integer()), Column('task_description', String(32)), diff --git a/reddwarf/db/sqlalchemy/migrate_repo/versions/005_rsdns_records.py b/reddwarf/db/sqlalchemy/migrate_repo/versions/005_rsdns_records.py new file mode 100644 index 0000000000..959425c4b2 --- /dev/null +++ b/reddwarf/db/sqlalchemy/migrate_repo/versions/005_rsdns_records.py @@ -0,0 +1,44 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# 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 sqlalchemy import ForeignKey +from sqlalchemy.schema import Column +from sqlalchemy.schema import MetaData +from sqlalchemy.schema import UniqueConstraint + +from reddwarf.db.sqlalchemy.migrate_repo.schema import Table +from reddwarf.db.sqlalchemy.migrate_repo.schema import create_tables +from reddwarf.db.sqlalchemy.migrate_repo.schema import drop_tables +from reddwarf.db.sqlalchemy.migrate_repo.schema import String + + +meta = MetaData() + + +rsdns_records = Table('rsdns_records', meta, + Column('name', String(length=255), primary_key=True), + Column('record_id', String(length=64))) + + +def upgrade(migrate_engine): + meta.bind = migrate_engine + create_tables([rsdns_records]) + + +def downgrade(migrate_engine): + meta.bind = migrate_engine + drop_tables([rsdns_records]) diff --git a/reddwarf/db/sqlalchemy/session.py b/reddwarf/db/sqlalchemy/session.py index dcc93c12c0..a2f850d48d 100644 --- a/reddwarf/db/sqlalchemy/session.py +++ b/reddwarf/db/sqlalchemy/session.py @@ -40,10 +40,12 @@ def configure_db(options, models_mapper=None): models_mapper.map(_ENGINE) else: from reddwarf.instance import models as base_models + from reddwarf.dns.rsdns import models as dns_models from reddwarf.extensions.mysql import models as mysql_models model_modules = [ base_models, + dns_models, mysql_models, ] diff --git a/reddwarf/dns/__init__.py b/reddwarf/dns/__init__.py new file mode 100644 index 0000000000..7d7be18132 --- /dev/null +++ b/reddwarf/dns/__init__.py @@ -0,0 +1,18 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 Openstack, LLC. +# All Rights Reserved. +# +# 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 manager import DnsManager diff --git a/reddwarf/dns/driver.py b/reddwarf/dns/driver.py new file mode 100644 index 0000000000..cc504d388f --- /dev/null +++ b/reddwarf/dns/driver.py @@ -0,0 +1,117 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 Openstack, LLC. +# All Rights Reserved. +# +# 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. + +""" +Dns Driver base class that all DNS drivers should inherit from +""" + + +class DnsDriver(object): + """The base class that all Dns drivers should inherit from.""" + + def __init__(self): + pass + + def create_entry(self, entry): + """Creates the entry in the driver at the given dns zone.""" + pass + + def delete_entry(self, name, type, dns_zone=None): + """Deletes an entry with the given name and type from a dns zone.""" + pass + + def get_entries_by_content(self, content, dns_zone=None): + """Retrieves all entries in a dns_zone with a matching content field""" + pass + + def get_entries_by_name(self, name, dns_zone=None): + """Retrieves all entries in a dns zone with the given name field.""" + pass + + def get_dns_zones(self, name=None): + """Returns all dns zones (optionally filtered by the name argument.""" + pass + + def modify_content(self, name, content, dns_zone): + #TODO(tim.simpson) I've found no use for this in RS impl of DNS w/ + # instances. Check to see its really needed. + pass + + def rename_entry(self, content, name, dns_zone): + #TODO(tim.simpson) I've found no use for this in RS impl of DNS w/ + # instances. Check to see its really needed. + pass + + +class DnsInstanceEntryFactory(object): + """Defines how instance DNS entries are created for instances. + + By default, the DNS entry returns None meaning instances do not get entries + associated with them. Override the create_entry method to change this + behavior. + + """ + + def create_entry(self, instance): + return None + + +class DnsSimpleInstanceEntryFactory(object): + """Creates a CNAME with the name being the instance name.""" + + def create_entry(self, instance): + return DnsEntry(name=instance.name, content=None, type="CNAME") + + +class DnsEntry(object): + """Simple representation of a DNS record.""" + + def __init__(self, name, content, type, ttl=None, priority=None, + dns_zone=None): + self.content = content + self.name = name + self.type = type + self.priority = priority + self.dns_zone = dns_zone + self.ttl = ttl + + def __repr__(self): + return 'DnsEntry(name="%s", content="%s", type="%s", ' \ + 'ttl=%s, priority=%s, dns_zone=%s)' % (self.name, self.content, + self.type, self.ttl, self.priority, self.dns_zone) + + def __str__(self): + return "{ name:%s, content:%s, type:%s, zone:%s }" % \ + (self.name, self.content, self.type, self.dns_zone) + + +class DnsZone(object): + """Represents a DNS Zone. + + For some APIs it is inefficient to simply represent a zone as a string + because this would necessitate a look up on every call. So this opaque + object can contain additional data needed by the DNS driver. The only + constant is it must contain the domain name of the zone. + + """ + + @property + def name(self): + return "" + + def __str__(self): + return self.name diff --git a/reddwarf/dns/manager.py b/reddwarf/dns/manager.py new file mode 100644 index 0000000000..2fb7138ca7 --- /dev/null +++ b/reddwarf/dns/manager.py @@ -0,0 +1,85 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 Openstack, LLC. +# All Rights Reserved. +# +# 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. + +""" +Dns manager. +""" +import logging + +from reddwarf.common import utils +from reddwarf.common import config +from reddwarf.dns.rsdns.driver import RsDnsInstanceEntryFactory + +LOG = logging.getLogger(__name__) + + +class DnsManager(object): + """Handles associating DNS to and from IPs.""" + + def __init__(self, dns_driver=None, dns_instance_entry_factory=None, + *args, **kwargs): + if not dns_driver: + dns_driver = config.Config.get("dns_driver", + "reddwarf.dns.driver.DnsDriver") + dns_driver = utils.import_object(dns_driver) + self.driver = dns_driver() + + if not dns_instance_entry_factory: + dns_instance_entry_factory = config.Config.get( + 'dns_instance_entry_factory', + 'reddwarf.dns.driver.DnsInstanceEntryFactory') + entry_factory = utils.import_object(dns_instance_entry_factory) + self.entry_factory = entry_factory() + + def create_instance_entry(self, instance_id, content): + """Connects a new instance with a DNS entry. + + :param instance_id: The reddwarf instance_id to associate. + :param content: The IP content attached to the instance. + + """ + entry = self.entry_factory.create_entry(instance_id) + LOG.debug("Creating entry address %s." % str(entry)) + if entry: + entry.content = content[0] + self.driver.create_entry(entry) + + def delete_instance_entry(self, instance_id, content=None): + """Removes a DNS entry associated to an instance. + + :param instance_id: The reddwarf instance id to associate. + :param content: The IP content attached to the instance. + + """ + entry = self.entry_factory.create_entry(instance_id) + LOG.debug("Deleting instance entry with %s" % str(entry)) + if entry: + self.driver.delete_entry(entry.name, entry.type) + + def update_hostname(self, instance): + """ + Create the hostname field based on the instance id. + Use instance by default + """ + dns_support = config.Config.get('reddwarf_dns_support', 'False') + if utils.bool_from_string(dns_support): + entry = self.entry_factory.create_entry(instance.id) + instance.hostname = entry.name + instance.save() + else: + instance.hostname = instance.name + instance.save() diff --git a/reddwarf/dns/rsdns/__init__.py b/reddwarf/dns/rsdns/__init__.py new file mode 100644 index 0000000000..90d37c5bff --- /dev/null +++ b/reddwarf/dns/rsdns/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2010 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# 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. diff --git a/reddwarf/dns/rsdns/driver.py b/reddwarf/dns/rsdns/driver.py new file mode 100644 index 0000000000..a6114cbac6 --- /dev/null +++ b/reddwarf/dns/rsdns/driver.py @@ -0,0 +1,221 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 Openstack, LLC. +# All Rights Reserved. +# +# 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. + +""" +Dns Driver that uses Rackspace DNSaaS. +""" + +import hashlib + +import logging +from reddwarf.common import config +from reddwarf.common import exception +from reddwarf.common.exception import NotFound +from reddwarf.dns.rsdns.models import RsDnsRecord +from rsdns.client import DNSaas +from rsdns.client.future import RsDnsError + +from reddwarf.dns.driver import DnsEntry + +DNS_HOSTNAME = config.Config.get("dns_hostname", "") +DNS_ACCOUNT_ID = config.Config.get("dns_account_id", 0) +DNS_AUTH_URL = config.Config.get("dns_auth_url", "") +DNS_DOMAIN_NAME = config.Config.get("dns_domain_name", "") +DNS_USERNAME = config.Config.get("dns_username", "") +DNS_PASSKEY = config.Config.get("dns_passkey", "") +DNS_MANAGEMENT_BASE_URL = config.Config.get("dns_management_base_url", "") +DNS_TTL = config.Config.get("dns_ttl", 300) +DNS_DOMAIN_ID = config.Config.get("dns_domain_id", 1) + + +LOG = logging.getLogger(__name__) + + +class EntryToRecordConverter(object): + + def __init__(self, default_dns_zone): + self.default_dns_zone = default_dns_zone + + def domain_to_dns_zone(self, domain): + return RsDnsZone(id=domain.id, name=domain.name) + + def name_to_long_name(self, name, dns_zone=None): + dns_zone = dns_zone or self.default_dns_zone + if name: + long_name = name + "." + dns_zone.name + else: + long_name = "" + return long_name + + def record_to_entry(self, record, dns_zone): + entry_name = record.name + return DnsEntry(name=entry_name, content=record.data, type=record.type, + ttl=record.ttl, dns_zone=dns_zone) + + +def create_client_with_flag_values(): + """Creates a RS DNSaaS client using the Flag values.""" + if DNS_MANAGEMENT_BASE_URL == None: + raise RuntimeError("Missing flag value for dns_management_base_url.") + return DNSaas(DNS_ACCOUNT_ID, DNS_USERNAME, DNS_PASSKEY, + auth_url=DNS_AUTH_URL, + management_base_url=DNS_MANAGEMENT_BASE_URL) + + +def find_default_zone(dns_client, raise_if_zone_missing=True): + """Using the domain_name from the FLAG values, creates a zone. + + Because RS DNSaaS needs the ID, we need to find this value before we start. + In testing it's difficult to keep up with it because the database keeps + getting wiped... maybe later we could go back to storing it as a FLAG value + + """ + domain_name = DNS_DOMAIN_NAME + try: + domains = dns_client.domains.list(name=domain_name) + for domain in domains: + if domain.name == domain_name: + return RsDnsZone(id=domain.id, name=domain_name) + except NotFound: + pass + if not raise_if_zone_missing: + return RsDnsZone(id=None, name=domain_name) + raise RuntimeError("The dns_domain_name from the FLAG values (%s) " + "does not exist! account_id=%s, username=%s, LIST=%s" + % (domain_name, DNS_ACCOUNT_ID, DNS_USERNAME, domains)) + + +class RsDnsDriver(object): + """Uses RS DNSaaS""" + + def __init__(self, raise_if_zone_missing=True): + self.dns_client = create_client_with_flag_values() + self.dns_client.authenticate() + self.default_dns_zone = RsDnsZone(id=DNS_DOMAIN_ID, + name=DNS_DOMAIN_NAME) + self.converter = EntryToRecordConverter(self.default_dns_zone) + if DNS_TTL < 300: + raise Exception("TTL value '--dns_ttl=%s' should be greater than"\ + " 300" % DNS_TTL) + + def create_entry(self, entry): + dns_zone = entry.dns_zone or self.default_dns_zone + if dns_zone.id == None: + raise TypeError("The entry's dns_zone must have an ID specified.") + name = entry.name # + "." + dns_zone.name + LOG.debug("Going to create RSDNS entry %s." % name) + try: + future = self.dns_client.records.create(domain=dns_zone.id, + record_name=name, + record_data=entry.content, + record_type=entry.type, + record_ttl=entry.ttl) + try: + #poll_until(lambda : future.ready, sleep_time=2, + # time_out=60*2) + while(future.ready is None): + import time + time.sleep(2) + + if len(future.resource) < 1: + raise RsDnsError("No DNS records were created.") + elif len(future.resource) > 1: + LOG.error("More than one DNS record created. Ignoring.") + actual_record = future.resource[0] + RsDnsRecord.create(name=name, record_id=actual_record.id) + LOG.debug("Added RS DNS entry.") + except RsDnsError as rde: + LOG.error("An error occurred creating DNS entry!") + raise + except Exception as ex: + LOG.error("Error when creating a DNS record!") + raise + + def delete_entry(self, name, type, dns_zone=None): + dns_zone = dns_zone or self.default_dns_zone + long_name = name + db_record = RsDnsRecord.find_by(name=name) + record = self.dns_client.records.get(domain_id=dns_zone.id, + record_id=db_record.id) + if record.name != name or record.type != 'A': + LOG.error("Tried to delete DNS record with name=%s, id=%s, but the" + " database returned a DNS record with the name %s and " + "type %s." % (name, db_record.id, record.name, + record.type)) + raise exception.DnsRecordNotFound(name) + self.dns_client.records.delete(domain_id=dns_zone.id, + record_id=record.id) + db_record.delete() + + def get_entries(self, name=None, content=None, dns_zone=None): + dns_zone = dns_zone or self.defaucreate_entrylt_dns_zone + long_name = name # self.converter.name_to_long_name(name) + records = self.dns_client.records.list(domain_id=dns_zone.id, + record_name=long_name, + record_address=content) + return [self.converter.record_to_entry(record, dns_zone) + for record in records] + + def get_entries_by_content(self, content, dns_zone=None): + return self.get_entries(content=content) + + def get_entries_by_name(self, name, dns_zone=None): + return self.get_entries(name=name, dns_zone=dns_zone) + + def get_dns_zones(self, name=None): + domains = self.dns_client.domains.list(name=name) + return [self.converter.domain_to_dns_zone(domain) + for domain in domains] + + def modify_content(self, *args, **kwargs): + raise NotImplementedError("Not implemented for RS DNS.") + + def rename_entry(self, *args, **kwargs): + raise NotImplementedError("Not implemented for RS DNS.") + + +class RsDnsInstanceEntryFactory(object): + """Defines how instance DNS entries are created for instances.""" + + def __init__(self, dns_domain_id=None): + dns_domain_id = dns_domain_id or DNS_DOMAIN_ID + self.default_dns_zone = RsDnsZone(id=dns_domain_id, + name=DNS_DOMAIN_NAME) + + def create_entry(self, instance_id): + id = instance_id + hostname = ("%s.%s" % (hashlib.sha1(id).hexdigest(), + self.default_dns_zone.name)) + return DnsEntry(name=hostname, content=None, type="A", + ttl=DNS_TTL, dns_zone=self.default_dns_zone) + + +class RsDnsZone(object): + + def __init__(self, id, name): + self.name = name + self.id = id + + def __eq__(self, other): + return isinstance(other, RsDnsZone) and\ + self.name == other.name and self.id == other.id + + def __str__(self): + return "%s:%s" % (self.id, self.name) + + + diff --git a/reddwarf/dns/rsdns/models.py b/reddwarf/dns/rsdns/models.py new file mode 100644 index 0000000000..60e9dd4573 --- /dev/null +++ b/reddwarf/dns/rsdns/models.py @@ -0,0 +1,80 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2010-2011 OpenStack LLC. +# All Rights Reserved. +# +# 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. + +""" +Model classes that map instance Ip to dns record. +""" + +import logging + +from reddwarf import db +from reddwarf.common.models import ModelBase +from reddwarf.instance.models import InvalidModelError +from reddwarf.instance.models import ModelNotFoundError + +LOG = logging.getLogger(__name__) + + +def persisted_models(): + return { + 'rsdns_records': RsDnsRecord, + } + + +class RsDnsRecord(ModelBase): + + _data_fields = ['name', 'record_id'] + _table_name = 'rsdns_records' + + def __init__(self, name, record_id): + self.name = name + self.record_id = record_id + + @classmethod + def create(cls, **values): + record = cls(**values).save() + if not record.is_valid(): + raise InvalidModelError(record.errors) + return record + + def save(self): + if not self.is_valid(): + raise InvalidModelError(self.errors) + LOG.debug(_("Saving %s: %s") % + (self.__class__.__name__, self.__dict__)) + return db.db_api.save(self) + + def delete(self): + LOG.debug(_("Deleting %s: %s") % + (self.__class__.__name__, self.__dict__)) + return db.db_api.delete(self) + + @classmethod + def find_by(cls, **conditions): + model = cls.get_by(**conditions) + if model is None: + raise ModelNotFoundError(_("%s Not Found") % cls.__name__) + return model + + @classmethod + def get_by(cls, **kwargs): + return db.db_api.find_by(cls, **cls._process_conditions(kwargs)) + + @classmethod + def _process_conditions(cls, raw_conditions): + """Override in inheritors to format/modify any conditions.""" + return raw_conditions diff --git a/reddwarf/instance/models.py b/reddwarf/instance/models.py index cc4f5bef93..cdf2dc6100 100644 --- a/reddwarf/instance/models.py +++ b/reddwarf/instance/models.py @@ -31,12 +31,14 @@ from reddwarf.instance.tasks import InstanceTask from reddwarf.instance.tasks import InstanceTasks from reddwarf.common.models import ModelBase from novaclient import exceptions as nova_exceptions +from reddwarf.common.remote import create_dns_client from reddwarf.common.remote import create_nova_client from reddwarf.common.remote import create_nova_volume_client from reddwarf.common.remote import create_guest_client from eventlet import greenthread +from reddwarf.instance.views import get_ip_address CONFIG = config.Config @@ -188,6 +190,12 @@ class Instance(object): #TODO(tim.simpson): Put this in the task manager somehow to shepard # deletion? + dns_support = config.Config.get("reddwarf_dns_support", 'False') + LOG.debug(_("reddwarf dns support = %s") % dns_support) + if utils.bool_from_string(dns_support): + dns_client = create_dns_client(self.context) + dns_client.delete_instance_entry(instance_id=self.db_info['id']) + def _delete_server(self): try: self.server.delete() @@ -299,6 +307,21 @@ class Instance(object): guest.prepare(512, model_schemas, users=[], device_path=device_path, mount_point=mount_point) + + dns_support = config.Config.get("reddwarf_dns_support", 'False') + LOG.debug(_("reddwarf dns support = %s") % dns_support) + if utils.bool_from_string(dns_support): + #TODO: Bring back our good friend poll_until. + while(server.addresses == {}): + import time + time.sleep(1) + server = client.servers.get(server.id) + LOG.debug("Waiting for address %s" % server.addresses) + + dns_client = create_dns_client(context) + dns_client.update_hostname(db_info) + dns_client.create_instance_entry(db_info['id'], + get_ip_address(server.addresses)) return Instance(context, db_info, server, service_status, volumes) def get_guest(self): diff --git a/reddwarf/instance/views.py b/reddwarf/instance/views.py index 150d9bcead..03c612a762 100644 --- a/reddwarf/instance/views.py +++ b/reddwarf/instance/views.py @@ -45,6 +45,7 @@ class InstanceView(object): instance_dict = { "id": self.instance.id, "name": self.instance.name, + "hostname": self.instance.db_info.hostname, "status": self.instance.status, "links": self.instance.links } diff --git a/rsdns/__init__.py b/rsdns/__init__.py new file mode 100644 index 0000000000..0c23e99988 --- /dev/null +++ b/rsdns/__init__.py @@ -0,0 +1,22 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright (c) 2011 Openstack, LLC. +# All Rights Reserved. +# +# 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. + +""" +Dns Driver that uses Rackspace DNSaaS. +""" + +__all__ = ["client"] diff --git a/rsdns/client/__init__.py b/rsdns/client/__init__.py new file mode 100644 index 0000000000..60285e745e --- /dev/null +++ b/rsdns/client/__init__.py @@ -0,0 +1,24 @@ +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# 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. + +""" +dnsclient module. +""" + +from rsdns.client.dns_client import DNSaas +from rsdns.client.dns_client import DNSaasClient +from rsdns.client.domains import DomainsManager +from rsdns.client.records import RecordsManager + diff --git a/rsdns/client/dns_client.py b/rsdns/client/dns_client.py new file mode 100644 index 0000000000..e86cfbd906 --- /dev/null +++ b/rsdns/client/dns_client.py @@ -0,0 +1,122 @@ +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# 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. + +""" +DNS Client interface. Child of OpenStack client to handle auth issues. +We have to duplicate a lot of code from the OpenStack client since so much +is different here. +""" + +import logging + +import exceptions + +try: + import json +except ImportError: + import simplejson as json + + +from novaclient.client import HTTPClient +from novaclient.v1_1.client import Client + +LOG = logging.getLogger('rsdns.client.dns_client') + + +class DNSaasClient(HTTPClient): + + def __init__(self, accountId, user, apikey, auth_url, management_base_url): + tenant = "dbaas" + super(DNSaasClient, self).__init__(user, apikey, tenant, auth_url) + self.accountId = accountId + self.management_base_url = management_base_url + self.api_key = apikey + self.disable_ssl_certificate_validation = True + self.service = "cloudDNS" + + def authenticate(self): + """Set the management url and auth token""" + req_body = {'credentials':{'username':self.user, 'key':self.api_key}} + resp, body = self.request(self.auth_url, "POST", body=req_body) + if 'access' in body: + if not self.management_url: + # Assume the new Keystone lite: + catalog = body['access']['serviceCatalog'] + for service in catalog: + if service['name'] == self.service: + self.management_url = service['adminURL'] + self.auth_token = body['access']['token']['id'] + else: + # Assume pre-Keystone Light: + try: + if not self.management_url: + keys = ['auth', + 'serviceCatalog', + self.service, + 0, + 'publicURL'] + url = body + for key in keys: + url = url[key] + self.management_url = url + self.auth_token = body['auth']['token']['id'] + except KeyError: + raise NotImplementedError("Service: %s is not available" + % self.service) + + def request(self, *args, **kwargs): + kwargs.setdefault('headers', kwargs.get('headers', {})) + kwargs['headers']['User-Agent'] = self.USER_AGENT + kwargs['headers']['Accept'] = 'application/json' + if 'body' in kwargs: + kwargs['headers']['Content-Type'] = 'application/json' + kwargs['body'] = json.dumps(kwargs['body']) + LOG.debug("REQ HEADERS:" + str(kwargs['headers'])) + LOG.debug("REQ BODY:" + str(kwargs['body'])) + + resp, body = super(HTTPClient, self).request(*args, **kwargs) + + self.http_log(args, kwargs, resp, body) + + if body: + try: + body = json.loads(body) + except ValueError: + pass + else: + body = None + + if resp.status in (400, 401, 403, 404, 408, 409, 413, 500, 501): + raise exceptions.from_response(resp, body) + + return resp, body + +class DNSaas(Client): + """ + Top-level object to access the DNSaas service + """ + + def __init__(self, accountId, username, apikey, + auth_url='https://auth.api.rackspacecloud.com/v1.0', + management_base_url=None): + from rsdns.client.dns_client import DNSaasClient + from rsdns.client.domains import DomainsManager + from rsdns.client.records import RecordsManager + + super(DNSaas, self).__init__(self, accountId, username, apikey, auth_url, management_base_url) + self.client = DNSaasClient(accountId, username, apikey, auth_url, + management_base_url) + self.domains = DomainsManager(self) + self.records = RecordsManager(self) \ No newline at end of file diff --git a/rsdns/client/domains.py b/rsdns/client/domains.py new file mode 100644 index 0000000000..ea26cd5f63 --- /dev/null +++ b/rsdns/client/domains.py @@ -0,0 +1,89 @@ +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# 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. + +""" +Domains interface. +""" + +from novaclient import base + +import os +from rsdns.client.future import FutureResource + + +class Domain(base.Resource): + """ + A Domain has a name and stores records. In the API they are id'd by ints. + """ + + def response_list_name(self): + return "domains" + +class FutureDomain(FutureResource): + + def convert_callback(self, resp, body): + return Domain(self.manager, body) + + def response_list_name(self): + return "domains" + + +class DomainsManager(base.ManagerWithFind): + """ + Manage :class:`Domain` resources. + """ + resource_class = Domain + + def create(self, name): + """Not implemented / needed yet.""" + if os.environ.get("ADD_DOMAINS", "False") == 'True': + accountId = self.api.client.accountId + data = {"domains": + [ + {"name": name, + "ttl":"5600", + "emailAddress":"dbaas_dns@rackspace.com", + } + ] + } + resp, body = self.api.client.post("/domains", body=data) + if resp.status == 202: + return FutureDomain(self, **body) + raise RuntimeError("Did not expect response " + str(resp.status)) + else: + raise NotImplementedError("No need for create.") + + def create_from_list(self, list): + return [self.resource_class(self, res) for res in list] + + def delete(self, *args, **kwargs): + """Not implemented / needed yet.""" + raise NotImplementedError("No need for create.") + + def list(self, name=None): + """ + Get a list of all domains. + + :rtype: list of :class:`Domain` + """ + url = "/domains" + if name: + url += "?name=" + name + resp, body = self.api.client.get(url) + try: + list = body['domains'] + except KeyError: + raise RuntimeError('Body was missing "domains" key.') + return self.create_from_list(list) diff --git a/rsdns/client/exceptions.py b/rsdns/client/exceptions.py new file mode 100644 index 0000000000..8f0d8f1521 --- /dev/null +++ b/rsdns/client/exceptions.py @@ -0,0 +1,53 @@ +# Copyright 2011 OpenStack LLC +# +# 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 novaclient import exceptions + + +class UnprocessableEntity(exceptions.ClientException): + """ + HTTP 422 - Unprocessable Entity: The request cannot be processed. + """ + http_status = 422 + message = "Unprocessable Entity" + + +_code_map = dict((c.http_status, c) for c in [UnprocessableEntity]) + + +def from_response(response, body): + """ + Return an instance of an ClientException or subclass + based on an httplib2 response. + + Usage:: + + resp, body = http.request(...) + if resp.status != 200: + raise exception_from_response(resp, body) + """ + cls = _code_map.get(response.status, None) + if not cls: + cls = exceptions._code_map.get(response.status, + exceptions.ClientException) + if body: + message = "n/a" + details = "n/a" + if hasattr(body, 'keys'): + error = body[body.keys()[0]] + message = error.get('message', None) + details = error.get('details', None) + return cls(code=response.status, message=message, details=details) + else: + return cls(code=response.status) diff --git a/rsdns/client/future.py b/rsdns/client/future.py new file mode 100644 index 0000000000..5d4e85f4f2 --- /dev/null +++ b/rsdns/client/future.py @@ -0,0 +1,54 @@ + +class RsDnsError(RuntimeError): + + def __init__(self, error): + self.error_msg = "" + try: + for message in error['validationErrors']['messages']: + self.error_msg += message + except KeyError: + self.error_msg += "... (did not understand the RsDNS response)." + super(RsDnsError, self).__init__(self.error_msg) + + def __str__(self): + return self.message + +class FutureResource(object): + """Polls a callback url to return a resource.""" + + def __init__(self, manager, jobId, callbackUrl, status, **kwargs): + self.manager = manager + self.jobId = jobId + self.callbackUrl = unicode(callbackUrl) + self.result = None + management_url = unicode(self.manager.api.client.management_url) + if self.callbackUrl.startswith(management_url): + self.callbackUrl = self.callbackUrl[len(management_url):] + + def call_callback(self): + return self.manager.api.client.get(self.callbackUrl + + "?showDetails=true") + + def poll(self): + if not self.result: + resp, body = self.call_callback() + if resp.status == 202: + return None + if resp.status == 200: + if body['status'] == 'ERROR': + raise RsDnsError(body['error']) + elif body['status'] != 'COMPLETED': + return None + resp_list = body['response'][self.response_list_name()] + self.result = self.manager.create_from_list(resp_list) + #self.resource_class(self, res) for res in list] + #self.result = Domain(self.manager, body['self.convert_callback(resp, body) + return self.result + + @property + def ready(self): + return (self.result or self.poll()) is not None + + @property + def resource(self): + return self.result or self.poll() diff --git a/rsdns/client/records.py b/rsdns/client/records.py new file mode 100644 index 0000000000..df90170b57 --- /dev/null +++ b/rsdns/client/records.py @@ -0,0 +1,146 @@ +# Copyright 2011 OpenStack LLC. +# All Rights Reserved. +# +# 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. + +""" +Records interface. +""" + +import urlparse + +from novaclient import base +from rsdns.client.future import FutureResource + + +class FutureRecord(FutureResource): + + def convert_callback(self, resp, body): + try: + list = body['records'] + except NameError: + raise RuntimeError('Body was missing "records" or "record" key.') + if len(list) != 1: + raise RuntimeError('Return result had ' + str(len(list)) + + 'records, not 1.') + return Record(self, list[0]) + + def response_list_name(self): + return "records" + + +class Record(base.Resource): + """ + A Record is a individual dns record (Cname, A, MX, etc..) + """ + + pass + + +class RecordsManager(base.ManagerWithFind): + """ + Manage :class:`Record` resources. + """ + resource_class = Record + + def create(self, domain, record_name, record_data, record_type, record_ttl): + """ + Create a new Record on the given domain + + :param domain: The ID of the :class:`Domain` to get. + :param record: The ID of the :class:`Record` to get. + :rtype: :class:`Record` + """ + data = {"records":[{"type": record_type, "name": record_name, + "data": record_data, "ttl": record_ttl }]} + resp, body = self.api.client.post("/domains/%s/records" % \ + base.getid(domain), body=data) + if resp.status == 202: + return FutureRecord(self, **body) + raise RuntimeError("Did not expect response when creating a DNS record " + "%s" % str(resp.status)) + + def create_from_list(self, list): + return [self.resource_class(self, res) for res in list] + + def delete(self, domain_id, record_id): + self._delete("/domains/%s/records/%s" % (domain_id, record_id)) + + def match_record(self, record, name=None, address=None, type=None): + assert(isinstance(record, Record)) + return (not name or record.name == name) and \ + (not address or record.data == address) and \ + (not type or record.type == type) + + def get(self, domain_id, record_id): + """ + Get a single record by id. + + :rtype: Single instance of :class:`Record` + """ + url = "/domains/%s/records" % domain_id + if record_id: + url += ("/%s" % record_id) + resp, body = self.api.client.get(url) + try: + item = body + except IndexError: + raise RuntimeError('Body was missing record element.') + return self.resource_class(self, item) + + def list(self, domain_id, record_id=None, record_name=None, + record_address=None, record_type=None): + """ + Get a list of all records under a domain. + + :rtype: list of :class:`Record` + """ + url = "/domains/%s/records" % domain_id + if record_id: + url += ("/%s" % record_id) + offset = 0 + list = [] + while offset is not None: + next_url = "%s?offset=%d" % (url, offset) + partial_list, offset = self.page_list(next_url) + list += partial_list + all_records = self.create_from_list(list) + return [record for record in all_records + if self.match_record(record, record_name, record_address, + record_type)] + + def page_list(self, url): + """ + Given a URL and an offset, returns a tuple containing a list and the + next URL. + """ + resp, body = self.api.client.get(url) + try: + list = body['records'] + except NameError: + raise RuntimeError('Body was missing "records" or "record" key.') + next_offset = None + links = body.get('links', []) + for link in links: + if link['rel'] == 'next': + next = link['href'] + params = urlparse.parse_qs(urlparse.urlparse(next).query) + offset_list = params.get('offset', []) + if len(offset_list) == 1: + next_offset = int(offset_list[0]) + elif len(offset_list) == 0: + next_offset = None + else: + raise RuntimeError("Next href had multiple offset params!") + return (list, next_offset) +