summaryrefslogtreecommitdiffstats
path: root/dnsdesig
diff options
context:
space:
mode:
authorAndrew Gauld <ag1282@att.com>2017-08-23 13:57:51 -0400
committerAndrew Gauld <ag1282@att.com>2017-08-23 13:58:41 -0400
commit26d8a24ade044273fe585df50f6715f8582f74e9 (patch)
treee2a2b5abbfd1e13448cc6c9e4400b41f281dddf9 /dnsdesig
parent4468e8b611f85290633fee8ee9256297976bd2d9 (diff)
Seed code for Cloudify dns/Designate plugin
Change-Id: Ibc157472d6001076959face4ff3a06a808096f78 Issue-Id: CCSDK-66 Signed-off-by: Andrew Gauld <ag1282@att.com>
Diffstat (limited to 'dnsdesig')
-rw-r--r--dnsdesig/LICENSE.txt17
-rw-r--r--dnsdesig/README.md22
-rw-r--r--dnsdesig/dns_types.yaml65
-rw-r--r--dnsdesig/dnsdesig/__init__.py28
-rw-r--r--dnsdesig/dnsdesig/dns_plugin.py151
-rw-r--r--dnsdesig/requirements.txt0
-rw-r--r--dnsdesig/setup.py35
-rw-r--r--dnsdesig/tests/test_plugin.py272
-rw-r--r--dnsdesig/tox.ini26
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