Establish clean homelab infrastructure baseline
lint / yaml (push) Has been cancelled
lint / ansible (push) Has been cancelled
lint / terraform (push) Has been cancelled

Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
2026-09-09 16:47:20 +00:00
commit 88a02ababa
418 changed files with 50579 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
#!/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")
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
"""Generate the VyOS interface + OSPF config from NetBox.
uv run --with requests python netbox/generate/vyos-ospf.py [--diff]
This is the ONE case CONTEXT.md §8 step 5 nominates as worth automating: today the VyOS
interface addresses, the `area 0 network` list and the PVE SDN VNets must be kept in sync
BY HAND, and forgetting the OSPF line is silent — the subnet exists, has a gateway, and
is simply unreachable from anywhere else.
DERIVATION RULES (these are the interesting part, not the code):
addresses every IP assigned to a vyos-rtr interface
area 0 every prefix that vyos-rtr HAS AN INTERFACE IN
passive interfaces whose prefix has role `sdn`
The second rule matters. The obvious rule — "the SDN prefixes" — is WRONG and was caught
by diffing against the live router: it omits 192.168.10.0/24, but the LAN must be in
area 0 or VyOS has no adjacency with the NEC IX or the laptop and nothing is advertised
at all. Deriving from "where does this router actually have an address" produces the LAN
for free and cannot forget a future VNet.
The third rule is why eth0 is NOT passive: it is the only interface that must form
adjacencies. eth1/eth2 face guests and are advertised without peering.
Read-only. It prints config; it does not touch the router.
"""
from __future__ import annotations
import ipaddress
import json
import os
import subprocess
import sys
import urllib.request
from pathlib import Path
ROUTER = "vyos-rtr"
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, v)
BASE = os.environ.get("NETBOX_URL", "").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[str]:
vm = get(f"/virtualization/virtual-machines/?name={ROUTER}")["results"]
if not vm:
sys.exit(f"{ROUTER} not found in NetBox")
prefixes = get("/ipam/prefixes/?limit=200")["results"]
ifaces = sorted(get(f"/virtualization/interfaces/?virtual_machine_id={vm[0]['id']}")["results"],
key=lambda i: i["name"])
lines, areas, passive = [], [], []
for iface in ifaces:
for addr in get(f"/ipam/ip-addresses/?vminterface_id={iface['id']}")["results"]:
lines.append(f"set interfaces ethernet {iface['name']} address '{addr['address']}'")
ip = ipaddress.ip_interface(addr["address"]).ip
# The prefix this address sits in == a network this router participates in.
for p in prefixes:
if ip in ipaddress.ip_network(p["prefix"]):
if p["prefix"] not in areas:
areas.append(p["prefix"])
if (p.get("role") or {}).get("slug") == "sdn":
passive.append(iface["name"])
break
# Keep the LAN first: it is the transit network, and reading the config that way
# matches how the adjacency is reasoned about.
areas.sort(key=lambda p: (not p.startswith("192.168."), p))
lines += [f"set protocols ospf area 0 network '{p}'" for p in areas]
lines += [f"set protocols ospf interface {i} passive" for i in sorted(set(passive))]
return lines
def live() -> list[str]:
out = subprocess.run(
["ssh", "-o", "ConnectTimeout=8", "-o", "BatchMode=yes", "[email protected]",
"/opt/vyatta/bin/vyatta-op-cmd-wrapper show configuration commands"],
capture_output=True, text=True, timeout=60).stdout
return [l.strip() for l in out.splitlines()
if ("ospf area" in l or "ospf interface" in l or
("ethernet eth" in l and "address" in l))]
if __name__ == "__main__":
gen = generate()
if "--diff" not in sys.argv:
print("\n".join(gen))
sys.exit(0)
have = live()
only_live = [l for l in have if l not in gen]
only_gen = [l for l in gen if l not in have]
for l in gen:
print((" " if l in have else "+ ") + l)
for l in only_live:
print("- " + l)
print(f"\nmatched={len(gen) - len(only_gen)} generated_only={len(only_gen)} live_only={len(only_live)}")
sys.exit(1 if (only_gen or only_live) else 0)