netbox2cpnr.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616
  1. #!/usr/bin/env python
  2. from elemental_utils import ElementalDns, ElementalNetbox
  3. from elemental_utils.cpnr.query import RequestError
  4. # from elemental_utils.cpnr.query import RequestError
  5. from elemental_utils import cpnr
  6. from utils import (
  7. dedup_cnames,
  8. get_cname_record,
  9. launch_parallel_task,
  10. restart_dns_servers,
  11. get_reverse_zone,
  12. )
  13. from pynetbox.core.response import Record
  14. from pynetbox.models.ipam import IpAddresses
  15. import CLEUCreds # type: ignore
  16. from cleu.config import Config as C # type: ignore
  17. # from pynetbox.models.virtualization import VirtualMachines
  18. from colorama import Fore, Style
  19. from typing import Union, Tuple, List
  20. from dataclasses import dataclass, field
  21. from threading import Lock
  22. import os
  23. # import ipaddress
  24. import logging.config
  25. import logging
  26. import argparse
  27. import sys
  28. # import json
  29. import re
  30. # import hvac
  31. logging.config.fileConfig(os.path.realpath(os.path.dirname(os.path.realpath(__file__)) + "/dns_logger.conf"))
  32. logger = logging.getLogger(__name__)
  33. EDNS_MODIFIED = False
  34. @dataclass
  35. class ARecord:
  36. """Class representing a DNS Address record."""
  37. hostname: str
  38. ip: str
  39. domain: str
  40. nb_record: IpAddresses
  41. _name: str
  42. @dataclass
  43. class CNAMERecord:
  44. """Class representing a DNS CNAME record."""
  45. alias: str
  46. domain: str
  47. host: ARecord
  48. _name: str
  49. @dataclass
  50. class PTRRecord:
  51. """Class representing a DNS PTR record."""
  52. rev_ip: str
  53. hostname: str
  54. rzone: str
  55. nb_record: IpAddresses
  56. _name: str
  57. @dataclass
  58. class TXTRecord:
  59. """Class representing a DNS TXT record."""
  60. name: str
  61. value: str
  62. @dataclass
  63. class DnsRecords:
  64. """Class for tracking DNS records to delete and create."""
  65. creates: list = field(default_factory=list)
  66. deletes: List[Tuple] = field(default_factory=list)
  67. lock: Lock = Lock()
  68. def get_txt_record(ip: IpAddresses) -> str:
  69. """Return a serialized form of an IP/VM/device object for use in a TXT record.
  70. Args:
  71. :ip IpAddresses: IP address object to process
  72. Returns:
  73. :str: TXT record data
  74. """
  75. result = "v=_netbox "
  76. atype = ip.assigned_object_type
  77. if atype == "virtualization.vminterface":
  78. result += (
  79. f"url={ip.assigned_object.virtual_machine.serialize()['url']} type=vm id={ip.assigned_object.virtual_machine.id} ip_id={ip.id}"
  80. )
  81. elif atype == "dcim.interface":
  82. result += f"url={ip.assigned_object.device.serialize()['url']} type=device id={ip.assigned_object.device.id} ip_id={ip.id}"
  83. else:
  84. result += f"ip_id={ip.id} type=ip"
  85. return f'"{result}"'
  86. def get_dns_name(ip: IpAddresses) -> str:
  87. """Get a DNS name based on the IP object's assigned object.
  88. Args:
  89. :ip IpAddresses: IP address object to check
  90. Returns:
  91. :str: DNS name if one is found else None
  92. """
  93. dns_name = None
  94. if ip.assigned_object:
  95. atype = ip.assigned_object_type
  96. aobj = ip.assigned_object
  97. if atype == "virtualization.vminterface":
  98. if aobj.virtual_machine.primary_ip4 == ip:
  99. dns_name = aobj.virtual_machine.name.lower()
  100. elif ip.dns_name and ip.dns_name != "":
  101. dns_name = ip.dns_name.strip().lower()
  102. elif atype == "dcim.interface":
  103. if aobj.device.primary_ip4 == ip:
  104. dns_name = aobj.device.name.lower()
  105. elif ip.dns_name and ip.dns_name != "":
  106. dns_name = ip.dns_name.strip().lower()
  107. elif ip.dns_name and ip.dns_name != "":
  108. dns_name = ip.dns_name.strip().lower()
  109. return dns_name
  110. def check_record(ip: IpAddresses, primary_domain: str, edns: ElementalDns, enb: ElementalNetbox, wip_records: DnsRecords) -> None:
  111. """Check to see if a given NetBox IP object needs DNS updates.
  112. Args:
  113. :ip IpAddresses: NetBox IP address object to check
  114. :primary_domain str: Primary domain name for the records for the IP/host with trailing '.'
  115. :edns ElementalDns: ElementalDns object representing the auth DNS for the primary_domain
  116. :enb ElementalNetbox: ElementalNetbox object for querying
  117. :wip_records DnsRecords: Object to hold the results of the function
  118. """
  119. dns_name = get_dns_name(ip)
  120. # If we don't have a name, then we have nothing to check.
  121. if not dns_name:
  122. return
  123. if not re.match(r"^[a-z0-9-]+$", dns_name):
  124. logger.warning(f"⛔️ Invalid DNS name {dns_name} for IP {ip.address}")
  125. return
  126. ip_address = ip.address.split("/")[0]
  127. rzone_name = get_reverse_zone(ip_address)
  128. ptr_name = ip_address.split(".")[::-1][0]
  129. old_ptrs = []
  130. # Get the current A record from DNS (if it exists)
  131. current_host_record = edns.host.get(dns_name, zoneOrigin=primary_domain)
  132. # Get the current PTR record from DNS (if it exists)
  133. current_ptr_record = edns.rrset.get(ptr_name, zoneOrigin=rzone_name)
  134. # Declare an A record for the current object.
  135. a_record = ARecord(dns_name, ip_address, primary_domain, ip, dns_name)
  136. # Track whether or not we need a change
  137. change_needed = False
  138. if not current_host_record:
  139. # An A record doesn't yet exist.
  140. change_needed = True
  141. else:
  142. if ip_address not in current_host_record.addrs["stringItem"]:
  143. # An A record exists for the hostname but pointing to a different IP. Remove it.
  144. change_needed = True
  145. # Also, remove the old PTR.
  146. for addr in current_host_record.addrs["stringItem"]:
  147. old_ptrs.append((addr.split(".")[::-1][0], get_reverse_zone(addr)))
  148. else:
  149. # Check if we have a TXT meta-record. If this does not exist the existing host record will be removed and a new one added
  150. change_needed = check_txt_record(current_host_record, ip, edns)
  151. if current_ptr_record:
  152. found_match = False
  153. for rr in current_ptr_record.rrList["CCMRRItem"]:
  154. if rr["rrType"] == "PTR" and rr["rdata"] == f"{dns_name}.{primary_domain}":
  155. found_match = True
  156. break
  157. if not found_match:
  158. change_needed = True
  159. if change_needed:
  160. # If a change is required in the A/PTR records, mark the old records for removal and add
  161. # the new records.
  162. wip_records.lock.acquire()
  163. if current_host_record:
  164. if (current_host_record.name, primary_domain) not in wip_records.deletes:
  165. wip_records.deletes.append((current_host_record.name, primary_domain))
  166. # Cleanup the old PTRs, too.
  167. for old_ptr in old_ptrs:
  168. if old_ptr not in wip_records.deletes:
  169. wip_records.deletes.append(old_ptr)
  170. if current_ptr_record:
  171. if (current_ptr_record.name, rzone_name) not in wip_records.deletes:
  172. wip_records.deletes.append((current_ptr_record.name, rzone_name))
  173. # Delete the old A record, too.
  174. for rr in current_ptr_record.rrList["CCMRRItem"]:
  175. if rr["rrType"] == "PTR":
  176. host_name = rr["rdata"].split(".")[0]
  177. if (host_name, primary_domain) not in wip_records.deletes:
  178. wip_records.deletes.append((host_name, primary_domain))
  179. wip_records.creates.append(a_record)
  180. wip_records.lock.release()
  181. # Process any CNAMEs that may exist for this record.
  182. check_cnames(ip=ip, dns_name=dns_name, primary_domain=primary_domain, a_record=a_record, edns=edns, wip_records=wip_records)
  183. def check_cnames(
  184. ip: IpAddresses, dns_name: str, primary_domain: str, a_record: ARecord, edns: ElementalDns, wip_records: DnsRecords
  185. ) -> None:
  186. """Determine CNAME records to create/delete.
  187. Args:
  188. :ip IpAddresses: IP address object to check
  189. :dns_name str: Main hostname of the record
  190. :primary_domain str: Primary domain name of the record
  191. :a_record ARecord: A record object to link CNAMEs to
  192. :enb ElementalNetbox: ElementalNetbox object for NetBox queries
  193. :wip_records DnsRecords: DnsRecords object to hold the results
  194. """
  195. cnames = ip.custom_fields.get("CNAMEs")
  196. if not cnames:
  197. cnames = ""
  198. else:
  199. cnames = cnames.lower().strip()
  200. primary_cname = ""
  201. # Add the IP's DNS Name as a CNAME if it is unique.
  202. if ip.dns_name and ip.dns_name != "" and ip.dns_name.strip().lower() != dns_name:
  203. primary_cname = ip.dns_name.strip().lower()
  204. if cnames == "" and primary_cname != "":
  205. cnames = primary_cname
  206. elif primary_cname != "":
  207. cnames += f",{primary_cname}"
  208. if cnames != "":
  209. cname_list = dedup_cnames(cnames.split(","), primary_domain)
  210. for cname in cname_list:
  211. current_domain = ".".join(cname.split(".")[1:])
  212. alias = cname.split(".")[0]
  213. cname_record = CNAMERecord(alias, current_domain, a_record, alias)
  214. current_cname_record = get_cname_record(alias, current_domain, edns)
  215. wip_records.lock.acquire()
  216. if not current_cname_record:
  217. # There isn't a CNAME already, so add a new CNAME record.
  218. wip_records.creates.append(cname_record)
  219. else:
  220. found_match = False
  221. for rr in current_cname_record.rrList["CCMRRItem"]:
  222. if rr["rrType"] == "CNAME" and rr["rdata"] == f"{dns_name}.{primary_domain}":
  223. # The existing CNAME record points to the correct A record, so we don't need a change.
  224. found_match = True
  225. break
  226. if not found_match:
  227. # CNAME exists but was not consistent, so remove the old one and add a new one.
  228. if (current_cname_record.name, current_cname_record.zoneOrigin) not in wip_records.deletes:
  229. wip_records.deletes.append((current_cname_record.name, current_cname_record.zoneOrigin))
  230. wip_records.creates.append(cname_record)
  231. wip_records.lock.release()
  232. # Note: This code will leave stale CNAMEs (i.e., CNAMEs that point to non-existent hosts or CNAMEs that
  233. # are no longer used). Those will be cleaned up by another script.
  234. def check_txt_record(current_host_record: cpnr.models.model.Record, ip: IpAddresses, edns: ElementalDns) -> bool:
  235. rrs = edns.rrset.get(current_host_record.name, zoneOrigin=current_host_record.zoneOrigin)
  236. rdata = get_txt_record(ip)
  237. change_needed = True
  238. if rrs:
  239. # This SHOULD always be true
  240. for rr in rrs.rrList["CCMRRItem"]:
  241. if rr["rrType"] == "TXT":
  242. if rr["rdata"] == rdata:
  243. change_needed = False
  244. else:
  245. logger.debug(
  246. f"TXT record for {current_host_record.name} in domain {current_host_record.zoneOrigin} exists, but it is "
  247. f"'{rr['rdata']}' and it should be '{rdata}'"
  248. )
  249. break
  250. return change_needed
  251. def print_records(wip_records: DnsRecords, primary_domain: str, tenant: Record) -> None:
  252. """Print the records to be processed.
  253. Args:
  254. :wip_records DnsRecords: DnsRecords object containing the records to process
  255. :primary_domain str: Primary domain to append when needed
  256. :tenant Record: A NetBox Tenant for which this DNS record applies
  257. """
  258. print(f"DNS records to be deleted for tenant {tenant.name} ({len(wip_records.deletes)} records):")
  259. for rec in wip_records.deletes:
  260. print(f"\t{Fore.RED}DELETE{Style.RESET_ALL} {rec[0]}.{rec[1]}")
  261. print(f"DNS records to be created for tenant {tenant.name} ({len(wip_records.creates)} records):")
  262. for rec in wip_records.creates:
  263. if isinstance(rec, ARecord):
  264. print(f"\t{Fore.GREEN}CREATE{Style.RESET_ALL} [A] {rec.hostname}.{primary_domain} : {rec.ip}")
  265. print(f"\t{Fore.GREEN}CREATE{Style.RESET_ALL} [PTR] {rec.ip}.{get_reverse_zone(rec.ip)} ==> {rec.hostname}.{primary_domain}")
  266. print(f"\t{Fore.GREEN}CREATE{Style.RESET_ALL} [TXT] {rec.hostname}.{primary_domain} : {get_txt_record(rec.nb_record)}")
  267. elif isinstance(rec, CNAMERecord):
  268. print(f"\t{Fore.GREEN}CREATE{Style.RESET_ALL} [CNAME] {rec.alias}.{rec.domain} ==> {rec.host.hostname}.{rec.host.domain}")
  269. elif isinstance(rec, PTRRecord):
  270. print(f"\t{Fore.GREEN}CREATE{Style.RESET_ALL} [PTR] {rec.rev_ip}.{rec.rzone} ==> {rec.hostname}")
  271. # def delete_txt_record(name: str, domain: str, edns: ElementalDns) -> None:
  272. # """Delete a TXT record associated with an A record.
  273. # Args:
  274. # :name str: Name of the record to delete
  275. # :domain str: Domain name where the record should be added
  276. # :edns ElementalDns: ElementalDns object to use
  277. # """
  278. # rrs = edns.rrset.get(name, zoneOrigin=domain)
  279. # if rrs:
  280. # if len(rrs.rrList["CCMRRItem"]) == 1 and rrs.rrList["CCMRRItem"][0]["rrType"] == "TXT":
  281. # rrs.delete()
  282. # logger.info(f"🧼 Deleted TXT record for {name} in domain {domain}")
  283. # else:
  284. # rrList = []
  285. # changed = False
  286. # for rr in rrs.rrList["CCMRRItem"]:
  287. # if rr["rrType"] != "TXT":
  288. # rrList.append(rr)
  289. # else:
  290. # logger.info(f"🧼 Removing TXT record from RRSet for {name} in domain {domain}")
  291. # changed = True
  292. # if changed:
  293. # rrs.rrList["CCMRRItem"] = rrList
  294. # rrs.save()
  295. def delete_record(cpnr_record: Tuple, primary_domain: str, edns: ElementalDns) -> None:
  296. """Delete a record from CPNR.
  297. Args:
  298. :cpnr_record Tuple: CPNR record to delete in a Tuple of (name, domain) format
  299. :primary_domain str: Primary DNS domain
  300. :edns ElementalDns: ElementalDns object of the auth DNS server
  301. """
  302. global EDNS_MODIFIED
  303. name = cpnr_record[0]
  304. domain = cpnr_record[1]
  305. # Build an RRSet to delete.
  306. rrs = edns.rrset.get(name, zoneOrigin=domain)
  307. if rrs:
  308. try:
  309. rrs.delete()
  310. except RequestError as e:
  311. if e.req.status_code != 404:
  312. # We may end up deleting the same record twice.
  313. # If it's already gone, don't complain.
  314. raise
  315. else:
  316. logger.info(f"🧼 Successfully deleted record set for {name}.{domain}")
  317. EDNS_MODIFIED = True
  318. host = edns.host.get(name, zoneOrigin=domain)
  319. if host:
  320. try:
  321. host.delete()
  322. except RequestError as e:
  323. if e.req.status_code != 404:
  324. # We may end up deleting the same record twice.
  325. # If it's already gone, don't complain.
  326. raise
  327. else:
  328. logger.info(f"🧼 Successfully deleted host for {name}.{domain}")
  329. EDNS_MODIFIED = True
  330. def add_record(record: Union[ARecord, CNAMERecord, PTRRecord], primary_domain: str, edns: ElementalDns) -> None:
  331. """Add a new DNS record to CPNR.
  332. Args:
  333. :cpnr_record Record: Record to add
  334. :primary_domain str: Primary domain name to add if the record doesn't contain it
  335. :edns ElementalDns: ElementalDns object to use for adding the record
  336. :dac DAC: DNS as code object
  337. """
  338. global EDNS_MODIFIED
  339. cpnr_record = {}
  340. if isinstance(record, ARecord):
  341. cpnr_record["name"] = record.hostname
  342. cpnr_record["addrs"] = {"stringItem": [record.ip]}
  343. cpnr_record["zoneOrigin"] = primary_domain
  344. cpnr_record["createPtrRecords"] = True
  345. txt_record = get_txt_record(record.nb_record)
  346. edns.host.add(**cpnr_record)
  347. logger.info(f"🎨 Successfully created record for host {record.hostname} : {record.ip}")
  348. rrs = edns.rrset.get(record.hostname, zoneOrigin=primary_domain)
  349. rrs.rrList["CCMRRItem"].append({"rdata": txt_record, "rrClass": "IN", "rrType": "TXT"})
  350. rrs.save()
  351. logger.info(f"🎨 Successfully created TXT meta-record for host {record.hostname} in domain {primary_domain}")
  352. EDNS_MODIFIED = True
  353. elif isinstance(record, CNAMERecord):
  354. curr_edns = edns
  355. cpnr_record["name"] = record.alias
  356. cpnr_record["zoneOrigin"] = record.domain
  357. target = f"{record.host.hostname}.{record.host.domain}"
  358. cpnr_record["rrList"] = {"CCMRRItem": [{"rdata": target, "rrClass": "IN", "rrType": "CNAME"}]}
  359. curr_edns.rrset.add(**cpnr_record)
  360. logger.info(f"🎨 Successfully created CNAME record in domain {record.domain} for alias {record.alias} ==> {target}")
  361. EDNS_MODIFIED = True
  362. else:
  363. # PTR records are not created by themselves for the moment.
  364. logger.warning(f"⛔️ Unexpected record of type {type(record)}")
  365. def dump_hosts(records: list[Union[ARecord, CNAMERecord, PTRRecord]], primary_domain: str, output_file: str) -> None:
  366. """Dump the A and CNAME records to a hosts-like file
  367. Args:
  368. :records list: List of records to dump
  369. :primary_domain str: Primary domain name to add if the record doesn't contain it
  370. :output_file str: Path to the output file
  371. """
  372. aliases = {}
  373. hosts = {}
  374. for record in records:
  375. if isinstance(record, PTRRecord):
  376. continue
  377. if isinstance(record, ARecord):
  378. fqdn = f"{record.hostname}.{primary_domain}"
  379. hosts[record.ip] = fqdn
  380. if fqdn not in aliases:
  381. aliases[fqdn] = [fqdn, record.hostname]
  382. else:
  383. aliases[fqdn] += [fqdn, record.hostname]
  384. elif isinstance(record, CNAMERecord):
  385. fqdn = f"{record.host.hostname}.{record.host.domain}"
  386. if fqdn not in aliases:
  387. aliases[fqdn] = [f"{record.alias}.{record.domain}"]
  388. else:
  389. aliases[fqdn].append(f"{record.alias}.{record.domain}")
  390. with open(output_file, "a") as fd:
  391. for ip, hname in hosts.items():
  392. fd.write(f"{ip}\t{' '.join(aliases[hname])}\n")
  393. def parse_args() -> object:
  394. """Parse any command line arguments.
  395. Returns:
  396. :object: Object representing the arguments passed
  397. """
  398. parser = argparse.ArgumentParser(prog=sys.argv[0], description="Sync NetBox elements to CPNR")
  399. parser.add_argument(
  400. "--site",
  401. metavar="<SITE>",
  402. help="Site to sync",
  403. required=False,
  404. )
  405. parser.add_argument(
  406. "--tenant",
  407. metavar="<TENANT>",
  408. help="Tenant to sync",
  409. required=False,
  410. )
  411. parser.add_argument(
  412. "--dry-run",
  413. action="store_true",
  414. help="Do a dry-run (no changes made)",
  415. required=False,
  416. )
  417. parser.add_argument(
  418. "--dummy", metavar="<DUMMY SERVER>", help="Override main DNS server with a dummy server (only used with --tenant", required=False
  419. )
  420. parser.add_argument("--dump-hosts", action="store_true", help="Dump records to a hosts file", required=False)
  421. parser.add_argument("--hosts-output", metavar="<OUTPUT_FILE>", help="Path to file to dump host records", required=False)
  422. args = parser.parse_args()
  423. if args.site and args.tenant:
  424. print("Only one of --site or --tenant can be given")
  425. exit(1)
  426. if not args.site and not args.tenant:
  427. print("One of --site or --tenant must be provided")
  428. exit(1)
  429. if args.dummy and not args.tenant:
  430. print("--dummy requires --tenant")
  431. exit(1)
  432. if args.dump_hosts and not args.hosts_output:
  433. print("A hosts output file must be specified")
  434. exit(1)
  435. return args
  436. def main():
  437. os.environ["NETBOX_ADDRESS"] = C.NETBOX_SERVER
  438. os.environ["NETBOX_API_TOKEN"] = CLEUCreds.NETBOX_API_TOKEN
  439. os.environ["CPNR_USERNAME"] = CLEUCreds.CPNR_USERNAME
  440. os.environ["CPNR_PASSWORD"] = CLEUCreds.CPNR_PASSWORD
  441. args = parse_args()
  442. if args.site:
  443. lower_site = args.site.lower()
  444. if args.tenant:
  445. lower_tenant = args.tenant.lower()
  446. enb = ElementalNetbox()
  447. if args.dump_hosts:
  448. with open(args.hosts_output, "w") as fd:
  449. fd.truncate()
  450. # 1. Get a list of all tenants. If we work tenant-by-tenant, we will likely remain connected
  451. # to the same DNS server.
  452. tenants = enb.tenancy.tenants.all()
  453. for tenant in tenants:
  454. if args.site and str(tenant.group.parent).lower() != lower_site:
  455. continue
  456. if args.tenant and tenant.name.lower() != lower_tenant:
  457. continue
  458. primary_domain = C.DNS_DOMAIN + "."
  459. edns = ElementalDns(url=f"https://{C.DNS_SERVER}:8443/")
  460. ecdnses = C.CDNS_SERVERS
  461. # 2. Get all IP addresses for the tenant.
  462. ip_addresses = list(enb.ipam.ip_addresses.filter(tenant_id=tenant.id))
  463. if len(ip_addresses) == 0:
  464. continue
  465. wip_records = DnsRecords()
  466. # 3. Use thread pools to obtain a list of records to delete then create (updates are done as a delete+create).
  467. launch_parallel_task(
  468. check_record, "check DNS record(s)", ip_addresses, "address", 20, False, primary_domain, edns, enb, wip_records
  469. )
  470. # 4. If desired, dump all hosts to a file.
  471. if args.dump_hosts:
  472. dump_hosts(wip_records.creates, primary_domain, args.hosts_output)
  473. # 5. If doing a dry-run, only print out the changes.
  474. if args.dry_run:
  475. print_records(wip_records, primary_domain, tenant)
  476. continue
  477. # 6. Process records to be deleted first. Use thread pools again to parallelize this.
  478. success = launch_parallel_task(delete_record, "delete DNS record", wip_records.deletes, None, 20, True, primary_domain, edns)
  479. if not success:
  480. break
  481. # 7. Process records to be added next. Use thread pools again to parallelize this.
  482. launch_parallel_task(add_record, "add DNS record", wip_records.creates, "_name", 20, False, primary_domain, edns)
  483. # 7. Restart affected DNS servers.
  484. if not args.dry_run:
  485. # Technically nothing is modified in dry-run, but just to be safe.
  486. if EDNS_MODIFIED:
  487. restart_dns_servers(edns, ecdnses)
  488. if __name__ == "__main__":
  489. main()