mirror of
				https://github.com/kevinveenbirkenbach/computer-playbook.git
				synced 2025-11-04 04:08:15 +00:00 
			
		
		
		
	refactor(dns): replace sys-dns-parent-hosts with sys-dns-wildcards; emit only *.parent wildcards from CURRENT_PLAY_DOMAINS_ALL
Rename filter parent_build_records→wildcard_records; create only wildcard (*.parent) A/AAAA records (no base/apex); switch to CURRENT_PLAY_DOMAINS_ALL; update vars to SYN_DNS_WILDCARD_RECORDS; adjust role/task names, defaults, and docs; add unittest expecting *.a.b from www.a.b.example.com. See: https://chatgpt.com/share/68c35dc1-7170-800f-8fbe-772e61780597
This commit is contained in:
		@@ -1,24 +0,0 @@
 | 
			
		||||
# sys-dns-parent-hosts
 | 
			
		||||
 | 
			
		||||
Create Cloudflare DNS A/AAAA records only for **parent hosts** (hosts that have children),
 | 
			
		||||
and always include the **apex** (SLD.TLD) as a parent.
 | 
			
		||||
 | 
			
		||||
Examples:
 | 
			
		||||
- c.wiki.example.com  -> parent: wiki.example.com
 | 
			
		||||
- a.b.example.com     -> parent: b.example.com
 | 
			
		||||
- example.com (apex)  -> always included
 | 
			
		||||
 | 
			
		||||
## Inputs
 | 
			
		||||
- parent_dns_domains (list[str], optional): FQDNs to evaluate. If empty, the role flattens CURRENT_PLAY_DOMAINS.
 | 
			
		||||
- PRIMARY_DOMAIN (apex), defaults_networks.internet.ip4, optional defaults_networks.internet.ip6
 | 
			
		||||
- Flags:
 | 
			
		||||
  - parent_dns_enabled (bool, default: true)
 | 
			
		||||
  - parent_dns_ipv6_enabled (bool, default: true)
 | 
			
		||||
  - parent_dns_proxied (bool, default: false)
 | 
			
		||||
 | 
			
		||||
## Usage
 | 
			
		||||
- Include the role once after your constructor stage has set CURRENT_PLAY_DOMAINS.
 | 
			
		||||
 | 
			
		||||
## Tests
 | 
			
		||||
Unit tests: tests/unit/roles/sys-dns-parent-hosts/filter_plugins/test_parent_dns.py
 | 
			
		||||
Run with: pytest -q
 | 
			
		||||
@@ -1,2 +0,0 @@
 | 
			
		||||
parent_dns_proxied: false
 | 
			
		||||
parent_dns_domains: []
 | 
			
		||||
@@ -1,3 +0,0 @@
 | 
			
		||||
- block:
 | 
			
		||||
  - include_tasks: 01_core.yml
 | 
			
		||||
  when: run_once_sys_dns_parent_hosts is not defined
 | 
			
		||||
							
								
								
									
										20
									
								
								roles/sys-dns-wildcards/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								roles/sys-dns-wildcards/README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
# sys-dns-wildcards
 | 
			
		||||
 | 
			
		||||
Create Cloudflare DNS **wildcard** A/AAAA records (`*.parent`) only for **parent hosts** (hosts that have children).
 | 
			
		||||
The **apex** (SLD.TLD) is considered when computing parents, but **no base host** or `*.apex` record is created by this role.
 | 
			
		||||
 | 
			
		||||
Examples:
 | 
			
		||||
- c.wiki.example.com  -> parent: wiki.example.com -> creates: `*.wiki.example.com`
 | 
			
		||||
- a.b.example.com     -> parent: b.example.com    -> creates: `*.b.example.com`
 | 
			
		||||
- example.com (apex)  -> used to detect parents, but **no** `example.com` or `*.example.com` record is created
 | 
			
		||||
 | 
			
		||||
## Inputs
 | 
			
		||||
- parent_dns_domains (list[str], optional): FQDNs to evaluate. If empty, the role flattens CURRENT_PLAY_DOMAINS_ALL.
 | 
			
		||||
- PRIMARY_DOMAIN (apex), defaults_networks.internet.ip4, optional defaults_networks.internet.ip6
 | 
			
		||||
- Flags:
 | 
			
		||||
  - parent_dns_enabled (bool, default: true)
 | 
			
		||||
  - parent_dns_ipv6_enabled (bool, default: true)
 | 
			
		||||
  - parent_dns_proxied (bool, default: false)
 | 
			
		||||
 | 
			
		||||
## Usage
 | 
			
		||||
- Include the role once after your constructor stage has set CURRENT_PLAY_DOMAINS_ALL.
 | 
			
		||||
							
								
								
									
										3
									
								
								roles/sys-dns-wildcards/defaults/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								roles/sys-dns-wildcards/defaults/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
parent_dns_proxied: true
 | 
			
		||||
parent_dns_domains: []
 | 
			
		||||
