Skip to content

Commit 75f556e

Browse files
authored
Merge pull request #1683 from grycap/dydns
Dydns
2 parents e15f401 + 46de28f commit 75f556e

File tree

6 files changed

+281
-72
lines changed

6 files changed

+281
-72
lines changed

IM/connectors/CloudConnector.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -755,16 +755,16 @@ def manage_dns_entries(self, op, vm, auth_data, extra_args=None):
755755
else:
756756
raise Exception("Invalid DNS operation.")
757757
except NotImplementedError as niex:
758-
# Use EC2 as back up for all providers if EC2 credentials are available
759-
# TODO: Change it to DyDNS when the full API is available
760-
if auth_data.getAuthInfo("EC2"):
761-
from IM.connectors.EC2 import EC2CloudConnector
758+
# Use DyDNS as back up for all providers if IM credentials uses token auth
759+
im_auth = auth_data.getAuthInfo("InfrastructureManager")
760+
if im_auth and im_auth[0].get("token") or (hostname.startswith("dydns:") and "@" in hostname):
761+
from IM.connectors.EGI import EGICloudConnector
762762
if op == "add":
763-
success = EC2CloudConnector.add_dns_entry(self, hostname, domain, ip, auth_data)
763+
success = EGICloudConnector.add_dns_entry(self, hostname, domain, ip, auth_data)
764764
if success and entry not in vm.dns_entries:
765765
vm.dns_entries.append(entry)
766766
elif op == "del":
767-
EC2CloudConnector.del_dns_entry(self, hostname, domain, ip, auth_data)
767+
EGICloudConnector.del_dns_entry(self, hostname, domain, ip, auth_data)
768768
if entry in vm.dns_entries:
769769
vm.dns_entries.remove(entry)
770770
else:

IM/connectors/EC2.py

Lines changed: 3 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1335,23 +1335,8 @@ def _get_change_batch(action, fqdn, ip):
13351335

13361336
def add_dns_entry(self, hostname, domain, ip, auth_data, extra_args=None):
13371337
try:
1338-
# Workaround to use EC2 as the default case.
1339-
if self.type == "EC2":
1340-
conn = self.get_connection('universal', auth_data, 'route53')
1341-
else:
1342-
auths = auth_data.getAuthInfo("EC2")
1343-
if not auths:
1344-
raise NoAuthData(self.type)
1345-
else:
1346-
auth = auths[0]
1347-
conn = boto3.client('route53', region_name='universal',
1348-
aws_access_key_id=auth['username'],
1349-
aws_secret_access_key=auth['password'])
1350-
1338+
conn = self.get_connection('universal', auth_data, 'route53')
13511339
zone = EC2CloudConnector._get_zone(conn, domain)
1352-
if not zone:
1353-
raise CloudConnectorException("Could not find DNS zone to update")
1354-
zone_id = zone['Id']
13551340

13561341
if not zone:
13571342
self.log_info("Creating DNS zone %s" % domain)
@@ -1360,6 +1345,7 @@ def add_dns_entry(self, hostname, domain, ip, auth_data, extra_args=None):
13601345
self.log_info("DNS zone %s exists. Do not create." % domain)
13611346

