mirror of
https://github.com/apache/cloudstack
synced 2026-08-02 05:26:35 +00:00
402 lines
15 KiB
Python
402 lines
15 KiB
Python
# Licensed to the Apache Software Foundation (ASF) under one
|
|
# or more contributor license agreements. See the NOTICE file
|
|
# distributed with this work for additional information
|
|
# regarding copyright ownership. The ASF licenses this file
|
|
# to you 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 marvin.cloudstackTestCase import cloudstackTestCase
|
|
from marvin.cloudstackAPI import *
|
|
from nose.plugins.attrib import attr
|
|
|
|
import subprocess
|
|
import time
|
|
import logging
|
|
import socket
|
|
|
|
class TestCloudStackDNSFramework(cloudstackTestCase):
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
"""
|
|
Pre-requisite:
|
|
Bring up PDNS via docker compose (external dependency for DNS provider).
|
|
"""
|
|
super(TestCloudStackDNSFramework, cls).setUpClass()
|
|
cls.api_client = cls.testClient.getApiClient()
|
|
cls.pdns_unavailable = False
|
|
|
|
cls.logger = logging.getLogger("TestCloudStackDNSFramework")
|
|
cls.stream_handler = logging.StreamHandler()
|
|
cls.logger.setLevel(logging.DEBUG)
|
|
cls.logger.addHandler(cls.stream_handler)
|
|
# -------------------------
|
|
# Detect Marvin VM IP (reachable by MS)
|
|
# -------------------------
|
|
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
s.connect(("8.8.8.8", 80))
|
|
cls.marvin_vm_ip = s.getsockname()[0]
|
|
finally:
|
|
s.close()
|
|
|
|
cls.logger.info(f"Detected Marvin VM IP: {cls.marvin_vm_ip}")
|
|
|
|
# -------------------------
|
|
# PDNS compose config
|
|
# -------------------------
|
|
|
|
cls.compose_file = "/marvin/pdns/docker-compose.yml"
|
|
cls.compose_dir = "/marvin/pdns"
|
|
cls.logger.info("Bringing up PDNS via docker compose...")
|
|
|
|
up_cmd = [
|
|
"docker", "compose",
|
|
"-f", cls.compose_file,
|
|
"up", "-d"
|
|
]
|
|
|
|
result = subprocess.run(
|
|
up_cmd,
|
|
cwd=cls.compose_dir,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True
|
|
)
|
|
|
|
if result.returncode != 0:
|
|
cls.tearDownClass()
|
|
raise Exception(f"Failed to start PDNS:\n{result.stderr}")
|
|
|
|
# Wait for PDNS to be ready with polling
|
|
cls.pdns_url = f"http://{cls.marvin_vm_ip}"
|
|
cls.logger.info(f"PDNS endpoint: {cls.pdns_url}:8081")
|
|
cls._wait_for_pdns_ready()
|
|
|
|
@classmethod
|
|
def _wait_for_pdns_ready(cls, timeout=90, interval=1):
|
|
"""
|
|
Poll PDNS API until it responds or timeout is reached.
|
|
Logs a message and sets cls.pdns_unavailable if PDNS is not reachable.
|
|
"""
|
|
import urllib.request
|
|
api_url = f"{cls.pdns_url}:8081/api/v1/servers"
|
|
cls.logger.info(f"Waiting for PDNS to be ready at {api_url} (timeout: {timeout}s)")
|
|
for _ in range(timeout // interval):
|
|
try:
|
|
req = urllib.request.Request(api_url)
|
|
req.add_header("X-API-Key", "supersecretapikey")
|
|
resp = urllib.request.urlopen(req, timeout=2)
|
|
if resp.status == 200:
|
|
cls.logger.info("PDNS is up and running")
|
|
cls.pdns_unavailable = False
|
|
return
|
|
except Exception:
|
|
pass
|
|
time.sleep(interval)
|
|
|
|
cls.logger.warning("PDNS did not become ready within timeout; skipping tests")
|
|
cls.pdns_unavailable = True
|
|
|
|
def setUp(self):
|
|
if self.__class__.pdns_unavailable:
|
|
import unittest
|
|
raise unittest.SkipTest("PDNS is unavailable; skipping test")
|
|
|
|
|
|
@attr(tags=["advanced"], required_hardware="true")
|
|
def test_01_list_dns_providers(self):
|
|
"""
|
|
List DNS providers, expect PowerDNS provider to be present
|
|
"""
|
|
list_providers_cmd = listDnsProviders.listDnsProvidersCmd()
|
|
self.logger.info("Listing DNS providers to verify PowerDNS presence")
|
|
response = self.api_client.listDnsProviders(list_providers_cmd)
|
|
self.assertIsNotNone(response, "Failed to list DNS providers")
|
|
self.logger.info(f"DNS Providers found: {[provider.name for provider in response]}")
|
|
|
|
|
|
@attr(tags=["advanced"], required_hardware="true")
|
|
def test_02_add_dns_server(self):
|
|
"""
|
|
Register PDNS as DNS provider in CloudStack
|
|
"""
|
|
self.logger.info("Adding PDNS DNS server")
|
|
|
|
response = self._add_dns_server()
|
|
self.assertIsNotNone(response, "Failed to add DNS provider")
|
|
self.__class__.dns_server_id = response.id
|
|
self.logger.info(f"DNS Provider added: {response.id}")
|
|
self.assertIsNotNone(response.id, "DNS server ID should not be None")
|
|
|
|
|
|
@attr(tags=["advanced"], required_hardware="true")
|
|
def test_03_list_dns_servers(self):
|
|
"""
|
|
List DNS servers and verify the newly added PDNS provider is present
|
|
"""
|
|
self.logger.info("Listing DNS servers to verify addition")
|
|
list_cmd = listDnsServers.listDnsServersCmd()
|
|
list_cmd.id = self.dns_server_id
|
|
response = self.api_client.listDnsServers(list_cmd)
|
|
self.assertIsNotNone(response, "Failed to list DNS servers")
|
|
self.assertEqual(len(response), 1, "Expected exactly one DNS server")
|
|
self.assertEqual(response[0].id, self.dns_server_id, "DNS server ID mismatch")
|
|
|
|
|
|
@attr(tags=["advanced"], required_hardware="true")
|
|
def test_04_create_dns_zone(self):
|
|
"""
|
|
Create a DNS zone in the added PDNS provider
|
|
"""
|
|
self.logger.info("Creating a DNS zone")
|
|
response = self._create_zone(self.dns_server_id)
|
|
self.assertIsNotNone(response, "Failed to create DNS zone")
|
|
self.assertIsNotNone(response.id, "DNS zone ID should not be None")
|
|
self.__class__.dns_zone_id = response.id
|
|
self.logger.info(f"DNS Zone created: {response.id}")
|
|
|
|
|
|
@attr(tags=["advanced"], required_hardware="true")
|
|
def test_05_list_dns_zones(self):
|
|
"""
|
|
List DNS zones and verify the newly created zone is present
|
|
"""
|
|
self.logger.info("Listing DNS zones to verify creation")
|
|
list_zones_cmd = listDnsZones.listDnsZonesCmd()
|
|
list_zones_cmd.id = self.dns_zone_id
|
|
response = self.api_client.listDnsZones(list_zones_cmd)
|
|
self.assertIsNotNone(response, "Failed to list DNS zones")
|
|
self.assertEqual(len(response), 1, "Expected exactly one DNS zone")
|
|
self.assertEqual(response[0].id, self.dns_zone_id, "DNS zone ID mismatch")
|
|
self.assertEqual(response[0].name, "example.com", "DNS zone name mismatch")
|
|
|
|
@attr(tags=["advanced"], required_hardware="true")
|
|
def test_06_create_a_dns_record(self):
|
|
"""
|
|
Create a DNS record in the previously created zone
|
|
"""
|
|
self.logger.info("Creating A DNS record")
|
|
response = self._create_record(
|
|
self.dns_zone_id,
|
|
"www.example.com",
|
|
"A",
|
|
"10.1.1.10"
|
|
)
|
|
self.assertIsNotNone(response, "Failed to create DNS record")
|
|
self.assertEqual(response.name, "www.example.com", "DNS record name mismatch")
|
|
self._assert_dns("www.example.com", "A", expected="10.1.1.10")
|
|
|
|
@attr(tags=["advanced"], required_hardware="true")
|
|
def test_07_create_aaaa_dns_records(self):
|
|
"""
|
|
Create AAAA DNS records in the previously created zone
|
|
"""
|
|
self.logger.info("Creating AAAA DNS records")
|
|
response = self._create_record(
|
|
self.dns_zone_id,
|
|
"www.example.com",
|
|
"AAAA",
|
|
"2001:db8::10"
|
|
)
|
|
self.assertIsNotNone(response, "Failed to create AAAA DNS record")
|
|
self.assertTrue(response.name is not None, "DNS record name should not be None")
|
|
self._assert_dns("www.example.com", "AAAA", expected="2001:db8::10")
|
|
|
|
@attr(tags=["advanced"], required_hardware="true")
|
|
def test_08_create_mx_dns_record(self):
|
|
"""
|
|
Create an MX DNS record in the previously created zone
|
|
"""
|
|
self.logger.info("Creating an MX DNS record")
|
|
response = self._create_record(
|
|
self.dns_zone_id,
|
|
"example.com",
|
|
"MX",
|
|
"10 mail.example.com"
|
|
)
|
|
self.assertIsNotNone(response, "Failed to create MX DNS record")
|
|
self.assertTrue(response.name is not None, "DNS record name should not be None")
|
|
self._assert_dns("example.com", "MX", contains=["10", "mail.example.com"])
|
|
|
|
|
|
@attr(tags=["advanced"], required_hardware="true")
|
|
def test_09_list_dns_records(self):
|
|
"""
|
|
List DNS records in the zone and verify the created records are present
|
|
"""
|
|
self.logger.info("Listing DNS records to verify creation")
|
|
list_records_cmd = listDnsRecords.listDnsRecordsCmd()
|
|
list_records_cmd.dnszoneid = self.dns_zone_id
|
|
response = self.api_client.listDnsRecords(list_records_cmd)
|
|
self.assertIsNotNone(response, "Failed to list DNS records")
|
|
self.assertEqual(len(response), 4, "Expected four DNS records, including NS record")
|
|
record_types = set(record.type for record in response)
|
|
self.assertSetEqual(record_types, {"NS", "A", "AAAA", "MX"}, "DNS record types mismatch")
|
|
|
|
@attr(tags=["advanced"], required_hardware="true")
|
|
def test_10_delete_dns_record(self):
|
|
"""
|
|
Delete one of the DNS records and verify it's removed
|
|
"""
|
|
self.logger.info("Deleting a DNS record")
|
|
delete_record_cmd = deleteDnsRecord.deleteDnsRecordCmd()
|
|
delete_record_cmd.name = "www.example.com"
|
|
delete_record_cmd.type = "A"
|
|
delete_record_cmd.dnszoneid = self.dns_zone_id
|
|
delete_response = self.api_client.deleteDnsRecord(delete_record_cmd)
|
|
self.assertIsNotNone(delete_response, "Failed to delete DNS record")
|
|
self.logger.info(f"DNS Record deleted: {delete_record_cmd.name}")
|
|
|
|
# Verify deletion
|
|
list_record_cmd = listDnsRecords.listDnsRecordsCmd()
|
|
list_record_cmd.dnszoneid = self.dns_zone_id
|
|
response_after_deletion = self.api_client.listDnsRecords(list_record_cmd)
|
|
self.assertEqual(len(response_after_deletion), 3, "Expected three DNS records after deletion")
|
|
remaining_record_names = set(record.name for record in response_after_deletion)
|
|
self.assertNotIn(delete_record_cmd.name, remaining_record_names, "Deleted DNS record still present")
|
|
|
|
@attr(tags=["advanced"], required_hardware="true")
|
|
def test_11_delete_dns_zone(self):
|
|
"""
|
|
Delete the DNS zone and verify it's removed
|
|
"""
|
|
self.logger.info("Deleting the DNS zone")
|
|
delete_zone_cmd = deleteDnsZone.deleteDnsZoneCmd()
|
|
delete_zone_cmd.id = self.dns_zone_id
|
|
response = self.api_client.deleteDnsZone(delete_zone_cmd)
|
|
self.assertIsNotNone(response, "Failed to delete DNS zone")
|
|
self.logger.info(f"DNS Zone deleted: {self.dns_zone_id}")
|
|
|
|
# Verify deletion
|
|
list_zones_cmd = listDnsZones.listDnsZonesCmd()
|
|
list_zones_cmd.id = self.dns_zone_id
|
|
try:
|
|
self.api_client.listDnsZones(list_zones_cmd)
|
|
self.fail("DNS zone still exists after deletion")
|
|
except Exception as e:
|
|
self.logger.info(f"Expected exception after delete: {str(e)}")
|
|
|
|
@attr(tags=["advanced"], required_hardware="true")
|
|
def test_12_delete_dns_server(self):
|
|
"""
|
|
Delete the PDNS DNS server and verify it's removed
|
|
"""
|
|
self.logger.info("Deleting the PDNS DNS server")
|
|
delete_cmd = deleteDnsServer.deleteDnsServerCmd()
|
|
delete_cmd.id = self.dns_server_id
|
|
response = self.api_client.deleteDnsServer(delete_cmd)
|
|
self.assertIsNotNone(response, "Failed to delete DNS server")
|
|
self.logger.info(f"DNS Server deleted: {self.dns_server_id}")
|
|
|
|
# Verify deletion
|
|
list_cmd = listDnsServers.listDnsServersCmd()
|
|
list_cmd.id = self.dns_server_id
|
|
response = self.api_client.listDnsServers(list_cmd)
|
|
dns_servers = response or []
|
|
self.assertEqual(len(dns_servers), 0, "Expected no DNS servers after deletion")
|
|
|
|
@classmethod
|
|
def tearDownClass(cls):
|
|
"""
|
|
Stop PDNS after tests
|
|
"""
|
|
|
|
try:
|
|
cls.logger.info("Stopping PDNS stack...")
|
|
|
|
cmd = [
|
|
"docker", "compose",
|
|
"-f", cls.compose_file,
|
|
"down"
|
|
]
|
|
|
|
subprocess.run(cmd, cwd=cls.compose_dir)
|
|
|
|
finally:
|
|
super(TestCloudStackDNSFramework, cls).tearDownClass()
|
|
|
|
|
|
def _create_record(self, zone_id, name, rtype, contents):
|
|
cmd = createDnsRecord.createDnsRecordCmd()
|
|
cmd.dnszoneid = zone_id
|
|
cmd.name = name
|
|
cmd.type = rtype
|
|
cmd.contents = contents
|
|
|
|
return self.api_client.createDnsRecord(cmd)
|
|
|
|
|
|
def _add_dns_server(self):
|
|
cmd = addDnsServer.addDnsServerCmd()
|
|
cmd.name = "pdns-server"
|
|
cmd.url = self.pdns_url
|
|
cmd.dnsapikey = "supersecretapikey"
|
|
cmd.provider = "PowerDNS"
|
|
cmd.nameservers = ["ns1.example.com", "ns2.example.com"]
|
|
cmd.externalserverid = "localhost"
|
|
cmd.ispublic = True
|
|
cmd.port = 8081
|
|
cmd.publicdomainsuffix = "pdns-public.example.com"
|
|
|
|
return self.api_client.addDnsServer(cmd)
|
|
|
|
|
|
def _create_zone(self, server_id):
|
|
cmd = createDnsZone.createDnsZoneCmd()
|
|
cmd.dnsserverid = server_id
|
|
cmd.name = "example.com"
|
|
cmd.description = "Test DNS Zone for PDNS"
|
|
|
|
return self.api_client.createDnsZone(cmd)
|
|
|
|
|
|
def _dig(self, name, rtype):
|
|
dns_ip = self.__class__.marvin_vm_ip
|
|
dns_port = 53
|
|
|
|
cmd = [
|
|
"dig",
|
|
f"@{dns_ip}",
|
|
"-p",
|
|
str(dns_port),
|
|
name,
|
|
rtype,
|
|
"+short"
|
|
]
|
|
self.logger.info(f"Running: {' '.join(cmd)}")
|
|
result = subprocess.run(
|
|
cmd,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True
|
|
)
|
|
|
|
self.assertEqual(result.returncode, 0, f"dig failed: {result.stderr}")
|
|
output = result.stdout.strip().replace("\n", " ")
|
|
self.logger.info(f"dig output: {output}")
|
|
return output
|
|
|
|
|
|
def _assert_dns(self, name, rtype, expected=None, contains=None):
|
|
output = self._dig(name, rtype)
|
|
|
|
if expected is not None:
|
|
self.assertIn(expected, output)
|
|
|
|
if contains:
|
|
for item in contains:
|
|
self.assertIn(item, output)
|
|
|
|
return output
|