diff --git a/poppy/dns/rackspace/services.py b/poppy/dns/rackspace/services.py index 57e0e3e0..b7b3ecc7 100644 --- a/poppy/dns/rackspace/services.py +++ b/poppy/dns/rackspace/services.py @@ -68,10 +68,8 @@ class ServicesController(base.ServicesBase): # ex. cdnXXX.altcdn.com subdomain_name = '{0}{1}.{2}'.format(shard_prefix, shard_id, cdn_domain_name) - subdomain = self._get_subdomain(subdomain_name) # create CNAME record for adding cname_records = [] - dns_links = {} shared_ssl_subdomain_name = None @@ -85,40 +83,13 @@ class ServicesController(base.ServicesBase): name = domain_name else: if old_operator_url is not None: - # verify sub-domain exists - regex_match = re.match( - r'^.*(' + shard_prefix + '[0-9]+\.' + - re.escape(cdn_domain_name) + ')$', - old_operator_url + created_dns_links = self._create_preferred_cname_record( + domain_name, + certificate, + old_operator_url, + links[link] ) - my_sub_domain_name = regex_match.groups(-1)[0] - if my_sub_domain_name is None: - raise ValueError('Unable to parse old provider url') - - # add to cname record - my_sub_domain = self._get_subdomain(my_sub_domain_name) - LOG.info( - "Updating DNS Record for HTTPS upgrade " - "domain {0}. CNAME update from {1} to {2}".format( - my_sub_domain_name, - old_operator_url, - links[link] - ) - ) - - old_dns_record = my_sub_domain.find_record( - 'CNAME', - old_operator_url - ) - my_sub_domain.update_record( - old_dns_record, - data=links[link] - ) - - dns_links[link] = { - 'provider_url': links[link], - 'operator_url': old_operator_url - } + dns_links.update(created_dns_links) continue else: name = '{0}.{1}'.format(domain_name, subdomain_name) @@ -140,11 +111,71 @@ class ServicesController(base.ServicesBase): else: cname_records.append(cname_record) # add the cname records - if cname_records != []: + if len(cname_records) > 0: + subdomain = self._get_subdomain(subdomain_name) LOG.info("Creating DNS Record - {0}".format(cname_records)) subdomain.add_records(cname_records) return dns_links + def _create_preferred_cname_record( + self, domain_name, certificate, operator_url, provider_url): + """Creates a CNAME chain with designated operator_url + + :param domain_name: domain name that CNAME chain is created for + :param certificate: certificate type + :operator_url: The preferred operator url + :provider_url: provider url + :return dns_links: Map from provider access URL to DNS access URL + """ + + shard_prefix = self._driver.rackdns_conf.shard_prefix + cdn_domain_name = self._driver.rackdns_conf.url + + dns_links = {} + + # verify sub-domain exists + regex_match = re.match( + r'^.*(' + shard_prefix + '[0-9]+\.' + + re.escape(cdn_domain_name) + ')$', + operator_url + ) + my_sub_domain_name = regex_match.groups(-1)[0] + + if my_sub_domain_name is None: + raise ValueError('Unable to parse old operator url') + + # add to cname record + my_sub_domain = self._get_subdomain(my_sub_domain_name) + LOG.info( + "Updating dns record {0}. " + "CNAME create/update from {1} to {2}".format( + my_sub_domain_name, + operator_url, + provider_url + ) + ) + + try: + old_dns_record = my_sub_domain.find_record('CNAME', operator_url) + except exc.DomainRecordNotFound: + my_sub_domain.add_records( + [{ + 'type': 'CNAME', + 'name': operator_url, + 'data': provider_url, + 'ttl': 300 + }] + ) + else: + my_sub_domain.update_record(old_dns_record, data=provider_url) + + dns_links[(domain_name, certificate, operator_url)] = { + 'provider_url': provider_url, + 'operator_url': operator_url + } + + return dns_links + def _search_cname_record(self, access_url, shared_ssl_flag): """Search a CNAME record @@ -158,7 +189,7 @@ class ServicesController(base.ServicesBase): suffix = self._driver.rackdns_conf.shared_ssl_domain_suffix else: suffix = self._driver.rackdns_conf.url - # Note: use rindex to find last occurence of the suffix + # Note: use rindex to find last occurrence of the suffix shard_name = access_url[:access_url.rindex(suffix)-1].split('.')[-1] subdomain_name = '.'.join([shard_name, suffix]) @@ -184,6 +215,8 @@ class ServicesController(base.ServicesBase): :param shared_ssl_flag: flag indicating if this is a shared ssl domain :return error_msg: returns error message, if any """ + LOG.info('Attempting to delete DNS records for : {0}'.format( + access_url)) records = self._search_cname_record(access_url, shared_ssl_flag) # delete the record diff --git a/poppy/manager/default/services.py b/poppy/manager/default/services.py index b50dd8d4..ff74d9e7 100644 --- a/poppy/manager/default/services.py +++ b/poppy/manager/default/services.py @@ -812,3 +812,81 @@ class DefaultServicesController(base.ServicesController): is_upgrade = True break return is_upgrade + + def update_access_url_service( + self, project_id, service_id, access_url_changes): + try: + service_old = self.storage_controller.get_service( + project_id, + service_id + ) + except ValueError as e: + # If service is not found + LOG.warning('Get service {0} failed. ' + 'Error message: {1}'.format(service_id, e)) + raise errors.ServiceNotFound(e) + + updated_details = False + provider_details = service_old.provider_details + domain_name = access_url_changes.get('domain_name') + for provider in provider_details: + for access_url in provider_details[provider].access_urls: + if access_url.get('domain') == domain_name: + if ( + 'operator_url' in access_url and + 'provider_url' in access_url + ): + new_access_url = access_url_changes['operator_url'] + new_provider_url = access_url_changes['provider_url'] + if access_url.get('shared_ssl_flag', False) is True: + raise errors.InvalidOperation( + 'Changing access urls for shared ssl domains ' + 'is not supported.') + if not new_access_url.startswith(domain_name): + LOG.info('Invalid access_url/domain_name.') + raise errors.InvalidResourceName( + 'Invalid access_url/domain_name.') + if new_access_url == access_url['operator_url']: + LOG.info( + "No changes made, both old and new access " + "urls are the same. " + "Domain '{0}'.".format(domain_name)) + return False + if new_provider_url != access_url['provider_url']: + raise errors.InvalidOperation( + 'Please use the migrate domain functionality ' + 'to migrate the domain to a new cert.' + ) + certificate = ( + "shared" + if access_url.get('shared_ssl_flag', False) is True + else None + ) + self.dns_controller._create_preferred_cname_record( + domain_name, + certificate, + new_access_url, + new_provider_url + ) + self.dns_controller._delete_cname_record( + access_url['operator_url'], + access_url.get('shared_ssl_flag', False) + ) + access_url['provider_url'] = new_provider_url + access_url['operator_url'] = new_access_url + updated_details = True + break + + if updated_details is True: + self.storage_controller.update_provider_details( + project_id, + service_id, + provider_details + ) + else: + err_msg = 'Domain {0} could not be found on service {1}.'.format( + domain_name, service_id) + LOG.error(err_msg) + raise ValueError(err_msg) + + return updated_details diff --git a/poppy/model/helpers/provider_details.py b/poppy/model/helpers/provider_details.py index a24bc9ea..60e102b3 100644 --- a/poppy/model/helpers/provider_details.py +++ b/poppy/model/helpers/provider_details.py @@ -155,7 +155,7 @@ class ProviderDetail(common.DictSerializableModel): """Return an access url object for a domain. :param domain: domain to use as search key - :type domain: poppy.model.helpers.domain.Domain + :type domain: str :returns: access_url -- dict containing matching domain """ diff --git a/poppy/transport/pecan/controllers/v1/admin.py b/poppy/transport/pecan/controllers/v1/admin.py index 585a3036..cb94cc17 100644 --- a/poppy/transport/pecan/controllers/v1/admin.py +++ b/poppy/transport/pecan/controllers/v1/admin.py @@ -25,6 +25,7 @@ from poppy.transport.pecan.models.response import service as resp_service_model from poppy.transport.validators import helpers from poppy.transport.validators.schemas import background_jobs from poppy.transport.validators.schemas import domain_migration +from poppy.transport.validators.schemas import provider_details_update from poppy.transport.validators.schemas import service_action from poppy.transport.validators.schemas import service_limit from poppy.transport.validators.schemas import service_status @@ -77,6 +78,60 @@ class DomainMigrationController(base.Controller, hooks.HookController): return pecan.Response(None, 202) +class AdminProviderDetailsController(base.Controller, hooks.HookController): + __hooks__ = [poppy_hooks.Context(), poppy_hooks.Error()] + + def __init__(self, driver): + super(AdminProviderDetailsController, self).__init__(driver) + + @pecan.expose('json') + @decorators.validate( + service_id=rule.Rule( + helpers.is_valid_service_id(), + helpers.abort_with_message), + request=rule.Rule( + helpers.json_matches_service_schema( + provider_details_update.ProviderDetailsUpdateSchema.get_schema( + "update_provider_access_url", "PATCH") + ), + helpers.abort_with_message, + stoplight_helpers.pecan_getter)) + def patch_one(self, service_id): + request_json = json.loads(pecan.request.body.decode('utf-8')) + project_id = request_json.get('project_id', None) + domain_name = request_json.get('domain_name', None) + operator_url = request_json.get('operator_url', None) + provider_url = request_json.get('provider_url', None) + + if not helpers.is_valid_domain_name(domain_name): + pecan.abort(400, detail='Domain {0} is not valid'.format( + domain_name)) + + changes_made = False + try: + changes_made = self._driver.manager.services_controller.\ + update_access_url_service( + project_id, + service_id, + access_url_changes={ + 'domain_name': domain_name, + 'operator_url': operator_url, + 'provider_url': provider_url + } + ) + except errors.ServiceNotFound: + pecan.abort(404, detail='Service {0} could not be found'.format( + service_id)) + except (errors.InvalidOperation, errors.InvalidResourceName) as e: + pecan.abort(400, detail='{0}'.format(e)) + except (LookupError, ValueError): + pecan.abort(404, detail='Domain {0} could not be found'.format( + domain_name)) + + status_code = 201 if changes_made is True else 202 + return pecan.Response(None, status=status_code) + + class BackgroundJobController(base.Controller, hooks.HookController): __hooks__ = [poppy_hooks.Context(), poppy_hooks.Error()] @@ -552,6 +607,9 @@ class AdminServiceController(base.Controller, hooks.HookController): super(AdminServiceController, self).__init__(driver) self.__class__.action = OperatorServiceActionController(driver) self.__class__.status = ServiceStatusController(driver) + self.__class__.provider_details = AdminProviderDetailsController( + driver + ) @pecan.expose('json') @decorators.validate( diff --git a/poppy/transport/validators/schemas/provider_details_update.py b/poppy/transport/validators/schemas/provider_details_update.py new file mode 100644 index 00000000..b5b2c0c6 --- /dev/null +++ b/poppy/transport/validators/schemas/provider_details_update.py @@ -0,0 +1,54 @@ +# Copyright (c) 2015 Rackspace, Inc. +# +# 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 poppy.transport.validators import schema_base + + +class ProviderDetailsUpdateSchema(schema_base.SchemaBase): + + """JSON Schema validation for /admin/services/provider_details.""" + + schema = { + 'update_provider_access_url': { + 'PATCH': { + 'type': 'object', + 'additionalProperties': False, + 'properties': { + 'project_id': { + 'type': 'string', + 'required': True + }, + 'domain_name': { + 'type': 'string', + 'required': True, + 'minLength': 3, + 'maxLength': 253 + }, + 'operator_url': { + 'type': 'string', + 'required': True, + 'minLength': 3, + 'maxLength': 253 + }, + 'provider_url': { + 'type': 'string', + 'required': True, + 'minLength': 3, + 'maxLength': 253 + } + } + } + } + } diff --git a/tests/unit/dns/rackspace/test_services.py b/tests/unit/dns/rackspace/test_services.py index 7631faf2..df9dc8ab 100644 --- a/tests/unit/dns/rackspace/test_services.py +++ b/tests/unit/dns/rackspace/test_services.py @@ -973,6 +973,115 @@ class TestServicesUpdate(base.TestCase): self.assertIsNotNone( access_urls_map[provider_name][domain_new.domain]) + @mock.patch('re.match') + def test_update_add_domains_https_upgrade_regex_exception(self, re_mock): + re_mock.return_value.groups.return_value = (None,) + subdomain = mock.Mock() + subdomain.add_records = mock.Mock() + self.client.find = mock.Mock(return_value=subdomain) + + domains_new = [ + domain.Domain('test.domain.com'), + domain.Domain('blog.domain.com') + ] + + self.service_old.domains = domains_new + service_new = service.Service( + service_id=self.service_old.service_id, + name='myservice', + domains=domains_new, + origins=[], + flavor_id='standard') + + responders = [{ + 'Fastly': { + 'id': str(uuid.uuid4()), + 'links': [ + { + 'domain': u'test.domain.com', + 'href': u'test.domain.com.global.prod.fastly.net', + 'rel': 'access_url' + }, + { + 'domain': u'blog.domain.com', + 'href': u'blog.domain.com.global.prod.fastly.net', + 'rel': 'access_url', + 'certificate': 'san', + 'old_operator_url': 'old.operator.url.cdn99.mycdn.com' + } + ]} + }] + + dns_details = self.controller.update( + self.service_old, + service_new, + responders + ) + self.assertTrue('error' in dns_details['Fastly']) + self.assertTrue('error_detail' in dns_details['Fastly']) + self.assertTrue('error_class' in dns_details['Fastly']) + self.assertTrue('ValueError' in dns_details['Fastly']['error_class']) + + def test_update_add_domains_https_upgrade_create_cname_record(self): + subdomain = mock.Mock() + subdomain.add_records = mock.Mock() + subdomain.find_record.side_effect = exc.DomainRecordNotFound( + "Mock -- couldn't find cname record." + ) + self.client.find = mock.Mock(return_value=subdomain) + + domains_new = [ + domain.Domain('test.domain.com'), + domain.Domain('blog.domain.com') + ] + + self.service_old.domains = domains_new + service_new = service.Service( + service_id=self.service_old.service_id, + name='myservice', + domains=domains_new, + origins=[], + flavor_id='standard') + + responders = [{ + 'Fastly': { + 'id': str(uuid.uuid4()), + 'links': [ + { + 'domain': u'test.domain.com', + 'href': u'test.domain.com.global.prod.fastly.net', + 'rel': 'access_url' + }, + { + 'domain': u'blog.domain.com', + 'href': u'blog.domain.com.global.prod.fastly.net', + 'rel': 'access_url', + 'certificate': 'san', + 'old_operator_url': 'old.operator.url.cdn99.mycdn.com' + } + ]} + }] + + dns_details = self.controller.update( + self.service_old, + service_new, + responders + ) + + access_urls_map = {} + for provider_name in dns_details: + access_urls_map[provider_name] = {} + access_urls_list = dns_details[provider_name]['access_urls'] + for access_urls in access_urls_list: + access_urls_map[provider_name][access_urls['domain']] = ( + access_urls['operator_url']) + + for responder in responders: + for provider_name in responder: + for domain_new in domains_new: + self.assertIsNotNone( + access_urls_map[provider_name][domain_new.domain]) + def test_update_add_domains_keeps_log_delivery(self): subdomain = mock.Mock() subdomain.add_records = mock.Mock() diff --git a/tests/unit/manager/default/test_services.py b/tests/unit/manager/default/test_services.py index 2b3ddce4..208ba851 100644 --- a/tests/unit/manager/default/test_services.py +++ b/tests/unit/manager/default/test_services.py @@ -1724,3 +1724,272 @@ class DefaultManagerServiceTests(base.TestCase): self.assertEqual(domains, self.sc.get_domains_by_provider_url('provider_url')) + + def test_update_access_url_positive(self): + service_obj = service.load_from_json(self.service_json) + service_obj.status = u'deployed' + service_obj.provider_details = { + 'Akamai': provider_details.ProviderDetail( + provider_service_id=[ + { + "protocol": "http", + "certificate": None, + "policy_name": "www.test1.com" + } + ], + access_urls=[ + { + "provider_url": "altcdn.com.mdc.edgesuite.net", + "domain": "www.test1.com", + "operator_url": "www.test1.com.cdn136.myraxcdn.net" + } + ], + status="deployed", + ) + } + self.sc.storage_controller.get_service.return_value = service_obj + + updated = self.sc.update_access_url_service( + "project_id", + "service_id", + { + 'domain_name': 'www.test1.com', + 'operator_url': 'www.test1.com.cdn137.myraxcdn.net', + 'provider_url': 'altcdn.com.mdc.edgesuite.net' + } + ) + + self.assertTrue(updated) + + def test_update_access_url_service_not_found(self): + self.sc.storage_controller.get_service.side_effect = ( + ValueError('Mock -- Service not found.') + ) + + self.assertRaises( + errors.ServiceNotFound, + self.sc.update_access_url_service, + "project_id", + "service_id", + { + 'domain_name': 'www.test1.com', + 'operator_url': 'www.test1.com.cdn137.myraxcdn.net', + 'provider_url': 'altcdn.com.mdc.edgesuite.net' + } + ) + + def test_update_access_url_no_op_patch(self): + service_obj = service.load_from_json(self.service_json) + service_obj.status = u'deployed' + service_obj.provider_details = { + 'Akamai': provider_details.ProviderDetail( + provider_service_id=[ + { + "protocol": "http", + "certificate": None, + "policy_name": "www.test1.com" + } + ], + access_urls=[ + { + "provider_url": "altcdn.com.mdc.edgesuite.net", + "domain": "www.test1.com", + "operator_url": "www.test1.com.cdn136.myraxcdn.net" + } + ], + status="deployed", + ) + } + self.sc.storage_controller.get_service.return_value = service_obj + + updated = self.sc.update_access_url_service( + "project_id", + "service_id", + { + 'domain_name': 'www.test1.com', + 'operator_url': 'www.test1.com.cdn136.myraxcdn.net', + 'provider_url': 'altcdn.com.mdc.edgesuite.net' + } + ) + + self.assertFalse(updated) + + def test_update_access_url_provider_url_mismatch(self): + service_obj = service.load_from_json(self.service_json) + service_obj.status = u'deployed' + service_obj.provider_details = { + 'Akamai': provider_details.ProviderDetail( + provider_service_id=[ + { + "protocol": "http", + "certificate": None, + "policy_name": "www.test1.com" + } + ], + access_urls=[ + { + "provider_url": "altcdn.com.mdc.edgesuite.net", + "domain": "www.test1.com", + "operator_url": "www.test1.com.cdn136.myraxcdn.net" + } + ], + status="deployed", + ) + } + self.sc.storage_controller.get_service.return_value = service_obj + + self.assertRaises( + errors.InvalidOperation, + self.sc.update_access_url_service, + "project_id", + "service_id", + { + 'domain_name': 'www.test1.com', + 'operator_url': 'www.test1.com.cdn137.myraxcdn.net', + 'provider_url': 'raxcdn.com.mdc.edgesuite.net' + } + ) + + def test_update_access_url_mismatch_access_url_and_domain(self): + service_obj = service.load_from_json(self.service_json) + service_obj.status = u'deployed' + service_obj.provider_details = { + 'Akamai': provider_details.ProviderDetail( + provider_service_id=[ + { + "protocol": "http", + "certificate": None, + "policy_name": "www.test1.com" + } + ], + access_urls=[ + { + "provider_url": "altcdn.com.mdc.edgesuite.net", + "domain": "www.test1.com", + "operator_url": "www.test1.com.cdn136.myraxcdn.net" + } + ], + status="deployed", + ) + } + self.sc.storage_controller.get_service.return_value = service_obj + + self.assertRaises( + errors.InvalidResourceName, + self.sc.update_access_url_service, + "project_id", + "service_id", + { + 'domain_name': 'www.test1.com', + 'operator_url': 'www.test2.com.cdn137.myraxcdn.net', + 'provider_url': 'altcdn.com.mdc.edgesuite.net' + } + ) + + def test_update_access_url_missing_provider_url(self): + service_obj = service.load_from_json(self.service_json) + service_obj.status = u'deployed' + service_obj.provider_details = { + 'Akamai': provider_details.ProviderDetail( + provider_service_id=[ + { + "protocol": "http", + "certificate": None, + "policy_name": "www.test1.com" + } + ], + access_urls=[ + { + "domain": "www.test1.com", + "operator_url": "www.test1.com.cdn136.myraxcdn.net" + } + ], + status="deployed", + ) + } + self.sc.storage_controller.get_service.return_value = service_obj + + self.assertRaises( + ValueError, + self.sc.update_access_url_service, + "project_id", + "service_id", + { + 'domain_name': 'www.test1.com', + 'operator_url': 'www.test1.com.cdn137.myraxcdn.net', + 'provider_url': 'altcdn.com.mdc.edgesuite.net' + } + ) + + def test_update_access_url_no_matching_access_urls(self): + service_obj = service.load_from_json(self.service_json) + service_obj.status = u'deployed' + service_obj.provider_details = { + 'Akamai': provider_details.ProviderDetail( + provider_service_id=[ + { + "protocol": "http", + "certificate": None, + "policy_name": "www.test1.com" + } + ], + access_urls=[ + { + "provider_url": "altcdn.com.mdc.edgesuite.net", + "domain": "www.test2.com", + "operator_url": "www.test2.com.cdn136.myraxcdn.net" + } + ], + status="deployed", + ) + } + self.sc.storage_controller.get_service.return_value = service_obj + + self.assertRaises( + ValueError, + self.sc.update_access_url_service, + "project_id", + "service_id", + { + 'domain_name': 'www.test1.com', + 'operator_url': 'www.test1.com.cdn137.myraxcdn.net', + 'provider_url': 'altcdn.com.mdc.edgesuite.net' + } + ) + + def test_update_access_url_shared_ssl_domain(self): + service_obj = service.load_from_json(self.service_json) + service_obj.status = u'deployed' + service_obj.provider_details = { + 'Akamai': provider_details.ProviderDetail( + provider_service_id=[ + { + "protocol": "https", + "certificate": "shared", + "policy_name": "test99.scdn1.secure.cdn.net" + } + ], + access_urls=[ + { + "provider_url": "scdn1.secure.cdn.net.edgekey.net", + "domain": "test99.scdn1.secure.cdn.net", + "shared_ssl_flag": True, + "operator_url": "test99.scdn1.secure.cdn.net" + } + ], + status="deployed", + ) + } + self.sc.storage_controller.get_service.return_value = service_obj + + self.assertRaises( + errors.InvalidOperation, + self.sc.update_access_url_service, + "project_id", + "service_id", + { + 'domain_name': 'test99.scdn1.secure.cdn.net', + 'operator_url': 'test99.scdn2.secure.cdn.net', + 'provider_url': 'scdn2.secure.cdn.net.edgekey.net' + } + )