13621347
if zone:
1348+
zone_id = zone['Id']
13631349
fqdn = hostname + "." + domain
13641350
records = conn.list_resource_record_sets(HostedZoneId=zone_id,
13651351
StartRecordName=fqdn,
@@ -1379,18 +1365,7 @@ def add_dns_entry(self, hostname, domain, ip, auth_data, extra_args=None):
13791365
return False
13801366

13811367
def del_dns_entry(self, hostname, domain, ip, auth_data, extra_args=None):
1382-
# Workaround to use EC2 as the default case.
1383-
if self.type == "EC2":
1384-
conn = self.get_connection('universal', auth_data, 'route53')
1385-
else:
1386-
auths = auth_data.getAuthInfo("EC2")
1387-
if not auths:
1388-
raise NoAuthData(self.type)
1389-
else:
1390-
auth = auths[0]
1391-
conn = boto3.client('route53', region_name='universal',
1392-
aws_access_key_id=auth['username'],
1393-
aws_secret_access_key=auth['password'])
1368+
conn = self.get_connection('universal', auth_data, 'route53')
13941369
zone = EC2CloudConnector._get_zone(conn, domain)
13951370
if not zone:
13961371
self.log_info("The DNS zone %s does not exists. Do not delete records." % domain)

IM/connectors/EGI.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
# IM - Infrastructure Manager
2+
# Copyright (C) 2011 - GRyCAP - Universitat Politecnica de Valencia
3+
#
4+
# This program is free software: you can redistribute it and/or modify
5+
# it under the terms of the GNU General Public License as published by
6+
# the Free Software Foundation, either version 3 of the License, or
7+
# (at your option) any later version.
8+
#
9+
# This program is distributed in the hope that it will be useful,
10+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12+
# GNU General Public License for more details.
13+
#
14+
# You should have received a copy of the GNU General Public License
15+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
16+
17+
18+
import base64
19+
import requests
20+
from .CloudConnector import CloudConnector
21+
22+
23+
class EGICloudConnector(CloudConnector):
24+
"""
25+
Cloud connector for the EGI cloud provider, that allows to manage the DNS entries with DyDNS service
26+
"""
27+
28+
type = "EGI"
29+
"""str with the name of the provider."""
30+
DYDNS_URL = "https://nsupdate.fedcloud.eu"
31+
DEFAULT_TIMEOUT = 10
32+
33+
def _get_domains(self, token):
34+
"""
35+
List the domains available in the DyDNS service
36+
"""
37+
url = f'{self.DYDNS_URL}/nic/domains'
38+
resp = requests.get(url, headers={'Authorization': f'Bearer {token}'}, timeout=self.DEFAULT_TIMEOUT)
39+
if resp.status_code != 200:
40+
self.log_error(f"Error getting domains: {resp.text}")
41+
return None
42+
output = resp.json()
43+
if output.get("status") != "ok":
44+
self.log_error(f"Error getting domains: {output.get('message', 'Unknown error')}")
45+
return None
46+
domains = []
47+
for domain in output.get("private", []) + output.get("public", []):
48+
if domain.get("available"):
49+
domains.append(domain["name"])
50+
return domains
51+
52+
def _get_host(self, hostname, domain, token):
53+
"""
54+
Look for a host registered in the DyDNS service
55+
"""
56+
if hostname == "*":
57+
parts = domain.split(".")
58+
domain = ".".join(parts[1:])
59+
hostname = parts[0]
60+
url = f'{self.DYDNS_URL}/nic/hosts?domain={domain}'
61+
resp = requests.get(url, headers={'Authorization': f'Bearer {token}'}, timeout=self.DEFAULT_TIMEOUT)
62+
if resp.status_code != 200:
63+
self.log_error(f"Error getting host {hostname}.{domain}: {resp.text}")
64+
return None
65+
66+
output = resp.json()
67+
if output.get("status") != "ok":
68+
self.log_error(f"Error getting host {hostname}.{domain}: {output.get('message', 'Unknown error')}")
69+
return None
70+
71+
for host in output.get("hosts", []):
72+
if host.get("name") == hostname:
73+
return host
74+
75+
return None
76+
77+
def add_dns_entry(self, hostname, domain, ip, auth_data, extra_args=None):
78+
"""
79+
Add a DNS entry to the DNS server
80+
"""
81+
im_auth = auth_data.getAuthInfo("InfrastructureManager")
82+
try:
83+
secret = None
84+
if im_auth and im_auth[0].get("token"):
85+
self.log_debug(f"Registering DNS entry {hostname}.{domain} with DyDNS oauth token")
86+
token = im_auth[0].get("token")
87+
# Check if the host already exists
88+
host = self._get_host(hostname, domain, token)
89+
if host:
90+
self.log_debug(f"DNS entry {hostname}.{domain} already exists")
91+
if ip in [host.get("ipv4"), host.get("ipv6")]:
92+
print(f"DNS entry {hostname}.{domain} already has the IP {ip}")
93+
return True
94+
else:
95+
commennt = 'IM created DNS entry'
96+
if hostname == "*":
97+
url = f'{self.DYDNS_URL}/nic/register?fqdn={domain}&comment={commennt}&wildcard=true'
98+
else:
99+
url = f'{self.DYDNS_URL}/nic/register?fqdn={hostname}.{domain}&comment={commennt}'
100+
resp = requests.get(url, headers={'Authorization': f'Bearer {token}'}, timeout=self.DEFAULT_TIMEOUT)
101+
if resp.status_code != 200:
102+
self.log_error(f"Error registering DNS entry {hostname}.{domain}: {resp.text}")
103+
return False
104+
105+
resp_json = resp.json()
106+
if resp_json.get("status") != "ok":
107+
self.log_error(f"Error registering DNS entry {hostname}.{domain}:"
108+
f" {resp_json.get('message', 'Unknown error')}")
109+
return False
110+
elif hostname.startswith("dydns:") and "@" in hostname:
111+
self.log_debug(f"Updating DNS entry {hostname}.{domain} with secret")
112+
parts = hostname[6:].split("@")
113+
secret = parts[0]
114+
hostname = parts[1]
115+
domain = domain[:-1]
116+
else:
117+
self.log_error(f"Error updating DNS entry {hostname}.{domain}: No secret nor token provided")
118+
return False
119+
120+
if secret:
121+
auth = f"{hostname}.{domain}:{secret}"
122+
headers = {"Authorization": "Basic %s" % base64.b64encode(auth.encode()).decode()}
123+
else:
124+
headers = {'Authorization': f'Bearer {token}'}
125+
126+
fqdn = f'{hostname}.{domain}'
127+
if hostname == "*":
128+
fqdn = domain
129+
url = f'{self.DYDNS_URL}/nic/update?hostname={fqdn}&myip={ip}'
130+
resp = requests.get(url, headers=headers, timeout=self.DEFAULT_TIMEOUT)
131+
if resp.status_code != 200:
132+
self.log_error(f"Error updating DNS entry {hostname}.{domain}: {resp.text}")
133+
return False
134+
return True
135+
except Exception as e:
136+
self.log_error(f"Error registering DNS entry {hostname}.{domain}: {str(e)}")
137+
return False
138+
139+
def del_dns_entry(self, hostname, domain, ip, auth_data, extra_args=None):
140+
"""
141+
Delete a DNS entry from the DNS server
142+
"""
143+
im_auth = auth_data.getAuthInfo("InfrastructureManager")
144+
try:
145+
if im_auth and im_auth[0].get("token"):
146+
self.log_debug(f"Deleting DNS entry {hostname}.{domain} with DyDNS oauth token")
147+
token = im_auth[0].get("token")
148+
149+
host = self._get_host(hostname, domain, token)
150+
if not host:
151+
self.log_debug(f"DNS entry {hostname}.{domain} does not exist. Do not need to delete.")
152+
return True
153+
154+
if hostname == "*":
155+
url = f'{self.DYDNS_URL}/nic/unregister?fqdn={domain}'
156+
else:
157+
url = f'{self.DYDNS_URL}/nic/unregister?fqdn={hostname}.{domain}'
158+
resp = requests.get(url, headers={'Authorization': f'Bearer {token}'}, timeout=self.DEFAULT_TIMEOUT)
159+
if resp.status_code != 200:
160+
self.log_error(f"Error deleting DNS entry {hostname}.{domain}: {resp.text}")
161+
return False
162+
else:
163+
self.log_error(f"Error updating DNS entry {hostname}.{domain}: No token provided")
164+
return False
165+
except Exception as e:
166+
self.log_error(f"Error deleting DNS entry {hostname}.{domain}: {str(e)}")
167+
return False
168+
169+
return True

IM/connectors/OpenStack.py

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -590,38 +590,6 @@ def updateVMInfo(self, vm, auth_data):
590590

591591
return (True, vm)
592592

593-
def add_dns_entry(self, hostname, domain, ip, auth_data, extra_args=None):
594-
# Special case for EGI DyDNS
595-
# format of the hostname: dydns:secret@hostname
596-
if hostname.startswith("dydns:") and "@" in hostname:
597-
parts = hostname[6:].split("@")
598-
auth = "%s.%s:%s" % (parts[1], domain[:-1], parts[0])
599-
headers = {"Authorization": "Basic %s" % base64.b64encode(auth.encode()).decode()}
600-
url = "https://nsupdate.fedcloud.eu/nic/update?hostname=%s.%s&myip=%s" % (parts[1],
601-
domain[:-1],
602-
ip)
603-
try:
604-
resp = requests.get(url, headers=headers, timeout=10)
605-
resp.raise_for_status()
606-
except Exception as ex:
607-
self.error_messages += "Error creating DNS entries %s.\n" % str(ex)
608-
self.log_exception("Error creating DNS entries")
609-
return False
610-
else:
611-
# TODO: https://docs.openstack.org/designate/latest/index.html
612-
raise NotImplementedError("Should have implemented this")
613-
return True
614-
615-
def del_dns_entry(self, hostname, domain, ip, auth_data, extra_args=None):
616-
# Special case for EGI DyDNS
617-
# format of the hostname: dydns:secret@hostname
618-
if hostname.startswith("dydns:") and "@" in hostname:
619-
self.log_info("DYDNS entry. Cannot be deleted.")
620-
else:
621-
# TODO: https://docs.openstack.org/designate/latest/index.html
622-
raise NotImplementedError("Should have implemented this")
623-
return True
624-
625593
@staticmethod
626594
def map_radl_ost_networks(vm, ost_nets):
627595
"""

test/unit/connectors/EGI.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
#! /usr/bin/env python
2+
#
3+
# IM - Infrastructure Manager
4+
# Copyright (C) 2011 - GRyCAP - Universitat Politecnica de Valencia
5+
#
6+
# This program is free software: you can redistribute it and/or modify
7+
# it under the terms of the GNU General Public License as published by
8+
# the Free Software Foundation, either version 3 of the License, or
9+
# (at your option) any later version.
10+
#
11+
# This program is distributed in the hope that it will be useful,
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14+
# GNU General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU General Public License
17+
# along with this program. If not, see <http://www.gnu.org/licenses/>.
18+
import sys
19+
import unittest
20+
21+
sys.path.append(".")
22+
sys.path.append("..")
23+
from IM.auth import Authentication
24+
from IM.connectors.EGI import EGICloudConnector
25+
from mock import patch, MagicMock, call
26+
27+
28+
class TestEGIConnector(unittest.TestCase):
29+
"""
30+
Class to test the EGI connector
31+
"""
32+
33+
@patch('requests.get')
34+
@patch('IM.connectors.EGI.EGICloudConnector._get_host')
35+
def test_add_dns(self, mock_get_host, mock_get):
36+
mock_get_host.return_value = None
37+
mock_get.return_value = MagicMock(status_code=200, json=lambda: {"status": "ok",
38+
"host": {"update_secret": "123"}})
39+
auth_data = Authentication([{'type': 'InfrastructureManager', 'token': 'access_token'}])
40+
cloud = EGICloudConnector(None, None)
41+
success = EGICloudConnector.add_dns_entry(cloud, "hostname", "domain", "ip", auth_data)
42+
self.assertTrue(success)
43+
self.assertEqual(mock_get.call_count, 2)
44+
eurl1 = f"{EGICloudConnector.DYDNS_URL}/nic/register?fqdn=hostname.domain&comment=IM created DNS entry"
45+
eurl2 = f"{EGICloudConnector.DYDNS_URL}/nic/update?hostname=hostname.domain&myip=ip"
46+
calls = [call(eurl1, headers={'Authorization': 'Bearer access_token'}, timeout=10),
47+
call(eurl2, headers={'Authorization': 'Bearer access_token'}, timeout=10)]
48+
mock_get.assert_has_calls(calls)
49+
50+
@patch('requests.get')
51+
@patch('IM.connectors.EGI.EGICloudConnector._get_host')
52+
def test_add_dydns(self, mock_get_host, mock_get):
53+
mock_get_host.return_value = None
54+
mock_get.return_value = MagicMock(status_code=200, json=lambda: {"status": "ok",
55+
"host": {"update_secret": "123"}})
56+
cloud = EGICloudConnector(None, None)
57+
auth_data = Authentication([{'type': 'InfrastructureManager', 'username': 'user', 'password': 'pass'}])
58+
success = EGICloudConnector.add_dns_entry(cloud, "dydns:123@hostname", "domain.", "ip", auth_data)
59+
self.assertTrue(success)
60+
eurl = f"{EGICloudConnector.DYDNS_URL}/nic/update?hostname=hostname.domain&myip=ip"
61+
self.assertEqual(mock_get.call_count, 1)
62+
mock_get.assert_any_call(eurl, headers={'Authorization': 'Basic aG9zdG5hbWUuZG9tYWluOjEyMw=='}, timeout=10)
63+
64+
@patch('requests.get')
65+
@patch('IM.connectors.EGI.EGICloudConnector._get_host')
66+
def test_del_dns(self, mock_get_host, mock_get):
67+
mock_get_host.return_value = {"name": "hostname"}
68+
mock_get.return_value = MagicMock(status_code=200, json=lambda: {"status": "ok"})
69+
auth_data = Authentication([{'type': 'InfrastructureManager', 'token': 'access_token'}])
70+
cloud = EGICloudConnector(None, None)
71+
success = EGICloudConnector.del_dns_entry(cloud, "hostname", "domain", "ip", auth_data)
72+
self.assertTrue(success)
73+
eurl = f"{EGICloudConnector.DYDNS_URL}/nic/unregister?fqdn=hostname.domain"
74+
mock_get.assert_called_with(eurl, headers={'Authorization': 'Bearer access_token'}, timeout=10)
75+
76+
@patch('requests.get')
77+
def test_get_host(self, mock_get):
78+
mock_get.return_value = MagicMock(status_code=200, json=lambda: {"status": "ok",
79+
"hosts": [{"name": "hostname"}]})
80+
cloud = EGICloudConnector(None, None)
81+
host = EGICloudConnector._get_host(cloud, "hostname", "domain", "access_token")
82+
self.assertEqual(host["name"], "hostname")
83+
self.assertEqual(mock_get.call_count, 1)
84+
eurl = f"{EGICloudConnector.DYDNS_URL}/nic/hosts?domain=domain"
85+
mock_get.assert_called_with(eurl, headers={'Authorization': 'Bearer access_token'}, timeout=10)
86+
87+
@patch('requests.get')
88+
def test_get_domains(self, mock_get):
89+
mock_get.return_value = MagicMock(status_code=200, json=lambda: {"status": "ok",
90+
"private": [{"name": "domain1",
91+
"available": True}],
92+
"public": [{"name": "domain2",
93+
"available": True}]})
94+
cloud = EGICloudConnector(None, None)
95+
domains = EGICloudConnector._get_domains(cloud, "access_token")
96+
self.assertEqual(domains, ["domain1", "domain2"])
97+
self.assertEqual(mock_get.call_count, 1)
98+
eurl = f"{EGICloudConnector.DYDNS_URL}/nic/domains"
99+
mock_get.assert_called_with(eurl, headers={'Authorization': 'Bearer access_token'}, timeout=10)
100+
101+
102+
if __name__ == '__main__':
103+
unittest.main()

0 commit comments

Comments
 (0)