From caa257dafc98b49f4f5b45b931f219f7559ed58b Mon Sep 17 00:00:00 2001 From: Tobias Urdin Date: Mon, 18 Dec 2023 13:12:40 +0100 Subject: [PATCH] Migrate passthrough to openstacksdk This migrates the passthrough code to using the openstacksdk instead. This should also have the added benefit of improving the security posture of this dashboard. There is a bug in openstacksdk for Designate floating IPs which will be solved in [1], for now we workaround that so that older versions of openstacksdk is supported. This also fixes the 6 year old bug of supporting pagination for the designate dashboard. [1] https://review.opendev.org/c/openstack/openstacksdk/+/903879 Closes-Bug: 1729261 Change-Id: Id5ebdc5849d46dc10ab864a54afe37eb9c8f71b7 --- designatedashboard/api/rest/__init__.py | 2 +- designatedashboard/api/rest/designate.py | 272 ++++++++++++++++++ designatedashboard/api/rest/passthrough.py | 119 -------- designatedashboard/sdk_connection.py | 50 ++++ .../os-designate-floatingip/api.service.js | 6 +- .../os-designate-recordset/api.service.js | 2 +- .../notes/openstacksdk-11483491f9978bd1.yaml | 4 + requirements.txt | 1 + 8 files changed, 332 insertions(+), 124 deletions(-) create mode 100644 designatedashboard/api/rest/designate.py delete mode 100644 designatedashboard/api/rest/passthrough.py create mode 100644 designatedashboard/sdk_connection.py create mode 100644 releasenotes/notes/openstacksdk-11483491f9978bd1.yaml diff --git a/designatedashboard/api/rest/__init__.py b/designatedashboard/api/rest/__init__.py index 6e741ff..1e898c3 100644 --- a/designatedashboard/api/rest/__init__.py +++ b/designatedashboard/api/rest/__init__.py @@ -13,4 +13,4 @@ # limitations under the License. """REST API for Horizon dashboard Javascript code. """ -from . import passthrough # noqa +from . import designate # noqa diff --git a/designatedashboard/api/rest/designate.py b/designatedashboard/api/rest/designate.py new file mode 100644 index 0000000..d78073e --- /dev/null +++ b/designatedashboard/api/rest/designate.py @@ -0,0 +1,272 @@ +# Copyright (c) 2023 Binero +# +# 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 designatedashboard.sdk_connection import get_sdk_connection +from django.views import generic +import logging + +from openstack_dashboard.api.rest import urls +from openstack_dashboard.api.rest import utils as rest_utils + +from openstack.dns.v2 import floating_ip as _fip + + +LOG = logging.getLogger(__name__) + + +def _sdk_object_to_list(object): + """Converts an SDK generator object to a list of dictionaries. + + :param object: SDK generator object + :returns: List of dictionaries + """ + result_list = [] + for item in object: + result_list.append(item.to_dict()) + return result_list + + +def create_zone(request): + """Create zone.""" + data = request.DATA + + conn = get_sdk_connection(request) + build_kwargs = dict( + name=data['name'], + email=data['email'], + type=data['type'], + ) + if data.get('description', None): + build_kwargs['description'] = data['description'] + if data.get('ttl', None): + build_kwargs['ttl'] = data['ttl'] + if data.get('masters', None): + build_kwargs['masters'] = data['masters'] + + zone = conn.dns.create_zone(**build_kwargs) + return zone.to_dict() + + +def update_zone(request, **kwargs): + """Update zone.""" + data = request.DATA + zone_id = kwargs.get('zone_id') + + conn = get_sdk_connection(request) + build_kwargs = dict( + email=data['email'], + description=data['description'], + ttl=data['ttl'], + ) + zone = conn.dns.update_zone( + zone_id, **build_kwargs) + return zone.to_dict() + + +@urls.register +class Zones(generic.View): + """API for zones.""" + + url_regex = r'dns/v2/zones/$' + + @rest_utils.ajax() + def get(self, request): + """List zones for current project.""" + conn = get_sdk_connection(request) + zones = _sdk_object_to_list(conn.dns.zones()) + return {'zones': zones} + + @rest_utils.ajax(data_required=True) + def post(self, request): + """Create zone.""" + return create_zone(request) + + +@urls.register +class Zone(generic.View): + """API for zone.""" + url_regex = r'dns/v2/zones/(?P[^/]+)/$' + + @rest_utils.ajax() + def get(self, request, zone_id): + """Get zone.""" + conn = get_sdk_connection(request) + zone = conn.dns.find_zone(zone_id) + return zone.to_dict() + + @rest_utils.ajax(data_required=True) + def patch(self, request, zone_id): + """Edit zone.""" + kwargs = {'zone_id': zone_id} + update_zone(request, **kwargs) + + @rest_utils.ajax() + def delete(self, request, zone_id): + """Delete zone.""" + conn = get_sdk_connection(request) + conn.dns.delete_zone(zone_id, ignore_missing=True) + + +def create_recordset(request, **kwargs): + """Create recordset.""" + data = request.DATA + zone_id = kwargs.get('zone_id') + + conn = get_sdk_connection(request) + build_kwargs = dict( + name=data['name'], + type=data['type'], + ttl=data['ttl'], + records=data['records'], + ) + if data.get('description', None): + build_kwargs['description'] = data['description'] + + rs = conn.dns.create_recordset( + zone_id, **build_kwargs) + return rs.to_dict() + + +def update_recordset(request, **kwargs): + """Update recordset.""" + data = request.DATA + zone_id = kwargs.get('zone_id') + rs_id = kwargs.get('rs_id') + + conn = get_sdk_connection(request) + + build_kwargs = dict() + if data.get('description', None): + build_kwargs['description'] = data['description'] + if data.get('ttl', None): + build_kwargs['ttl'] = data['ttl'] + if data.get('records', None): + build_kwargs['records'] = data['records'] + + build_kwargs['zone_id'] = zone_id + rs = conn.dns.update_recordset( + rs_id, **build_kwargs) + return rs.to_dict() + + +def _populate_zone_id(items, zone_id): + for item in items: + item['zone_id'] = zone_id + return items + + +@urls.register +class RecordSets(generic.View): + """API for recordsets.""" + url_regex = r'dns/v2/zones/(?P[^/]+)/recordsets/$' + + @rest_utils.ajax() + def get(self, request, zone_id): + """Get recordsets.""" + conn = get_sdk_connection(request) + rsets = _sdk_object_to_list(conn.dns.recordsets(zone_id)) + return {'recordsets': _populate_zone_id(rsets, zone_id)} + + @rest_utils.ajax(data_required=True) + def post(self, request, zone_id): + """Create recordset.""" + kwargs = {'zone_id': zone_id} + return create_recordset(request, **kwargs) + + +@urls.register +class RecordSet(generic.View): + """API for recordset.""" + url_regex = r'dns/v2/zones/(?P[^/]+)/recordsets/(?P[^/]+)/$' # noqa + + @rest_utils.ajax() + def get(self, request, zone_id, rs_id): + """Get recordset.""" + conn = get_sdk_connection(request) + rs = conn.dns.get_recordset(rs_id, zone_id) + rs_dict = rs.to_dict() + rs_dict['zone_id'] = zone_id + return rs_dict + + @rest_utils.ajax(data_required=True) + def put(self, request, zone_id, rs_id): + """Edit recordset.""" + kwargs = {'zone_id': zone_id, 'rs_id': rs_id} + update_recordset(request, **kwargs) + + @rest_utils.ajax() + def delete(self, request, zone_id, rs_id): + """Delete recordset.""" + conn = get_sdk_connection(request) + conn.dns.delete_recordset(rs_id, zone_id, ignore_missing=True) + + +@urls.register +class DnsFloatingIps(generic.View): + """API for floatingips.""" + url_regex = r'dns/v2/reverse/floatingips/$' + + @rest_utils.ajax() + def get(self, request): + """Get floatingips.""" + conn = get_sdk_connection(request) + fips = _sdk_object_to_list(conn.dns.floating_ips()) + return {'floatingips': fips} + + +def update_dns_floatingip(request, **kwargs): + """Update recordset.""" + data = request.DATA + fip_id = kwargs.get('fip_id') + + conn = get_sdk_connection(request) + + build_kwargs = dict( + ptrdname=data['ptrdname'], + ) + if data.get('description', None): + build_kwargs['description'] = data['description'] + if data.get('ttl', None): + build_kwargs['ttl'] = data['ttl'] + + # TODO(tobias-urdin): Bug in openstacksdk + # https://review.opendev.org/c/openstack/openstacksdk/+/903879 + obj = conn.dns._get_resource( + _fip.FloatingIP, fip_id, **build_kwargs) + obj.resource_key = None + has_body = True + if build_kwargs['ptrdname'] is None: + has_body = False + fip = obj.commit(conn.dns, has_body=has_body) + + return fip.to_dict() + + +@urls.register +class DnsFloatingIp(generic.View): + """API for dns floatingip.""" + url_regex = r'dns/v2/reverse/floatingips/(?P[^/]+)/$' + + @rest_utils.ajax() + def get(self, request, fip_id): + """Get floatingip.""" + conn = get_sdk_connection(request) + fip = conn.dns.get_floating_ip(fip_id) + return fip.to_dict() + + @rest_utils.ajax(data_required=True) + def patch(self, request, fip_id): + """Edit floatingip.""" + kwargs = {'fip_id': fip_id} + update_dns_floatingip(request, **kwargs) diff --git a/designatedashboard/api/rest/passthrough.py b/designatedashboard/api/rest/passthrough.py deleted file mode 100644 index 61d250e..0000000 --- a/designatedashboard/api/rest/passthrough.py +++ /dev/null @@ -1,119 +0,0 @@ -# Copyright 2016, Hewlett Packard Enterprise Development, LP -# -# 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. -"""API for the passthrough service. -""" -from django.conf import settings -from django.views import generic -import functools -import logging -import requests -from requests.exceptions import HTTPError - -from horizon import exceptions -from openstack_dashboard.api import base -from openstack_dashboard.api.rest import urls -from openstack_dashboard.api.rest import utils as rest_utils - -LOG = logging.getLogger(__name__) - - -def _passthrough_request(request_method, url, - request, data=None, params=None): - """Makes a request to the appropriate service API with an optional payload. - - Should set any necessary auth headers and SSL parameters. - """ - - # Set verify if a CACERT is set and SSL_NO_VERIFY isn't True - verify = getattr(settings, 'OPENSTACK_SSL_CACERT', None) - if getattr(settings, 'OPENSTACK_SSL_NO_VERIFY', False): - verify = False - - service_url = _get_service_url(request, 'dns') - request_url = '{}{}'.format( - service_url, - url if service_url.endswith('/') else ('/' + url) - ) - - response = request_method( - request_url, - headers={'X-Auth-Token': request.user.token.id}, - json=data, - verify=verify, - params=params - ) - - try: - response.raise_for_status() - except HTTPError as e: - LOG.debug(e.response.content) - for error in rest_utils.http_errors: - if (e.response.status_code == getattr(error, 'status_code', 0) and - exceptions.HorizonException in error.__bases__): - raise error - raise - - return response - - -# Create some convenience partial functions -passthrough_get = functools.partial(_passthrough_request, requests.get) -passthrough_post = functools.partial(_passthrough_request, requests.post) -passthrough_put = functools.partial(_passthrough_request, requests.put) -passthrough_patch = functools.partial(_passthrough_request, requests.patch) -passthrough_delete = functools.partial(_passthrough_request, requests.delete) - - -def _get_service_url(request, service): - """Get service's URL from keystone; allow an override in settings""" - service_url = getattr(settings, service.upper() + '_URL', None) - try: - service_url = base.url_for(request, service) - except exceptions.ServiceCatalogException: - pass - # Currently the keystone endpoint is http://host:port/ - # without the version. - return service_url - - -@urls.register -class Passthrough(generic.View): - """Pass-through API for executing service requests. - - Horizon only adds auth and CORS proxying. - """ - url_regex = r'dns/(?P.+)$' - - @rest_utils.ajax() - def get(self, request, path): - return passthrough_get(path, request).json() - - @rest_utils.ajax() - def post(self, request, path): - data = dict(request.DATA) if request.DATA else {} - return passthrough_post(path, request, data).json() - - @rest_utils.ajax() - def put(self, request, path): - data = dict(request.DATA) if request.DATA else {} - return passthrough_put(path, request, data).json() - - @rest_utils.ajax() - def patch(self, request, path): - data = dict(request.DATA) if request.DATA else {} - return passthrough_patch(path, request, data).json() - - @rest_utils.ajax() - def delete(self, request, path): - return passthrough_delete(path, request).json() diff --git a/designatedashboard/sdk_connection.py b/designatedashboard/sdk_connection.py new file mode 100644 index 0000000..6132b71 --- /dev/null +++ b/designatedashboard/sdk_connection.py @@ -0,0 +1,50 @@ +# Copyright Red Hat +# +# 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 django.conf import settings + +import designatedashboard +from openstack import config as occ +from openstack import connection + + +def get_sdk_connection(request): + """Creates an SDK connection based on the request. + + :param request: Django request object + :returns: SDK connection object + """ + # NOTE(mordred) Nothing says love like two inverted booleans + # The config setting is NO_VERIFY which is, in fact, insecure. + # get_one_cloud wants verify, so we pass 'not insecure' to verify. + insecure = getattr(settings, 'OPENSTACK_SSL_NO_VERIFY', False) + cacert = getattr(settings, 'OPENSTACK_SSL_CACERT', None) + # Pass interface to honor 'OPENSTACK_ENDPOINT_TYPE' + interface = getattr(settings, 'OPENSTACK_ENDPOINT_TYPE', 'publicURL') + # Pass load_yaml_config as this is a Django service with its own config + # and we don't want to accidentally pick up a clouds.yaml file. We want to + # use the settings we're passing in. + cloud_config = occ.OpenStackConfig(load_yaml_config=False).get_one_cloud( + verify=not insecure, + cacert=cacert, + interface=interface, + region_name=request.user.services_region, + auth_type='token', + auth=dict( + project_id=request.user.project_id, + project_domain_id=request.user.domain_id, + auth_token=request.user.token.unscoped_token, + auth_url=request.user.endpoint), + app_name='designate-dashboard', + app_version=designatedashboard.__version__) + return connection.from_config(cloud_config=cloud_config) diff --git a/designatedashboard/static/designatedashboard/resources/os-designate-floatingip/api.service.js b/designatedashboard/static/designatedashboard/resources/os-designate-floatingip/api.service.js index 77d9a2c..afd6d49 100644 --- a/designatedashboard/static/designatedashboard/resources/os-designate-floatingip/api.service.js +++ b/designatedashboard/static/designatedashboard/resources/os-designate-floatingip/api.service.js @@ -61,7 +61,7 @@ */ function list(params) { var config = params ? {params: params} : {}; - return httpService.get(apiPassthroughUrl + 'v2/reverse/floatingips', config) + return httpService.get(apiPassthroughUrl + 'v2/reverse/floatingips/', config) .catch(function () { toastService.add('error', gettext('Unable to retrieve the floating ip PTRs.')); }); @@ -69,7 +69,7 @@ function get(id, params) { var config = params ? {params: params} : {}; - return httpService.get(apiPassthroughUrl + 'v2/reverse/floatingips/' + id, config) + return httpService.get(apiPassthroughUrl + 'v2/reverse/floatingips/' + id + '/', config) .catch(function () { toastService.add('error', gettext('Unable to get the floating ip PTR ' + id)); }); @@ -95,7 +95,7 @@ ttl: data.ttl }; return httpService.patch( - apiPassthroughUrl + 'v2/reverse/floatingips/' + floatingIpID, apiData) + apiPassthroughUrl + 'v2/reverse/floatingips/' + floatingIpID + '/', apiData) .catch(function () { toastService.add('error', gettext('Unable to set the floating IP PTR record.')); }); diff --git a/designatedashboard/static/designatedashboard/resources/os-designate-recordset/api.service.js b/designatedashboard/static/designatedashboard/resources/os-designate-recordset/api.service.js index 88da513..5105515 100644 --- a/designatedashboard/static/designatedashboard/resources/os-designate-recordset/api.service.js +++ b/designatedashboard/static/designatedashboard/resources/os-designate-recordset/api.service.js @@ -130,7 +130,7 @@ records: data.records }; return httpService.put( - apiPassthroughUrl + 'v2/zones/' + zoneId + '/recordsets/' + recordSetId, apiData) + apiPassthroughUrl + 'v2/zones/' + zoneId + '/recordsets/' + recordSetId + '/', apiData) .catch(function () { toastService.add('error', gettext('Unable to update the record set.')); }); diff --git a/releasenotes/notes/openstacksdk-11483491f9978bd1.yaml b/releasenotes/notes/openstacksdk-11483491f9978bd1.yaml new file mode 100644 index 0000000..99dcab5 --- /dev/null +++ b/releasenotes/notes/openstacksdk-11483491f9978bd1.yaml @@ -0,0 +1,4 @@ +--- +upgrade: + - | + The designate dashboard now needs openstacksdk diff --git a/requirements.txt b/requirements.txt index de5452e..8758041 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,4 @@ pbr!=2.1.0,>=2.0.0 # Apache-2.0 horizon>=17.1.0 # Apache-2.0 +openstacksdk>=0.62.0 # Apache-2.0