mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2026-11-10 12:47:04 +00:00
perf(sys-dns-cloudflare-records): skip records the zone already holds as declared
Every deploy sent each declared record through cloudflare_dns, one async
job per record, although nearly all of them already stood unchanged; the
galaxyserver 2fd6c6d deploy spent about two minutes collecting them.
A new action plugin, cloudflare_records_converged, reads each zone once
(all pages) and splits the declared records into pending and converged.
It judges A, AAAA, CNAME, MX, TXT and SRV with the same lookup key and
the same defaults the community.general 14.0.0 module applies; every
other type, every solo or absent entry, and every record set whose
entries would undo each other stay pending, as do all records of a zone
that cannot be read (with a warning). Only pending records reach the
unchanged apply and retry loop, so failures stay per record and loud.
The test pins the plugin's defaults against the module's argument_spec
and the reviewed collection version, and covers the hazardous groups
with a mutation check.
🎯 Fix: 92 %
💥 Break: 3 %
🌱 Efficiency: –
⚡ Speed: –
🛡️ Security: ~100 %
🏆 Score: 89
🚦 Gate: ✅ 90/5, efficiency and speed <= 100, security 100 %
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
025ebe9754
commit
59889bb5af
3 files changed
+1145
-2
No files matched your search
@@ -0,0 +1,444 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections import Counter
|
||||
from http.client import HTTPException
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode
|
||||
|
||||
from ansible.errors import AnsibleActionFail
|
||||
from ansible.module_utils.common.validation import check_type_int
|
||||
from ansible.module_utils.parsing.convert_bool import boolean
|
||||
from ansible.module_utils.urls import ConnectionError as UrlsConnectionError
|
||||
from ansible.module_utils.urls import open_url
|
||||
from ansible.plugins.action import ActionBase
|
||||
|
||||
API = "https://api.cloudflare.com/client/v4"
|
||||
REVIEWED_COMMUNITY_GENERAL = "14.0.0"
|
||||
PAGE_SIZE = 5000
|
||||
ARGUMENTS = frozenset({"api_token", "records", "timeout"})
|
||||
JUDGED_TYPES = ("A", "AAAA", "CNAME", "MX", "TXT", "SRV")
|
||||
LOWERED_TYPES = ("CNAME", "MX", "SRV")
|
||||
PROXIABLE_TYPES = ("A", "AAAA", "CNAME")
|
||||
APPLY_TTL = 1
|
||||
APPLY_PROXIED = False
|
||||
APPLY_SOLO = False
|
||||
APPLY_STATE = "present"
|
||||
APPLY_MX_PRIORITY = 10
|
||||
MODULE_PRIORITY = 1
|
||||
MODULE_PROXIED = False
|
||||
READ_FAILURES = (
|
||||
OSError,
|
||||
ValueError,
|
||||
KeyError,
|
||||
TypeError,
|
||||
HTTPException,
|
||||
UrlsConnectionError,
|
||||
)
|
||||
|
||||
|
||||
def _get(path: str, token: str, timeout: int) -> dict[str, Any]:
|
||||
"""
|
||||
Args:
|
||||
path: API path below ``API``, query string included.
|
||||
token: Cloudflare API token, sent in the bearer header only.
|
||||
timeout: seconds one socket operation may stall.
|
||||
|
||||
Returns:
|
||||
The decoded answer; raises ``ValueError`` when Cloudflare reports no success.
|
||||
"""
|
||||
response = open_url(
|
||||
API + path,
|
||||
method="GET",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
answer = json.loads(response.read())
|
||||
if not isinstance(answer, dict) or answer.get("success") is not True:
|
||||
errors = answer.get("errors") if isinstance(answer, dict) else answer
|
||||
raise ValueError(f"GET {path} answered without success: {errors}")
|
||||
return answer
|
||||
|
||||
|
||||
def zone_records(zone: str, token: str, timeout: int) -> list[dict[str, Any]]:
|
||||
"""
|
||||
Args:
|
||||
zone: Cloudflare zone name.
|
||||
token: Cloudflare API token.
|
||||
timeout: seconds one socket operation may stall.
|
||||
|
||||
Returns:
|
||||
Every DNS record of the zone, across all pages; raises ``ValueError``
|
||||
unless exactly one zone carries the name.
|
||||
"""
|
||||
zones = _get("/zones?" + urlencode({"name": zone}), token, timeout)["result"]
|
||||
if len(zones) != 1:
|
||||
raise ValueError(f"{len(zones)} Cloudflare zones match {zone}")
|
||||
records: list[dict[str, Any]] = []
|
||||
page = 1
|
||||
while True:
|
||||
answer = _get(
|
||||
f"/zones/{zones[0]['id']}/dns_records?"
|
||||
+ urlencode({"page": page, "per_page": PAGE_SIZE}),
|
||||
token,
|
||||
timeout,
|
||||
)
|
||||
records.extend(answer["result"])
|
||||
if page >= int(answer["result_info"]["total_pages"]):
|
||||
return records
|
||||
page += 1
|
||||
|
||||
|
||||
def _int(value: Any) -> int | None:
|
||||
"""
|
||||
Args:
|
||||
value: a record field the module reads as ``int``.
|
||||
|
||||
Returns:
|
||||
The integer ansible-core's ``check_type_int`` makes of the field, or
|
||||
``None`` when it is a boolean or ``check_type_int`` rejects it.
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
return check_type_int(value)
|
||||
except (TypeError, OverflowError):
|
||||
return None
|
||||
|
||||
|
||||
def _bool(value: Any) -> bool | None:
|
||||
"""
|
||||
Args:
|
||||
value: a record field the module reads as ``bool``.
|
||||
|
||||
Returns:
|
||||
The boolean, or ``None`` when the field is not one.
|
||||
"""
|
||||
try:
|
||||
return boolean(value, strict=True)
|
||||
except TypeError:
|
||||
return None
|
||||
|
||||
|
||||
def _owner(record: Any) -> tuple[str, str, str] | None:
|
||||
"""
|
||||
Args:
|
||||
record: one ``cloudflare_records`` entry.
|
||||
|
||||
Returns:
|
||||
``(zone, type, name)`` of the record set the module edits for the
|
||||
entry, normalised as the module normalises them, or ``None`` when the
|
||||
entry names no A, AAAA, CNAME, MX, TXT or SRV record set.
|
||||
"""
|
||||
if not isinstance(record, dict):
|
||||
return None
|
||||
rtype = record.get("type")
|
||||
zone = record.get("zone")
|
||||
name = record.get("name")
|
||||
if rtype not in JUDGED_TYPES:
|
||||
return None
|
||||
if not all(isinstance(v, str) and v for v in (zone, name)):
|
||||
return None
|
||||
zone = zone.lower()
|
||||
fqdn = name.lower()
|
||||
if fqdn == "@":
|
||||
fqdn = zone
|
||||
if not fqdn.endswith(zone):
|
||||
fqdn = f"{fqdn}.{zone}"
|
||||
if rtype == "SRV":
|
||||
service = record.get("service")
|
||||
proto = record.get("proto")
|
||||
if not all(isinstance(v, str) and v for v in (service, proto)):
|
||||
return None
|
||||
service = service.lower()
|
||||
proto = proto.lower()
|
||||
if not service.startswith("_"):
|
||||
service = f"_{service}"
|
||||
if not proto.startswith("_"):
|
||||
proto = f"_{proto}"
|
||||
fqdn = f"{service}.{proto}.{fqdn}"
|
||||
return zone, rtype, fqdn
|
||||
|
||||
|
||||
def _hazardous(record: dict[str, Any]) -> bool:
|
||||
"""
|
||||
Args:
|
||||
record: one ``cloudflare_records`` entry ``_owner`` keyed.
|
||||
|
||||
Returns:
|
||||
True when the module run for the entry may delete records of its
|
||||
record set: it is solo, its solo flag reads as no boolean, or it is
|
||||
not in state present.
|
||||
"""
|
||||
solo = _bool(record["solo"]) if "solo" in record else APPLY_SOLO
|
||||
return solo is not False or record.get("state", APPLY_STATE) != "present"
|
||||
|
||||
|
||||
def expected(record: Any) -> dict[str, Any] | None:
|
||||
"""
|
||||
Args:
|
||||
record: one ``cloudflare_records`` entry as ``tasks/apply.yml`` of
|
||||
``sys-dns-cloudflare-records`` hands it to
|
||||
``community.general.cloudflare_dns``.
|
||||
|
||||
Returns:
|
||||
``type``, ``name``, ``content``, ``ttl``, ``priority``, ``proxied``,
|
||||
``data`` and ``solo`` of the record the module converges the entry
|
||||
to, or ``None`` unless the entry is a present A, AAAA, CNAME, MX, TXT
|
||||
or SRV record whose every field reads as the module reads it.
|
||||
"""
|
||||
owner = _owner(record)
|
||||
if owner is None:
|
||||
return None
|
||||
_zone, rtype, fqdn = owner
|
||||
if record.get("state", APPLY_STATE) != "present":
|
||||
return None
|
||||
raw = record.get("content" if rtype in ("A", "AAAA") else "value")
|
||||
if not (isinstance(raw, str) and raw):
|
||||
return None
|
||||
|
||||
if rtype in LOWERED_TYPES:
|
||||
content = raw.rstrip(".").lower()
|
||||
elif rtype == "AAAA":
|
||||
content = raw.lower()
|
||||
else:
|
||||
content = raw
|
||||
|
||||
ttl = _int(record["ttl"]) if "ttl" in record else APPLY_TTL
|
||||
solo = _bool(record["solo"]) if "solo" in record else APPLY_SOLO
|
||||
if rtype in ("A", "AAAA"):
|
||||
proxied = _bool(record["proxied"]) if "proxied" in record else APPLY_PROXIED
|
||||
elif rtype == "CNAME":
|
||||
proxied = MODULE_PROXIED
|
||||
else:
|
||||
proxied = None
|
||||
if rtype == "MX":
|
||||
priority = (
|
||||
_int(record["priority"]) if "priority" in record else APPLY_MX_PRIORITY
|
||||
)
|
||||
elif rtype == "SRV":
|
||||
priority = _int(record.get("priority"))
|
||||
else:
|
||||
priority = MODULE_PRIORITY
|
||||
if None in (ttl, solo, priority) or (rtype in PROXIABLE_TYPES and proxied is None):
|
||||
return None
|
||||
|
||||
data = None
|
||||
if rtype == "SRV":
|
||||
weight = _int(record.get("weight"))
|
||||
port = _int(record.get("port"))
|
||||
if weight is None or port is None:
|
||||
return None
|
||||
data = {"target": content, "port": port, "weight": weight, "priority": priority}
|
||||
|
||||
return {
|
||||
"type": rtype,
|
||||
"name": fqdn,
|
||||
"content": content,
|
||||
"ttl": ttl,
|
||||
"priority": priority,
|
||||
"proxied": proxied,
|
||||
"data": data,
|
||||
"solo": solo,
|
||||
}
|
||||
|
||||
|
||||
def _srv_key(rr: dict[str, Any]) -> tuple[Any, Any, str] | None:
|
||||
"""
|
||||
Args:
|
||||
rr: one DNS record as the Cloudflare API returns it.
|
||||
|
||||
Returns:
|
||||
``(weight, port, target)`` the module matches SRV records by, or
|
||||
``None`` when the record carries no SRV data.
|
||||
"""
|
||||
data = rr.get("data")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
return (
|
||||
data.get("weight"),
|
||||
data.get("port"),
|
||||
str(data.get("target")).rstrip(".").lower(),
|
||||
)
|
||||
|
||||
|
||||
def _unquoted(content: Any) -> str:
|
||||
"""
|
||||
Args:
|
||||
content: a TXT record content.
|
||||
|
||||
Returns:
|
||||
The content without one pair of surrounding double quotes.
|
||||
"""
|
||||
text = str(content)
|
||||
if len(text) >= 2 and text.startswith('"') and text.endswith('"'):
|
||||
return text[1:-1]
|
||||
return text
|
||||
|
||||
|
||||
def _identity(want: dict[str, Any]) -> Any:
|
||||
"""
|
||||
Args:
|
||||
want: the record ``expected`` returns.
|
||||
|
||||
Returns:
|
||||
The key the module finds the record by within its record set:
|
||||
``(weight, port, target)`` for SRV, ``()`` for CNAME, whose record
|
||||
set holds one record, and the case-folded content otherwise, TXT
|
||||
without one pair of surrounding quotes.
|
||||
"""
|
||||
if want["type"] == "SRV":
|
||||
return (want["data"]["weight"], want["data"]["port"], want["content"])
|
||||
if want["type"] == "CNAME":
|
||||
return ()
|
||||
if want["type"] == "TXT":
|
||||
return _unquoted(want["content"]).lower()
|
||||
return want["content"].lower()
|
||||
|
||||
|
||||
def converged(want: dict[str, Any], zone_rrs: list[Any]) -> bool:
|
||||
"""
|
||||
Args:
|
||||
want: the record ``expected`` returns.
|
||||
zone_rrs: every DNS record of the record's zone.
|
||||
|
||||
Returns:
|
||||
True only when the zone already holds exactly the record the module
|
||||
would leave behind, so the module call would change nothing.
|
||||
"""
|
||||
same_name = [
|
||||
rr
|
||||
for rr in zone_rrs
|
||||
if isinstance(rr, dict)
|
||||
and rr.get("type") == want["type"]
|
||||
and str(rr.get("name")).lower() == want["name"]
|
||||
]
|
||||
key = _identity(want)
|
||||
if want["type"] == "SRV":
|
||||
matches = [rr for rr in same_name if _srv_key(rr) == key]
|
||||
elif want["type"] == "CNAME":
|
||||
matches = same_name
|
||||
elif want["type"] == "TXT":
|
||||
matches = [
|
||||
rr for rr in same_name if _unquoted(rr.get("content")).lower() == key
|
||||
]
|
||||
else:
|
||||
matches = [rr for rr in same_name if str(rr.get("content")).lower() == key]
|
||||
if len(matches) != 1 or (want["solo"] and len(same_name) != 1):
|
||||
return False
|
||||
rr = matches[0]
|
||||
if rr.get("name") != want["name"] or rr.get("ttl") != want["ttl"]:
|
||||
return False
|
||||
if "priority" in rr and rr["priority"] != want["priority"]:
|
||||
return False
|
||||
if "comment" not in rr or rr["comment"] is not None or rr.get("tags") != []:
|
||||
return False
|
||||
if want["type"] in PROXIABLE_TYPES and (
|
||||
"proxied" not in rr or rr["proxied"] != want["proxied"]
|
||||
):
|
||||
return False
|
||||
if want["type"] == "SRV":
|
||||
return rr.get("data") == want["data"]
|
||||
return rr.get("content") == want["content"]
|
||||
|
||||
|
||||
def split(
|
||||
records: list[Any], token: str, timeout: int
|
||||
) -> tuple[list[Any], list[str], list[str]]:
|
||||
"""
|
||||
Args:
|
||||
records: the ``cloudflare_records`` list.
|
||||
token: Cloudflare API token.
|
||||
timeout: seconds one socket operation may stall.
|
||||
|
||||
Returns:
|
||||
``(pending, converged, warnings)``: the entries the module still has
|
||||
to apply, in their original order, the labels of the entries the
|
||||
zone already holds as declared, and one warning per zone whose
|
||||
records could not be read, whose entries all stay pending. Every
|
||||
entry of a record set declared more than once stays pending when
|
||||
one of its entries is hazardous or two of them declare the record
|
||||
the module finds by one ``_identity`` differently, since each
|
||||
verdict alone would let the last entry's apply undo the other's.
|
||||
"""
|
||||
owners = [_owner(record) for record in records]
|
||||
wants = [expected(record) for record in records]
|
||||
sizes = Counter(owner for owner in owners if owner is not None)
|
||||
declared: dict[tuple[Any, Any], dict[str, Any]] = {}
|
||||
blocked: set[tuple[str, str, str]] = set()
|
||||
for owner, record, want in zip(owners, records, wants, strict=True):
|
||||
if owner is None or sizes[owner] < 2:
|
||||
continue
|
||||
if _hazardous(record) or (
|
||||
want is not None
|
||||
and declared.setdefault((owner, _identity(want)), want) != want
|
||||
):
|
||||
blocked.add(owner)
|
||||
zones: dict[str, list[Any] | None] = {}
|
||||
pending: list[Any] = []
|
||||
labels: list[str] = []
|
||||
warnings: list[str] = []
|
||||
for owner, record, candidate in zip(owners, records, wants, strict=True):
|
||||
want = None if owner in blocked else candidate
|
||||
if want is None:
|
||||
pending.append(record)
|
||||
continue
|
||||
zone = owner[0]
|
||||
if zone not in zones:
|
||||
try:
|
||||
zones[zone] = zone_records(zone, token, timeout)
|
||||
except READ_FAILURES as error:
|
||||
zones[zone] = None
|
||||
warnings.append(
|
||||
f"Cloudflare zone {zone}: its records could not be read "
|
||||
f"({error}), so every record of it goes through cloudflare_dns"
|
||||
)
|
||||
if zones[zone] is not None and converged(want, zones[zone]):
|
||||
labels.append(f"{want['type']} {want['name']}")
|
||||
else:
|
||||
pending.append(record)
|
||||
return pending, labels, warnings
|
||||
|
||||
|
||||
class ActionModule(ActionBase):
|
||||
TRANSFERS_FILES = False
|
||||
|
||||
def run(
|
||||
self, tmp: Any = None, task_vars: dict[str, Any] | None = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Args:
|
||||
tmp: unused, kept for the ``ActionBase`` signature.
|
||||
task_vars: the task's variables.
|
||||
|
||||
Returns:
|
||||
The action result with ``pending`` and ``converged``.
|
||||
"""
|
||||
result = super().run(tmp, task_vars)
|
||||
args = self._task.args or {}
|
||||
unexpected = sorted(set(args) - ARGUMENTS)
|
||||
missing = sorted(ARGUMENTS - set(args))
|
||||
if unexpected or missing:
|
||||
raise AnsibleActionFail(
|
||||
"cloudflare_records_converged: takes exactly api_token, records "
|
||||
f"and timeout; unknown {unexpected}, missing {missing}"
|
||||
)
|
||||
records = args["records"]
|
||||
if not isinstance(records, list):
|
||||
raise AnsibleActionFail(
|
||||
"cloudflare_records_converged: records must be a list"
|
||||
)
|
||||
timeout = _int(args["timeout"])
|
||||
if timeout is None:
|
||||
raise AnsibleActionFail(
|
||||
"cloudflare_records_converged: timeout must be an integer"
|
||||
)
|
||||
|
||||
pending, labels, warnings = split(records, str(args["api_token"]), timeout)
|
||||
result.update(changed=False, pending=pending, converged=labels)
|
||||
if warnings:
|
||||
result["warnings"] = warnings
|
||||
return result
|
||||
@@ -7,9 +7,17 @@
|
||||
no_log: "{{ cloudflare_no_log | bool }}"
|
||||
when: MODE_ASSERT | bool
|
||||
|
||||
- name: 🗂️ Queue every Cloudflare record for the first attempt
|
||||
- name: 🔎 Read each zone once, set aside the records it already holds
|
||||
cloudflare_records_converged:
|
||||
api_token: "{{ lookup('api', 'cloudflare.api_token') }}"
|
||||
records: "{{ cloudflare_records }}"
|
||||
timeout: "{{ lookup('timeout', cloudflare_request_timeout | int) | int }}"
|
||||
no_log: "{{ cloudflare_no_log | bool }}"
|
||||
register: _cf_converged
|
||||
|
||||
- name: 🗂️ Queue every drifted Cloudflare record for the first attempt
|
||||
ansible.builtin.set_fact:
|
||||
_cf_pending: "{{ cloudflare_records }}"
|
||||
_cf_pending: "{{ _cf_converged.pending }}"
|
||||
_cf_retry: []
|
||||
|
||||
- name: 🔁 Apply the Cloudflare records, retrying only hung and transiently failed ones
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
import ast
|
||||
import configparser
|
||||
import json
|
||||
import re
|
||||
import unittest
|
||||
from http.client import IncompleteRead
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
from ansible.errors import AnsibleActionFail
|
||||
from ansible.module_utils.urls import ConnectionError as UrlsConnectionError
|
||||
|
||||
from plugins.action import cloudflare_records_converged as plugin
|
||||
from plugins.action.cloudflare_records_converged import ActionModule
|
||||
from utils import PROJECT_ROOT
|
||||
from utils.cache.files import read_text
|
||||
from utils.cache.yaml import load_yaml_any, load_yaml_str
|
||||
from utils.install.collections import declared_pins
|
||||
from utils.roles.mapping import ROLE_FILE_TASKS_MAIN
|
||||
|
||||
ROLE = PROJECT_ROOT / "roles" / "sys-dns-cloudflare-records"
|
||||
TOKEN = "cf-secret-token-value"
|
||||
ZONE = "example.org"
|
||||
ZONE_ID = "zone-id-1"
|
||||
|
||||
|
||||
def rr(rtype, name, content=None, **extra):
|
||||
record = {
|
||||
"id": f"{rtype}-{name}-{content}",
|
||||
"type": rtype,
|
||||
"name": name,
|
||||
"ttl": 1,
|
||||
"comment": None,
|
||||
"tags": [],
|
||||
}
|
||||
if content is not None:
|
||||
record["content"] = content
|
||||
if rtype in ("A", "AAAA", "CNAME"):
|
||||
record["proxied"] = False
|
||||
record.update(extra)
|
||||
return record
|
||||
|
||||
|
||||
class _Answer:
|
||||
def __init__(self, payload):
|
||||
self.payload = payload
|
||||
|
||||
def read(self):
|
||||
return json.dumps(self.payload).encode()
|
||||
|
||||
|
||||
class _FakeCloudflare:
|
||||
def __init__(self, pages, zones=None, error=None):
|
||||
self.pages = pages
|
||||
self.zones = [{"id": ZONE_ID, "name": ZONE}] if zones is None else zones
|
||||
self.error = error
|
||||
self.calls = []
|
||||
|
||||
def __call__(self, url, method, headers, timeout):
|
||||
self.calls.append(
|
||||
{"url": url, "method": method, "headers": headers, "timeout": timeout}
|
||||
)
|
||||
if self.error is not None:
|
||||
raise self.error
|
||||
parts = urlsplit(url)
|
||||
query = parse_qs(parts.query)
|
||||
if parts.path.endswith("/zones"):
|
||||
return _Answer(
|
||||
{
|
||||
"success": True,
|
||||
"result": [z for z in self.zones if z["name"] == query["name"][0]],
|
||||
}
|
||||
)
|
||||
page = int(query["page"][0])
|
||||
return _Answer(
|
||||
{
|
||||
"success": True,
|
||||
"result": self.pages[page - 1],
|
||||
"result_info": {"page": page, "total_pages": len(self.pages)},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def run_split(records, cloudflare):
|
||||
with patch.object(plugin, "open_url", side_effect=cloudflare):
|
||||
return plugin.split(records, TOKEN, 20)
|
||||
|
||||
|
||||
def a(name, content="203.0.113.7", **extra):
|
||||
return {"type": "A", "zone": ZONE, "name": name, "content": content, **extra}
|
||||
|
||||
|
||||
def cname(name, target):
|
||||
return {"type": "CNAME", "zone": ZONE, "name": name, "value": target}
|
||||
|
||||
|
||||
def srv(service, port, **extra):
|
||||
return {
|
||||
"type": "SRV",
|
||||
"zone": ZONE,
|
||||
"name": ZONE,
|
||||
"service": service,
|
||||
"proto": "_tcp",
|
||||
"priority": 20,
|
||||
"weight": 1,
|
||||
"port": port,
|
||||
"value": "Mail.Example.org.",
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def srv_rr(service, port, priority=20, target="mail.example.org", **extra):
|
||||
return rr(
|
||||
"SRV",
|
||||
f"{service}._tcp.{ZONE}",
|
||||
priority=priority,
|
||||
data={"target": target, "port": port, "weight": 1, "priority": priority},
|
||||
**extra,
|
||||
)
|
||||
|
||||
|
||||
class TestCloudflareRecordsConverged(unittest.TestCase):
|
||||
def test_converged_records_cost_one_zone_read_whatever_their_number(self):
|
||||
records = [a(f"*.app{i}") for i in range(20)] + [
|
||||
{"type": "AAAA", "zone": ZONE, "name": "*", "content": "2001:DB8::7"}
|
||||
]
|
||||
zone = [rr("A", f"*.app{i}.{ZONE}", "203.0.113.7") for i in range(20)]
|
||||
zone.append(rr("AAAA", f"*.{ZONE}", "2001:db8::7"))
|
||||
cloudflare = _FakeCloudflare([zone])
|
||||
|
||||
pending, labels, warnings = run_split(records, cloudflare)
|
||||
|
||||
self.assertEqual(pending, [])
|
||||
self.assertEqual(len(labels), 21)
|
||||
self.assertEqual(warnings, [])
|
||||
self.assertEqual(len(cloudflare.calls), 2)
|
||||
self.assertTrue(all(c["method"] == "GET" for c in cloudflare.calls))
|
||||
self.assertTrue(all(c["timeout"] == 20 for c in cloudflare.calls))
|
||||
self.assertTrue(all(TOKEN not in c["url"] for c in cloudflare.calls))
|
||||
self.assertEqual(
|
||||
cloudflare.calls[0]["headers"]["Authorization"], f"Bearer {TOKEN}"
|
||||
)
|
||||
|
||||
def test_every_drift_the_module_would_write_keeps_the_record_pending(self):
|
||||
drifts = {
|
||||
"missing": [],
|
||||
"other content": [rr("A", f"www.{ZONE}", "203.0.113.8")],
|
||||
"ttl": [rr("A", f"www.{ZONE}", "203.0.113.7", ttl=300)],
|
||||
"proxied": [rr("A", f"www.{ZONE}", "203.0.113.7", proxied=True)],
|
||||
"proxied unknown": [
|
||||
{
|
||||
k: v
|
||||
for k, v in rr("A", f"www.{ZONE}", "203.0.113.7").items()
|
||||
if k != "proxied"
|
||||
}
|
||||
],
|
||||
"comment": [rr("A", f"www.{ZONE}", "203.0.113.7", comment="hand made")],
|
||||
"comment unknown": [
|
||||
{
|
||||
k: v
|
||||
for k, v in rr("A", f"www.{ZONE}", "203.0.113.7").items()
|
||||
if k != "comment"
|
||||
}
|
||||
],
|
||||
"tags": [rr("A", f"www.{ZONE}", "203.0.113.7", tags=["team:ops"])],
|
||||
"priority": [rr("A", f"www.{ZONE}", "203.0.113.7", priority=5)],
|
||||
"duplicate": [
|
||||
rr("A", f"www.{ZONE}", "203.0.113.7"),
|
||||
rr("A", f"www.{ZONE}", "203.0.113.7", id="second"),
|
||||
],
|
||||
}
|
||||
for drift, zone in drifts.items():
|
||||
with self.subTest(drift=drift):
|
||||
record = a("www")
|
||||
pending, labels, _ = run_split([record], _FakeCloudflare([zone]))
|
||||
self.assertEqual(pending, [record])
|
||||
self.assertEqual(labels, [])
|
||||
|
||||
def test_a_record_matches_the_declared_proxied_and_ttl(self):
|
||||
record = a("www", proxied=True, ttl="1")
|
||||
zone = [rr("A", f"www.{ZONE}", "203.0.113.7", proxied=True)]
|
||||
|
||||
pending, labels, _ = run_split([record], _FakeCloudflare([zone]))
|
||||
|
||||
self.assertEqual(pending, [])
|
||||
self.assertEqual(labels, [f"A www.{ZONE}"])
|
||||
|
||||
def test_solo_record_stays_pending_while_another_record_shares_its_name(self):
|
||||
record = a("mail", solo=True)
|
||||
alone = [rr("A", f"mail.{ZONE}", "203.0.113.7")]
|
||||
crowded = [*alone, rr("A", f"mail.{ZONE}", "198.51.100.1")]
|
||||
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([alone]))[0], [])
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([crowded]))[0], [record])
|
||||
self.assertEqual(run_split([a("mail")], _FakeCloudflare([crowded]))[0], [])
|
||||
|
||||
def test_cname_is_normalized_like_the_module_and_compared_by_target(self):
|
||||
record = {
|
||||
"type": "CNAME",
|
||||
"zone": ZONE,
|
||||
"name": "autoconfig",
|
||||
"value": "Mail.Example.org.",
|
||||
"proxied": True,
|
||||
}
|
||||
held = [rr("CNAME", f"autoconfig.{ZONE}", "mail.example.org")]
|
||||
moved = [rr("CNAME", f"autoconfig.{ZONE}", "other.example.org")]
|
||||
proxied = [rr("CNAME", f"autoconfig.{ZONE}", "mail.example.org", proxied=True)]
|
||||
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([held]))[0], [])
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([moved]))[0], [record])
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([proxied]))[0], [record])
|
||||
|
||||
def test_mx_priority_defaults_like_apply_and_is_compared(self):
|
||||
record = {"type": "MX", "zone": ZONE, "name": "@", "value": "mail.example.org"}
|
||||
held = [rr("MX", ZONE, "mail.example.org", priority=10)]
|
||||
other = [rr("MX", ZONE, "mail.example.org", priority=20)]
|
||||
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([held]))[0], [])
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([other]))[0], [record])
|
||||
self.assertEqual(
|
||||
run_split([dict(record, priority=20)], _FakeCloudflare([other]))[0], []
|
||||
)
|
||||
|
||||
def test_txt_content_is_compared_exactly(self):
|
||||
record = {
|
||||
"type": "TXT",
|
||||
"zone": ZONE,
|
||||
"name": "_dmarc",
|
||||
"value": '"v=DMARC1; p=reject"',
|
||||
"solo": True,
|
||||
}
|
||||
held = [rr("TXT", f"_dmarc.{ZONE}", '"v=DMARC1; p=reject"')]
|
||||
unquoted = [rr("TXT", f"_dmarc.{ZONE}", "v=DMARC1; p=reject")]
|
||||
recased = [rr("TXT", f"_dmarc.{ZONE}", '"V=DMARC1; p=reject"')]
|
||||
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([held]))[0], [])
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([unquoted]))[0], [record])
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([recased]))[0], [record])
|
||||
|
||||
def test_srv_is_matched_by_weight_port_target_and_compared_by_data(self):
|
||||
record = srv("submission", 587, solo=True)
|
||||
cases = {
|
||||
"held": ([srv_rr("_submission", 587)], []),
|
||||
"priority": ([srv_rr("_submission", 587, priority=10)], [record]),
|
||||
"port": ([srv_rr("_submission", 465)], [record]),
|
||||
"ambiguous": (
|
||||
[
|
||||
srv_rr("_submission", 587),
|
||||
srv_rr("_submission", 587, priority=30, id="b"),
|
||||
],
|
||||
[record],
|
||||
),
|
||||
"stale data keys": (
|
||||
[
|
||||
dict(
|
||||
srv_rr("_submission", 587),
|
||||
data={
|
||||
"target": "mail.example.org",
|
||||
"port": 587,
|
||||
"weight": 1,
|
||||
"priority": 20,
|
||||
"service": "_submission",
|
||||
},
|
||||
)
|
||||
],
|
||||
[record],
|
||||
),
|
||||
}
|
||||
for case, (zone, want) in cases.items():
|
||||
with self.subTest(case=case):
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([zone]))[0], want)
|
||||
|
||||
def test_records_outside_the_judged_shapes_stay_pending_without_a_read(self):
|
||||
records = [
|
||||
a("gone", state="absent"),
|
||||
{"type": "CAA", "zone": ZONE, "name": "@", "value": "letsencrypt.org"},
|
||||
{"type": "A", "zone": ZONE, "name": "www"},
|
||||
a("www", ttl=None),
|
||||
a("www", proxied="perhaps"),
|
||||
{k: v for k, v in srv("imaps", 993).items() if k != "port"},
|
||||
"not a record",
|
||||
]
|
||||
cloudflare = _FakeCloudflare([[]])
|
||||
|
||||
pending, labels, warnings = run_split(records, cloudflare)
|
||||
|
||||
self.assertEqual(pending, records)
|
||||
self.assertEqual(labels, [])
|
||||
self.assertEqual(warnings, [])
|
||||
self.assertEqual(cloudflare.calls, [])
|
||||
|
||||
def test_an_unreadable_zone_leaves_its_records_to_the_module_and_warns_without_the_token(
|
||||
self,
|
||||
):
|
||||
records = [a("www"), a("api")]
|
||||
error = HTTPError(
|
||||
f"{plugin.API}/zones?name={ZONE}", 429, "Too Many Requests", {}, None
|
||||
)
|
||||
cloudflare = _FakeCloudflare([[]], error=error)
|
||||
|
||||
pending, labels, warnings = run_split(records, cloudflare)
|
||||
|
||||
self.assertEqual(pending, records)
|
||||
self.assertEqual(labels, [])
|
||||
self.assertEqual(len(warnings), 1)
|
||||
self.assertIn(ZONE, warnings[0])
|
||||
self.assertIn("429", warnings[0])
|
||||
self.assertNotIn(TOKEN, warnings[0])
|
||||
self.assertEqual(len(cloudflare.calls), 1)
|
||||
|
||||
def test_every_transport_failure_leaves_the_records_to_the_module(self):
|
||||
for error in (
|
||||
IncompleteRead(b""),
|
||||
TimeoutError("timed out"),
|
||||
URLError("refused"),
|
||||
UrlsConnectionError("tls"),
|
||||
):
|
||||
with self.subTest(error=type(error).__name__):
|
||||
record = a("www")
|
||||
pending, _, warnings = run_split(
|
||||
[record], _FakeCloudflare([[]], error=error)
|
||||
)
|
||||
self.assertEqual(pending, [record])
|
||||
self.assertEqual(len(warnings), 1)
|
||||
|
||||
def test_a_zone_name_without_exactly_one_zone_leaves_its_records_to_the_module(
|
||||
self,
|
||||
):
|
||||
record = a("www")
|
||||
pending, _, warnings = run_split([record], _FakeCloudflare([[]], zones=[]))
|
||||
|
||||
self.assertEqual(pending, [record])
|
||||
self.assertIn("0 Cloudflare zones match example.org", warnings[0])
|
||||
|
||||
def test_an_answer_without_success_leaves_its_records_to_the_module(self):
|
||||
record = a("www")
|
||||
with patch.object(
|
||||
plugin,
|
||||
"open_url",
|
||||
side_effect=lambda *args, **kwargs: _Answer(
|
||||
{"success": False, "errors": [{"code": 10000}]}
|
||||
),
|
||||
):
|
||||
pending, _, warnings = plugin.split([record], TOKEN, 20)
|
||||
|
||||
self.assertEqual(pending, [record])
|
||||
self.assertIn("10000", warnings[0])
|
||||
self.assertNotIn(TOKEN, warnings[0])
|
||||
|
||||
def test_every_page_of_the_zone_is_read(self):
|
||||
record = a("late")
|
||||
pages = [
|
||||
[rr("A", f"early{i}.{ZONE}", "203.0.113.7") for i in range(3)],
|
||||
[rr("A", f"late.{ZONE}", "203.0.113.7")],
|
||||
]
|
||||
cloudflare = _FakeCloudflare(pages)
|
||||
|
||||
pending, labels, _ = run_split([record], cloudflare)
|
||||
|
||||
self.assertEqual(pending, [])
|
||||
self.assertEqual(labels, [f"A late.{ZONE}"])
|
||||
self.assertEqual(len(cloudflare.calls), 3)
|
||||
|
||||
def test_each_zone_is_read_once_and_order_is_kept(self):
|
||||
other = {
|
||||
"type": "A",
|
||||
"zone": "Other.NET",
|
||||
"name": "www",
|
||||
"content": "203.0.113.7",
|
||||
}
|
||||
records = [a("one"), other, a("two")]
|
||||
zones = [
|
||||
{"id": ZONE_ID, "name": ZONE},
|
||||
{"id": "zone-id-2", "name": "other.net"},
|
||||
]
|
||||
cloudflare = _FakeCloudflare(
|
||||
[[rr("A", f"two.{ZONE}", "203.0.113.7")]], zones=zones
|
||||
)
|
||||
|
||||
pending, labels, _ = run_split(records, cloudflare)
|
||||
|
||||
self.assertEqual(pending, [records[0], other])
|
||||
self.assertEqual(labels, [f"A two.{ZONE}"])
|
||||
self.assertEqual(len(cloudflare.calls), 4)
|
||||
|
||||
def test_fields_the_module_rejects_as_int_stay_pending(self):
|
||||
held = [rr("A", f"www.{ZONE}", "203.0.113.7")]
|
||||
for ttl in (1.5, "1.5", True, "one"):
|
||||
with self.subTest(ttl=ttl):
|
||||
record = a("www", ttl=ttl)
|
||||
pending, labels, _ = run_split([record], _FakeCloudflare([held]))
|
||||
self.assertEqual(pending, [record])
|
||||
self.assertEqual(labels, [])
|
||||
for ttl in (1, "1", 1.0, "1.0"):
|
||||
with self.subTest(ttl=ttl):
|
||||
record = a("www", ttl=ttl)
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([held]))[0], [])
|
||||
mx = {"type": "MX", "zone": ZONE, "name": "@", "value": "mail.example.org"}
|
||||
mx_held = [rr("MX", ZONE, "mail.example.org", priority=10)]
|
||||
for priority in (10.5, False):
|
||||
with self.subTest(priority=priority):
|
||||
record = dict(mx, priority=priority)
|
||||
pending, _, _ = run_split([record], _FakeCloudflare([mx_held]))
|
||||
self.assertEqual(pending, [record])
|
||||
record = srv("submission", 587.5)
|
||||
zone = [srv_rr("_submission", 587)]
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([zone]))[0], [record])
|
||||
|
||||
def test_a_name_with_a_solo_entry_and_another_entry_stays_pending(self):
|
||||
records = [a("h", "192.0.2.1", solo=True), a("h", "192.0.2.2")]
|
||||
both = [rr("A", f"h.{ZONE}", "192.0.2.1"), rr("A", f"h.{ZONE}", "192.0.2.2")]
|
||||
for held in (both, both[:1], both[1:]):
|
||||
with self.subTest(held=[r["content"] for r in held]):
|
||||
pending, labels, _ = run_split(records, _FakeCloudflare([held]))
|
||||
self.assertEqual(pending, records)
|
||||
self.assertEqual(labels, [])
|
||||
|
||||
def test_a_name_another_entry_deletes_from_stays_pending(self):
|
||||
held = [rr("A", f"h.{ZONE}", "192.0.2.1")]
|
||||
records = [a("h", "192.0.2.1"), a("h", "192.0.2.1", state="absent")]
|
||||
|
||||
pending, labels, _ = run_split(records, _FakeCloudflare([held]))
|
||||
|
||||
self.assertEqual(pending, records)
|
||||
self.assertEqual(labels, [])
|
||||
|
||||
def test_a_hazard_on_one_name_or_type_leaves_the_others_judged(self):
|
||||
records = [
|
||||
a("h", "192.0.2.1", solo=True),
|
||||
a("h", "192.0.2.2"),
|
||||
a("other"),
|
||||
{"type": "AAAA", "zone": ZONE, "name": "h", "content": "2001:db8::1"},
|
||||
]
|
||||
zone = [
|
||||
rr("A", f"h.{ZONE}", "192.0.2.1"),
|
||||
rr("A", f"other.{ZONE}", "203.0.113.7"),
|
||||
rr("AAAA", f"h.{ZONE}", "2001:db8::1"),
|
||||
]
|
||||
|
||||
pending, labels, _ = run_split(records, _FakeCloudflare([zone]))
|
||||
|
||||
self.assertEqual(pending, records[:2])
|
||||
self.assertEqual(labels, [f"A other.{ZONE}", f"AAAA h.{ZONE}"])
|
||||
|
||||
def test_one_record_declared_twice_with_different_settings_stays_pending(self):
|
||||
x = f"x.{ZONE}"
|
||||
mx = {"type": "MX", "zone": ZONE, "name": "x", "value": "mx.example.org"}
|
||||
groups = {
|
||||
"cname target": (
|
||||
[cname("x", "a.example.org"), cname("x", "b.example.org")],
|
||||
[rr("CNAME", x, "a.example.org")],
|
||||
[rr("CNAME", x, "b.example.org")],
|
||||
),
|
||||
"a ttl": (
|
||||
[a("x", ttl=1), a("x", ttl=300)],
|
||||
[rr("A", x, "203.0.113.7")],
|
||||
[rr("A", x, "203.0.113.7", ttl=300)],
|
||||
),
|
||||
"mx priority": (
|
||||
[mx, dict(mx, value="MX.example.org.", priority=20)],
|
||||
[rr("MX", x, "mx.example.org", priority=10)],
|
||||
[rr("MX", x, "mx.example.org", priority=20)],
|
||||
),
|
||||
}
|
||||
for group, (records, *held) in groups.items():
|
||||
for zone in held:
|
||||
with self.subTest(group=group, held=zone):
|
||||
pending, labels, _ = run_split(records, _FakeCloudflare([zone]))
|
||||
self.assertEqual(pending, records)
|
||||
self.assertEqual(labels, [])
|
||||
|
||||
def test_entries_of_one_name_the_module_edits_as_distinct_or_equal_records_stay_judged(
|
||||
self,
|
||||
):
|
||||
x = f"x.{ZONE}"
|
||||
groups = {
|
||||
"distinct a": (
|
||||
[a("x", "192.0.2.1"), a("x", "192.0.2.2", ttl=300)],
|
||||
[rr("A", x, "192.0.2.1"), rr("A", x, "192.0.2.2", ttl=300)],
|
||||
),
|
||||
"distinct srv": (
|
||||
[srv("submission", 587), srv("submission", 465, priority=10)],
|
||||
[
|
||||
srv_rr("_submission", 587),
|
||||
srv_rr("_submission", 465, priority=10, id="b"),
|
||||
],
|
||||
),
|
||||
"equal a": (
|
||||
[a("x", ttl=1), a("x", ttl="1")],
|
||||
[rr("A", x, "203.0.113.7")],
|
||||
),
|
||||
}
|
||||
for group, (records, zone) in groups.items():
|
||||
with self.subTest(group=group):
|
||||
pending, labels, _ = run_split(records, _FakeCloudflare([zone]))
|
||||
self.assertEqual(pending, [])
|
||||
self.assertEqual(len(labels), 2)
|
||||
|
||||
def test_txt_held_both_quoted_and_unquoted_stays_pending(self):
|
||||
record = {
|
||||
"type": "TXT",
|
||||
"zone": ZONE,
|
||||
"name": "_dmarc",
|
||||
"value": '"v=DMARC1; p=reject"',
|
||||
}
|
||||
quoted = rr("TXT", f"_dmarc.{ZONE}", '"v=DMARC1; p=reject"')
|
||||
unquoted = rr("TXT", f"_dmarc.{ZONE}", "v=dmarc1; p=reject", id="unquoted")
|
||||
other = rr("TXT", f"_dmarc.{ZONE}", '"v=spf1 -all"', id="spf")
|
||||
|
||||
self.assertEqual(
|
||||
run_split([record], _FakeCloudflare([[quoted, unquoted]]))[0], [record]
|
||||
)
|
||||
self.assertEqual(run_split([record], _FakeCloudflare([[quoted, other]]))[0], [])
|
||||
|
||||
|
||||
class _FakeTask:
|
||||
def __init__(self, args):
|
||||
self.args = args
|
||||
|
||||
|
||||
def _action(args):
|
||||
action = object.__new__(ActionModule)
|
||||
action._task = _FakeTask(args)
|
||||
return action
|
||||
|
||||
|
||||
class TestCloudflareRecordsConvergedAction(unittest.TestCase):
|
||||
def test_run_returns_pending_and_converged_without_change(self):
|
||||
record = a("www")
|
||||
cloudflare = _FakeCloudflare([[rr("A", f"www.{ZONE}", "203.0.113.7")]])
|
||||
action = _action(
|
||||
{"api_token": TOKEN, "records": [record, a("api")], "timeout": "20"}
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"plugins.action.cloudflare_records_converged.ActionBase.run",
|
||||
autospec=True,
|
||||
return_value={},
|
||||
),
|
||||
patch.object(plugin, "open_url", side_effect=cloudflare),
|
||||
):
|
||||
result = action.run(task_vars={})
|
||||
|
||||
self.assertFalse(result["changed"])
|
||||
self.assertEqual(result["pending"], [a("api")])
|
||||
self.assertEqual(result["converged"], [f"A www.{ZONE}"])
|
||||
self.assertNotIn("warnings", result)
|
||||
self.assertNotIn(TOKEN, json.dumps(result))
|
||||
|
||||
def test_run_rejects_wrong_arguments(self):
|
||||
for args in (
|
||||
{"records": [], "timeout": 20},
|
||||
{"api_token": TOKEN, "records": [], "timeout": 20, "zone": ZONE},
|
||||
{"api_token": TOKEN, "records": "a", "timeout": 20},
|
||||
{"api_token": TOKEN, "records": [], "timeout": "soon"},
|
||||
):
|
||||
with (
|
||||
self.subTest(args=sorted(args)),
|
||||
patch(
|
||||
"plugins.action.cloudflare_records_converged.ActionBase.run",
|
||||
autospec=True,
|
||||
return_value={},
|
||||
),
|
||||
self.assertRaises(AnsibleActionFail),
|
||||
):
|
||||
_action(args).run(task_vars={})
|
||||
|
||||
|
||||
def _task(tasks, name):
|
||||
return next(t for t in tasks if t.get("name") == name)
|
||||
|
||||
|
||||
class TestApplyDefaultsMatchThePlugin(unittest.TestCase):
|
||||
def test_the_defaults_apply_hands_the_module_are_the_ones_the_plugin_predicts(self):
|
||||
tasks = load_yaml_any(str(ROLE / "tasks" / "apply.yml"))
|
||||
a_args = _task(tasks, "📌 Apply A/AAAA")["community.general.cloudflare_dns"]
|
||||
cname_args = _task(tasks, "📌 Apply CNAME/MX/TXT")[
|
||||
"community.general.cloudflare_dns"
|
||||
]
|
||||
srv_args = _task(tasks, "📌 Apply SRV")["community.general.cloudflare_dns"]
|
||||
|
||||
def default(text, field):
|
||||
match = re.search(rf"item\.{field} \| default\(([^)]*)\)", text)
|
||||
self.assertIsNotNone(match, f"{field} default not found in {text!r}")
|
||||
return load_yaml_str(match.group(1))
|
||||
|
||||
for args in (a_args, cname_args, srv_args):
|
||||
self.assertEqual(default(args["ttl"], "ttl"), plugin.APPLY_TTL)
|
||||
self.assertEqual(default(args["state"], "state"), plugin.APPLY_STATE)
|
||||
self.assertEqual(default(args["solo"], "solo"), plugin.APPLY_SOLO)
|
||||
self.assertEqual(default(a_args["proxied"], "proxied"), plugin.APPLY_PROXIED)
|
||||
self.assertEqual(
|
||||
default(cname_args["priority"], "priority"), plugin.APPLY_MX_PRIORITY
|
||||
)
|
||||
self.assertIn("omit", cname_args["priority"])
|
||||
self.assertNotIn("priority", a_args)
|
||||
self.assertNotIn("proxied", cname_args)
|
||||
self.assertNotIn("proxied", srv_args)
|
||||
self.assertEqual(srv_args["priority"], "{{ item.priority }}")
|
||||
self.assertEqual(a_args["content"], "{{ item.content }}")
|
||||
self.assertEqual(cname_args["value"], "{{ item.value }}")
|
||||
self.assertEqual(srv_args["value"], "{{ item.value }}")
|
||||
|
||||
def test_the_first_attempt_queues_only_what_the_plugin_left_pending(self):
|
||||
tasks = load_yaml_any(str(ROLE / ROLE_FILE_TASKS_MAIN))
|
||||
names = [t.get("name") for t in tasks]
|
||||
check = next(t for t in tasks if "cloudflare_records_converged" in t)
|
||||
queue = next(
|
||||
t for t in tasks if "_cf_pending" in t.get("ansible.builtin.set_fact", {})
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
check["cloudflare_records_converged"]["records"], "{{ cloudflare_records }}"
|
||||
)
|
||||
self.assertEqual(check["register"], "_cf_converged")
|
||||
self.assertIn("cloudflare_no_log", check["no_log"])
|
||||
self.assertEqual(
|
||||
queue["ansible.builtin.set_fact"]["_cf_pending"],
|
||||
"{{ _cf_converged.pending }}",
|
||||
)
|
||||
self.assertLess(names.index(check["name"]), names.index(queue["name"]))
|
||||
|
||||
|
||||
def _resolved_community_general() -> Path:
|
||||
"""
|
||||
Returns:
|
||||
The community.general directory of the first ``collections_paths``
|
||||
entry of the repository's ansible.cfg that holds one, as Ansible
|
||||
resolves it.
|
||||
"""
|
||||
config = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
config.read(PROJECT_ROOT / "ansible.cfg", encoding="utf-8")
|
||||
for entry in config.get("defaults", "collections_paths").split(":"):
|
||||
base = Path(entry).expanduser()
|
||||
if not base.is_absolute():
|
||||
base = PROJECT_ROOT / base
|
||||
candidate = base / "ansible_collections" / "community" / "general"
|
||||
if (candidate / "plugins" / "modules" / "cloudflare_dns.py").is_file():
|
||||
return candidate
|
||||
raise AssertionError("no collections_paths entry holds community.general")
|
||||
|
||||
|
||||
def _argument_spec_defaults(module: Path) -> dict:
|
||||
"""
|
||||
Args:
|
||||
module: path of an Ansible module source file.
|
||||
|
||||
Returns:
|
||||
``{option: default}`` for every option of the module's
|
||||
``argument_spec`` that declares a literal default.
|
||||
"""
|
||||
tree = ast.parse(read_text(str(module)))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.keyword) and node.arg == "argument_spec":
|
||||
defaults = {}
|
||||
for option in node.value.keywords:
|
||||
fields = {k.arg: k.value for k in option.value.keywords}
|
||||
if "default" in fields:
|
||||
defaults[option.arg] = ast.literal_eval(fields["default"])
|
||||
return defaults
|
||||
raise AssertionError(f"no argument_spec in {module}")
|
||||
|
||||
|
||||
class TestModuleDefaultsMatchThePlugin(unittest.TestCase):
|
||||
def test_the_module_defaults_the_plugin_predicts_match_the_resolved_collection(
|
||||
self,
|
||||
):
|
||||
collection = _resolved_community_general()
|
||||
manifest = json.loads(read_text(str(collection / "MANIFEST.json")))
|
||||
defaults = _argument_spec_defaults(
|
||||
collection / "plugins" / "modules" / "cloudflare_dns.py"
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
manifest["collection_info"]["version"], plugin.REVIEWED_COMMUNITY_GENERAL
|
||||
)
|
||||
self.assertEqual(defaults["priority"], plugin.MODULE_PRIORITY)
|
||||
self.assertEqual(defaults["proxied"], plugin.MODULE_PROXIED)
|
||||
|
||||
def test_a_new_community_general_pin_forces_a_review_of_the_plugin(self):
|
||||
pins = dict(
|
||||
declared_pins(PROJECT_ROOT / "requirements" / "requirements.galaxy.yml")
|
||||
)
|
||||
|
||||
self.assertEqual(pins["community.general"], plugin.REVIEWED_COMMUNITY_GENERAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user