111 lines
3.6 KiB
Python
111 lines
3.6 KiB
Python
from .models import DesiredRecord, ProviderRecord, SyncAction
|
|
|
|
|
|
def hostname_matches_domain(hostname: str, domain: str) -> bool:
|
|
"""Match a domain apex and any subdomain, without suffix false positives."""
|
|
candidate = hostname.lower().rstrip(".")
|
|
if candidate.startswith("*."):
|
|
candidate = candidate[2:]
|
|
|
|
base = domain.lower().rstrip(".")
|
|
return candidate == base or candidate.endswith("." + base)
|
|
|
|
|
|
def filter_desired_by_domain(
|
|
records: list[DesiredRecord],
|
|
domain: str | None,
|
|
enabled: bool,
|
|
) -> tuple[list[DesiredRecord], int]:
|
|
if not enabled:
|
|
return records, 0
|
|
|
|
if not domain:
|
|
raise RuntimeError(
|
|
"Domain filtering enabled without a configured DOMAIN"
|
|
)
|
|
|
|
matched = [
|
|
record
|
|
for record in records
|
|
if hostname_matches_domain(record.hostname, domain)
|
|
]
|
|
return matched, len(records) - len(matched)
|
|
|
|
|
|
|
|
|
|
def validate_desired(records: list[DesiredRecord]) -> tuple[dict[str, DesiredRecord], list[str]]:
|
|
desired: dict[str, DesiredRecord] = {}
|
|
warnings: list[str] = []
|
|
|
|
for record in records:
|
|
key = record.hostname.lower()
|
|
if key in desired:
|
|
warnings.append(
|
|
f"DUPLICATE {record.hostname} from {record.source}"
|
|
+ (f" id={record.source_id}" if record.source_id else "")
|
|
+ "; keeping first occurrence"
|
|
)
|
|
continue
|
|
desired[key] = record
|
|
|
|
return dict(sorted(desired.items())), warnings
|
|
|
|
|
|
def build_sync_plan(desired, current: dict[str, ProviderRecord], capabilities):
|
|
plan: list[SyncAction] = []
|
|
|
|
for hostname, record in desired.items():
|
|
if record.record_type not in capabilities.record_types:
|
|
plan.append(SyncAction("SKIP", record, reason=f"record type {record.record_type} unsupported"))
|
|
continue
|
|
|
|
if record.wildcard and not capabilities.wildcard_records:
|
|
plan.append(SyncAction("SKIP", record, reason="wildcard records unsupported"))
|
|
continue
|
|
|
|
existing = current.get(hostname.lower())
|
|
if existing is None:
|
|
if not capabilities.supports_create:
|
|
plan.append(SyncAction("SKIP", record, reason="provider cannot create records"))
|
|
else:
|
|
plan.append(SyncAction("ADD", record))
|
|
elif existing.ip == record.ip and existing.record_type == record.record_type:
|
|
plan.append(SyncAction("UNCHANGED", record, current=existing))
|
|
elif not capabilities.supports_update:
|
|
plan.append(SyncAction("SKIP", record, current=existing, reason="provider cannot update records"))
|
|
else:
|
|
plan.append(SyncAction("UPDATE", record, current=existing))
|
|
|
|
return plan
|
|
|
|
|
|
def summarize_plan(plan):
|
|
counts = {name: 0 for name in ("ADD", "UPDATE", "UNCHANGED", "SKIP")}
|
|
for item in plan:
|
|
counts[item.action] = counts.get(item.action, 0) + 1
|
|
return counts
|
|
|
|
|
|
def execute_sync(provider, plan):
|
|
counts = summarize_plan(plan)
|
|
|
|
for item in plan:
|
|
if item.action in {"UNCHANGED", "SKIP"}:
|
|
continue
|
|
|
|
if item.action == "ADD":
|
|
detail = provider.add_record(item.desired)
|
|
elif item.action == "UPDATE":
|
|
detail = provider.update_record(item.desired)
|
|
else:
|
|
raise RuntimeError(f"Unknown sync action: {item.action}")
|
|
|
|
if not provider.verify_record(item.desired):
|
|
raise RuntimeError(f"{item.desired.hostname}: {item.action.lower()} did not verify")
|
|
|
|
suffix = f" ({detail})" if detail else ""
|
|
print(f"APPLIED {item.action:<6} {item.desired.hostname} -> {item.desired.ip}{suffix}")
|
|
|
|
return counts
|