parent_dns_ipv6_enabled: true
 | 
			
		||||
@@ -25,16 +25,12 @@ def _parent_of_child(domain: str, apex: str) -> str | None:
 | 
			
		||||
    return ".".join(parts[1:])  # drop exactly the left-most label
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _flatten_domains(current_play_domains: dict) -> list[str]:
 | 
			
		||||
def _flatten_domains_any_structure(domains_like) -> list[str]:
 | 
			
		||||
    """
 | 
			
		||||
    Accept CURRENT_PLAY_DOMAINS values as:
 | 
			
		||||
      - str
 | 
			
		||||
      - list/tuple/set[str]
 | 
			
		||||
      - dict -> recurse one level (values must be str or list-like[str])
 | 
			
		||||
    Accepts CURRENT_PLAY_DOMAINS_ALL-like structures:
 | 
			
		||||
      - dict values: str | list/tuple/set[str] | dict (one level deeper)
 | 
			
		||||
    Returns unique, sorted host list.
 | 
			
		||||
    """
 | 
			
		||||
    if not isinstance(current_play_domains, dict):
 | 
			
		||||
        raise AnsibleFilterError("CURRENT_PLAY_DOMAINS must be a dict of {app_id: hostnames-or-structures}")
 | 
			
		||||
 | 
			
		||||
    hosts: list[str] = []
 | 
			
		||||
 | 
			
		||||
    def _add_any(x):
 | 
			
		||||
@@ -53,17 +49,18 @@ def _flatten_domains(current_play_domains: dict) -> list[str]:
 | 
			
		||||
            for v in x.values():
 | 
			
		||||
                _add_any(v)
 | 
			
		||||
            return
 | 
			
		||||
        raise AnsibleFilterError(f"Unsupported CURRENT_PLAY_DOMAINS value type: {type(x).__name__}")
 | 
			
		||||
        raise AnsibleFilterError(f"Unsupported value type: {type(x).__name__}")
 | 
			
		||||
 | 
			
		||||
    for v in current_play_domains.values():
 | 
			
		||||
    if not isinstance(domains_like, dict):
 | 
			
		||||
        raise AnsibleFilterError("Expected a dict for CURRENT_PLAY_DOMAINS_ALL")
 | 
			
		||||
    for v in domains_like.values():
 | 
			
		||||
        _add_any(v)
 | 
			
		||||
 | 
			
		||||
    return sorted(set(hosts))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _parents_from(domains: list[str], apex: str, *, min_child_depth: int, include_apex: bool) -> list[str]:
 | 
			
		||||
def _parents_from(domains: list[str], apex: str, *, min_child_depth: int) -> list[str]:
 | 
			
		||||
    _validate(apex)
 | 
			
		||||
    parents = set([apex]) if include_apex else set()
 | 
			
		||||
    parents = set()
 | 
			
		||||
    for d in domains:
 | 
			
		||||
        _validate(d)
 | 
			
		||||
        if not d.endswith(apex):
 | 
			
		||||
