Files
valid.nsupdate_zone/plugins/modules/nsupdate_zone.py
Daniel Akulenok 5307eef1b7 Fix AnsibleModule supports_diff parameter (v1.3.1)
- Correct supports_diff_mode to supports_diff in nsupdate_zone.py
- This fixes: AnsibleModule.__init__() got an unexpected keyword argument 'supports_diff_mode'
- Bump version to 1.3.1
2026-01-29 21:29:52 +01:00

959 lines
34 KiB
Python

#!/usr/bin/python
# -*- coding: utf-8 -*-
# Copyright: Contributors to the Ansible project
# GNU General Public License v3.0+ (see COPYING or https://www.gnu.org/licenses/gpl-3.0.txt)
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: "1.0.0"
author: Dan Kercher (@dkercher)
requirements:
- dnspython
extends_documentation_fragment:
- community.general.attributes
attributes:
check_mode:
support: full
diff_mode:
support: full
options:
zones:
description:
- List of zones to manage.
- Each zone dict must contain O(zones[].name) and O(zones[].records).
- O(zones[].dns_server) is optional if O(dns_server) is provided globally.
- Extra keys are allowed for flexibility and will be ignored.
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.
- Optional if global O(dns_server) is provided.
type: str
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: []
ignore_dnssec_records:
description:
- Automatically ignore DNSSEC-managed record types.
- When enabled, DNSKEY, RRSIG, NSEC, NSEC3, and NSEC3PARAM records are added to the ignore list.
- Useful when DNS servers manage DNSSEC records automatically and they should not be modified.
type: bool
default: true
ignore_soa_records:
description:
- Automatically ignore SOA records.
- When enabled, SOA records are added to the ignore list.
- Recommended to keep enabled as SOA records are typically managed by DNS servers themselves.
type: bool
default: true
validate_records:
description:
- Validate record values before applying changes.
- When enabled, performs syntax validation on record values (IPv4/IPv6 addresses, FQDNs, etc.).
- Prevents invalid records from being sent to the DNS server.
type: bool
default: true
dns_server:
description:
- Global DNS server to use for all zones that do not specify their own.
- Can be an IPv4/IPv6 address or FQDN.
- If specified, zones do not need O(zones[].dns_server).
type: str
notes:
- Verbose output showing per-record actions is available with C(-v) flag.
- Use C(-v) to see which records were Added, Removed, Changed, or Skipped.
- Use C(--diff) flag to see before/after state of DNS records.
"""
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 and verbose output
community.general.nsupdate_zone:
key_name: "nsupdate"
key_secret: "+bFQtBCta7j2vWkjPkAFtgA=="
# SOA and DNSSEC records are ignored by default
ignore_record_patterns:
- '^_acme-challenge\..*'
- '^_dnsauth\..*'
# Use -v flag for per-record action details
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 global server
community.general.nsupdate_zone:
key_name: "nsupdate"
key_secret: "+bFQtBCta7j2vWkjPkAFtgA=="
dns_server: ns1.dns.com
protocol: tcp
zones:
- name: bigzone1.com
records: "{{ bigzone1_records }}"
- name: bigzone2.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 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: AnsibleModule, zone_config: dict) -> None:
self.module = module
self.zone_name_str = zone_config['name']
self.records = zone_config['records']
# Get DNS server from zone config or fall back to global parameter
self.dns_server = zone_config.get('dns_server')
if not self.dns_server:
self.dns_server = module.params.get('dns_server')
if not self.dns_server:
self.module.fail_json(msg=f"DNS server must be specified for zone {self.zone_name_str} or via global dns_server parameter")
# 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.validate_records = module.params.get('validate_records', True)
# Add DNSSEC record types to ignore list if enabled
if module.params.get('ignore_dnssec_records', True):
dnssec_types = {'DNSKEY', 'RRSIG', 'NSEC', 'NSEC3', 'NSEC3PARAM'}
self.ignore_types.update(dnssec_types)
# Add SOA record type to ignore list if enabled
if module.params.get('ignore_soa_records', True):
self.ignore_types.add('SOA')
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) -> list[str]:
"""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) -> None:
"""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) -> None:
"""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) -> dict:
"""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 _validate_record_values(self, parsed_records: dict) -> None:
"""Validate record values if validation is enabled."""
if not self.validate_records:
return
for key, record in parsed_records.items():
name, record_type = key
name_str = name.to_text()
# Skip validation for records with state absent
if record['state'] == 'absent':
continue
# Validate based on record type
for value in record['values']:
try:
if record_type in ('A',):
ipaddress.ip_address(value)
elif record_type in ('AAAA',):
ipaddress.ip_address(value)
elif record_type in ('CNAME', 'MX', 'NS', 'PTR', 'SRV'):
# Validate FQDN - must end with dot or be resolvable
if not value.endswith('.'):
# Try to validate as partial name - just check it's not empty
if not value:
self.module.fail_json(
msg=f"Invalid {record_type} record at {name_str}: value cannot be empty"
)
else:
# Try to parse as absolute FQDN
try:
dns.name.from_text(value)
except Exception as e:
self.module.fail_json(
msg=f"Invalid {record_type} FQDN at {name_str}: {value} - {e}"
)
# TXT, SPF, and other text records don't need special validation
except ValueError as e:
self.module.fail_json(
msg=f"Invalid {record_type} record at {name_str}: {value} - {e}"
)
def _build_record_sets(self) -> dict:
"""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: dns.name.Name, record_type: str) -> bool:
"""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: dict) -> dict:
"""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: dict, current_records: dict) -> dict:
"""Compute adds, deletes, and updates needed."""
changes = {
'adds': [],
'deletes': [],
'updates': []
}
# Find adds, updates, and unchanged records
for key, desired in desired_records.items():
name_str = desired['name'].to_text()
record_type = desired['type']
if desired['state'] == 'absent':
# Handle explicit absents
if key in current_records:
changes['deletes'].append({
'name': desired['name'],
'type': desired['type'],
'values': desired['values']
})
if self.module._verbosity >= 1 or self.module._diff:
self.module.debug(f"[{self.zone_name_str}] Removed: {name_str} {desired['type']}")
else:
# State is 'present'
if key not in current_records:
# Record doesn't exist - add it
changes['adds'].append(desired)
if self.module._verbosity >= 1 or self.module._diff:
values_str = ', '.join(str(v) for v in desired['values'])
self.module.debug(f"[{self.zone_name_str}] Added: {name_str} {record_type} {values_str}")
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)
if self.module._verbosity >= 1 or self.module._diff:
before_values = ', '.join(str(v) for v in current['values'])
after_values = ', '.join(str(v) for v in desired['values'])
self.module.debug(f"[{self.zone_name_str}] Changed: {name_str} {record_type} ({before_values} -> {after_values})")
else:
# Record unchanged
if self.module._verbosity >= 2:
self.module.debug(f"[{self.zone_name_str}] Skipped: {name_str} {record_type} (unchanged)")
# 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
name_str = current['name'].to_text()
record_type = current['type']
changes['deletes'].append({
'name': current['name'],
'type': current['type'],
'values': current['values']
})
if self.module._verbosity >= 1 or self.module._diff:
self.module.debug(f"[{self.zone_name_str}] Removed: {name_str} {record_type}")
return changes
def _validate_cname_conflicts(self, desired_records: dict) -> None:
"""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: dict) -> dict:
"""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) -> dict:
"""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 records (if enabled)
self._validate_record_values(desired_records)
# Step 4: 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'])
# Add diff output if enabled
if self.module.diff:
result['diff'] = self._format_diff(changes)
# 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 _format_diff(self, changes: dict) -> dict:
"""Format changes as a diff structure for diff mode."""
diff_before = {}
diff_after = {}
# Process adds
for record in changes['adds']:
record_key = f"{record['name'].to_text()} {record['type']}"
diff_after[record_key] = sorted(str(v) for v in record['values'])
# Process deletes
for record in changes['deletes']:
record_key = f"{record['name'].to_text()} {record['type']}"
diff_before[record_key] = sorted(str(v) for v in record['values'])
# Process updates
for record in changes['updates']:
record_key = f"{record['name'].to_text()} {record['type']}"
# For updates, we need to show before state from current_records
# Since we don't have it here, we'll just show after state
diff_after[record_key] = sorted(str(v) for v in record['values'])
return {
'before': diff_before,
'after': diff_after
}
def process_single_zone(module: AnsibleModule, zone_config: dict) -> dict:
"""Process a single zone (for parallel execution)."""
manager = DNSZoneManager(module, zone_config)
return manager.process_zone()
def main() -> None:
"""Main entry point for the module."""
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'),
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=[]),
ignore_dnssec_records=dict(type='bool', default=True),
ignore_soa_records=dict(type='bool', default=True),
validate_records=dict(type='bool', default=True),
dns_server=dict(type='str')
),
supports_check_mode=True,
supports_diff=True,
required_together=[
['key_name', 'key_secret']
]
)
# Validate dnspython dependency
deps.validate(module, "dnspython")
zones = module.params['zones']
results = []
overall_changed = False
overall_failed = False
# Process zones sequentially
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()