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 = {} -}