Seed code for Cloudify dns/Designate plugin 61/8461/1
authorAndrew Gauld <ag1282@att.com>
Wed, 23 Aug 2017 17:57:51 +0000 (13:57 -0400)
committerAndrew Gauld <ag1282@att.com>
Wed, 23 Aug 2017 17:58:41 +0000 (13:58 -0400)
Change-Id: Ibc157472d6001076959face4ff3a06a808096f78
Issue-Id: CCSDK-66
Signed-off-by: Andrew Gauld <ag1282@att.com>
dnsdesig/LICENSE.txt [new file with mode: 0644]
dnsdesig/README.md [new file with mode: 0644]
dnsdesig/dns_types.yaml [new file with mode: 0644]
dnsdesig/dnsdesig/__init__.py [new file with mode: 0644]
dnsdesig/dnsdesig/dns_plugin.py [new file with mode: 0644]
dnsdesig/requirements.txt [new file with mode: 0644]
dnsdesig/setup.py [new file with mode: 0644]
dnsdesig/tests/test_plugin.py [new file with mode: 0644]
dnsdesig/tox.ini [new file with mode: 0644]

diff --git a/dnsdesig/LICENSE.txt b/dnsdesig/LICENSE.txt
new file mode 100644 (file)
index 0000000..f90f8f1
--- /dev/null
@@ -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 (file)
index 0000000..a3db6b7
--- /dev/null
@@ -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 (file)
index 0000000..9af2422
--- /dev/null
@@ -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 (file)
index 0000000..c629bea
--- /dev/null
@@ -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 (file)
index 0000000..ee755aa
--- /dev/null
@@ -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 (file)
index 0000000..e69de29
diff --git a/dnsdesig/setup.py b/dnsdesig/setup.py
new file mode 100644 (file)
index 0000000..9450390
--- /dev/null
@@ -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 (file)
index 0000000..730897a
--- /dev/null
@@ -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 (file)
index 0000000..9498c82
--- /dev/null
@@ -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