Establish clean homelab infrastructure baseline
Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
@@ -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")
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user