diff options
-rw-r--r-- | dnsdesig/LICENSE.txt | 17 | ||||
-rw-r--r-- | dnsdesig/README.md | 22 | ||||
-rw-r--r-- | dnsdesig/dns_types.yaml | 65 | ||||
-rw-r--r-- | dnsdesig/dnsdesig/__init__.py | 28 | ||||
-rw-r--r-- | dnsdesig/dnsdesig/dns_plugin.py | 151 | ||||
-rw-r--r-- | dnsdesig/requirements.txt | 0 | ||||
-rw-r--r-- | dnsdesig/setup.py | 35 | ||||
-rw-r--r-- | dnsdesig/tests/test_plugin.py | 272 | ||||
-rw-r--r-- | dnsdesig/tox.ini | 26 |
9 files changed, 616 insertions, 0 deletions
diff --git a/dnsdesig/LICENSE.txt b/dnsdesig/LICENSE.txt new file mode 100644 index 0000000..f90f8f1 --- /dev/null +++ b/dnsdesig/LICENSE.txt @@ -0,0 +1,17 @@ +============LICENSE_START======================================================= +org.onap.ccsdk +================================================================================ +Copyright (c) 2017 AT&T Intellectual Property. 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. +============LICENSE_END========================================================= diff --git a/dnsdesig/README.md b/dnsdesig/README.md new file mode 100644 index 0000000..a3db6b7 --- /dev/null +++ b/dnsdesig/README.md @@ -0,0 +1,22 @@ +<!-- +============LICENSE_START======================================================= +org.onap.ccsdk +================================================================================ +Copyright (c) 2017 AT&T Intellectual Property. 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. +============LICENSE_END========================================================= +--> + +# dnsdesig +OpenStack dns/designate cloudify plugin diff --git a/dnsdesig/dns_types.yaml b/dnsdesig/dns_types.yaml new file mode 100644 index 0000000..9af2422 --- /dev/null +++ b/dnsdesig/dns_types.yaml @@ -0,0 +1,65 @@ +# ============LICENSE_START==================================================== +# org.onap.ccsdk +# ============================================================================= +# Copyright (c) 2017 AT&T Intellectual Property. 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. +# ============LICENSE_END====================================================== + +tosca_definitions_version: cloudify_dsl_1_3 + +imports: + - http://www.getcloudify.org/spec/cloudify/3.4/types.yaml +plugins: + dns_designate: + executor: central_deployment_agent + package_name: dnsdesig + package_version: 1.0.0 + +node_types: + ccsdk.nodes.dns.arecord: + derived_from: cloudify.nodes.Root + properties: + fqdn: + description: 'FQDN of the DNS entry' + type: string + openstack: + description: 'map with keys username, password, tenant_name, auth_url, and region' + ttl: + description: 'time to live of the entry' + default: 300 + interfaces: + cloudify.interfaces.lifecycle: + create: + implementation: dns_designate.dnsdesig.dns_plugin.aneeded + inputs: + args: {} + delete: dns_designate.dnsdesig.dns_plugin.anotneeded + ccsdk.nodes.dns.cnamerecord: + derived_from: cloudify.nodes.Root + properties: + fqdn: + description: 'FQDN of the DNS entry' + type: string + openstack: + description: 'map with keys username, password, tenant_name, auth_url, and region' + ttl: + description: 'time to live of the entry' + default: 300 + interfaces: + cloudify.interfaces.lifecycle: + create: + implementation: dns_designate.dnsdesig.dns_plugin.cnameneeded + inputs: + args: {} + delete: dns_designate.dnsdesig.dns_plugin.cnamenotneeded diff --git a/dnsdesig/dnsdesig/__init__.py b/dnsdesig/dnsdesig/__init__.py new file mode 100644 index 0000000..c629bea --- /dev/null +++ b/dnsdesig/dnsdesig/__init__.py @@ -0,0 +1,28 @@ +# ============LICENSE_START==================================================== +# org.onap.ccsdk +# ============================================================================= +# Copyright (c) 2017 AT&T Intellectual Property. 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. +# ============LICENSE_END====================================================== + +import logging + +def get_module_logger(mod_name): + logger = logging.getLogger(mod_name) + handler=logging.StreamHandler() + formatter=logging.Formatter('%(asctime)s [%(name)-12s] %(levelname)-8s %(message)s') + handler.setFormatter(formatter) + logger.addHandler(handler) + logger.setLevel(logging.DEBUG) + return logger diff --git a/dnsdesig/dnsdesig/dns_plugin.py b/dnsdesig/dnsdesig/dns_plugin.py new file mode 100644 index 0000000..ee755aa --- /dev/null +++ b/dnsdesig/dnsdesig/dns_plugin.py @@ -0,0 +1,151 @@ +# ============LICENSE_START==================================================== +# org.onap.ccsdk +# ============================================================================= +# Copyright (c) 2017 AT&T Intellectual Property. 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. +# ============LICENSE_END====================================================== + +import requests +from urlparse import urlparse +from cloudify import ctx +from cloudify.decorators import operation +from cloudify.exceptions import NonRecoverableError, RecoverableError + +def _check_status(resp, msg): + if resp.status_code >= 300: + if resp.status_code >= 500: + raise RecoverableError(msg) + else: + raise NonRecoverableError(msg) + +def _get_auth_info(openstack): + resp = requests.post('{0}/tokens'.format(openstack['auth_url']), json={'auth':{'tenantName':openstack['tenant_name'],'passwordCredentials':{'username':openstack['username'], 'password':openstack['password']}}}) + _check_status(resp, 'Failed to get authorization token from OpenStack identity service') + respj = resp.json()['access'] + osauth={'X-Auth-Token': respj['token']['id'] } + urls = {} + for se in respj['serviceCatalog']: + type = se['type'] + for ep in se['endpoints']: + url = ep['publicURL'] + reg = ep['region'] + if not urls.has_key(reg): + urls[reg] = { } + if type not in urls[reg] or urls[reg][type] == '': + urls[reg][type] = url + if len(urls.keys()) == 1: + openstack['region'] = urls.keys()[0] + return { 'osauth': osauth, 'dns': urls[openstack['region']]['dns'] } + +def _dot(fqdn): + """ + Append a dot to a fully qualified domain name. + + DNS and Designate expect FQDNs to end with a dot, but human's conventionally don't do that. + """ + return '{0}.'.format(fqdn) + +def _get_domain(fqdn): + return fqdn[(fqdn.find('.') + 1):] + +def _get_zone_id(fqdn, access): + zn = _dot(_get_domain(fqdn)) + resp = requests.get('{0}/v2/zones'.format(access['dns']), headers=access['osauth']) + _check_status(resp, 'Failed to list DNS zones') + respj = resp.json()['zones'] + for ae in respj: + if ae['name'] == zn: + return ae['id'] + raise NonRecoverableError('DNS zone {0} not available for this tenant'.format(zn)) + +def _find_recordset(fqdn, type, zid, access): + fqdnd = _dot(fqdn) + resp = requests.get('{0}/v2/zones/{1}/recordsets?limit=1000'.format(access['dns'], zid), headers=access['osauth']) + _check_status(resp, 'Failed to list DNS record sets') + respj = resp.json()['recordsets'] + for rs in respj: + if rs['type'] == type and rs['name'] == fqdnd: + return rs + return None + +@operation +def aneeded(**kwargs): + """ + Create DNS A record, if not already present. Expect args: ip_addresses: [ ... ] + """ + try: + _doneed('A', kwargs['args']['ip_addresses']) + except NonRecoverableError as nre: + raise nre + except Exception as e: + raise NonRecoverableError(e) + +@operation +def anotneeded(**kwargs): + """ + Remove DNS A record, if present + """ + _noneed('A') + +@operation +def cnameneeded(**kwargs): + """ + Create DNS CNAME record, if not already present. Expect args: cname: '...' + """ + try: + _doneed('CNAME', [ _dot(kwargs['args']['cname']) ] ) + except NonRecoverableError as nre: + raise nre + except Exception as e: + raise NonRecoverableError(e) + +@operation +def cnamenotneeded(**kwargs): + """ + Remove DNS CNAME record, if present + """ + _noneed('CNAME') + +def _doneed(type, records): + """ + Create DNS entries, if not already present + """ + access = _get_auth_info(ctx.node.properties['openstack']) + fqdn = ctx.node.properties['fqdn'] + zid = _get_zone_id(fqdn, access) + rs = _find_recordset(fqdn, type, zid, access) + if not rs: + resp = requests.post('{0}/v2/zones/{1}/recordsets'.format(access['dns'], zid), json={ 'name': _dot(fqdn), 'type': type, 'records': records, 'ttl': ctx.node.properties['ttl'] }, headers=access['osauth']) + _check_status(resp, 'Failed to create DNS record set for {0}'.format(fqdn)) + else: + resp = requests.put('{0}/v2/zones/{1}/recordsets/{2}'.format(access['dns'], zid, rs['id']), json={ 'records': records, 'ttl': ctx.node.properties['ttl'] }, headers=access['osauth']) + _check_status(resp, 'Failed to update DNS record set for {0}'.format(fqdn)) + + +def _noneed(type): + """ + Remove DNS entries, if present + """ + try: + fqdn = ctx.node.properties['fqdn'] + access = _get_auth_info(ctx.node.properties['openstack']) + zid = _get_zone_id(fqdn, access) + rs = _find_recordset(fqdn, type, zid, access) + if rs: + resp = requests.delete('{0}/v2/zones/{1}/recordsets/{2}'.format(access['dns'], zid, rs['id']), headers=access['osauth']) + _check_status(resp, 'Failed to delete DNS record set for {0}'.format(fqdn)) + except NonRecoverableError as nre: + raise nre + except Exception as e: + raise NonRecoverableError(e) diff --git a/dnsdesig/requirements.txt b/dnsdesig/requirements.txt new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/dnsdesig/requirements.txt diff --git a/dnsdesig/setup.py b/dnsdesig/setup.py new file mode 100644 index 0000000..9450390 --- /dev/null +++ b/dnsdesig/setup.py @@ -0,0 +1,35 @@ +# ============LICENSE_START==================================================== +# org.onap.ccsdk +# ============================================================================= +# Copyright (c) 2017 AT&T Intellectual Property. 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. +# ============LICENSE_END====================================================== + +import os +from setuptools import setup, find_packages + +setup( + name='dnsdesig', + version='1.0.0', + packages=find_packages(), + author='AT&T', + description=('Cloudify plugin for creating DNS entries using Designate.'), + license='Apache 2.0', + keywords='', + url='https://wiki.onap.org', + zip_safe=False, + package_data={'':['LICENSE.txt']}, + install_requires=[ + ] +) diff --git a/dnsdesig/tests/test_plugin.py b/dnsdesig/tests/test_plugin.py new file mode 100644 index 0000000..730897a --- /dev/null +++ b/dnsdesig/tests/test_plugin.py @@ -0,0 +1,272 @@ +# ============LICENSE_START==================================================== +# org.onap.ccsdk +# ============================================================================= +# Copyright (c) 2017 AT&T Intellectual Property. 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. +# ============LICENSE_END====================================================== + +import pytest +import requests +import dnsdesig.dns_plugin +from cloudify.mocks import MockCloudifyContext +from cloudify.state import current_ctx +from cloudify.exceptions import NonRecoverableError +from cloudify import ctx + +class _resp(object): + def __init__(self, code, body = None): + self.status_code = code + if body is not None: + self._json = body + + def json(self): + return self._json + +def _same(a, b): + t1 = type(a) + t2 = type(b) + if t1 != t2: + return False + if t1 == dict: + if len(a) != len(b): + return False + for k, v in a.items(): + if k not in b or not _same(v, b[k]): + return False + return True + if t1 == list: + if len(a) != len(b): + return False + for i in range(len(a)): + if not _same(a[i], b[i]): + return False + return True + return a == b + +class _req(object): + def __init__(self, op, url, headers, resp, json = None): + self.resp = resp + self.op = op + self.url = url + self.headers = headers + self.json = json + + def check(self, op, url, headers, json): + if op != self.op or url != self.url: + return None + if self.headers is not None and not _same(self.headers, headers): + return None + if self.json is not None and not _same(self.json, json): + return None + return self.resp + +_nf = _resp(404) +_ar = _resp(401) +_np = _resp(403) +_ok = _resp(200, { 'something': 'or-other' }) + +_tok = 'at' + +_hdrs = { 'X-Auth-Token': _tok } + +_goodos = { + 'auth_url': 'https://example.com/identity', + 'password': 'pw', + 'region': 'r', + 'tenant_name': 'tn', + 'username': 'un' +} + +_bados = { + 'auth_url': 'https://example.com/identity', + 'password': 'xx', + 'region': 'r', + 'tenant_name': 'tn', + 'username': 'un' +} + + +_answers = [ + # Authenticate + _req('POST', 'https://example.com/identity/tokens', headers=None, resp=_resp(200, { + 'access': { + 'token': { + 'id': _tok + }, 'serviceCatalog': [ + { + 'type': 'dns', + 'endpoints': [ + { + 'publicURL': 'https://example.com/dns', + 'region': 'r' + } + ] + } + ] + } + }), json={ + 'auth': { + 'tenantName': 'tn', + 'passwordCredentials': { + 'username': 'un', + 'password': 'pw' + } + } + }), + # Invalid authentication + _req('POST', 'https://example.com/identity/tokens', headers=None, resp=_np), + # Get zones + _req('GET', 'https://example.com/dns/v2/zones', headers=_hdrs, resp=_resp(200, { + 'zones': [ + { + 'name': 'x.example.com.', + 'id': 'z1' + } + ] + })), + # Get recordsets + _req('GET', 'https://example.com/dns/v2/zones/z1/recordsets?limit=1000', headers=_hdrs, resp=_resp(200, { + 'recordsets': [ + { + 'id': 'ar1', + 'type': 'A', + 'name': 'a.x.example.com.', + 'ttl': 300, + 'records': [ + '87.65.43.21', + '98.76,54.32' + ] + }, { + 'id': 'cname1', + 'type': 'CNAME', + 'name': 'c.x.example.com.', + 'ttl': 300, + 'records': [ + 'a.x.example.com.' + ] + } + ] + })), + # Bad auth + _req('GET', 'https://example.com/dns/v2/zones/z1/recordsets?limit=1000', headers=None, resp=_ar), + # Create A recordset + _req('POST', 'https://example.com/dns/v2/zones/z1/recordsets', headers=_hdrs, resp=_ok, json={ + 'type': 'A', + 'name': 'b.x.example.com.', + 'ttl': 300, + 'records': [ + '34.56.78.12' + ] + }), + # Create CNAME recordset + _req('POST', 'https://example.com/dns/v2/zones/z1/recordsets', headers=_hdrs, resp=_ok, json={ + 'type': 'CNAME', + 'name': 'd.x.example.com.', + 'ttl': 300, + 'records': [ + 'b.x.example.com.' + ] + }), + # Update A recordset + _req('PUT', 'https://example.com/dns/v2/zones/z1/recordsets/ar1', headers=_hdrs, resp=_ok, json={ + + 'ttl': 300, + 'records': [ + '34.56.78.12' + ] + }), + # Update CNAME recordset + _req('PUT', 'https://example.com/dns/v2/zones/z1/recordsets/cname1', headers=_hdrs, resp=_ok, json={ + 'ttl': 300, + 'records': [ + 'b.x.example.com.' + ] + }), + # Delete A recordset + _req('DELETE', 'https://example.com/dns/v2/zones/z1/recordsets/ar1', headers=_hdrs, resp=_ok), + # Delete CNAME recordset + _req('DELETE', 'https://example.com/dns/v2/zones/z1/recordsets/cname1', headers=_hdrs, resp=_ok) +] + +def _match(op, url, headers, json = None): + for choice in _answers: + ret = choice.check(op, url, headers, json) + if ret is not None: + return ret + return _nf + +def _delete(url, headers): + return _match('DELETE', url, headers) + +def _get(url, headers): + return _match('GET', url, headers) + +def _post(url, json, headers = None): + return _match('POST', url, headers, json) + +def _put(url, json, headers = None): + return _match('PUT', url, headers, json) + +def _setup(os, fqdn, ttl=None): + def fcnbuilder(fcn): + def newfcn(monkeypatch): + monkeypatch.setattr(requests, 'delete', _delete) + monkeypatch.setattr(requests, 'get', _get) + monkeypatch.setattr(requests, 'post', _post) + monkeypatch.setattr(requests, 'put', _put) + properties = { 'fqdn': fqdn, 'openstack': os } + if ttl is not None: + properties['ttl'] = ttl + mock_ctx = MockCloudifyContext(node_id='test_node_id', node_name='test_node_name', properties=properties) + try: + current_ctx.set(mock_ctx) + fcn() + finally: + current_ctx.clear() + return newfcn + return fcnbuilder + +@_setup(_bados, 'a.x.example.com') +def test_dns_badauth(): + with pytest.raises(NonRecoverableError): + dnsdesig.dns_plugin.anotneeded() + +@_setup(_goodos, 'a.bad.example.com') +def test_dns_badzone(): + with pytest.raises(NonRecoverableError): + dnsdesig.dns_plugin.anotneeded() + +@_setup(_goodos, 'b.x.example.com', 300) +def test_dns_addarecord(): + dnsdesig.dns_plugin.aneeded(args={'ip_addresses': [ '34.56.78.12' ]}) + +@_setup(_goodos, 'a.x.example.com', 300) +def test_dns_modarecord(): + dnsdesig.dns_plugin.aneeded(args={'ip_addresses': [ '34.56.78.12' ]}) + +@_setup(_goodos, 'a.x.example.com') +def test_dns_delarecord(): + dnsdesig.dns_plugin.anotneeded() + +@_setup(_goodos, 'd.x.example.com', 300) +def test_dns_addcnamerecord(): + dnsdesig.dns_plugin.cnameneeded(args={'cname': 'b.x.example.com' }) + +@_setup(_goodos, 'c.x.example.com', 300) +def test_dns_modcnamerecord(): + dnsdesig.dns_plugin.cnameneeded(args={'cname': 'b.x.example.com' }) + +@_setup(_goodos, 'c.x.example.com') +def test_dns_delcname(): + dnsdesig.dns_plugin.cnamenotneeded() diff --git a/dnsdesig/tox.ini b/dnsdesig/tox.ini new file mode 100644 index 0000000..9498c82 --- /dev/null +++ b/dnsdesig/tox.ini @@ -0,0 +1,26 @@ +# ============LICENSE_START==================================================== +# org.onap.ccsdk +# ============================================================================= +# Copyright (c) 2017 AT&T Intellectual Property. 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. +# ============LICENSE_END====================================================== + +[tox] +envlist = py27 +[testenv] +deps= + pytest + cloudify==3.4 + requests +commands=pytest |