mirror of
				https://github.com/kevinveenbirkenbach/computer-playbook.git
				synced 2025-11-03 19:58:14 +00:00 
			
		
		
		
	feat(dns): add sys-svc-dns role and extend parent DNS handling
Introduce sys-svc-dns to bootstrap Cloudflare DNS prerequisites. Validates CLOUDFLARE_API_TOKEN, (optionally) manages CAA for base SLDs, and delegates parent record creation to sys-dns-parent-hosts. Wired into sys-stk-front-pure. sys-dns-parent-hosts: new parent_dns filter builds A/AAAA for each parent host and wildcard children (*.parent). Supports dict/list inputs for CURRENT_PLAY_DOMAINS, optional IPv6, proxied flag, and optional *.apex. Exposes a single parent_build_records entry point. Let’s Encrypt role cleanup: remove DNS/C AA management from sys-svc-letsencrypt; it now focuses on webroot challenge config and renew timer. Fixed path joins and run_once guards. Tests: update unit tests to allow wildcard outputs and dict-based CURRENT_PLAY_DOMAINS. Add generate_base_sld_domains filter. Documentation updates for both roles. Conversation: https://chatgpt.com/share/68c342f7-d20c-800f-b61f-cefeebcf1cd8
This commit is contained in:
		
							
								
								
									
										24
									
								
								roles/sys-dns-parent-hosts/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								roles/sys-dns-parent-hosts/README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,24 @@
 | 
			
		||||
# 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
 | 
			
		||||
							
								
								
									
										2
									
								
								roles/sys-dns-parent-hosts/defaults/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								roles/sys-dns-parent-hosts/defaults/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
parent_dns_proxied: false
 | 
			
		||||
parent_dns_domains: []
 | 
			
		||||
							
								
								
									
										195
									
								
								roles/sys-dns-parent-hosts/filter_plugins/parent_dns.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										195
									
								
								roles/sys-dns-parent-hosts/filter_plugins/parent_dns.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,195 @@
 | 
			
		||||
from ansible.errors import AnsibleFilterError
 | 
			
		||||
