#!/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, NSEC3PARAM, CDNSKEY, CDS, and TYPE65534 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 ignore_ns_records: description: - Automatically ignore NS records. - When enabled, NS records are added to the ignore list. - Recommended to keep enabled as NS 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 default_ttl: description: - Default TTL (Time To Live) in seconds for records that don't specify a TTL. - This value is used when a record does not have an explicit O(zones[].records[].ttl) value. - If not specified, the zone SOA MINIMUM value will be used as the default. type: int 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', 'CDNSKEY', 'CDS', 'TYPE65534'} 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') # Add NS record type to ignore list if enabled if module.params.get('ignore_ns_records', True): self.ignore_types.add('NS') 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 self.default_ttl = module.params.get('default_ttl') # User-specified default, or None to use SOA minimum def _get_default_ttl(self) -> int: """Get the effective default TTL, preferring user-specified over SOA minimum.""" if self.default_ttl is not None: return self.default_ttl return self.soa_minimum_ttl 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._get_default_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({ 'name': desired['name'], 'type': desired['type'], 'old_values': current['values'], 'new_values': desired['values'], 'old_ttl': current['ttl'], 'new_ttl': desired['ttl'] }) 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: import traceback error_msg = str(e) if not error_msg or error_msg == "": error_msg = f"{type(e).__name__}: {e}" result['error'] = error_msg result['failed'] = True # Include traceback in debug output for troubleshooting if self.module._verbosity >= 2: self.module.debug(f"[{self.zone_name_str}] Exception traceback: {traceback.format_exc()}") return result def _format_diff(self, changes: dict) -> dict: """Format changes as a diff structure for diff mode using zone file format.""" before_lines = [] after_lines = [] # Process deletes - records being removed (appear in before with -) for record in sorted(changes['deletes'], key=lambda r: (r['name'].to_text(), r['type'])): record_name = record['name'].to_text().rstrip('.') record_type = record['type'] ttl = record.get(\'ttl\', self._get_default_ttl()) values = sorted(str(v) for v in record['values']) for value in values: before_lines.append(f"-{record_name:<34} {ttl:<10} {record_type:<10} {value}") # Process adds - records being added (appear in after with +) for record in sorted(changes['adds'], key=lambda r: (r['name'].to_text(), r['type'])): record_name = record['name'].to_text().rstrip('.') record_type = record['type'] ttl = record.get(\'ttl\', self._get_default_ttl()) values = sorted(str(v) for v in record['values']) for value in values: after_lines.append(f"+{record_name:<34} {ttl:<10} {record_type:<10} {value}") # Process updates - records being changed (show - in before, + in after) for record in sorted(changes['updates'], key=lambda r: (r['name'].to_text(), r['type'])): record_name = record['name'].to_text().rstrip('.') record_type = record['type'] old_ttl = record.get(\'old_ttl\', self._get_default_ttl()) new_ttl = record.get(\'new_ttl\', self._get_default_ttl()) old_values = sorted(str(v) for v in record['old_values']) new_values = sorted(str(v) for v in record['new_values']) # Show old values in before with - for value in old_values: before_lines.append(f"-{record_name:<34} {old_ttl:<10} {record_type:<10} {value}") # Show new values in after with + for value in new_values: after_lines.append(f"+{record_name:<34} {new_ttl:<10} {record_type:<10} {value}") # Return diff with before/after as lists - Ansible will display these properly return { 'before': before_lines if before_lines else ['No changes'], 'after': after_lines if after_lines else ['No changes'] } 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 _filter_zone_config(zone_config: dict) -> dict: """Filter zone configuration to extract only known keys. This allows users to include extra metadata (comments, type, etc.) in their zone configuration without causing validation errors. """ # Validate required fields if 'name' not in zone_config: raise ValueError("zones[].name is required") if 'records' not in zone_config: raise ValueError("zones[].records is required") filtered = { 'name': zone_config.get('name'), 'dns_server': zone_config.get('dns_server'), 'records': [] } if 'records' in zone_config: for i, record in enumerate(zone_config['records']): if 'record' not in record: raise ValueError(f"zones[].records[{i}].record is required") if 'type' not in record: raise ValueError(f"zones[].records[{i}].type is required") if 'value' not in record: raise ValueError(f"zones[].records[{i}].value is required") filtered_record = { 'record': record.get('record'), 'type': record.get('type'), 'value': record.get('value'), } if 'ttl' in record: filtered_record['ttl'] = record['ttl'] if 'state' in record: state = record['state'] if state not in ['present', 'absent']: raise ValueError(f"zones[].records[{i}].state must be 'present' or 'absent'") filtered_record['state'] = state else: filtered_record['state'] = 'present' filtered['records'].append(filtered_record) return filtered def main() -> None: """Main entry point for the module.""" module = AnsibleModule( argument_spec=dict( zones=dict( type='list', elements='dict', required=True ), 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), ignore_ns_records=dict(type='bool', default=True), validate_records=dict(type='bool', default=True), default_ttl=dict(type='int'), dns_server=dict(type='str') ), supports_check_mode=True, required_together=[ ['key_name', 'key_secret'] ] ) # Validate dnspython dependency deps.validate(module, "dnspython") zones = module.params['zones'] # Filter zones to remove extra keys that might be present for documentation/metadata try: filtered_zones = [_filter_zone_config(zone) for zone in zones] except ValueError as e: module.fail_json(msg=f"Invalid zone configuration: {str(e)}") results = [] overall_changed = False overall_failed = False # Process zones sequentially for zone_config in filtered_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()