Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
95 lines
3.7 KiB
Python
95 lines
3.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate samba_ad_extra_a_records from NetBox.
|
|
|
|
uv run --with requests --with pyyaml python netbox/generate/samba-a-records.py [--diff]
|
|
|
|
CONTEXT.md §4 item 3: the AD DNS A records for non-domain-joined hosts are hand-listed in
|
|
../../../infrastructure/samba-ad/ansible/group_vars/all/vars.yml, duplicating addresses that already live in
|
|
NetBox. This derives them instead.
|
|
|
|
SOURCE OF TRUTH FOR *WHICH* HOSTS: the `dns_name` field on the NetBox IP address. That is
|
|
intent, not a derived fact — domain-joined machines register themselves in AD DNS and must
|
|
NOT get a static record, so "every IP in the LAN prefix" would be wrong. An address gets a
|
|
record iff someone set dns_name on it.
|
|
|
|
Deliberately NOT handled: service/ingress names such as netbox.ad.ddupan.top, which point
|
|
at the k3s gateway rather than at a host. Several of those share one address, and NetBox's
|
|
dns_name is single-valued per IP, so they stay hand-managed in vars.yml. This tool only
|
|
owns HOST records.
|
|
|
|
Read-only: prints YAML and diffs. It never writes to the DC — ../../../infrastructure/samba-ad applies it
|
|
(`ansible-playbook provision-dc.yml --tags dns`).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
ZONE = "ad.ddupan.top"
|
|
VARS = Path(__file__).parents[3] / "infrastructure/samba-ad/ansible/group_vars/all/vars.yml"
|
|
|
|
envfile = Path(__file__).parent.parent / "terraform" / ".env"
|
|
if envfile.exists():
|
|
for line in envfile.read_text().splitlines():
|
|
if line.strip() and not line.startswith("#") and "=" in line:
|
|
k, v = line.split("=", 1)
|
|
os.environ.setdefault(k.strip(), v.strip())
|
|
|
|
BASE = os.environ.get("NETBOX_URL", "https://netbox.ad.ddupan.top").rstrip("/") + "/api"
|
|
TOKEN = os.environ.get("NETBOX_TOKEN", "")
|
|
if not TOKEN:
|
|
sys.exit("NETBOX_TOKEN not set (expected in netbox/terraform/.env)")
|
|
|
|
|
|
def get(path: str):
|
|
req = urllib.request.Request(BASE + path, headers={"Authorization": f"Bearer {TOKEN}"})
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
return json.load(r)
|
|
|
|
|
|
def generate() -> list[dict]:
|
|
out = []
|
|
for a in get("/ipam/ip-addresses/?limit=500")["results"]:
|
|
dns = (a.get("dns_name") or "").strip().lower()
|
|
if not dns.endswith(f".{ZONE}"):
|
|
continue
|
|
name = dns[: -len(f".{ZONE}")]
|
|
ip = str(ipaddress.ip_interface(a["address"]).ip)
|
|
out.append({"name": name, "ip": ip})
|
|
# Stable order so the diff is meaningful rather than churn.
|
|
return sorted(out, key=lambda r: ipaddress.ip_address(r["ip"]))
|
|
|
|
|
|
def current() -> list[dict]:
|
|
"""Parse the existing hand-written list without pulling in the whole vars file."""
|
|
if not VARS.exists():
|
|
return []
|
|
txt = VARS.read_text()
|
|
m = re.search(r"^samba_ad_extra_a_records:\s*$(.*?)(?=^\S)", txt, re.S | re.M)
|
|
if not m:
|
|
return []
|
|
return [{"name": n, "ip": i}
|
|
for n, i in re.findall(r'name:\s*"([^"]+)".*?ip:\s*"([^"]+)"', m.group(1))]
|
|
|
|
|
|
if __name__ == "__main__":
|
|
gen = generate()
|
|
if "--diff" not in sys.argv:
|
|
print("samba_ad_extra_a_records:")
|
|
for r in gen:
|
|
print(f' - {{ name: "{r["name"]}", ip: "{r["ip"]}" }}')
|
|
sys.exit(0)
|
|
|
|
have = {(r["name"], r["ip"]) for r in current()}
|
|
want = {(r["name"], r["ip"]) for r in gen}
|
|
for n, i in sorted(want | have, key=lambda x: ipaddress.ip_address(x[1])):
|
|
mark = " " if (n, i) in want and (n, i) in have else ("+ " if (n, i) in want else "- ")
|
|
print(f"{mark}{n:<10} {i}")
|
|
print(f"\nin both={len(want & have)} netbox only={len(want - have)} vars.yml only={len(have - want)}")
|
|
print("\n+ = NetBox has it, vars.yml does not - = hand-listed, not derivable from NetBox")
|