From efeb148be742d3e67a9c53e4d0ab7885923bfcca Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 17:26:43 +1000 Subject: [PATCH] Recut the module to the NetBox catalogue only Device records, interfaces, MACs and cabling are written by the autodiscovery agent, and host names, roles and IP allocation by the UI, so terraform declaring them fights both owners. - drop the device, interface, MAC, per-host IP and net: tag resources - declare sites, device types, roles and platforms in their own config files - add interface templates per device type and model the Omada SG3429X - add per-site VLAN groups and VLANs, seeding vid 101 and 201 in syd1 - publish network profiles as site-scoped config contexts - rename subnets to net----- and restore dns/domain/next_server --- README.md | 144 ++++++++++-------- ci/puppetdb_backfill.py | 114 -------------- config/au/syd1/device-roles.yaml | 3 + .../au/syd1/device-types/optiplex-3070.yaml | 3 + config/au/syd1/device-types/sg3429x.yaml | 14 ++ config/au/syd1/devices/prodnxsr0014.yaml | 10 -- config/au/syd1/devices/prodnxsr0015.yaml | 10 -- config/au/syd1/devices/prodnxsr0016.yaml | 10 -- config/au/syd1/devices/prodnxsr0017.yaml | 10 -- config/au/syd1/devices/prodnxsr0018.yaml | 10 -- config/au/syd1/devices/prodnxsr0019.yaml | 10 -- .../au/syd1/network-profiles/fabric-dual.yaml | 12 ++ config/au/syd1/networks/mgmt.yaml | 4 - config/au/syd1/platforms.yaml | 3 + config/au/syd1/sites.yaml | 4 + config/au/syd1/subnets/drw1-prod.yaml | 8 - .../au/syd1/subnets/net-198-18-13-0-24.yaml | 12 ++ config/au/syd1/subnets/net-198-18-13.yaml | 8 - .../au/syd1/subnets/net-198-18-14-0-24.yaml | 12 ++ config/au/syd1/subnets/net-198-18-14.yaml | 8 - .../au/syd1/subnets/net-198-18-15-0-24.yaml | 12 ++ config/au/syd1/subnets/net-198-18-15.yaml | 8 - .../au/syd1/subnets/net-198-18-16-0-24.yaml | 12 ++ config/au/syd1/subnets/net-198-18-16.yaml | 8 - .../au/syd1/subnets/net-198-18-17-0-24.yaml | 12 ++ .../{local.yaml => net-198-18-25-0-24.yaml} | 0 config/au/syd1/vlans.yaml | 9 ++ environments/au/syd1/terragrunt.hcl | 38 ++--- modules/netbox/catalogue.tf | 59 +++++++ modules/netbox/custom_fields.tf | 14 +- modules/netbox/devices.tf | 109 ------------- modules/netbox/main.tf | 41 +---- modules/netbox/networks.tf | 65 ++++++++ modules/netbox/variables.tf | 142 +++++++++++------ 34 files changed, 443 insertions(+), 495 deletions(-) delete mode 100755 ci/puppetdb_backfill.py create mode 100644 config/au/syd1/device-roles.yaml create mode 100644 config/au/syd1/device-types/optiplex-3070.yaml create mode 100644 config/au/syd1/device-types/sg3429x.yaml delete mode 100644 config/au/syd1/devices/prodnxsr0014.yaml delete mode 100644 config/au/syd1/devices/prodnxsr0015.yaml delete mode 100644 config/au/syd1/devices/prodnxsr0016.yaml delete mode 100644 config/au/syd1/devices/prodnxsr0017.yaml delete mode 100644 config/au/syd1/devices/prodnxsr0018.yaml delete mode 100644 config/au/syd1/devices/prodnxsr0019.yaml create mode 100644 config/au/syd1/network-profiles/fabric-dual.yaml delete mode 100644 config/au/syd1/networks/mgmt.yaml create mode 100644 config/au/syd1/platforms.yaml create mode 100644 config/au/syd1/sites.yaml delete mode 100644 config/au/syd1/subnets/drw1-prod.yaml create mode 100644 config/au/syd1/subnets/net-198-18-13-0-24.yaml delete mode 100644 config/au/syd1/subnets/net-198-18-13.yaml create mode 100644 config/au/syd1/subnets/net-198-18-14-0-24.yaml delete mode 100644 config/au/syd1/subnets/net-198-18-14.yaml create mode 100644 config/au/syd1/subnets/net-198-18-15-0-24.yaml delete mode 100644 config/au/syd1/subnets/net-198-18-15.yaml create mode 100644 config/au/syd1/subnets/net-198-18-16-0-24.yaml delete mode 100644 config/au/syd1/subnets/net-198-18-16.yaml create mode 100644 config/au/syd1/subnets/net-198-18-17-0-24.yaml rename config/au/syd1/subnets/{local.yaml => net-198-18-25-0-24.yaml} (100%) create mode 100644 config/au/syd1/vlans.yaml create mode 100644 modules/netbox/catalogue.tf delete mode 100644 modules/netbox/devices.tf create mode 100644 modules/netbox/networks.tf diff --git a/README.md b/README.md index 2c96dff..f4a7684 100644 --- a/README.md +++ b/README.md @@ -1,99 +1,123 @@ # terraform-netbox -YAML-driven Terraform/Terragrunt for the state that belongs in **NetBox**: -prefixes and IP ranges, logical networks, physical devices, platforms, and the -custom-field definitions [bootapi](https://git.unkin.net/unkin/bootapi) reads. -NetBox is authoritative for all IP/interface data; this repo declares intent and -lets discovery/PuppetDB fill in hardware reality. +YAML-driven Terraform/Terragrunt for the **NetBox catalogue**: the stable +reference data a host is planned against. Device records are not here. + +## Ownership split + +| Owner | Objects | +|-------|---------| +| **this repo** | sites, prefixes, DHCP pool ranges, gateways, managed IPs, VLAN groups + VLANs, device types (incl. switch models and their interface templates), manufacturers, device roles, platforms, custom-field definitions, network profiles | +| **autodiscovery agent** | device records, interfaces, MAC addresses, switch cabling | +| **UI** | host names, role assignment, IP allocation (inventory -> planned) | + +Writing a device from here would fight both of them, so the module declares no +`netbox_device`. > Supersedes `terraform-infra`, which also drove Kea DHCP. Kea is managed by the > [kea-operator](https://git.unkin.net/unkin/kea-operator) CRs in argocd-apps -> (`apps/base/dhcp-system/cr/`) — both declaring the same subnets would fight, -> and two CRs for one prefix fails the kea config load. This repo is NetBox only. +> (`apps/base/dhcp-system/cr/`). ## Layout ``` -config///subnets/.yaml # one file per subnet (NetBox prefix + pool range) -config///networks/.yaml # logical networks devices join -config///devices/.yaml # one file per device (intent only) -config///custom-fields.yaml # custom-field DEFINITIONS -config///managed-ips.yaml # extra manually-managed IPs -environments///terragrunt.hcl # one Terragrunt env per region/dc -modules/netbox/ # the module -ci/puppetdb_backfill.py # reality backfill generator +config///sites.yaml # site slug -> name/description +config///subnets/net-----.yaml +config///device-types/.yaml # manufacturer, model, port templates +config///device-roles.yaml +config///platforms.yaml # slug -> display name +config///vlans.yaml # keyed per site +config///network-profiles/.yaml +config///custom-fields.yaml # custom-field DEFINITIONS +config///managed-ips.yaml +environments///terragrunt.hcl +modules/netbox/ ``` `make plan` / `make apply` reconcile the YAML against NetBox. State lives in -Consul under `infra/terraform/netbox///state`. +Consul under `infra/terraform/netbox///state`. A site that already +exists in NetBox must be imported, not re-created: site names are unique. -Sites, device roles, device types and manufacturers are all declared by being -referenced: any slug a subnet or device names is created. A site that already -exists in NetBox must be imported, not re-created, since site names are unique. +## Subnets (`subnets/net-----.yaml`) -## Subnet schema (`subnets/.yaml`) +The filename carries the network and mask length; the human label lives in +`description`. ```yaml prefix: 198.18.15.0/24 description: syd1 production -site: syd1 # NetBox site slug (optional) +site: syd1 # NetBox site slug +router: 254 # host-octet int -> gateway IP dhcp: # omit for a NetBox-only prefix enabled: true start: 200 # host-octet int stop: 220 -router: 254 # host-octet int -> gateway IP +dns: [198.18.200.7] +domain: main.unkin.net +next_server: 198.18.19.19 ``` -Creates `netbox_prefix` (+ an `netbox_ip_range` with role `dhcp` and the gateway -`netbox_ip_address`). A prefix backing a network is tagged `net:`. -The DHCP *server* config (pool, routers, DNS, next-server) is **not** here — it -is the `KeaSubnet` CR in argocd-apps. `dhcp` only records the pool in NetBox so -IPAM reflects what is handed out. +Creates `netbox_prefix`, plus a `netbox_ip_range` with role `dhcp` and the +gateway `netbox_ip_address`. `dns`, `domain` and `next_server` are read by no +resource today; they are retained so a later change can drive Kea from this +repo without re-deriving them. -## Device schema (`devices/.yaml`) — intent only +## Device types (`device-types/.yaml`) -Serial, real interface names (which vary by model) and inventory are owned by -discovery/PuppetDB, never hardcoded here. +`ports` declares interface templates as ranges, not 28 hand-written entries. +NetBox instantiates them on every device created from the type. ```yaml -site: syd1 -role: roles::base -model_hint: optiplex-3070 # NetBox device type AND bootapi catalog entry -provision: - platform: almalinux9 # NetBox platform slug -networks: - mgmt: 198.18.15.14/24 # requested IP; "" = next-available -pxe: true # device status staged vs active -bootstrap_mac: a4:bb:6d:xx:xx:xx # TRANSITIONAL +manufacturer: TP-Link +model: Omada SG3429X +u_height: 1 +ports: + - name_format: two-gigabitEthernet 1/0/%d # single %d + type: 2.5gbase-t + first: 1 + count: 24 + - name_format: ten-gigabitEthernet 1/0/%d + type: 10gbase-x-sfpp + first: 25 + count: 4 ``` -`model_hint` is written to the device's `provision_template` custom field: the -per-model OptiPlex catalog entries in `bootapi-templates` are named after the -device-type slug, so one field drives bootapi's per-model storage layout. -`provision.platform` becomes a `netbox_platform` and the device's `platform_id`, -which is bootapi's primary catalog selector. +## VLANs (`vlans.yaml`) -`bootstrap_mac` seeds one placeholder interface + MAC so bootapi can key the PXE -boot on MAC until the discovery image exists; remove it once backfill populates -the real NICs. +Keyed per site. Each site gets its own site-scoped `netbox_vlan_group`, so the +same VID may exist in more than one site. + +```yaml +syd1: + vid_ranges: [[1, 4094]] + vlans: + - vid: 101 + name: fabric_common_01 +``` + +## Network profiles (`network-profiles/.yaml`) + +A profile describes the interfaces a logical host should have. It is published +as a `netbox_config_context` scoped to its sites (and roles, if given), so the +UI reads profiles from the NetBox API rather than from git. + +```yaml +description: Dual-homed fabric host +sites: [syd1] +weight: 1000 +interfaces: + - name: fabric0 + vlan: 101 # VID in the site's VLAN group + subnet: net-198-18-13-0-24 # subnet key; the prefix is resolved into the context + subinterfaces: [] # optional, same shape +``` ## Custom fields (`custom-fields.yaml`) Field **definitions** for what bootapi reads: `pxe_enabled`, `provision_template`, `domain`, `nameservers`, `gateway`. `content_types` are -NetBox `app_label.model` strings. - -`pxe_enabled`'s *value* is deliberately not managed here — it is runtime state. -bootapi's `/provisioned` callback clears it when an install finishes so the host -boots local disk instead of re-imaging; setting it from terraform would -re-enable installs on every apply. - -## PuppetDB reality backfill - -`ci/puppetdb_backfill.py` sweeps provisioned hosts and emits their hardware -reality (serial/model/UUID + every interface's MAC/IPs) as reviewable YAML. -PuppetDB needs no auth but is **not reachable from CI** — run it in-cluster or -from puppet infra. Wiring its output into NetBox is a follow-up. +NetBox `app_label.model` strings. `pxe_enabled`'s *value* is deliberately not +managed here — it is runtime state cleared by bootapi's `/provisioned` callback. ## Providers + auth diff --git a/ci/puppetdb_backfill.py b/ci/puppetdb_backfill.py deleted file mode 100755 index f67e9d0..0000000 --- a/ci/puppetdb_backfill.py +++ /dev/null @@ -1,114 +0,0 @@ -#!/usr/bin/env python3 -"""Backfill NetBox device *reality* from PuppetDB for already-provisioned hosts. - -This emits the hardware-owned facts a running host reports (serial, model, UUID, -and every interface's name/MAC/IPs including the overlay/loopback addresses -Cobbler never had) as reviewable per-host YAML. It targets the *reality* side of -NetBox (interfaces + inventory) for the ~13 existing physicals; it is NOT the -intent `config/.../devices/*.yaml` (a human writes those). - -PuppetDB needs no auth (TLS terminates at the gateway; upstream is plain HTTP). -It is NOT reachable from CI, so run this in-cluster or from puppet infra: - - ./puppetdb_backfill.py --out ../reality \ - --url http://puppetdb.puppet.svc.cluster.local:8080/pdb/query/v4/facts \ - prodnxsr0001 prodnxsr0002 ... - -Fact paths follow Facter conventions (networking.*, dmi.*); verify against a live -factset (`GET /pdb/query/v4/factsets`) before trusting output on a new estate. -""" -import argparse -import json -import os -import sys -import urllib.parse -import urllib.request - -DEFAULT_URL = "http://puppetdb.puppet.svc.cluster.local:8080/pdb/query/v4/facts" - - -def query_facts(url, certname): - ast = json.dumps(["=", "certname", certname]) - full = url + "?" + urllib.parse.urlencode({"query": ast}) - with urllib.request.urlopen(full, timeout=30) as resp: - rows = json.load(resp) - return {row["name"]: row["value"] for row in rows} - - -def device_reality(certname, facts): - networking = facts.get("networking", {}) or {} - ifaces = networking.get("interfaces", {}) or {} - dmi = facts.get("dmi", {}) or {} - product = dmi.get("product", {}) or {} - - interfaces = [] - for name, data in sorted(ifaces.items()): - if name == "lo": - continue - ips = [] - for b in (data.get("bindings", []) or []): - if b.get("address"): - ips.append(b["address"]) - if data.get("ip") and data["ip"] not in ips: - ips.append(data["ip"]) - interfaces.append({ - "name": name, - "mac": data.get("mac"), - "ips": ips, - }) - - return { - "device": certname.split(".")[0], - "serial": product.get("serial_number") or facts.get("serialnumber"), - "model": product.get("name") or facts.get("productname"), - "uuid": product.get("uuid") or facts.get("uuid"), - "interfaces": interfaces, - } - - -def to_yaml(d): - # tiny dependency-free YAML emitter for this fixed shape - out = [ - f"device: {d['device']}", - f"serial: {d['serial'] or ''}", - f"model: {d['model'] or ''}", - f"uuid: {d['uuid'] or ''}", - "interfaces:", - ] - for i in d["interfaces"]: - out.append(f" - name: {i['name']}") - out.append(f" mac: {i['mac'] or ''}") - out.append(" ips: [%s]" % ", ".join(i["ips"])) - return "\n".join(out) + "\n" - - -def main(): - ap = argparse.ArgumentParser(description=__doc__) - ap.add_argument("hosts", nargs="+", help="certnames or short hostnames") - ap.add_argument("--url", default=os.environ.get("PUPPETDB_URL", DEFAULT_URL)) - ap.add_argument("--out", help="write .yaml here instead of stdout") - args = ap.parse_args() - - for host in args.hosts: - certname = host if "." in host else f"{host}.main.unkin.net" - try: - facts = query_facts(args.url, certname) - except Exception as e: # noqa: BLE001 - print(f"# {certname}: query failed: {e}", file=sys.stderr) - continue - if not facts: - print(f"# {certname}: no facts (not in PuppetDB)", file=sys.stderr) - continue - text = to_yaml(device_reality(certname, facts)) - if args.out: - os.makedirs(args.out, exist_ok=True) - path = os.path.join(args.out, f"{certname.split('.')[0]}.yaml") - with open(path, "w") as fh: - fh.write(text) - print(f"wrote {path}", file=sys.stderr) - else: - print(text) - - -if __name__ == "__main__": - main() diff --git a/config/au/syd1/device-roles.yaml b/config/au/syd1/device-roles.yaml new file mode 100644 index 0000000..45d7e94 --- /dev/null +++ b/config/au/syd1/device-roles.yaml @@ -0,0 +1,3 @@ +# NetBox device roles, keyed by role name. Slug is derived from the name. +roles::base: + description: Puppet base role diff --git a/config/au/syd1/device-types/optiplex-3070.yaml b/config/au/syd1/device-types/optiplex-3070.yaml new file mode 100644 index 0000000..e700437 --- /dev/null +++ b/config/au/syd1/device-types/optiplex-3070.yaml @@ -0,0 +1,3 @@ +manufacturer: Dell +model: OptiPlex 3070 +u_height: 1 diff --git a/config/au/syd1/device-types/sg3429x.yaml b/config/au/syd1/device-types/sg3429x.yaml new file mode 100644 index 0000000..37d14bd --- /dev/null +++ b/config/au/syd1/device-types/sg3429x.yaml @@ -0,0 +1,14 @@ +# TP-Link Omada SG3429X: 24x 2.5G copper + 4x 10G SFP+. +# NetBox instantiates these interfaces on every device created from the type. +manufacturer: TP-Link +model: Omada SG3429X +u_height: 1 +ports: + - name_format: two-gigabitEthernet 1/0/%d + type: 2.5gbase-t + first: 1 + count: 24 + - name_format: ten-gigabitEthernet 1/0/%d + type: 10gbase-x-sfpp + first: 25 + count: 4 diff --git a/config/au/syd1/devices/prodnxsr0014.yaml b/config/au/syd1/devices/prodnxsr0014.yaml deleted file mode 100644 index ab61131..0000000 --- a/config/au/syd1/devices/prodnxsr0014.yaml +++ /dev/null @@ -1,10 +0,0 @@ -site: syd1 -role: roles::base -model_hint: optiplex-3070 -provision: - platform: almalinux9 -networks: - mgmt: 198.18.15.14/24 -pxe: true -# transitional: removed once the discovery image / puppetdb backfill populates real NIC MACs -bootstrap_mac: a4:bb:6d:a4:e5:c1 diff --git a/config/au/syd1/devices/prodnxsr0015.yaml b/config/au/syd1/devices/prodnxsr0015.yaml deleted file mode 100644 index 2c0e23c..0000000 --- a/config/au/syd1/devices/prodnxsr0015.yaml +++ /dev/null @@ -1,10 +0,0 @@ -site: syd1 -role: roles::base -model_hint: optiplex-3070 -provision: - platform: almalinux9 -networks: - mgmt: 198.18.15.15/24 -pxe: true -# transitional: removed once the discovery image / puppetdb backfill populates real NIC MACs -bootstrap_mac: a4:bb:6d:a6:30:c4 diff --git a/config/au/syd1/devices/prodnxsr0016.yaml b/config/au/syd1/devices/prodnxsr0016.yaml deleted file mode 100644 index da747cd..0000000 --- a/config/au/syd1/devices/prodnxsr0016.yaml +++ /dev/null @@ -1,10 +0,0 @@ -site: syd1 -role: roles::base -model_hint: optiplex-3070 -provision: - platform: almalinux9 -networks: - mgmt: 198.18.15.16/24 -pxe: true -# transitional: removed once the discovery image / puppetdb backfill populates real NIC MACs -bootstrap_mac: a4:bb:6d:9f:22:13 diff --git a/config/au/syd1/devices/prodnxsr0017.yaml b/config/au/syd1/devices/prodnxsr0017.yaml deleted file mode 100644 index ce6baf6..0000000 --- a/config/au/syd1/devices/prodnxsr0017.yaml +++ /dev/null @@ -1,10 +0,0 @@ -site: syd1 -role: roles::base -model_hint: optiplex-3070 -provision: - platform: almalinux9 -networks: - mgmt: 198.18.15.17/24 -pxe: true -# transitional: removed once the discovery image / puppetdb backfill populates real NIC MACs -bootstrap_mac: 8c:04:ba:9c:b6:08 diff --git a/config/au/syd1/devices/prodnxsr0018.yaml b/config/au/syd1/devices/prodnxsr0018.yaml deleted file mode 100644 index 5741013..0000000 --- a/config/au/syd1/devices/prodnxsr0018.yaml +++ /dev/null @@ -1,10 +0,0 @@ -site: syd1 -role: roles::base -model_hint: optiplex-3070 -provision: - platform: almalinux9 -networks: - mgmt: 198.18.15.18/24 -pxe: true -# transitional: removed once the discovery image / puppetdb backfill populates real NIC MACs -bootstrap_mac: a4:bb:6d:a4:db:94 diff --git a/config/au/syd1/devices/prodnxsr0019.yaml b/config/au/syd1/devices/prodnxsr0019.yaml deleted file mode 100644 index ecbdad5..0000000 --- a/config/au/syd1/devices/prodnxsr0019.yaml +++ /dev/null @@ -1,10 +0,0 @@ -site: syd1 -role: roles::base -model_hint: optiplex-3070 -provision: - platform: almalinux9 -networks: - mgmt: 198.18.15.19/24 -pxe: true -# transitional: removed once the discovery image / puppetdb backfill populates real NIC MACs -bootstrap_mac: a4:bb:6d:a4:56:11 diff --git a/config/au/syd1/network-profiles/fabric-dual.yaml b/config/au/syd1/network-profiles/fabric-dual.yaml new file mode 100644 index 0000000..2ccd800 --- /dev/null +++ b/config/au/syd1/network-profiles/fabric-dual.yaml @@ -0,0 +1,12 @@ +# A network profile describes the interfaces a logical host should have. The UI +# reads it back from the NetBox config-context API when planning a host. +description: Dual-homed fabric host, one leg per common fabric VLAN +sites: [syd1] +weight: 1000 +interfaces: + - name: fabric0 + vlan: 101 + subnet: net-198-18-13-0-24 + - name: fabric1 + vlan: 201 + subnet: net-198-18-14-0-24 diff --git a/config/au/syd1/networks/mgmt.yaml b/config/au/syd1/networks/mgmt.yaml deleted file mode 100644 index a8dc884..0000000 --- a/config/au/syd1/networks/mgmt.yaml +++ /dev/null @@ -1,4 +0,0 @@ -subnet: net-198-18-15 -gateway: 198.18.15.254 -dns: [198.18.19.16] -search: main.unkin.net diff --git a/config/au/syd1/platforms.yaml b/config/au/syd1/platforms.yaml new file mode 100644 index 0000000..e5023a2 --- /dev/null +++ b/config/au/syd1/platforms.yaml @@ -0,0 +1,3 @@ +# NetBox platforms, slug -> display name. A device's platform slug is bootapi's +# primary distro-catalog selector. +almalinux9: almalinux9 diff --git a/config/au/syd1/sites.yaml b/config/au/syd1/sites.yaml new file mode 100644 index 0000000..157e7b2 --- /dev/null +++ b/config/au/syd1/sites.yaml @@ -0,0 +1,4 @@ +# NetBox sites, keyed by slug. +syd1: + name: syd1 + description: Sydney region, datacentre 1 diff --git a/config/au/syd1/subnets/drw1-prod.yaml b/config/au/syd1/subnets/drw1-prod.yaml deleted file mode 100644 index 871e4e0..0000000 --- a/config/au/syd1/subnets/drw1-prod.yaml +++ /dev/null @@ -1,8 +0,0 @@ -prefix: 198.18.17.0/24 -description: drw1-prod -site: syd1 -dhcp: - enabled: true - start: 200 - stop: 220 -router: 1 diff --git a/config/au/syd1/subnets/net-198-18-13-0-24.yaml b/config/au/syd1/subnets/net-198-18-13-0-24.yaml new file mode 100644 index 0000000..e1d2970 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-13-0-24.yaml @@ -0,0 +1,12 @@ +prefix: 198.18.13.0/24 +description: syd1 production +site: syd1 +router: 254 +dhcp: + enabled: true + start: 200 + stop: 220 +# Carried for a later change that drives Kea from this repo; no resource reads them yet. +dns: [198.18.200.7] +domain: main.unkin.net +next_server: 198.18.19.19 diff --git a/config/au/syd1/subnets/net-198-18-13.yaml b/config/au/syd1/subnets/net-198-18-13.yaml deleted file mode 100644 index 3d839e2..0000000 --- a/config/au/syd1/subnets/net-198-18-13.yaml +++ /dev/null @@ -1,8 +0,0 @@ -prefix: 198.18.13.0/24 -description: syd1 production -site: syd1 -dhcp: - enabled: true - start: 200 - stop: 220 -router: 254 diff --git a/config/au/syd1/subnets/net-198-18-14-0-24.yaml b/config/au/syd1/subnets/net-198-18-14-0-24.yaml new file mode 100644 index 0000000..faca862 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-14-0-24.yaml @@ -0,0 +1,12 @@ +prefix: 198.18.14.0/24 +description: syd1 production +site: syd1 +router: 254 +dhcp: + enabled: true + start: 200 + stop: 220 +# Carried for a later change that drives Kea from this repo; no resource reads them yet. +dns: [198.18.200.7] +domain: main.unkin.net +next_server: 198.18.19.19 diff --git a/config/au/syd1/subnets/net-198-18-14.yaml b/config/au/syd1/subnets/net-198-18-14.yaml deleted file mode 100644 index 7a6971e..0000000 --- a/config/au/syd1/subnets/net-198-18-14.yaml +++ /dev/null @@ -1,8 +0,0 @@ -prefix: 198.18.14.0/24 -description: syd1 production -site: syd1 -dhcp: - enabled: true - start: 200 - stop: 220 -router: 254 diff --git a/config/au/syd1/subnets/net-198-18-15-0-24.yaml b/config/au/syd1/subnets/net-198-18-15-0-24.yaml new file mode 100644 index 0000000..3280621 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-15-0-24.yaml @@ -0,0 +1,12 @@ +prefix: 198.18.15.0/24 +description: syd1 production +site: syd1 +router: 254 +dhcp: + enabled: true + start: 200 + stop: 220 +# Carried for a later change that drives Kea from this repo; no resource reads them yet. +dns: [198.18.200.7] +domain: main.unkin.net +next_server: 198.18.19.19 diff --git a/config/au/syd1/subnets/net-198-18-15.yaml b/config/au/syd1/subnets/net-198-18-15.yaml deleted file mode 100644 index 5ab17a6..0000000 --- a/config/au/syd1/subnets/net-198-18-15.yaml +++ /dev/null @@ -1,8 +0,0 @@ -prefix: 198.18.15.0/24 -description: syd1 production -site: syd1 -dhcp: - enabled: true - start: 200 - stop: 220 -router: 254 diff --git a/config/au/syd1/subnets/net-198-18-16-0-24.yaml b/config/au/syd1/subnets/net-198-18-16-0-24.yaml new file mode 100644 index 0000000..bca23a1 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-16-0-24.yaml @@ -0,0 +1,12 @@ +prefix: 198.18.16.0/24 +description: syd1 production +site: syd1 +router: 254 +dhcp: + enabled: true + start: 200 + stop: 220 +# Carried for a later change that drives Kea from this repo; no resource reads them yet. +dns: [198.18.200.7] +domain: main.unkin.net +next_server: 198.18.19.19 diff --git a/config/au/syd1/subnets/net-198-18-16.yaml b/config/au/syd1/subnets/net-198-18-16.yaml deleted file mode 100644 index 2125300..0000000 --- a/config/au/syd1/subnets/net-198-18-16.yaml +++ /dev/null @@ -1,8 +0,0 @@ -prefix: 198.18.16.0/24 -description: syd1 production -site: syd1 -dhcp: - enabled: true - start: 200 - stop: 220 -router: 254 diff --git a/config/au/syd1/subnets/net-198-18-17-0-24.yaml b/config/au/syd1/subnets/net-198-18-17-0-24.yaml new file mode 100644 index 0000000..2e7c460 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-17-0-24.yaml @@ -0,0 +1,12 @@ +prefix: 198.18.17.0/24 +description: drw1-prod +site: syd1 +router: 1 +dhcp: + enabled: true + start: 200 + stop: 220 +# Carried for a later change that drives Kea from this repo; no resource reads them yet. +dns: [198.18.200.7] +domain: main.unkin.net +next_server: 198.18.19.19 diff --git a/config/au/syd1/subnets/local.yaml b/config/au/syd1/subnets/net-198-18-25-0-24.yaml similarity index 100% rename from config/au/syd1/subnets/local.yaml rename to config/au/syd1/subnets/net-198-18-25-0-24.yaml diff --git a/config/au/syd1/vlans.yaml b/config/au/syd1/vlans.yaml new file mode 100644 index 0000000..7370109 --- /dev/null +++ b/config/au/syd1/vlans.yaml @@ -0,0 +1,9 @@ +# VLANs keyed by site slug. Each site gets its own NetBox VLAN group scoped to +# the site, so the same VID may exist in more than one site. +syd1: + vid_ranges: [[1, 4094]] + vlans: + - vid: 101 + name: fabric_common_01 + - vid: 201 + name: fabric_common_02 diff --git a/environments/au/syd1/terragrunt.hcl b/environments/au/syd1/terragrunt.hcl index c32020e..1fe6847 100644 --- a/environments/au/syd1/terragrunt.hcl +++ b/environments/au/syd1/terragrunt.hcl @@ -9,29 +9,27 @@ locals { dc = local.parts[1] cfg_dir = "${get_repo_root()}/config/${local.region}/${local.dc}" - subnet_files = fileset(local.cfg_dir, "subnets/*.yaml") subnets = { - for f in local.subnet_files : + for f in fileset(local.cfg_dir, "subnets/*.yaml") : trimsuffix(basename(f), ".yaml") => yamldecode(file("${local.cfg_dir}/${f}")) } - network_files = fileset(local.cfg_dir, "networks/*.yaml") - networks = { - for f in local.network_files : + device_types = { + for f in fileset(local.cfg_dir, "device-types/*.yaml") : trimsuffix(basename(f), ".yaml") => yamldecode(file("${local.cfg_dir}/${f}")) } - device_files = fileset(local.cfg_dir, "devices/*.yaml") - devices = { - for f in local.device_files : + network_profiles = { + for f in fileset(local.cfg_dir, "network-profiles/*.yaml") : trimsuffix(basename(f), ".yaml") => yamldecode(file("${local.cfg_dir}/${f}")) } - managed_ips_file = "${local.cfg_dir}/managed-ips.yaml" - managed_ips = fileexists(local.managed_ips_file) ? yamldecode(file(local.managed_ips_file)) : [] - - custom_fields_file = "${local.cfg_dir}/custom-fields.yaml" - custom_fields = fileexists(local.custom_fields_file) ? yamldecode(file(local.custom_fields_file)) : {} + sites = yamldecode(file("${local.cfg_dir}/sites.yaml")) + device_roles = yamldecode(file("${local.cfg_dir}/device-roles.yaml")) + platforms = yamldecode(file("${local.cfg_dir}/platforms.yaml")) + vlans = yamldecode(file("${local.cfg_dir}/vlans.yaml")) + custom_fields = yamldecode(file("${local.cfg_dir}/custom-fields.yaml")) + managed_ips = yamldecode(file("${local.cfg_dir}/managed-ips.yaml")) } terraform { @@ -39,9 +37,13 @@ terraform { } inputs = { - subnets = local.subnets - networks = local.networks - devices = local.devices - managed_ips = local.managed_ips - custom_fields = local.custom_fields + sites = local.sites + subnets = local.subnets + device_types = local.device_types + device_roles = local.device_roles + platforms = local.platforms + vlans = local.vlans + network_profiles = local.network_profiles + custom_fields = local.custom_fields + managed_ips = local.managed_ips } diff --git a/modules/netbox/catalogue.tf b/modules/netbox/catalogue.tf new file mode 100644 index 0000000..421d00a --- /dev/null +++ b/modules/netbox/catalogue.tf @@ -0,0 +1,59 @@ +locals { + manufacturers = toset([for t in var.device_types : t.manufacturer]) + + # One interface template per port, flattened to ":". + interface_templates = merge([ + for slug, t in var.device_types : { + for e in flatten([ + for p in t.ports : [ + for i in range(p.count) : { + name = format(p.name_format, p.first + i) + type = p.type + mgmt_only = p.mgmt_only + } + ] + ]) : "${slug}:${e.name}" => merge(e, { device_type = slug }) + } + ]...) +} + +resource "netbox_manufacturer" "this" { + for_each = local.manufacturers + + name = each.value + slug = lower(replace(each.value, "/[^0-9A-Za-z]+/", "-")) +} + +resource "netbox_device_type" "this" { + for_each = var.device_types + + manufacturer_id = tonumber(netbox_manufacturer.this[each.value.manufacturer].id) + model = each.value.model + slug = each.key + u_height = each.value.u_height +} + +resource "netbox_interface_template" "this" { + for_each = local.interface_templates + + device_type_id = tonumber(netbox_device_type.this[each.value.device_type].id) + name = each.value.name + type = each.value.type + mgmt_only = each.value.mgmt_only +} + +resource "netbox_device_role" "this" { + for_each = var.device_roles + + name = each.key + slug = lower(replace(each.key, "::", "-")) + color_hex = each.value.color_hex + description = each.value.description +} + +resource "netbox_platform" "this" { + for_each = var.platforms + + name = each.value + slug = each.key +} diff --git a/modules/netbox/custom_fields.tf b/modules/netbox/custom_fields.tf index 9a22185..e74c556 100644 --- a/modules/netbox/custom_fields.tf +++ b/modules/netbox/custom_fields.tf @@ -1,8 +1,8 @@ -# Custom-field DEFINITIONS and platforms: the two things bootapi needs NetBox to -# carry before it can pick anything but the default distro. +# Custom-field DEFINITIONS: what bootapi needs NetBox to carry before it can +# pick anything but the default distro. # # Definitions only. Values are set where they are owned: -# provision_template -> here, on the device (stable intent) +# provision_template -> by the UI/autodiscovery, on the device # pxe_enabled -> NOT managed here. It is runtime state: bootapi's # /provisioned callback clears it at the end of an # install so a host does not re-image on its next PXE. @@ -17,11 +17,3 @@ resource "netbox_custom_field" "this" { label = each.value.label description = each.value.description } - -# A device's platform slug is bootapi's primary distro-catalog selector. -resource "netbox_platform" "this" { - for_each = local.platforms - - name = each.value - slug = each.value -} diff --git a/modules/netbox/devices.tf b/modules/netbox/devices.tf deleted file mode 100644 index 0ecb8fa..0000000 --- a/modules/netbox/devices.tf +++ /dev/null @@ -1,109 +0,0 @@ -locals { - # network name -> backing prefix id (from the subnets above). - network_prefix_id = { for nname, n in var.networks : nname => netbox_prefix.this[n.subnet].id } - - # Distinct device inventory objects to create in NetBox. - device_models = toset([for d in var.devices : d.model_hint if d.model_hint != null]) - device_roles = toset([for d in var.devices : d.role]) - - # Devices that carry a transitional bootstrap MAC (placeholder PXE interface). - bootstrap_devices = { for k, d in var.devices : k => d if d.bootstrap_mac != null } - - # Flatten device network memberships: ":" -> {device, network, ip}. - device_networks = merge([ - for dname, d in var.devices : { - for nname, ip in d.networks : "${dname}:${nname}" => { - device = dname - network = nname - ip = ip - } - } - ]...) - - static_device_ips = { for k, dn in local.device_networks : k => dn if dn.ip != "" } - auto_device_ips = { for k, dn in local.device_networks : k => dn if dn.ip == "" } -} - -resource "netbox_manufacturer" "dell" { - count = length(local.device_models) > 0 ? 1 : 0 - name = "Dell" - slug = "dell" -} - -resource "netbox_device_type" "this" { - for_each = local.device_models - - manufacturer_id = netbox_manufacturer.dell[0].id - model = title(replace(each.value, "-", " ")) - slug = each.value -} - -resource "netbox_device_role" "this" { - for_each = local.device_roles - - name = each.value - slug = lower(replace(each.value, "::", "-")) - color_hex = "9e9e9e" -} - -resource "netbox_device" "this" { - for_each = var.devices - - name = each.key - device_type_id = netbox_device_type.this[each.value.model_hint].id - role_id = netbox_device_role.this[each.value.role].id - site_id = tonumber(netbox_site.this[each.value.site].id) - status = each.value.pxe ? "staged" : "active" - - platform_id = try(netbox_platform.this[each.value.provision.platform].id, null) - - # model_hint IS the distro-catalog entry name for the per-model OptiPlex - # entries, so it doubles as the provision_template override bootapi selects on. - custom_fields = each.value.model_hint != null ? { - provision_template = each.value.model_hint - } : {} - - depends_on = [netbox_custom_field.this] -} - -# Transitional: bootapi keys PXE on MAC and no discovery image exists yet, so seed a -# single placeholder interface carrying bootstrap_mac. Discovery replaces it with the -# real (model-specific) NICs later; the interface name is a neutral label, not a -# hardware assumption. -resource "netbox_device_interface" "bootstrap" { - for_each = local.bootstrap_devices - - device_id = netbox_device.this[each.key].id - name = "bootstrap" - type = "1000base-t" -} - -resource "netbox_mac_address" "bootstrap" { - for_each = local.bootstrap_devices - - mac_address = each.value.bootstrap_mac - device_interface_id = netbox_device_interface.bootstrap[each.key].id -} - -resource "netbox_ip_address" "device" { - for_each = local.static_device_ips - - ip_address = each.value.ip - status = "active" - description = each.value.device - device_interface_id = try(netbox_device_interface.bootstrap[each.value.device].id, null) -} - -resource "netbox_available_ip_address" "device" { - for_each = local.auto_device_ips - - prefix_id = local.network_prefix_id[each.value.network] - status = "active" - description = each.value.device - device_interface_id = try(netbox_device_interface.bootstrap[each.value.device].id, null) - - # Machines are never re-IPed (a replacement is a new machine); keep the allocation sticky. - lifecycle { - ignore_changes = [prefix_id] - } -} diff --git a/modules/netbox/main.tf b/modules/netbox/main.tf index 30038fc..cea0a7f 100644 --- a/modules/netbox/main.tf +++ b/modules/netbox/main.tf @@ -7,44 +7,16 @@ locals { # Subnets that declare a gateway. gateways = { for k, v in var.subnets : k => v if v.router != null } - - # Distinct platform slugs declared by devices. - platforms = toset([ - for d in var.devices : d.provision.platform - if d.provision != null && d.provision.platform != null - ]) - - # Distinct NetBox site slugs referenced by any subnet or device. - sites = toset(concat( - [for v in var.subnets : v.site if v.site != null], - [for d in var.devices : d.site], - )) - - # net: tags to apply to each subnet's prefix (a prefix may back many networks). - prefix_net_tags = { - for sk in keys(var.subnets) : sk => [ - for nname, n in var.networks : "net:${nname}" if n.subnet == sk - ] - } } -# Sites are declared by being referenced: every slug named by a subnet or a -# device is created, exactly as device roles, types and manufacturers are. A -# site that already exists in NetBox must be imported rather than re-created +# A site that already exists in NetBox must be imported rather than re-created # (NetBox site names are unique). resource "netbox_site" "this" { - for_each = local.sites + for_each = var.sites - name = each.value - slug = each.value -} - -# One tag per network so prefixes can be discovered by network membership. -resource "netbox_tag" "network" { - for_each = var.networks - - name = "net:${each.key}" - slug = "net-${each.key}" + name = each.value.name + slug = each.key + description = each.value.description } resource "netbox_prefix" "this" { @@ -54,9 +26,6 @@ resource "netbox_prefix" "this" { status = "active" description = each.value.description site_id = each.value.site != null ? tonumber(netbox_site.this[each.value.site].id) : null - tags = [for t in local.prefix_net_tags[each.key] : t] - - depends_on = [netbox_tag.network] } # Role tagging a range as DHCP-managed; created once and shared by every range. diff --git a/modules/netbox/networks.tf b/modules/netbox/networks.tf new file mode 100644 index 0000000..ee40112 --- /dev/null +++ b/modules/netbox/networks.tf @@ -0,0 +1,65 @@ +locals { + # ":" -> {site, vid, name, description}. + site_vlans = merge([ + for site, v in var.vlans : { + for vl in v.vlans : "${site}:${vl.vid}" => merge(vl, { site = site }) + } + ]...) + + subnet_prefix = { for k, v in var.subnets : k => v.prefix } +} + +resource "netbox_vlan_group" "this" { + for_each = var.vlans + + name = "${each.key} vlans" + slug = "${each.key}-vlans" + scope_type = "dcim.site" + scope_id = tonumber(netbox_site.this[each.key].id) + vid_ranges = each.value.vid_ranges +} + +resource "netbox_vlan" "this" { + for_each = local.site_vlans + + name = each.value.name + vid = each.value.vid + site_id = tonumber(netbox_site.this[each.value.site].id) + group_id = tonumber(netbox_vlan_group.this[each.value.site].id) + status = "active" + description = each.value.description +} + +# Network profiles: the interfaces a logical host should have. Published as a +# config context so the UI reads them from the NetBox API rather than from git. +resource "netbox_config_context" "network_profile" { + for_each = var.network_profiles + + name = "network-profile:${each.key}" + description = each.value.description + weight = each.value.weight + sites = [for s in each.value.sites : tonumber(netbox_site.this[s].id)] + roles = [for r in each.value.roles : tonumber(netbox_device_role.this[r].id)] + + data = jsonencode({ + network_profile = { + name = each.key + interfaces = [ + for i in each.value.interfaces : { + name = i.name + vlan = i.vlan + subnet = i.subnet + prefix = local.subnet_prefix[i.subnet] + subinterfaces = [ + for s in i.subinterfaces : { + name = s.name + vlan = s.vlan + subnet = s.subnet + prefix = s.subnet != null ? local.subnet_prefix[s.subnet] : null + } + ] + } + ] + } + }) +} diff --git a/modules/netbox/variables.tf b/modules/netbox/variables.tf index b740f16..1059c1b 100644 --- a/modules/netbox/variables.tf +++ b/modules/netbox/variables.tf @@ -1,9 +1,20 @@ +variable "sites" { + description = "NetBox sites keyed by slug." + type = map(object({ + name = string + description = optional(string, "") + })) + default = {} +} + variable "subnets" { description = <<-EOT - Map of subnets keyed by name (config file basename). `dhcp` records the pool - as a NetBox ip_range only; the DHCP server itself is the kea-operator's - KeaSubnet CRs in argocd-apps, so pool/router/DNS/next-server for the server - are declared there, not here. + Map of subnets keyed by name (config file basename, + net-----). `dhcp` records the pool as a NetBox ip_range + only; the DHCP server itself is the kea-operator's KeaSubnet CRs in + argocd-apps. `dns`, `domain` and `next_server` are not read by any resource + here -- they are retained so a later change can drive Kea from this repo + without re-deriving them. EOT type = map(object({ prefix = string @@ -15,45 +26,90 @@ variable "subnets" { start = number stop = number })) + dns = optional(list(string), []) + domain = optional(string) + next_server = optional(string) })) default = {} } -variable "networks" { +variable "device_types" { description = <<-EOT - Named logical networks devices join, keyed by network name. `subnet` binds the - network to a subnet (its prefix is tagged net: and used for device IP - allocation). gateway/dns/search are per-network provisioning constants (carried - for downstream consumers; not per-device). + Device-type catalogue keyed by NetBox slug. `ports` declares interface + templates as ranges: each block names `count` ports from `first`, formatted + with `name_format` (a single %d). NetBox instantiates them on every device + created from the type. Manufacturers are created from the distinct + `manufacturer` values. EOT type = map(object({ - subnet = string - gateway = optional(string) - dns = optional(list(string), []) - search = optional(string) + manufacturer = string + model = string + u_height = optional(number, 1) + ports = optional(list(object({ + name_format = string + type = string + first = optional(number, 1) + count = number + mgmt_only = optional(bool, false) + })), []) })) default = {} } -variable "devices" { +variable "device_roles" { + description = "NetBox device roles keyed by role name; the slug is derived from the name." + type = map(object({ + description = optional(string, "") + color_hex = optional(string, "9e9e9e") + })) + default = {} +} + +variable "platforms" { + description = "NetBox platforms, slug -> display name. A device's platform slug is bootapi's distro-catalog selector." + type = map(string) + default = {} +} + +variable "vlans" { description = <<-EOT - Intent-only device declarations keyed by device name. Reality (serial, real - interface names, MACs) is owned by discovery/PuppetDB, not this file. - `networks` maps a network name to a requested IP in CIDR form, or "" to - allocate the next-available from the network's prefix. `bootstrap_mac` is - transitional: it seeds a placeholder interface so bootapi can key the PXE boot - on MAC until the discovery image exists. + VLANs keyed by site slug. Each site gets one NetBox VLAN group scoped to the + site, so the same VID may exist in more than one site. EOT type = map(object({ - site = string - role = string - model_hint = optional(string) - provision = optional(object({ - platform = optional(string) + vid_ranges = optional(list(list(number)), [[1, 4094]]) + vlans = list(object({ + vid = number + name = string + description = optional(string, "") + })) + })) + default = {} +} + +variable "network_profiles" { + description = <<-EOT + Network profiles keyed by profile name. A profile describes the interfaces a + logical host should have: one entry per interface, each bound to a VLAN VID + and a subnet key, optionally with subinterfaces. Published as a NetBox + config context scoped to `sites` (and `roles`, if given) so the UI reads + profiles from the NetBox API. + EOT + type = map(object({ + description = optional(string, "") + sites = list(string) + roles = optional(list(string), []) + weight = optional(number, 1000) + interfaces = list(object({ + name = string + vlan = number + subnet = string + subinterfaces = optional(list(object({ + name = string + vlan = number + subnet = optional(string) + })), []) })) - networks = optional(map(string), {}) - pxe = optional(bool, false) - bootstrap_mac = optional(string) })) default = {} } @@ -67,6 +123,21 @@ variable "managed_ips" { default = [] } +variable "custom_fields" { + description = <<-EOT + NetBox custom-field definitions keyed by field name. These are the field + DEFINITIONS only; values are set per device by the owning service. + `content_types` are NetBox "app_label.model" strings, e.g. "dcim.device". + EOT + type = map(object({ + type = optional(string, "text") + content_types = list(string) + label = optional(string) + description = optional(string) + })) + default = {} +} + variable "vault_address" { description = "Vault server address for the token data source." type = string @@ -78,18 +149,3 @@ variable "netbox_server_url" { type = string default = "https://netbox.k8s.syd1.au.unkin.net" } - -variable "custom_fields" { - description = <<-EOT - NetBox custom-field definitions keyed by field name. These are the field - DEFINITIONS only; values are set per device (or left to the owning service). - `content_types` are NetBox "app_label.model" strings, e.g. "dcim.device". - EOT - type = map(object({ - type = optional(string, "text") - content_types = list(string) - label = optional(string) - description = optional(string) - })) - default = {} -}