First commit
This commit is contained in:
0
plugins/modules/__init__.py
Normal file
0
plugins/modules/__init__.py
Normal file
834
plugins/modules/nsupdate_zone.py
Normal file
834
plugins/modules/nsupdate_zone.py
Normal file
@@ -0,0 +1,834 @@
|
||||
#!/usr/bin/python
|
||||
|
||||
# Copyright (c) 2026, Dan Kercher
|
||||
#
|
||||
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
DOCUMENTATION = r"""
|
||||
module: nsupdate_zone
|
||||
|
||||
short_description: Manage complete DNS zones using AXFR and atomic batched updates
|
||||
description:
|
||||
- Fetch complete zone state via AXFR zone transfer.
|
||||
- Compare current zone state with desired state specified in YAML.
|
||||
- Compute minimal required changes (adds, deletes, updates).
|
||||
- Apply all changes atomically using batched DNS UPDATE messages per RFC 2136.
|
||||
- Support for configurable ignore patterns (e.g., NS records, ACME challenges).
|
||||
- Efficient management of large zones with hundreds or thousands of records.
|
||||
version_added: 10.7.0
|
||||
requirements:
|
||||
- dnspython
|
||||
author: "Dan Kercher"
|
||||
extends_documentation_fragment:
|
||||
- community.general.attributes
|
||||
attributes:
|
||||
check_mode:
|
||||
support: full
|
||||
diff_mode:
|
||||
support: none
|
||||
options:
|
||||
zones:
|
||||
description:
|
||||
- List of zones to manage.
|
||||
- Each zone dict must contain O(zones[].name), O(zones[].dns_server), and O(zones[].records).
|
||||
type: list
|
||||
elements: dict
|
||||
required: true
|
||||
suboptions:
|
||||
name:
|
||||
description:
|
||||
- Zone name (e.g., V(example.com)).
|
||||
type: str
|
||||
required: true
|
||||
dns_server:
|
||||
description:
|
||||
- DNS server to query and update, specified by IPv4/IPv6 address or FQDN.
|
||||
type: str
|
||||
required: true
|
||||
records:
|
||||
description:
|
||||
- List of DNS records for this zone.
|
||||
type: list
|
||||
elements: dict
|
||||
required: true
|
||||
suboptions:
|
||||
record:
|
||||
description:
|
||||
- Record name within the zone.
|
||||
- If the name ends with a dot (.), it is treated as a FQDN.
|
||||
- Otherwise, the zone name is automatically appended.
|
||||
- Use the zone FQDN with trailing dot to reference the zone apex (e.g., V(example.com.)).
|
||||
type: str
|
||||
required: true
|
||||
type:
|
||||
description:
|
||||
- DNS record type.
|
||||
type: str
|
||||
required: true
|
||||
value:
|
||||
description:
|
||||
- Record value(s).
|
||||
- Can be a single string or a list of strings for multiple values.
|
||||
- For records with multiple fields (MX, SRV, etc.), separate fields with spaces.
|
||||
type: raw
|
||||
required: true
|
||||
ttl:
|
||||
description:
|
||||
- Record TTL in seconds.
|
||||
- If omitted, uses the zone SOA MINIMUM value.
|
||||
type: int
|
||||
state:
|
||||
description:
|
||||
- Whether this record should be present or absent.
|
||||
type: str
|
||||
choices: ['present', 'absent']
|
||||
default: 'present'
|
||||
key_name:
|
||||
description:
|
||||
- TSIG key name for authentication.
|
||||
type: str
|
||||
key_secret:
|
||||
description:
|
||||
- TSIG key secret, associated with O(key_name).
|
||||
type: str
|
||||
key_algorithm:
|
||||
description:
|
||||
- Key algorithm used by O(key_secret).
|
||||
choices: ['HMAC-MD5.SIG-ALG.REG.INT', 'hmac-md5', 'hmac-sha1', 'hmac-sha224', 'hmac-sha256', 'hmac-sha384', 'hmac-sha512']
|
||||
default: 'hmac-md5'
|
||||
type: str
|
||||
protocol:
|
||||
description:
|
||||
- Transport protocol (TCP or UDP).
|
||||
- TCP is recommended for large zones and provides better reliability.
|
||||
default: 'tcp'
|
||||
choices: ['tcp', 'udp']
|
||||
type: str
|
||||
port:
|
||||
description:
|
||||
- TCP/UDP port for DNS server connection.
|
||||
default: 53
|
||||
type: int
|
||||
ignore_record_types:
|
||||
description:
|
||||
- List of record types to ignore during comparison.
|
||||
- Records of these types in the current zone will not trigger deletions.
|
||||
- Useful for preserving server-managed records like NS at zone apex.
|
||||
type: list
|
||||
elements: str
|
||||
default: ['NS']
|
||||
ignore_record_patterns:
|
||||
description:
|
||||
- List of regex patterns for record names to ignore.
|
||||
- Records matching these patterns will not be compared or modified.
|
||||
- Useful for ignoring ACME challenge records (e.g., V(^_acme-challenge\\..*)).
|
||||
type: list
|
||||
elements: str
|
||||
default: []
|
||||
parallel_zones:
|
||||
description:
|
||||
- Process multiple zones concurrently.
|
||||
- Each zone update is still atomic, but different zones are processed in parallel.
|
||||
- Experimental feature - use with caution.
|
||||
type: bool
|
||||
default: false
|
||||
"""
|
||||
|
||||
EXAMPLES = r"""
|
||||
- name: Manage a complete DNS zone
|
||||
community.general.nsupdate_zone:
|
||||
key_name: "nsupdate"
|
||||
key_secret: "+bFQtBCta7j2vWkjPkAFtgA=="
|
||||
zones:
|
||||
- name: example.com
|
||||
dns_server: ns1.example.com
|
||||
records:
|
||||
# Zone apex A record
|
||||
- record: 'example.com.'
|
||||
type: A
|
||||
value: 192.168.1.1
|
||||
ttl: 3600
|
||||
|
||||
# Subdomain with multiple A records
|
||||
- record: www
|
||||
type: A
|
||||
value:
|
||||
- 192.168.1.10
|
||||
- 192.168.1.11
|
||||
ttl: 300
|
||||
|
||||
# CNAME record
|
||||
- record: blog
|
||||
type: CNAME
|
||||
value: www
|
||||
|
||||
# MX records with priority
|
||||
- record: 'example.com.'
|
||||
type: MX
|
||||
value:
|
||||
- "10 mail1.example.com."
|
||||
- "20 mail2.example.com."
|
||||
|
||||
# TXT records
|
||||
- record: 'example.com.'
|
||||
type: TXT
|
||||
value:
|
||||
- "v=spf1 mx a include:_spf.google.com ~all"
|
||||
- "google-site-verification=abc123"
|
||||
|
||||
# Remove a record
|
||||
- record: old-server
|
||||
type: A
|
||||
value: 192.168.1.99
|
||||
state: absent
|
||||
|
||||
- name: Manage multiple zones with ignore patterns
|
||||
community.general.nsupdate_zone:
|
||||
key_name: "nsupdate"
|
||||
key_secret: "+bFQtBCta7j2vWkjPkAFtgA=="
|
||||
ignore_record_types:
|
||||
- NS
|
||||
- SOA
|
||||
ignore_record_patterns:
|
||||
- '^_acme-challenge\..*'
|
||||
- '^_dnsauth\..*'
|
||||
zones:
|
||||
- name: example.com
|
||||
dns_server: 10.1.1.1
|
||||
records:
|
||||
- record: 'example.com.'
|
||||
type: A
|
||||
value: 192.168.1.1
|
||||
|
||||
- name: example.org
|
||||
dns_server: 10.1.1.1
|
||||
records:
|
||||
- record: 'example.org.'
|
||||
type: A
|
||||
value: 192.168.2.1
|
||||
|
||||
- name: Manage large zone with parallel processing
|
||||
community.general.nsupdate_zone:
|
||||
key_name: "nsupdate"
|
||||
key_secret: "+bFQtBCta7j2vWkjPkAFtgA=="
|
||||
parallel_zones: true
|
||||
protocol: tcp
|
||||
zones:
|
||||
- name: bigzone1.com
|
||||
dns_server: ns1.dns.com
|
||||
records: "{{ bigzone1_records }}"
|
||||
|
||||
- name: bigzone2.com
|
||||
dns_server: ns1.dns.com
|
||||
records: "{{ bigzone2_records }}"
|
||||
"""
|
||||
|
||||
RETURN = r"""
|
||||
results:
|
||||
description: Results for each zone processed.
|
||||
returned: always
|
||||
type: list
|
||||
elements: dict
|
||||
contains:
|
||||
zone:
|
||||
description: Zone name.
|
||||
type: str
|
||||
sample: 'example.com'
|
||||
changed:
|
||||
description: Whether the zone was modified.
|
||||
type: bool
|
||||
sample: true
|
||||
dns_rc:
|
||||
description: DNS response code.
|
||||
type: int
|
||||
sample: 0
|
||||
dns_rc_str:
|
||||
description: DNS response code (string representation).
|
||||
type: str
|
||||
sample: 'NOERROR'
|
||||
changes:
|
||||
description: Summary of changes applied.
|
||||
type: dict
|
||||
contains:
|
||||
adds:
|
||||
description: Number of records added.
|
||||
type: int
|
||||
sample: 5
|
||||
deletes:
|
||||
description: Number of records deleted.
|
||||
type: int
|
||||
sample: 2
|
||||
updates:
|
||||
description: Number of records updated.
|
||||
type: int
|
||||
sample: 3
|
||||
error:
|
||||
description: Error message if zone processing failed.
|
||||
type: str
|
||||
sample: 'AXFR failed: connection timeout'
|
||||
changed:
|
||||
description: Whether any zone was modified.
|
||||
type: bool
|
||||
returned: always
|
||||
sample: true
|
||||
failed:
|
||||
description: Whether any zone processing failed.
|
||||
type: bool
|
||||
returned: always
|
||||
sample: false
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import re
|
||||
from binascii import Error as binascii_error
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
from ansible.module_utils.basic import AnsibleModule
|
||||
|
||||
from ansible_collections.valid.nsupdate_zone.plugins.module_utils import deps
|
||||
|
||||
with deps.declare("dnspython", url="https://github.com/rthalley/dnspython"):
|
||||
import dns.exception
|
||||
import dns.message
|
||||
import dns.name
|
||||
import dns.query
|
||||
import dns.rcode
|
||||
import dns.rdatatype
|
||||
import dns.resolver
|
||||
import dns.tsig
|
||||
import dns.tsigkeyring
|
||||
import dns.update
|
||||
import dns.zone
|
||||
|
||||
|
||||
class DNSZoneManager:
|
||||
def __init__(self, module, zone_config):
|
||||
self.module = module
|
||||
self.zone_name_str = zone_config['name']
|
||||
self.dns_server = zone_config['dns_server']
|
||||
self.records = zone_config['records']
|
||||
|
||||
# Normalize zone name to dns.name.Name object
|
||||
if not self.zone_name_str.endswith('.'):
|
||||
self.zone_name_str += '.'
|
||||
self.zone_name = dns.name.from_text(self.zone_name_str)
|
||||
|
||||
# Resolve server address
|
||||
self.server_ips = self._resolve_server() or []
|
||||
|
||||
# Setup authentication
|
||||
self._setup_authentication()
|
||||
|
||||
# Configuration
|
||||
self.protocol = module.params['protocol']
|
||||
self.port = module.params['port']
|
||||
self.ignore_types = set(module.params['ignore_record_types'])
|
||||
self.ignore_patterns = [re.compile(pattern) for pattern in module.params['ignore_record_patterns']]
|
||||
|
||||
# State
|
||||
self.current_zone = None
|
||||
self.soa_minimum_ttl = 3600 # Default, will be updated from SOA
|
||||
|
||||
def _resolve_server(self):
|
||||
"""Resolve DNS server FQDN to IP addresses."""
|
||||
server = self.dns_server
|
||||
|
||||
# Check if it's already an IP address
|
||||
try:
|
||||
ipaddress.ip_address(server)
|
||||
return [server]
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Resolve FQDN to IP addresses
|
||||
try:
|
||||
resolver = dns.resolver.Resolver()
|
||||
ips = []
|
||||
|
||||
# Try A records
|
||||
try:
|
||||
for rdata in resolver.resolve(server, 'A'):
|
||||
ips.append(str(rdata))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Try AAAA records
|
||||
try:
|
||||
for rdata in resolver.resolve(server, 'AAAA'):
|
||||
ips.append(str(rdata))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not ips:
|
||||
self.module.fail_json(msg=f"Cannot resolve DNS server {server}")
|
||||
|
||||
return ips
|
||||
except Exception as e:
|
||||
self.module.fail_json(msg=f"Failed to resolve DNS server {server}: {e}")
|
||||
|
||||
def _setup_authentication(self):
|
||||
"""Setup TSIG authentication if configured."""
|
||||
key_name = self.module.params['key_name']
|
||||
key_secret = self.module.params['key_secret']
|
||||
key_algorithm = self.module.params['key_algorithm']
|
||||
|
||||
if key_name and key_secret:
|
||||
if key_algorithm == "hmac-md5":
|
||||
self.algorithm = "HMAC-MD5.SIG-ALG.REG.INT"
|
||||
else:
|
||||
self.algorithm = key_algorithm
|
||||
|
||||
try:
|
||||
self.keyring = dns.tsigkeyring.from_text({key_name: key_secret})
|
||||
self.keyname = key_name
|
||||
except TypeError:
|
||||
self.module.fail_json(msg="Missing key_secret")
|
||||
except binascii_error as e:
|
||||
self.module.fail_json(msg=f"TSIG key error: {e}")
|
||||
else:
|
||||
self.keyring = None
|
||||
self.keyname = None
|
||||
self.algorithm = None
|
||||
|
||||
def _perform_axfr(self):
|
||||
"""Perform AXFR zone transfer to get current zone state."""
|
||||
for server_ip in self.server_ips:
|
||||
try:
|
||||
if self.keyring:
|
||||
zone = dns.zone.from_xfr(
|
||||
dns.query.xfr(
|
||||
server_ip,
|
||||
self.zone_name,
|
||||
port=self.port,
|
||||
keyring=self.keyring,
|
||||
keyname=self.keyname,
|
||||
keyalgorithm=self.algorithm
|
||||
)
|
||||
)
|
||||
else:
|
||||
zone = dns.zone.from_xfr(
|
||||
dns.query.xfr(
|
||||
server_ip,
|
||||
self.zone_name,
|
||||
port=self.port
|
||||
)
|
||||
)
|
||||
|
||||
# Extract SOA minimum TTL
|
||||
soa_rdataset = zone.find_rdataset('@', dns.rdatatype.SOA)
|
||||
if soa_rdataset:
|
||||
for rdata in soa_rdataset:
|
||||
self.soa_minimum_ttl = rdata.minimum
|
||||
break
|
||||
|
||||
self.current_zone = zone
|
||||
return
|
||||
|
||||
except dns.exception.FormError as e:
|
||||
self.module.fail_json(msg=f"AXFR failed for {self.zone_name_str}: malformed response: {e}")
|
||||
except dns.tsig.PeerBadKey as e:
|
||||
self.module.fail_json(msg=f"AXFR failed for {self.zone_name_str}: bad TSIG key: {e}")
|
||||
except dns.tsig.PeerBadSignature as e:
|
||||
self.module.fail_json(msg=f"AXFR failed for {self.zone_name_str}: bad TSIG signature: {e}")
|
||||
except Exception as e:
|
||||
# Try next server if available
|
||||
if server_ip == self.server_ips[-1]:
|
||||
self.module.fail_json(msg=f"AXFR failed for {self.zone_name_str}: {e}")
|
||||
continue
|
||||
|
||||
self.module.fail_json(msg=f"AXFR failed for {self.zone_name_str}: all servers failed")
|
||||
|
||||
def _parse_yaml_records(self):
|
||||
"""Parse and normalize YAML records into a comparable format."""
|
||||
parsed_records = {}
|
||||
|
||||
for record_config in self.records:
|
||||
record_name = record_config['record']
|
||||
record_type = record_config['type'].upper()
|
||||
record_values = record_config['value']
|
||||
record_ttl = record_config.get('ttl', self.soa_minimum_ttl)
|
||||
record_state = record_config.get('state', 'present')
|
||||
|
||||
# Normalize record name
|
||||
if record_name.endswith('.'):
|
||||
# Absolute FQDN
|
||||
fqdn = record_name
|
||||
else:
|
||||
# Relative name - append zone
|
||||
fqdn = f"{record_name}.{self.zone_name_str}"
|
||||
|
||||
# Normalize to dns.name.Name
|
||||
try:
|
||||
name_obj = dns.name.from_text(fqdn)
|
||||
except Exception as e:
|
||||
self.module.fail_json(msg=f"Invalid record name {record_name}: {e}")
|
||||
continue # For type checker, though fail_json exits
|
||||
|
||||
# Normalize values to list
|
||||
if not isinstance(record_values, list):
|
||||
record_values = [record_values]
|
||||
|
||||
# Store in parsed_records
|
||||
key = (name_obj, record_type)
|
||||
if key not in parsed_records:
|
||||
parsed_records[key] = {
|
||||
'name': name_obj,
|
||||
'type': record_type,
|
||||
'ttl': record_ttl,
|
||||
'values': set(),
|
||||
'state': record_state
|
||||
}
|
||||
|
||||
# Add values
|
||||
for value in record_values:
|
||||
parsed_records[key]['values'].add(str(value))
|
||||
|
||||
return parsed_records
|
||||
|
||||
def _build_record_sets(self):
|
||||
"""Build comparable record sets from current zone."""
|
||||
if not self.current_zone:
|
||||
return {}
|
||||
|
||||
record_sets = {}
|
||||
|
||||
for name, node in self.current_zone.items():
|
||||
abs_name = name.derelativize(self.zone_name)
|
||||
|
||||
for rdataset in node.rdatasets:
|
||||
record_type = dns.rdatatype.to_text(rdataset.rdtype)
|
||||
|
||||
key = (abs_name, record_type)
|
||||
if key not in record_sets:
|
||||
record_sets[key] = {
|
||||
'name': abs_name,
|
||||
'type': record_type,
|
||||
'ttl': rdataset.ttl,
|
||||
'values': set()
|
||||
}
|
||||
|
||||
for rdata in rdataset:
|
||||
record_sets[key]['values'].add(rdata.to_text())
|
||||
|
||||
return record_sets
|
||||
|
||||
def _should_ignore_record(self, name, record_type):
|
||||
"""Check if a record should be ignored based on configured patterns."""
|
||||
# Check type ignore
|
||||
if record_type in self.ignore_types:
|
||||
return True
|
||||
|
||||
# Check pattern ignore
|
||||
name_str = name.to_text()
|
||||
for pattern in self.ignore_patterns:
|
||||
if pattern.match(name_str):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _filter_ignored_records(self, record_sets):
|
||||
"""Remove ignored records from record sets."""
|
||||
filtered = {}
|
||||
for key, record_data in record_sets.items():
|
||||
name, record_type = key
|
||||
if not self._should_ignore_record(name, record_type):
|
||||
filtered[key] = record_data
|
||||
return filtered
|
||||
|
||||
def _compute_changes(self, desired_records, current_records):
|
||||
"""Compute adds, deletes, and updates needed."""
|
||||
changes = {
|
||||
'adds': [],
|
||||
'deletes': [],
|
||||
'updates': []
|
||||
}
|
||||
|
||||
# Find adds and updates
|
||||
for key, desired in desired_records.items():
|
||||
if desired['state'] == 'absent':
|
||||
# Handle explicit absents
|
||||
if key in current_records:
|
||||
changes['deletes'].append({
|
||||
'name': desired['name'],
|
||||
'type': desired['type'],
|
||||
'values': desired['values']
|
||||
})
|
||||
else:
|
||||
# State is 'present'
|
||||
if key not in current_records:
|
||||
# Record doesn't exist - add it
|
||||
changes['adds'].append(desired)
|
||||
else:
|
||||
# Record exists - check if values differ
|
||||
current = current_records[key]
|
||||
if desired['values'] != current['values'] or desired['ttl'] != current['ttl']:
|
||||
changes['updates'].append(desired)
|
||||
|
||||
# Find deletes (records in current but not in desired, unless ignored)
|
||||
for key, current in current_records.items():
|
||||
if key not in desired_records:
|
||||
# Not in desired state - delete it
|
||||
changes['deletes'].append({
|
||||
'name': current['name'],
|
||||
'type': current['type'],
|
||||
'values': current['values']
|
||||
})
|
||||
|
||||
return changes
|
||||
|
||||
def _validate_cname_conflicts(self, desired_records):
|
||||
"""Validate that CNAME records don't conflict with other record types."""
|
||||
name_types = {}
|
||||
|
||||
for key, record in desired_records.items():
|
||||
if record['state'] != 'present':
|
||||
continue
|
||||
|
||||
name, record_type = key
|
||||
if name not in name_types:
|
||||
name_types[name] = set()
|
||||
name_types[name].add(record_type)
|
||||
|
||||
for name, types in name_types.items():
|
||||
if 'CNAME' in types and len(types) > 1:
|
||||
self.module.fail_json(
|
||||
msg=f"CNAME conflict at {name.to_text()}: cannot have CNAME with other record types {types}"
|
||||
)
|
||||
|
||||
def _apply_changes(self, changes):
|
||||
"""Apply changes using a single batched UPDATE message."""
|
||||
# Create update message
|
||||
update = dns.update.Update(
|
||||
self.zone_name,
|
||||
keyring=self.keyring,
|
||||
keyname=self.keyname,
|
||||
keyalgorithm=self.algorithm
|
||||
)
|
||||
|
||||
# Add all delete operations first
|
||||
for delete_op in changes['deletes']:
|
||||
name = delete_op['name']
|
||||
record_type = delete_op['type']
|
||||
# Delete the entire RRset
|
||||
update.delete(name, record_type)
|
||||
|
||||
# Add all update operations (delete then add)
|
||||
for update_op in changes['updates']:
|
||||
name = update_op['name']
|
||||
record_type = update_op['type']
|
||||
ttl = update_op['ttl']
|
||||
|
||||
# Delete existing
|
||||
update.delete(name, record_type)
|
||||
|
||||
# Add new values
|
||||
for value in update_op['values']:
|
||||
update.add(name, ttl, record_type, value)
|
||||
|
||||
# Add all add operations
|
||||
for add_op in changes['adds']:
|
||||
name = add_op['name']
|
||||
record_type = add_op['type']
|
||||
ttl = add_op['ttl']
|
||||
|
||||
for value in add_op['values']:
|
||||
update.add(name, ttl, record_type, value)
|
||||
|
||||
# Send update message
|
||||
for server_ip in self.server_ips:
|
||||
try:
|
||||
if self.protocol == 'tcp':
|
||||
response = dns.query.tcp(update, server_ip, port=self.port, timeout=10)
|
||||
else:
|
||||
response = dns.query.udp(update, server_ip, port=self.port, timeout=10)
|
||||
|
||||
rcode = response.rcode()
|
||||
rcode_str = dns.rcode.to_text(rcode)
|
||||
|
||||
if rcode != dns.rcode.NOERROR:
|
||||
self.module.fail_json(
|
||||
msg=f"DNS UPDATE failed for {self.zone_name_str}: {rcode_str}",
|
||||
dns_rc=rcode,
|
||||
dns_rc_str=rcode_str
|
||||
)
|
||||
|
||||
return {
|
||||
'dns_rc': rcode,
|
||||
'dns_rc_str': rcode_str
|
||||
}
|
||||
|
||||
except dns.tsig.PeerBadKey as e:
|
||||
self.module.fail_json(msg=f"UPDATE failed: bad TSIG key: {e}")
|
||||
except dns.tsig.PeerBadSignature as e:
|
||||
self.module.fail_json(msg=f"UPDATE failed: bad TSIG signature: {e}")
|
||||
except Exception as e:
|
||||
if server_ip == self.server_ips[-1]:
|
||||
self.module.fail_json(msg=f"UPDATE failed for {self.zone_name_str}: {e}")
|
||||
continue
|
||||
|
||||
self.module.fail_json(msg=f"UPDATE failed for {self.zone_name_str}: all servers failed")
|
||||
|
||||
def process_zone(self):
|
||||
"""Main processing logic for a single zone."""
|
||||
result = {
|
||||
'zone': self.zone_name_str,
|
||||
'changed': False,
|
||||
'dns_rc': 0,
|
||||
'dns_rc_str': 'NOERROR',
|
||||
'changes': {
|
||||
'adds': 0,
|
||||
'deletes': 0,
|
||||
'updates': 0
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
# Step 1: Perform AXFR
|
||||
self._perform_axfr()
|
||||
|
||||
# Step 2: Parse YAML records
|
||||
desired_records = self._parse_yaml_records()
|
||||
|
||||
# Step 3: Validate CNAME conflicts
|
||||
self._validate_cname_conflicts(desired_records)
|
||||
|
||||
# Step 4: Build current record sets
|
||||
current_records = self._build_record_sets()
|
||||
|
||||
# Step 5: Filter ignored records
|
||||
current_records = self._filter_ignored_records(current_records)
|
||||
|
||||
# Step 6: Compute changes
|
||||
changes = self._compute_changes(desired_records, current_records)
|
||||
|
||||
# Step 7: Determine if changes needed
|
||||
total_changes = len(changes['adds']) + len(changes['deletes']) + len(changes['updates'])
|
||||
|
||||
if total_changes == 0:
|
||||
result['changed'] = False
|
||||
return result
|
||||
|
||||
result['changed'] = True
|
||||
result['changes']['adds'] = len(changes['adds'])
|
||||
result['changes']['deletes'] = len(changes['deletes'])
|
||||
result['changes']['updates'] = len(changes['updates'])
|
||||
|
||||
# Step 8: Apply changes (unless check mode)
|
||||
if not self.module.check_mode:
|
||||
update_result = self._apply_changes(changes)
|
||||
if update_result:
|
||||
result['dns_rc'] = update_result['dns_rc']
|
||||
result['dns_rc_str'] = update_result['dns_rc_str']
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
result['error'] = str(e)
|
||||
result['failed'] = True
|
||||
return result
|
||||
|
||||
|
||||
def process_single_zone(module, zone_config):
|
||||
"""Process a single zone (for parallel execution)."""
|
||||
manager = DNSZoneManager(module, zone_config)
|
||||
return manager.process_zone()
|
||||
|
||||
|
||||
def main():
|
||||
module = AnsibleModule(
|
||||
argument_spec=dict(
|
||||
zones=dict(
|
||||
type='list',
|
||||
elements='dict',
|
||||
required=True,
|
||||
options=dict(
|
||||
name=dict(type='str', required=True),
|
||||
dns_server=dict(type='str', required=True),
|
||||
records=dict(
|
||||
type='list',
|
||||
elements='dict',
|
||||
required=True,
|
||||
options=dict(
|
||||
record=dict(type='str', required=True),
|
||||
type=dict(type='str', required=True),
|
||||
value=dict(type='raw', required=True),
|
||||
ttl=dict(type='int'),
|
||||
state=dict(type='str', choices=['present', 'absent'], default='present')
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
key_name=dict(type='str'),
|
||||
key_secret=dict(type='str', no_log=True),
|
||||
key_algorithm=dict(
|
||||
type='str',
|
||||
default='hmac-md5',
|
||||
choices=[
|
||||
'HMAC-MD5.SIG-ALG.REG.INT',
|
||||
'hmac-md5',
|
||||
'hmac-sha1',
|
||||
'hmac-sha224',
|
||||
'hmac-sha256',
|
||||
'hmac-sha384',
|
||||
'hmac-sha512'
|
||||
]
|
||||
),
|
||||
protocol=dict(type='str', default='tcp', choices=['tcp', 'udp']),
|
||||
port=dict(type='int', default=53),
|
||||
ignore_record_types=dict(type='list', elements='str', default=['NS']),
|
||||
ignore_record_patterns=dict(type='list', elements='str', default=[]),
|
||||
parallel_zones=dict(type='bool', default=False)
|
||||
),
|
||||
supports_check_mode=True,
|
||||
required_together=[
|
||||
['key_name', 'key_secret']
|
||||
]
|
||||
)
|
||||
|
||||
# Validate dnspython dependency
|
||||
deps.validate(module, "dnspython")
|
||||
|
||||
zones = module.params['zones']
|
||||
parallel_zones = module.params['parallel_zones']
|
||||
|
||||
results = []
|
||||
overall_changed = False
|
||||
overall_failed = False
|
||||
|
||||
if parallel_zones and len(zones) > 1:
|
||||
# Process zones in parallel
|
||||
with ThreadPoolExecutor(max_workers=min(len(zones), 5)) as executor:
|
||||
futures = {
|
||||
executor.submit(process_single_zone, module, zone_config): zone_config
|
||||
for zone_config in zones
|
||||
}
|
||||
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
results.append(result)
|
||||
if result.get('changed', False):
|
||||
overall_changed = True
|
||||
if result.get('failed', False):
|
||||
overall_failed = True
|
||||
else:
|
||||
# Process zones serially
|
||||
for zone_config in zones:
|
||||
result = process_single_zone(module, zone_config)
|
||||
results.append(result)
|
||||
if result.get('changed', False):
|
||||
overall_changed = True
|
||||
if result.get('failed', False):
|
||||
overall_failed = True
|
||||
|
||||
module.exit_json(
|
||||
changed=overall_changed,
|
||||
failed=overall_failed,
|
||||
results=results
|
||||
)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Reference in New Issue
Block a user