Files
homelab-infra/apps/netbox/generate/samba-a-records.py
T
panxiao81 88a02ababa
lint / yaml (push) Has been cancelled
lint / ansible (push) Has been cancelled
lint / terraform (push) Has been cancelled
Establish clean homelab infrastructure baseline
Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
2026-09-09 16:47:20 +00:00

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")