@@ -75,21 +72,6 @@ def _parents_from(domains: list[str], apex: str, *, min_child_depth: int, includ
 | 
			
		||||
    return sorted(parents)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _relative_names(fqdns: list[str], apex: str) -> list[str]:
 | 
			
		||||
    """FQDN -> relative name; '' represents the apex."""
 | 
			
		||||
    _validate(apex)
 | 
			
		||||
    out: list[str] = []
 | 
			
		||||
    for d in fqdns:
 | 
			
		||||
        _validate(d)
 | 
			
		||||
        if not d.endswith(apex):
 | 
			
		||||
            continue
 | 
			
		||||
        if d == apex:
 | 
			
		||||
            out.append("")
 | 
			
		||||
        else:
 | 
			
		||||
            out.append(d[: -(len(apex) + 1)])  # strip ".apex"
 | 
			
		||||
    return sorted(set(out))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _is_global(ip: str) -> bool:
 | 
			
		||||
    try:
 | 
			
		||||
        return ipaddress.ip_address(ip).is_global
 | 
			
		||||
@@ -97,99 +79,79 @@ def _is_global(ip: str) -> bool:
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _build_cf_records(
 | 
			
		||||
    rel_parents: list[str],
 | 
			
		||||
def _build_wildcard_records(
 | 
			
		||||
    parents: list[str],
 | 
			
		||||
    apex: str,
 | 
			
		||||
    *,
 | 
			
		||||
    ip4: str,
 | 
			
		||||
    ip6: str | None,
 | 
			
		||||
    ipv6_enabled: bool,
 | 
			
		||||
    proxied: bool,
 | 
			
		||||
    wildcard_children: bool,
 | 
			
		||||
    apex_wildcard: bool,
 | 
			
		||||
    ipv6_enabled: bool,
 | 
			
		||||
) -> list[dict]:
 | 
			
		||||
    if not isinstance(rel_parents, list):
 | 
			
		||||
        raise AnsibleFilterError("rel_parents must be list[str]")
 | 
			
		||||
    if not isinstance(parents, list):
 | 
			
		||||
        raise AnsibleFilterError("parents must be list[str]")
 | 
			
		||||
    _validate(apex)
 | 
			
		||||
    if not ip4:
 | 
			
		||||
        raise AnsibleFilterError("ip4 required")
 | 
			
		||||
 | 
			
		||||
    records: list[dict] = []
 | 
			
		||||
 | 
			
		||||
    def _add_one(name: str, rtype: str, content: str):
 | 
			
		||||
    def _add(name: str, rtype: str, content: str):
 | 
			
		||||
        records.append({
 | 
			
		||||
            "zone": apex,
 | 
			
		||||
            "type": rtype,
 | 
			
		||||
            "name": name if name else "@",
 | 
			
		||||
            "name": name,
 | 
			
		||||
            "content": content,
 | 
			
		||||
            "proxied": bool(proxied),
 | 
			
		||||
            "ttl": 1,
 | 
			
		||||
        })
 | 
			
		||||
 | 
			
		||||
    for rel in sorted(set(rel_parents)):
 | 
			
		||||
        # base (parent) host
 | 
			
		||||
        _add_one(rel, "A", str(ip4))
 | 
			
		||||
        if ipv6_enabled and ip6 and _is_global(str(ip6)):
 | 
			
		||||
            _add_one(rel, "AAAA", str(ip6))
 | 
			
		||||
 | 
			
		||||
        # wildcard children under the parent
 | 
			
		||||
        if rel and wildcard_children:
 | 
			
		||||
    for p in sorted(set(parents)):
 | 
			
		||||
        rel = p[:-len(apex)-1] if p != apex else ""  # relative part; apex shouldn't produce wildcard
 | 
			
		||||
        if not rel:
 | 
			
		||||
            # Do NOT create *.apex here (explicitly excluded by requirement)
 | 
			
		||||
            continue
 | 
			
		||||
        wc = f"*.{rel}"
 | 
			
		||||
            _add_one(wc, "A", str(ip4))
 | 
			
		||||
        _add(wc, "A", str(ip4))
 | 
			
		||||
        if ipv6_enabled and ip6 and _is_global(str(ip6)):
 | 
			
		||||
                _add_one(wc, "AAAA", str(ip6))
 | 
			
		||||
 | 
			
		||||
    # optional apex wildcard (*.example.com)
 | 
			
		||||
    if apex_wildcard:
 | 
			
		||||
        _add_one("*", "A", str(ip4))
 | 
			
		||||
        if ipv6_enabled and ip6 and _is_global(str(ip6)):
 | 
			
		||||
            _add_one("*", "AAAA", str(ip6))
 | 
			
		||||
 | 
			
		||||
            _add(wc, "AAAA", str(ip6))
 | 
			
		||||
    return records
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def parent_build_records(
 | 
			
		||||
    current_play_domains: dict,
 | 
			
		||||
def wildcard_records(
 | 
			
		||||
    current_play_domains_all: dict,
 | 
			
		||||
    apex: str,
 | 
			
		||||
    ip4: str,
 | 
			
		||||
    ip6: str | None = None,
 | 
			
		||||
    proxied: bool = False,
 | 
			
		||||
    explicit_domains: list[str] | None = None,
 | 
			
		||||
    include_apex: bool = True,
 | 
			
		||||
    min_child_depth: int = 2,
 | 
			
		||||
    wildcard_children: bool = True,
 | 
			
		||||
    include_apex_wildcard: bool = False,
 | 
			
		||||
    ipv6_enabled: bool = False,
 | 
			
		||||
    ipv6_enabled: bool = True,
 | 
			
		||||
) -> list[dict]:
 | 
			
		||||
    """
 | 
			
		||||
    Return Cloudflare A/AAAA records for:
 | 
			
		||||
      - each parent host ('' == apex),
 | 
			
		||||
      - optionally '*.parent' for wildcard children,
 | 
			
		||||
      - optionally '*.apex'.
 | 
			
		||||
    Build only wildcard records for parents:
 | 
			
		||||
      for each parent 'parent.apex' -> create '*.parent' A/AAAA.
 | 
			
		||||
      No base parent records and no '*.apex' are created.
 | 
			
		||||
    """
 | 
			
		||||
    # source domains
 | 
			
		||||
    # Source domains
 | 
			
		||||
    if explicit_domains and len(explicit_domains) > 0:
 | 
			
		||||
        domains = sorted(set(explicit_domains))
 | 
			
		||||
    else:
 | 
			
		||||
        domains = _flatten_domains(current_play_domains)
 | 
			
		||||
        domains = _flatten_domains_any_structure(current_play_domains_all)
 | 
			
		||||
 | 
			
		||||
    parents = _parents_from(domains, apex, min_child_depth=min_child_depth, include_apex=include_apex)
 | 
			
		||||
    rel_parents = _relative_names(parents, apex)
 | 
			
		||||
 | 
			
		||||
    return _build_cf_records(
 | 
			
		||||
        rel_parents,
 | 
			
		||||
    parents = _parents_from(domains, apex, min_child_depth=min_child_depth)
 | 
			
		||||
    return _build_wildcard_records(
 | 
			
		||||
        parents,
 | 
			
		||||
        apex,
 | 
			
		||||
        ip4=ip4,
 | 
			
		||||
        ip6=ip6,
 | 
			
		||||
        ipv6_enabled=ipv6_enabled,
 | 
			
		||||
        proxied=proxied,
 | 
			
		||||
        wildcard_children=wildcard_children,
 | 
			
		||||
        apex_wildcard=include_apex_wildcard,
 | 
			
		||||
        ipv6_enabled=ipv6_enabled,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FilterModule(object):
 | 
			
		||||
    def filters(self):
 | 
			
		||||
        return {
 | 
			
		||||
            "parent_build_records": parent_build_records,
 | 
			
		||||
            "wildcard_records": wildcard_records,
 | 
			
		||||
        }
 | 
			
		||||
@@ -1,6 +1,6 @@
 | 
			
		||||
galaxy_info:
 | 
			
		||||
  author: "Kevin Veen-Birkenbach"
 | 
			
		||||
  description: "Create Cloudflare DNS records only for parent hosts (and apex)."
 | 
			
		||||
  description: "Create Cloudflare wildcard DNS records (*.parent) for parent hosts; no base or *.apex records."
 | 
			
		||||
  license: "Infinito.Nexus NonCommercial License"
 | 
			
		||||
  min_ansible_version: "2.12"
 | 
			
		||||
  galaxy_tags: [dns, cloudflare, automation]
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
  include_role:
 | 
			
		||||
    name: sys-dns-cloudflare-records
 | 
			
		||||
  vars:
 | 
			
		||||
    cloudflare_records: "{{ SYN_DNS_PARENT_HOSTS_RECORDS }}"
 | 
			
		||||
    cloudflare_records: "{{ SYN_DNS_WILDCARD_RECORDS }}"
 | 
			
		||||
  when: DNS_PROVIDER == 'cloudflare'
 | 
			
		||||
 | 
			
		||||
- include_tasks: utils/run_once.yml
 | 
			
		||||
							
								
								
									
										3
									
								
								roles/sys-dns-wildcards/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								roles/sys-dns-wildcards/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
- block:
 | 
			
		||||
  - include_tasks: 01_core.yml
 | 
			
		||||
  when: run_once_sys_dns_wildcards is not defined
 | 
			
		||||
@@ -1,12 +1,12 @@
 | 
			
		||||
SYN_DNS_PARENT_HOSTS_RECORDS: >-
 | 
			
		||||
SYN_DNS_WILDCARD_RECORDS: >-
 | 
			
		||||
  {{ CURRENT_PLAY_DOMAINS_ALL
 | 
			
		||||
      | parent_build_records(
 | 
			
		||||
      | wildcard_records(
 | 
			
		||||
          PRIMARY_DOMAIN,
 | 
			
		||||
          defaults_networks.internet.ip4,
 | 
			
		||||
          (defaults_networks.internet.ip6 | default('')),
 | 
			
		||||
          parent_dns_proxied,
 | 
			
		||||
          (parent_dns_domains | default([])),
 | 
			
		||||
          True,
 | 
			
		||||
          2
 | 
			
		||||
          2,
 | 
			
		||||
          parent_dns_ipv6_enabled
 | 
			
		||||
        )
 | 
			
		||||
  }}
 | 
			
		||||
@@ -3,7 +3,7 @@
 | 
			
		||||
Bootstrap and maintain **DNS prerequisites** for your web stack on Cloudflare.
 | 
			
		||||
 | 
			
		||||
This role validates credentials and (by default) ensures:
 | 
			
		||||
- **Parent host A/AAAA records** (incl. the **apex** SLD.TLD) via `sys-dns-parent-hosts`
 | 
			
		||||
- **Wildcard A/AAAA records** (`*.parent`) for parent hosts via `sys-dns-wildcards` (no base/apex records)
 | 
			
		||||
- *(Optional)* **CAA** records for Let’s Encrypt (kept as a commented block you can re-enable)
 | 
			
		||||
 | 
			
		||||
Runs **once per play** and is safe to include in stacks that roll out many domains.
 | 
			
		||||
@@ -13,9 +13,9 @@ Runs **once per play** and is safe to include in stacks that roll out many domai
 | 
			
		||||
## What it does
 | 
			
		||||
 | 
			
		||||
1. **Validate `CLOUDFLARE_API_TOKEN`** is present (early fail if missing).
 | 
			
		||||
2. **Ensure parent DNS exists** (apex + “parent” FQDNs derived from children):
 | 
			
		||||
   - Delegates to [`sys-dns-parent-hosts`](../sys-dns-parent-hosts/README.md)
 | 
			
		||||
   - Creates A (and AAAA if enabled upstream) on the Cloudflare zone, optionally proxied.
 | 
			
		||||
2. **Ensure wildcard parent DNS exists** (`*.parent` derived from children):
 | 
			
		||||
   - Delegates to [`sys-dns-wildcards`](../sys-dns-wildcards/README.md)
 | 
			
		||||
   - Creates `A` (and `AAAA` if enabled) wildcard records on the Cloudflare zone, optionally proxied. 
 | 
			
		||||
3. *(Optional)* **CAA records** for all base SLDs (commented in the tasks; enable if you want CAA managed here).
 | 
			
		||||
 | 
			
		||||
> Parent hosts example:  
 | 
			
		||||
 
 | 
			
		||||
@@ -22,11 +22,11 @@
 | 
			
		||||
  async: "{{ ASYNC_TIME if ASYNC_ENABLED | bool else omit }}"
 | 
			
		||||
  poll:  "{{ ASYNC_POLL if ASYNC_ENABLED | bool else omit }}"
 | 
			
		||||
 | 
			
		||||
- name: "Ensure parent DNS (apex + parent FQDNs) exists"
 | 
			
		||||
- name: "Ensure wildcard parent DNS (*.parent) exists"
 | 
			
		||||
  include_role:
 | 
			
		||||
    name: sys-dns-parent-hosts
 | 
			
		||||
    name: sys-dns-wildcards
 | 
			
		||||
  vars:
 | 
			
		||||
    parent_dns_proxied: false
 | 
			
		||||
  when: run_once_sys_dns_parent_hosts is not defined
 | 
			
		||||
  when: run_once_sys_dns_wildcards is not defined
 | 
			
		||||
 | 
			
		||||
- include_tasks: utils/run_once.yml
 | 
			
		||||
@@ -1,131 +0,0 @@
 | 
			
		||||
import os
 | 
			
		||||
import sys
 | 
			
		||||
import unittest
 | 
			
		||||
 | 
			
		||||
# Make the filter plugin importable
 | 
			
		||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../../.."))
 | 
			
		||||
FILTER_PATH = os.path.join(ROOT, "roles", "sys-dns-parent-hosts", "filter_plugins")
 | 
			
		||||
if FILTER_PATH not in sys.path:
 | 
			
		||||
    sys.path.insert(0, FILTER_PATH)
 | 
			
		||||
 | 
			
		||||
from parent_dns import parent_build_records  # noqa: E402
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def has_record(records, rtype, name, zone):
 | 
			
		||||
    """True if an exact (type, name, zone) record exists."""
 | 
			
		||||
    return any(
 | 
			
		||||
        r.get("type") == rtype and r.get("name") == name and r.get("zone") == zone
 | 
			
		||||
        for r in records
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def has_name_or_wildcard(records, rtype, label, zone):
 | 
			
		||||
    """True if either <label> or *.<label> exists for (type, zone)."""
 | 
			
		||||
    return has_record(records, rtype, label, zone) or has_record(
 | 
			
		||||
        records, rtype, f"*.{label}", zone
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestParentDNS(unittest.TestCase):
 | 
			
		||||
    def test_end_to_end_with_ipv6(self):
 | 
			
		||||
        current = {
 | 
			
		||||
            "web-app-foo": [
 | 
			
		||||
                "example.com",
 | 
			
		||||
                "wiki.example.com",
 | 
			
		||||
                "c.wiki.example.com",
 | 
			
		||||
                "a.b.example.com",
 | 
			
		||||
            ],
 | 
			
		||||
            "web-app-bar": ["foo.other.com"],  # different apex -> ignored
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        recs = parent_build_records(
 | 
			
		||||
            current_play_domains=current,
 | 
			
		||||
            apex="example.com",
 | 
			
		||||
            ip4="192.0.2.10",
 | 
			
		||||
            ip6="2001:db8::10",  # AAAA may or may not be emitted by role; treat as optional
 | 
			
		||||
            proxied=True,
 | 
			
		||||
            explicit_domains=None,
 | 
			
		||||
            include_apex=True,
 | 
			
		||||
            min_child_depth=2,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Apex must resolve
 | 
			
		||||
        self.assertTrue(has_record(recs, "A", "@", "example.com"))
 | 
			
		||||
 | 
			
		||||
        # Parents may be plain or wildcard (or both)
 | 
			
		||||
        self.assertTrue(has_name_or_wildcard(recs, "A", "wiki", "example.com"))
 | 
			
		||||
        self.assertTrue(has_name_or_wildcard(recs, "A", "b", "example.com"))
 | 
			
		||||
 | 
			
		||||
        # AAAA optional: if present, at least apex AAAA must exist
 | 
			
		||||
        if any(r.get("type") == "AAAA" for r in recs):
 | 
			
		||||
            self.assertTrue(has_record(recs, "AAAA", "@", "example.com"))
 | 
			
		||||
 | 
			
		||||
        # Proxied flag is propagated
 | 
			
		||||
        self.assertTrue(all(r.get("proxied") is True for r in recs if r["type"] in ("A", "AAAA")))
 | 
			
		||||
 | 
			
		||||
    def test_explicit_domains_without_ipv6(self):
 | 
			
		||||
        explicit = ["example.com", "c.wiki.example.com", "x.y.example.com"]
 | 
			
		||||
 | 
			
		||||
        recs = parent_build_records(
 | 
			
		||||
            current_play_domains={"ignore": ["foo.example.com"]},
 | 
			
		||||
            apex="example.com",
 | 
			
		||||
            ip4="198.51.100.5",
 | 
			
		||||
            ip6="",  # No IPv6 -> no AAAA expected
 | 
			
		||||
            proxied=False,
 | 
			
		||||
            explicit_domains=explicit,
 | 
			
		||||
            include_apex=True,
 | 
			
		||||
            min_child_depth=2,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        # Apex must resolve
 | 
			
		||||
        self.assertTrue(has_record(recs, "A", "@", "example.com"))
 | 
			
		||||
 | 
			
		||||
        # Parents may be plain or wildcard
 | 
			
		||||
        self.assertTrue(has_name_or_wildcard(recs, "A", "wiki", "example.com"))
 | 
			
		||||
        self.assertTrue(has_name_or_wildcard(recs, "A", "y", "example.com"))
 | 
			
		||||
 | 
			
		||||
        # No IPv6 supplied -> there should be no AAAA records
 | 
			
		||||
        self.assertFalse(any(r.get("type") == "AAAA" for r in recs))
 | 
			
		||||
 | 
			
		||||
    def test_current_play_domains_may_contain_dicts(self):
 | 
			
		||||
        # Dict values with strings and lists inside must be accepted and flattened.
 | 
			
		||||
        current = {
 | 
			
		||||
            "web-app-foo": {
 | 
			
		||||
                "prod": "wiki.example.com",
 | 
			
		||||
                "preview": ["c.wiki.example.com"],
 | 
			
		||||
            },
 | 
			
		||||
            "web-app-bar": ["irrelevant.other.com"],  # different apex, ignored
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        recs = parent_build_records(
 | 
			
		||||
            current_play_domains=current,
 | 
			
		||||
            apex="example.com",
 | 
			
		||||
            ip4="203.0.113.7",
 | 
			
		||||
            ip6=None,
 | 
			
		||||
            proxied=False,
 | 
			
		||||
            explicit_domains=None,
 | 
			
		||||
            include_apex=True,
 | 
			
		||||
            min_child_depth=2,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        self.assertTrue(has_record(recs, "A", "@", "example.com"))
 | 
			
		||||
        self.assertTrue(has_name_or_wildcard(recs, "A", "wiki", "example.com"))
 | 
			
		||||
 | 
			
		||||
    def test_invalid_inputs_raise(self):
 | 
			
		||||
        with self.assertRaises(Exception):
 | 
			
		||||
            parent_build_records(
 | 
			
		||||
                current_play_domains={"ok": ["example.com"]},
 | 
			
		||||
                apex="",  # invalid apex
 | 
			
		||||
                ip4="192.0.2.1",
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
        with self.assertRaises(Exception):
 | 
			
		||||
            parent_build_records(
 | 
			
		||||
                current_play_domains={"ok": ["example.com"]},
 | 
			
		||||
                apex="example.com",
 | 
			
		||||
                ip4="",  # required
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    unittest.main()
 | 
			
		||||
@@ -0,0 +1,211 @@
 | 
			
		||||
# tests/unit/roles/sys-dns-wildcards/filter_plugins/test_wildcard_dns.py
 | 
			
		||||
import unittest
 | 
			
		||||
import importlib.util
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _load_module():
 | 
			
		||||
    """
 | 
			
		||||
    Load the wildcard_dns filter plugin from:
 | 
			
		||||
      roles/sys-dns-wildcards/filter_plugins/wildcard_dns.py
 | 
			
		||||
    """
 | 
			
		||||
    here = Path(__file__).resolve()
 | 
			
		||||
    # Go up to repository root (…/tests/unit/roles/… → 5 levels up)
 | 
			
		||||
    repo_root = here.parents[5] if len(here.parents) >= 6 else here.parents[0]
 | 
			
		||||
 | 
			
		||||
    path = repo_root / "roles" / "sys-dns-wildcards" / "filter_plugins" / "wildcard_dns.py"
 | 
			
		||||
    if not path.exists():
 | 
			
		||||
        raise FileNotFoundError(f"Could not find {path}")
 | 
			
		||||
 | 
			
		||||
    spec = importlib.util.spec_from_file_location("wildcard_dns", path)
 | 
			
		||||
    mod = importlib.util.module_from_spec(spec)
 | 
			
		||||
    assert spec and spec.loader
 | 
			
		||||
    spec.loader.exec_module(mod)  # type: ignore[attr-defined]
 | 
			
		||||
    return mod
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
_wildcard_dns = _load_module()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _get_filter():
 | 
			
		||||
    """Return the wildcard_records filter function from the plugin."""
 | 
			
		||||
    fm = _wildcard_dns.FilterModule()
 | 
			
		||||
    filters = fm.filters()
 | 
			
		||||
    if "wildcard_records" not in filters:
 | 
			
		||||
        raise AssertionError("wildcard_records filter not found")
 | 
			
		||||
    return filters["wildcard_records"]
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _as_set(records):
 | 
			
		||||
    """Normalize records for order-independent comparison."""
 | 
			
		||||
    return {
 | 
			
		||||
        (r.get("type"), r.get("name"), r.get("content"), bool(r.get("proxied")))
 | 
			
		||||
        for r in records
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class TestWildcardDNS(unittest.TestCase):
 | 
			
		||||
    def setUp(self):
 | 
			
		||||
        self.wildcard_records = _get_filter()
 | 
			
		||||
 | 
			
		||||
    def test_only_wildcards_no_apex_or_base(self):
 | 
			
		||||
        apex = "example.com"
 | 
			
		||||
        cpda = {
 | 
			
		||||
            "svc-a": ["c.wiki.example.com", "a.b.example.com"],
 | 
			
		||||
            "svc-b": {"extra": ["www.a.b.example.com"]},
 | 
			
		||||
            "svc-c": "example.com",
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        recs = self.wildcard_records(
 | 
			
		||||
            current_play_domains_all=cpda,
 | 
			
		||||
            apex=apex,
 | 
			
		||||
            ip4="203.0.113.10",
 | 
			
		||||
            ip6="2606:4700:4700::1111",
 | 
			
		||||
            proxied=True,
 | 
			
		||||
            explicit_domains=None,
 | 
			
		||||
            min_child_depth=2,
 | 
			
		||||
            ipv6_enabled=True,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
        got = _as_set(recs)
 | 
			
		||||
        expected = {
 | 
			
		||||
            ("A", "*.wiki", "203.0.113.10", True),
 | 
			
		||||
            ("AAAA", "*.wiki", "2606:4700:4700::1111", True),
 | 
			
		||||
            ("A", "*.b", "203.0.113.10", True),
 | 
			
		||||
            ("AAAA", "*.b", "2606:4700:4700::1111", True),
 | 
			
		||||
            # now included because www.a.b.example.com promotes a.b.example.com as a parent
 | 
			
		||||
            ("A", "*.a.b", "203.0.113.10", True),
 | 
			
		||||
            ("AAAA", "*.a.b", "2606:4700:4700::1111", True),
 | 
			
		||||
        }
 | 
			
		||||
        self.assertEqual(got, expected)
 | 
			
		||||
 | 
			
		||||
    def test_min_child_depth_prevents_apex_wildcard(self):
 | 
			
		||||
        apex = "example.com"
 | 
			
		||||
        cpda = {"svc": ["x.example.com"]}  # depth = 1
 | 
			
		||||
 | 
			
		||||
        recs = self.wildcard_records(
 | 
			
		||||
            current_play_domains_all=cpda,
 | 
			
		||||
            apex=apex,
 | 
			
		||||
            ip4="198.51.100.42",
 | 
			
		||||
            ip6="2606:4700:4700::1111",
 | 
			
		||||
            proxied=False,
 | 
			
		||||
            explicit_domains=None,
 | 
			
		||||
            min_child_depth=2,  # requires >= 2 → no parent derived
 | 
			
		||||
            ipv6_enabled=True,
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(recs, [])
 | 
			
		||||
 | 
			
		||||
    def test_ipv6_disabled_and_private_ipv6_filtered(self):
 | 
			
		||||
        apex = "example.com"
 | 
			
		||||
        cpda = {"svc": ["a.b.example.com"]}
 | 
			
		||||
 | 
			
		||||
        # IPv6 disabled → only A record
 | 
			
		||||
        recs1 = self.wildcard_records(
 | 
			
		||||
            current_play_domains_all=cpda,
 | 
			
		||||
            apex=apex,
 | 
			
		||||
            ip4="203.0.113.9",
 | 
			
		||||
            ip6="2606:4700:4700::1111",
 | 
			
		||||
            proxied=False,
 | 
			
		||||
            explicit_domains=None,
 | 
			
		||||
            min_child_depth=2,
 | 
			
		||||
            ipv6_enabled=False,
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(_as_set(recs1), {("A", "*.b", "203.0.113.9", False)})
 | 
			
		||||
 | 
			
		||||
        # IPv6 enabled but ULA (not global) → skip AAAA
 | 
			
		||||
        recs2 = self.wildcard_records(
 | 
			
		||||
            current_play_domains_all=cpda,
 | 
			
		||||
            apex=apex,
 | 
			
		||||
            ip4="203.0.113.9",
 | 
			
		||||
            ip6="fd00::1",
 | 
			
		||||
            proxied=False,
 | 
			
		||||
            explicit_domains=None,
 | 
			
		||||
            min_child_depth=2,
 | 
			
		||||
            ipv6_enabled=True,
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(_as_set(recs2), {("A", "*.b", "203.0.113.9", False)})
 | 
			
		||||
 | 
			
		||||
    def test_proxied_flag_true_is_set(self):
 | 
			
		||||
        recs = self.wildcard_records(
 | 
			
		||||
            current_play_domains_all={"svc": ["a.b.example.com"]},
 | 
			
		||||
            apex="example.com",
 | 
			
		||||
            ip4="203.0.113.7",
 | 
			
		||||
            ip6=None,
 | 
			
		||||
            proxied=True,
 | 
			
		||||
            explicit_domains=None,
 | 
			
		||||
            min_child_depth=2,
 | 
			
		||||
            ipv6_enabled=True,
 | 
			
		||||
        )
 | 
			
		||||
        self.assertTrue(all(r.get("proxied") is True for r in recs))
 | 
			
		||||
        self.assertEqual(_as_set(recs), {("A", "*.b", "203.0.113.7", True)})
 | 
			
		||||
 | 
			
		||||
    def test_explicit_domains_override_source(self):
 | 
			
		||||
        cpda = {"svc": ["ignore.me.example.com", "a.b.example.com"]}
 | 
			
		||||
        explicit = ["c.wiki.example.com"]
 | 
			
		||||
 | 
			
		||||
        recs = self.wildcard_records(
 | 
			
		||||
            current_play_domains_all=cpda,
 | 
			
		||||
            apex="example.com",
 | 
			
		||||
            ip4="203.0.113.5",
 | 
			
		||||
            ip6="2606:4700:4700::1111",
 | 
			
		||||
            proxied=False,
 | 
			
		||||
            explicit_domains=explicit,
 | 
			
		||||
            min_child_depth=2,
 | 
			
		||||
            ipv6_enabled=True,
 | 
			
		||||
        )
 | 
			
		||||
        self.assertEqual(
 | 
			
		||||
            _as_set(recs),
 | 
			
		||||
            {
 | 
			
		||||
                ("A", "*.wiki", "203.0.113.5", False),
 | 
			
		||||
                ("AAAA", "*.wiki", "2606:4700:4700::1111", False),
 | 
			
		||||
            },
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def test_nested_structures_flattened_correctly(self):
 | 
			
		||||
        cpda = {
 | 
			
		||||
            "svc1": {
 | 
			
		||||
                "primary": ["c.wiki.example.com"],
 | 
			
		||||
                "extra": {"alt": ["a.b.example.com"]},
 | 
			
		||||
            },
 | 
			
		||||
            "svc2": "www.a.b.example.com",
 | 
			
		||||
            "svc3": ["x.example.net"],  # wrong apex → ignored
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        recs = self.wildcard_records(
 | 
			
		||||
            current_play_domains_all=cpda,
 | 
			
		||||
            apex="example.com",
 | 
			
		||||
            ip4="203.0.113.21",
 | 
			
		||||
            ip6="2606:4700:4700::1111",
 | 
			
		||||
            proxied=False,
 | 
			
		||||
            explicit_domains=None,
 | 
			
		||||
            min_child_depth=2,
 | 
			
		||||
            ipv6_enabled=True,
 | 
			
		||||
        )
 | 
			
		||||
        got = _as_set(recs)
 | 
			
		||||
        expected = {
 | 
			
		||||
            ("A", "*.wiki", "203.0.113.21", False),
 | 
			
		||||
            ("AAAA", "*.wiki", "2606:4700:4700::1111", False),
 | 
			
		||||
            ("A", "*.b", "203.0.113.21", False),
 | 
			
		||||
            ("AAAA", "*.b", "2606:4700:4700::1111", False),
 | 
			
		||||
            # now included because www.a.b.example.com promotes a.b.example.com as a parent
 | 
			
		||||
            ("A", "*.a.b", "203.0.113.21", False),
 | 
			
		||||
            ("AAAA", "*.a.b", "2606:4700:4700::1111", False),
 | 
			
		||||
        }
 | 
			
		||||
        self.assertEqual(got, expected)
 | 
			
		||||
 | 
			
		||||
    def test_error_on_missing_ip4(self):
 | 
			
		||||
        with self.assertRaises(Exception):
 | 
			
		||||
            self.wildcard_records(
 | 
			
		||||
                current_play_domains_all={"svc": ["a.b.example.com"]},
 | 
			
		||||
                apex="example.com",
 | 
			
		||||
                ip4="",  # must not be empty
 | 
			
		||||
                ip6=None,
 | 
			
		||||
                proxied=False,
 | 
			
		||||
                explicit_domains=None,
 | 
			
		||||
                min_child_depth=2,
 | 
			
		||||
                ipv6_enabled=True,
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == "__main__":
 | 
			
		||||
    unittest.main()
 | 
			
		||||
		Reference in New Issue
	
	Block a user