import ipaddress
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _validate(d: str) -> None:
 | 
			
		||||
    if not isinstance(d, str) or not d.strip() or d.startswith(".") or d.endswith(".") or ".." in d:
 | 
			
		||||
        raise AnsibleFilterError(f"Invalid domain: {d!r}")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _depth(domain: str, apex: str) -> int:
 | 
			
		||||
    dl, al = domain.split("."), apex.split(".")
 | 
			
		||||
    if not domain.endswith(apex) or len(dl) <= len(al):
 | 
			
		||||
        return 0
 | 
			
		||||
    return len(dl) - len(al)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _parent_of_child(domain: str, apex: str) -> str | None:
 | 
			
		||||
    """For a child like a.b.example.com return b.example.com; else None (needs depth >= 2)."""
 | 
			
		||||
    if not domain.endswith(apex):
 | 
			
		||||
        return None
 | 
			
		||||
    parts = domain.split(".")
 | 
			
		||||
    apex_len = len(apex.split("."))
 | 
			
		||||
    if len(parts) <= apex_len + 1:
 | 
			
		||||
        return None
 | 
			
		||||
    return ".".join(parts[1:])  # drop exactly the left-most label
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _flatten_domains(current_play_domains: dict) -> 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])
 | 
			
		||||
    """
 | 
			
		||||
    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):
 | 
			
		||||
        if x is None:
 | 
			
		||||
            return
 | 
			
		||||
        if isinstance(x, str):
 | 
			
		||||
            hosts.append(x)
 | 
			
		||||
            return
 | 
			
		||||
        if isinstance(x, (list, tuple, set)):
 | 
			
		||||
            for i in x:
 | 
			
		||||
                if not isinstance(i, str):
 | 
			
		||||
                    raise AnsibleFilterError(f"Non-string hostname in list: {i!r}")
 | 
			
		||||
                hosts.append(i)
 | 
			
		||||
            return
 | 
			
		||||
        if isinstance(x, dict):
 | 
			
		||||
            for v in x.values():
 | 
			
		||||
                _add_any(v)
 | 
			
		||||
            return
 | 
			
		||||
        raise AnsibleFilterError(f"Unsupported CURRENT_PLAY_DOMAINS value type: {type(x).__name__}")
 | 
			
		||||
 | 
			
		||||
    for v in current_play_domains.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]:
 | 
			
		||||
    _validate(apex)
 | 
			
		||||
    parents = set([apex]) if include_apex else set()
 | 
			
		||||
    for d in domains:
 | 
			
		||||
        _validate(d)
 | 
			
		||||
        if not d.endswith(apex):
 | 
			
		||||
            continue
 | 
			
		||||
        if _depth(d, apex) >= min_child_depth:
 | 
			
		||||
            p = _parent_of_child(d, apex)
 | 
			
		||||
            if p:
 | 
			
		||||
                parents.add(p)
 | 
			
		||||
    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
 | 
			
		||||
    except Exception:
 | 
			
		||||
        return False
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def _build_cf_records(
 | 
			
		||||
    rel_parents: list[str],
 | 
			
		||||
    apex: str,
 | 
			
		||||
    *,
 | 
			
		||||
    ip4: str,
 | 
			
		||||
    ip6: str | None,
 | 
			
		||||
    ipv6_enabled: bool,
 | 
			
		||||
    proxied: bool,
 | 
			
		||||
    wildcard_children: bool,
 | 
			
		||||
    apex_wildcard: bool,
 | 
			
		||||
) -> list[dict]:
 | 
			
		||||
    if not isinstance(rel_parents, list):
 | 
			
		||||
        raise AnsibleFilterError("rel_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):
 | 
			
		||||
        records.append({
 | 
			
		||||
            "zone": apex,
 | 
			
		||||
            "type": rtype,
 | 
			
		||||
            "name": name if name else "@",
 | 
			
		||||
            "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:
 | 
			
		||||
            wc = f"*.{rel}"
 | 
			
		||||
            _add_one(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))
 | 
			
		||||
 | 
			
		||||
    return records
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def parent_build_records(
 | 
			
		||||
    current_play_domains: 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,
 | 
			
		||||
) -> list[dict]:
 | 
			
		||||
    """
 | 
			
		||||
    Return Cloudflare A/AAAA records for:
 | 
			
		||||
      - each parent host ('' == apex),
 | 
			
		||||
      - optionally '*.parent' for wildcard children,
 | 
			
		||||
      - optionally '*.apex'.
 | 
			
		||||
    """
 | 
			
		||||
    # source domains
 | 
			
		||||
    if explicit_domains and len(explicit_domains) > 0:
 | 
			
		||||
        domains = sorted(set(explicit_domains))
 | 
			
		||||
    else:
 | 
			
		||||
        domains = _flatten_domains(current_play_domains)
 | 
			
		||||
 | 
			
		||||
    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,
 | 
			
		||||
        apex,
 | 
			
		||||
        ip4=ip4,
 | 
			
		||||
        ip6=ip6,
 | 
			
		||||
        ipv6_enabled=ipv6_enabled,
 | 
			
		||||
        proxied=proxied,
 | 
			
		||||
        wildcard_children=wildcard_children,
 | 
			
		||||
        apex_wildcard=include_apex_wildcard,
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class FilterModule(object):
 | 
			
		||||
    def filters(self):
 | 
			
		||||
        return {
 | 
			
		||||
            "parent_build_records": parent_build_records,
 | 
			
		||||
        }
 | 
			
		||||
							
								
								
									
										7
									
								
								roles/sys-dns-parent-hosts/meta/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								roles/sys-dns-parent-hosts/meta/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
			
		||||
galaxy_info:
 | 
			
		||||
  author: "Kevin Veen-Birkenbach"
 | 
			
		||||
  description: "Create Cloudflare DNS records only for parent hosts (and apex)."
 | 
			
		||||
  license: "Infinito.Nexus NonCommercial License"
 | 
			
		||||
  min_ansible_version: "2.12"
 | 
			
		||||
  galaxy_tags: [dns, cloudflare, automation]
 | 
			
		||||
dependencies: []
 | 
			
		||||
							
								
								
									
										9
									
								
								roles/sys-dns-parent-hosts/tasks/01_core.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								roles/sys-dns-parent-hosts/tasks/01_core.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,9 @@
 | 
			
		||||
---
 | 
			
		||||
- name: "Apply Cloudflare DNS for parent domains"
 | 
			
		||||
  include_role:
 | 
			
		||||
    name: sys-dns-cloudflare-records
 | 
			
		||||
  vars:
 | 
			
		||||
    cloudflare_records: "{{ SYN_DNS_PARENT_HOSTS_RECORDS }}"
 | 
			
		||||
  when: DNS_PROVIDER == 'cloudflare'
 | 
			
		||||
 | 
			
		||||
- include_tasks: utils/run_once.yml
 | 
			
		||||
							
								
								
									
										3
									
								
								roles/sys-dns-parent-hosts/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								roles/sys-dns-parent-hosts/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
- block:
 | 
			
		||||
  - include_tasks: 01_core.yml
 | 
			
		||||
  when: run_once_sys_dns_parent_hosts is not defined
 | 
			
		||||
							
								
								
									
										12
									
								
								roles/sys-dns-parent-hosts/vars/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										12
									
								
								roles/sys-dns-parent-hosts/vars/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,12 @@
 | 
			
		||||
SYN_DNS_PARENT_HOSTS_RECORDS: >-
 | 
			
		||||
  {{ CURRENT_PLAY_DOMAINS
 | 
			
		||||
      | parent_build_records(
 | 
			
		||||
          PRIMARY_DOMAIN,
 | 
			
		||||
          defaults_networks.internet.ip4,
 | 
			
		||||
          (defaults_networks.internet.ip6 | default('')),
 | 
			
		||||
          parent_dns_proxied,
 | 
			
		||||
          (parent_dns_domains | default([])),
 | 
			
		||||
          True,
 | 
			
		||||
          2
 | 
			
		||||
        )
 | 
			
		||||
  }}
 | 
			
		||||
@@ -6,5 +6,6 @@
 | 
			
		||||
    - sys-svc-webserver
 | 
			
		||||
    - sys-svc-cln-domains
 | 
			
		||||
    - sys-svc-letsencrypt
 | 
			
		||||
    - sys-svc-dns
 | 
			
		||||
  - include_tasks: utils/run_once.yml
 | 
			
		||||
  when: run_once_sys_stk_front_pure is not defined
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										22
									
								
								roles/sys-svc-dns/README.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								roles/sys-svc-dns/README.md
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
# sys-svc-dns
 | 
			
		||||
 | 
			
		||||
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`
 | 
			
		||||
- *(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.
 | 
			
		||||
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
## 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.
 | 
			
		||||
3. *(Optional)* **CAA records** for all base SLDs (commented in the tasks; enable if you want CAA managed here).
 | 
			
		||||
 | 
			
		||||
> Parent hosts example:  
 | 
			
		||||
> `c.wiki.example.com` → **parent** `wiki.example.com` (plus `example.com` apex)
 | 
			
		||||
							
								
								
									
										26
									
								
								roles/sys-svc-dns/meta/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								roles/sys-svc-dns/meta/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
			
		||||
galaxy_info:
 | 
			
		||||
  author: "Kevin Veen-Birkenbach"
 | 
			
		||||
  description: "Cloudflare DNS bootstrap: parent host A/AAAA (and optional CAA) — runs once per play."
 | 
			
		||||
  license: "Infinito.Nexus NonCommercial License"
 | 
			
		||||
  license_url: "https://s.infinito.nexus/license"
 | 
			
		||||
  company: |
 | 
			
		||||
    Kevin Veen-Birkenbach
 | 
			
		||||
    Consulting & Coaching Solutions
 | 
			
		||||
    https://www.veen.world
 | 
			
		||||
  min_ansible_version: "2.12"
 | 
			
		||||
  platforms:
 | 
			
		||||
    - name: Archlinux
 | 
			
		||||
      versions: [rolling]
 | 
			
		||||
  galaxy_tags:
 | 
			
		||||
    - dns
 | 
			
		||||
    - cloudflare
 | 
			
		||||
    - automation
 | 
			
		||||
    - letsencrypt
 | 
			
		||||
    - nginx
 | 
			
		||||
  repository: "https://s.infinito.nexus/code"
 | 
			
		||||
  issue_tracker_url: "https://s.infinito.nexus/issues"
 | 
			
		||||
  documentation: "https://docs.infinito.nexus"
 | 
			
		||||
  logo:
 | 
			
		||||
    class: "fa-solid fa-cloud"
 | 
			
		||||
  run_after: []
 | 
			
		||||
dependencies: []
 | 
			
		||||
@@ -1,5 +1,4 @@
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
- name: "Validate CLOUDFLARE_API_TOKEN"
 | 
			
		||||
  fail:
 | 
			
		||||
    msg: >
 | 
			
		||||
@@ -22,3 +21,12 @@
 | 
			
		||||
    label: "{{ item.0 }} → {{ item.1.tag }}"
 | 
			
		||||
  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"
 | 
			
		||||
  include_role:
 | 
			
		||||
    name: sys-dns-parent-hosts
 | 
			
		||||
  vars:
 | 
			
		||||
    parent_dns_proxied: false
 | 
			
		||||
  when: run_once_sys_dns_parent_hosts is not defined
 | 
			
		||||
 | 
			
		||||
- include_tasks: utils/run_once.yml
 | 
			
		||||
							
								
								
									
										4
									
								
								roles/sys-svc-dns/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								roles/sys-svc-dns/tasks/main.yml
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
- block:
 | 
			
		||||
  - include_tasks: 01_core.yml
 | 
			
		||||
    when: DNS_PROVIDER == 'cloudflare'
 | 
			
		||||
  when: run_once_sys_svc_dns is not defined
 | 
			
		||||
@@ -6,9 +6,7 @@
 | 
			
		||||
- name: create nginx letsencrypt config file
 | 
			
		||||
  template:
 | 
			
		||||
    src: "letsencrypt.conf.j2"
 | 
			
		||||
      dest: "{{NGINX.DIRECTORIES.HTTP.GLOBAL}}letsencrypt.conf"
 | 
			
		||||
    dest: "{{ [ NGINX.DIRECTORIES.HTTP.GLOBAL, 'letsencrypt.conf' ] | path_join }}"
 | 
			
		||||
  notify: restart openresty
 | 
			
		||||
 | 
			
		||||
  - name: "Set CAA records for all base domains"
 | 
			
		||||
    include_tasks: 01_set-caa-records.yml
 | 
			
		||||
    when: DNS_PROVIDER == 'cloudflare'
 | 
			
		||||
- include_tasks: utils/run_once.yml
 | 
			
		||||
@@ -1,4 +1,3 @@
 | 
			
		||||
- block:
 | 
			
		||||
  - include_tasks: 01_core.yml
 | 
			
		||||
  - include_tasks: utils/run_once.yml
 | 
			
		||||
  when: run_once_sys_svc_letsencrypt is not defined
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										0
									
								
								tests/unit/roles/sys-dns-parent-hosts/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								tests/unit/roles/sys-dns-parent-hosts/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,131 @@
 | 
			
		||||
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()
 | 
			
		||||
		Reference in New Issue
	
	Block a user