From d7c05b00ac37de6c95b9f2df8b934601d4c0dac6 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 15:38:02 +1000 Subject: [PATCH 01/10] Initial terraform-netbox: NetBox prefixes, devices, platforms, custom fields Carried over from terraform-infra, scoped to NetBox: Kea is managed by the kea-operator CRs. Devices carried no platform and NetBox had no custom fields, so bootapi could only ever render its default template. - prefixes, DHCP-pool ip_ranges, gateways, networks, devices and the PuppetDB backfill script, minus every Kea resource, provider and token - netbox_custom_field definitions from config///custom-fields.yaml - netbox_platform per declared platform, set as the device platform_id - model_hint written to the device provision_template custom field - state key infra/terraform/netbox, NetBox token from netbox/creds/terraform-netbox pxe_enabled's value is deliberately unmanaged: it is runtime state that bootapi's provisioned callback clears. --- .gitignore | 8 ++ .pre-commit-config.yaml | 22 +++++ .woodpecker/apply.yaml | 24 +++++ .woodpecker/plan.yaml | 22 +++++ .woodpecker/pre-commit.yaml | 18 ++++ Makefile | 37 +++++++ README.md | 104 +++++++++++++++++++- ci/puppetdb_backfill.py | 114 ++++++++++++++++++++++ config/au/syd1/custom-fields.yaml | 38 ++++++++ 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 ++ config/au/syd1/managed-ips.yaml | 5 + config/au/syd1/networks/mgmt.yaml | 4 + config/au/syd1/subnets/drw1-prod.yaml | 8 ++ config/au/syd1/subnets/local.yaml | 3 + config/au/syd1/subnets/net-198-18-13.yaml | 8 ++ config/au/syd1/subnets/net-198-18-14.yaml | 8 ++ config/au/syd1/subnets/net-198-18-15.yaml | 8 ++ config/au/syd1/subnets/net-198-18-16.yaml | 8 ++ environments/au/syd1/terragrunt.hcl | 47 +++++++++ environments/root.hcl | 15 +++ modules/netbox/custom_fields.tf | 27 +++++ modules/netbox/devices.tf | 109 +++++++++++++++++++++ modules/netbox/main.tf | 86 ++++++++++++++++ modules/netbox/providers.tf | 17 ++++ modules/netbox/variables.tf | 95 ++++++++++++++++++ modules/netbox/versions.tf | 13 +++ 31 files changed, 907 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .woodpecker/apply.yaml create mode 100644 .woodpecker/plan.yaml create mode 100644 .woodpecker/pre-commit.yaml create mode 100644 Makefile create mode 100755 ci/puppetdb_backfill.py create mode 100644 config/au/syd1/custom-fields.yaml create mode 100644 config/au/syd1/devices/prodnxsr0014.yaml create mode 100644 config/au/syd1/devices/prodnxsr0015.yaml create mode 100644 config/au/syd1/devices/prodnxsr0016.yaml create mode 100644 config/au/syd1/devices/prodnxsr0017.yaml create mode 100644 config/au/syd1/devices/prodnxsr0018.yaml create mode 100644 config/au/syd1/devices/prodnxsr0019.yaml create mode 100644 config/au/syd1/managed-ips.yaml create mode 100644 config/au/syd1/networks/mgmt.yaml create mode 100644 config/au/syd1/subnets/drw1-prod.yaml create mode 100644 config/au/syd1/subnets/local.yaml create mode 100644 config/au/syd1/subnets/net-198-18-13.yaml create mode 100644 config/au/syd1/subnets/net-198-18-14.yaml create mode 100644 config/au/syd1/subnets/net-198-18-15.yaml create mode 100644 config/au/syd1/subnets/net-198-18-16.yaml create mode 100644 environments/au/syd1/terragrunt.hcl create mode 100644 environments/root.hcl create mode 100644 modules/netbox/custom_fields.tf create mode 100644 modules/netbox/devices.tf create mode 100644 modules/netbox/main.tf create mode 100644 modules/netbox/providers.tf create mode 100644 modules/netbox/variables.tf create mode 100644 modules/netbox/versions.tf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c26d7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.terraform/ +.terragrunt-cache/ +*.tfstate +*.tfstate.* +*.tfplan +tfplan +crash.log +.terraform.lock.hcl diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0439d93 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: end-of-file-fixer + types: [yaml] + - id: trailing-whitespace + types: [yaml] + - repo: https://github.com/gruntwork-io/pre-commit + rev: v0.1.30 + hooks: + - id: tofu-fmt + - id: terragrunt-hcl-fmt + - repo: https://github.com/adrienverge/yamllint.git + rev: v1.37.1 + hooks: + - id: yamllint + args: + [ + "-d {extends: relaxed, rules: {line-length: disable}}", + "-s", + ] diff --git a/.woodpecker/apply.yaml b/.woodpecker/apply.yaml new file mode 100644 index 0000000..d05cf07 --- /dev/null +++ b/.woodpecker/apply.yaml @@ -0,0 +1,24 @@ +when: + - event: push + branch: main + +steps: + - name: apply + image: git.unkin.net/unkin/almalinux9-opentofu:20260606 + environment: + VAULT_AUTH_METHOD: kubernetes + VAULT_VERSION: "1.20.0" + commands: + - curl -fsSL -o /tmp/vault.zip "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/hashicorp-releases/vault/$${VAULT_VERSION}/vault_$${VAULT_VERSION}_linux_amd64.zip" && python3 -m zipfile -e /tmp/vault.zip /tmp/ && install -m0755 /tmp/vault /usr/local/bin/vault && rm -f /tmp/vault.zip /tmp/vault /tmp/LICENSE.txt + - make plan + - make apply + backend_options: + kubernetes: + serviceAccountName: terraform-netbox + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/plan.yaml b/.woodpecker/plan.yaml new file mode 100644 index 0000000..4e8d086 --- /dev/null +++ b/.woodpecker/plan.yaml @@ -0,0 +1,22 @@ +when: + - event: pull_request + +steps: + - name: plan + image: git.unkin.net/unkin/almalinux9-opentofu:20260606 + environment: + VAULT_AUTH_METHOD: kubernetes + VAULT_VERSION: "1.20.0" + commands: + - curl -fsSL -o /tmp/vault.zip "https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/hashicorp-releases/vault/$${VAULT_VERSION}/vault_$${VAULT_VERSION}_linux_amd64.zip" && python3 -m zipfile -e /tmp/vault.zip /tmp/ && install -m0755 /tmp/vault /usr/local/bin/vault && rm -f /tmp/vault.zip /tmp/vault /tmp/LICENSE.txt + - make plan + backend_options: + kubernetes: + serviceAccountName: terraform-netbox + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..5c5738f --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-opentofu:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3838764 --- /dev/null +++ b/Makefile @@ -0,0 +1,37 @@ +.PHONY: init plan apply format pre-commit + +VAULT_AUTH_METHOD ?= approle +VAULT_K8S_ROLE ?= woodpecker_terraform_netbox +VAULT_K8S_MOUNT ?= auth/k8s/au/syd1 +VAULT_K8S_JWT_PATH ?= /var/run/secrets/kubernetes.io/serviceaccount/token + +define vault_env + @export VAULT_ADDR="https://vault.service.consul:8200" && \ + if [ "$(VAULT_AUTH_METHOD)" = "kubernetes" ]; then \ + export VAULT_TOKEN=$$(vault write -field=token $(VAULT_K8S_MOUNT)/login role=$(VAULT_K8S_ROLE) jwt=$$(cat $(VAULT_K8S_JWT_PATH))); \ + else \ + export VAULT_TOKEN=$$(vault write -field=token auth/approle/login role_id=$$VAULT_ROLEID); \ + fi && \ + export CONSUL_HTTP_TOKEN=$$(vault read -field=token consul_root/au/syd1/creds/terraform-netbox) +endef + +init: + @$(call vault_env) && \ + terragrunt run --all --non-interactive init -- -upgrade + +plan: init + @$(call vault_env) && \ + terragrunt run --all --parallelism 4 --non-interactive plan + +apply: init + @$(call vault_env) && \ + terragrunt run --all --parallelism 2 --non-interactive apply + +format: + @echo "Formatting OpenTofu files..." + @tofu fmt -recursive . + @echo "Formatting Terragrunt files..." + @terragrunt hcl fmt + +pre-commit: + @uvx pre-commit run --all-files diff --git a/README.md b/README.md index dab319e..41f9663 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,105 @@ # terraform-netbox -YAML-driven Terraform for NetBox: prefixes, IPs, devices, platforms and custom fields \ No newline at end of file +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. + +> 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. + +## 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 +``` + +`make plan` / `make apply` reconcile the YAML against NetBox. State lives in +Consul under `infra/terraform/netbox///state`. + +## Subnet schema (`subnets/.yaml`) + +```yaml +prefix: 198.18.15.0/24 +description: syd1 production +site: syd1 # NetBox site slug (optional) +dhcp: # omit for a NetBox-only prefix + enabled: true + start: 200 # host-octet int + stop: 220 +router: 254 # host-octet int -> gateway IP +``` +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. + +## Device schema (`devices/.yaml`) — intent only + +Serial, real interface names (which vary by model) and inventory are owned by +discovery/PuppetDB, never hardcoded here. + +```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 +``` + +`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. + +`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. + +## 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. + +## Providers + auth + +| Provider | Source | +|----------|--------| +| netbox | `e-breuninger/netbox` 4.3.0 | +| vault | `hashicorp/vault` 5.6.0 | + +The NetBox token is minted per run from the Vault netbox engine +(`netbox/creds/terraform-netbox`) and lease-revoked when the run ends; Consul +state creds come from `consul_root/au/syd1/creds/terraform-netbox`. Both are +granted to the `terraform_netbox` AppRole and the `woodpecker_terraform_netbox` +k8s role in terraform-vault. diff --git a/ci/puppetdb_backfill.py b/ci/puppetdb_backfill.py new file mode 100755 index 0000000..f67e9d0 --- /dev/null +++ b/ci/puppetdb_backfill.py @@ -0,0 +1,114 @@ +#!/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/custom-fields.yaml b/config/au/syd1/custom-fields.yaml new file mode 100644 index 0000000..fd02bf4 --- /dev/null +++ b/config/au/syd1/custom-fields.yaml @@ -0,0 +1,38 @@ +# NetBox custom-field definitions bootapi reads (docs/data-model.md in bootapi). +# Definitions only -- pxe_enabled's VALUE is runtime state owned by bootapi's +# /provisioned callback, so terraform declares the field and never sets it. +pxe_enabled: + type: boolean + label: PXE enabled + description: Gate network install. Unset or true installs; the bootapi provisioned callback sets false so the host boots local disk. + content_types: + - dcim.device + +provision_template: + type: text + label: Provision template + description: Force a bootapi distro-catalog entry (e.g. optiplex-7080). Beats the platform slug. + content_types: + - dcim.device + +domain: + type: text + label: DNS domain + description: Overrides bootapi's BOOTAPI_DOMAIN for this device. + content_types: + - dcim.device + +nameservers: + type: text + label: Nameservers + description: Comma-separated resolvers; overrides bootapi's BOOTAPI_NAMESERVERS. + content_types: + - dcim.device + +gateway: + type: text + label: Gateway + description: Default gateway. Set on an IP address it wins over the device-level value. + content_types: + - dcim.device + - ipam.ipaddress diff --git a/config/au/syd1/devices/prodnxsr0014.yaml b/config/au/syd1/devices/prodnxsr0014.yaml new file mode 100644 index 0000000..ab61131 --- /dev/null +++ b/config/au/syd1/devices/prodnxsr0014.yaml @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..2c0e23c --- /dev/null +++ b/config/au/syd1/devices/prodnxsr0015.yaml @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..da747cd --- /dev/null +++ b/config/au/syd1/devices/prodnxsr0016.yaml @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..ce6baf6 --- /dev/null +++ b/config/au/syd1/devices/prodnxsr0017.yaml @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..5741013 --- /dev/null +++ b/config/au/syd1/devices/prodnxsr0018.yaml @@ -0,0 +1,10 @@ +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 new file mode 100644 index 0000000..ecbdad5 --- /dev/null +++ b/config/au/syd1/devices/prodnxsr0019.yaml @@ -0,0 +1,10 @@ +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/managed-ips.yaml b/config/au/syd1/managed-ips.yaml new file mode 100644 index 0000000..3a6cd9e --- /dev/null +++ b/config/au/syd1/managed-ips.yaml @@ -0,0 +1,5 @@ +# Manually managed extra IP addresses recorded in NetBox. +# Each entry needs a full-CIDR ip and a description, e.g.: +# - ip: 198.18.15.5/24 +# description: legacy appliance +[] diff --git a/config/au/syd1/networks/mgmt.yaml b/config/au/syd1/networks/mgmt.yaml new file mode 100644 index 0000000..a8dc884 --- /dev/null +++ b/config/au/syd1/networks/mgmt.yaml @@ -0,0 +1,4 @@ +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/subnets/drw1-prod.yaml b/config/au/syd1/subnets/drw1-prod.yaml new file mode 100644 index 0000000..871e4e0 --- /dev/null +++ b/config/au/syd1/subnets/drw1-prod.yaml @@ -0,0 +1,8 @@ +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/local.yaml b/config/au/syd1/subnets/local.yaml new file mode 100644 index 0000000..5e95703 --- /dev/null +++ b/config/au/syd1/subnets/local.yaml @@ -0,0 +1,3 @@ +prefix: 198.18.25.0/24 +description: syd1 netbox-only +site: syd1 diff --git a/config/au/syd1/subnets/net-198-18-13.yaml b/config/au/syd1/subnets/net-198-18-13.yaml new file mode 100644 index 0000000..3d839e2 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-13.yaml @@ -0,0 +1,8 @@ +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.yaml b/config/au/syd1/subnets/net-198-18-14.yaml new file mode 100644 index 0000000..7a6971e --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-14.yaml @@ -0,0 +1,8 @@ +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.yaml b/config/au/syd1/subnets/net-198-18-15.yaml new file mode 100644 index 0000000..5ab17a6 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-15.yaml @@ -0,0 +1,8 @@ +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.yaml b/config/au/syd1/subnets/net-198-18-16.yaml new file mode 100644 index 0000000..2125300 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-16.yaml @@ -0,0 +1,8 @@ +prefix: 198.18.16.0/24 +description: syd1 production +site: syd1 +dhcp: + enabled: true + start: 200 + stop: 220 +router: 254 diff --git a/environments/au/syd1/terragrunt.hcl b/environments/au/syd1/terragrunt.hcl new file mode 100644 index 0000000..c32020e --- /dev/null +++ b/environments/au/syd1/terragrunt.hcl @@ -0,0 +1,47 @@ +include "root" { + path = find_in_parent_folders("root.hcl") + expose = true +} + +locals { + parts = split("/", path_relative_to_include()) + region = local.parts[0] + 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 : + 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 : + 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 : + 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)) : {} +} + +terraform { + source = "${get_repo_root()}/modules/netbox" +} + +inputs = { + subnets = local.subnets + networks = local.networks + devices = local.devices + managed_ips = local.managed_ips + custom_fields = local.custom_fields +} diff --git a/environments/root.hcl b/environments/root.hcl new file mode 100644 index 0000000..d30c3f7 --- /dev/null +++ b/environments/root.hcl @@ -0,0 +1,15 @@ +generate "backend" { + path = "backend.tf" + if_exists = "overwrite_terragrunt" + contents = < here, on the device (stable intent) +# 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. +# Setting it from terraform would re-enable installs on +# every apply. +resource "netbox_custom_field" "this" { + for_each = var.custom_fields + + name = each.key + type = each.value.type + content_types = each.value.content_types + 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 new file mode 100644 index 0000000..f9a7027 --- /dev/null +++ b/modules/netbox/devices.tf @@ -0,0 +1,109 @@ +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(data.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 new file mode 100644 index 0000000..5adcb27 --- /dev/null +++ b/modules/netbox/main.tf @@ -0,0 +1,86 @@ +locals { + # Netmask suffix per subnet, e.g. "24". + masks = { for k, v in var.subnets : k => split("/", v.prefix)[1] } + + # Subnets that request a DHCP scope. + dhcp_subnets = { for k, v in var.subnets : k => v if try(v.dhcp.enabled, false) } + + # 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 + ] + } +} + +data "netbox_site" "this" { + for_each = local.sites + 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}" +} + +resource "netbox_prefix" "this" { + for_each = var.subnets + + prefix = each.value.prefix + status = "active" + description = each.value.description + site_id = each.value.site != null ? tonumber(data.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. +resource "netbox_ipam_role" "dhcp" { + name = "dhcp" + slug = "dhcp" +} + +resource "netbox_ip_range" "dhcp" { + for_each = local.dhcp_subnets + + start_address = "${cidrhost(each.value.prefix, each.value.dhcp.start)}/${local.masks[each.key]}" + end_address = "${cidrhost(each.value.prefix, each.value.dhcp.stop)}/${local.masks[each.key]}" + role_id = netbox_ipam_role.dhcp.id + status = "active" + description = "DHCP pool for ${each.key}" +} + +resource "netbox_ip_address" "gateway" { + for_each = local.gateways + + ip_address = "${cidrhost(each.value.prefix, each.value.router)}/${local.masks[each.key]}" + status = "active" + description = "gateway" +} + +resource "netbox_ip_address" "managed" { + for_each = { for m in var.managed_ips : m.ip => m } + + ip_address = each.value.ip + status = "active" + description = each.value.description +} diff --git a/modules/netbox/providers.tf b/modules/netbox/providers.tf new file mode 100644 index 0000000..d740bb5 --- /dev/null +++ b/modules/netbox/providers.tf @@ -0,0 +1,17 @@ +provider "vault" { + address = var.vault_address + # The woodpecker_terraform_netbox role cannot mint child tokens + # (auth/token/create is denied); use the login token directly. + skip_child_token = true +} + +# NetBox API token: minted per run by the vault-plugin-secrets-netbox engine +# (netbox/creds/terraform-netbox), lease-revoked when the run ends. +data "vault_generic_secret" "netbox" { + path = "netbox/creds/terraform-netbox" +} + +provider "netbox" { + server_url = var.netbox_server_url + api_token = data.vault_generic_secret.netbox.data["netbox_token"] +} diff --git a/modules/netbox/variables.tf b/modules/netbox/variables.tf new file mode 100644 index 0000000..b740f16 --- /dev/null +++ b/modules/netbox/variables.tf @@ -0,0 +1,95 @@ +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. + EOT + type = map(object({ + prefix = string + description = optional(string, "") + site = optional(string) + router = optional(number) + dhcp = optional(object({ + enabled = optional(bool, true) + start = number + stop = number + })) + })) + default = {} +} + +variable "networks" { + 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). + EOT + type = map(object({ + subnet = string + gateway = optional(string) + dns = optional(list(string), []) + search = optional(string) + })) + default = {} +} + +variable "devices" { + 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. + EOT + type = map(object({ + site = string + role = string + model_hint = optional(string) + provision = optional(object({ + platform = optional(string) + })) + networks = optional(map(string), {}) + pxe = optional(bool, false) + bootstrap_mac = optional(string) + })) + default = {} +} + +variable "managed_ips" { + description = "Manually managed extra IP addresses (full CIDR form, e.g. 198.18.15.5/24)." + type = list(object({ + ip = string + description = optional(string, "") + })) + default = [] +} + +variable "vault_address" { + description = "Vault server address for the token data source." + type = string + default = "https://vault.service.consul:8200" +} + +variable "netbox_server_url" { + description = "NetBox server base 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 = {} +} diff --git a/modules/netbox/versions.tf b/modules/netbox/versions.tf new file mode 100644 index 0000000..e1214b2 --- /dev/null +++ b/modules/netbox/versions.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.10" + required_providers { + netbox = { + source = "e-breuninger/netbox" + version = "4.3.0" + } + vault = { + source = "hashicorp/vault" + version = "5.6.0" + } + } +} -- 2.47.3 From 4c9bfb0448e744e84be3120ad0c7c3b79b3c80cd Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 15:40:11 +1000 Subject: [PATCH 02/10] Read the minted NetBox credential from the engine's token field The engine returns it as "token"; "netbox_token" does not exist, so the plan failed with Invalid index before reaching NetBox. --- modules/netbox/providers.tf | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/netbox/providers.tf b/modules/netbox/providers.tf index d740bb5..a1c799a 100644 --- a/modules/netbox/providers.tf +++ b/modules/netbox/providers.tf @@ -6,12 +6,14 @@ provider "vault" { } # NetBox API token: minted per run by the vault-plugin-secrets-netbox engine -# (netbox/creds/terraform-netbox), lease-revoked when the run ends. +# (netbox/creds/terraform-netbox), lease-revoked when the run ends. The engine +# returns the credential as "token" (alongside token_scheme/authorization); it is +# a bare nbt_ v2 value, which NetBox accepts under either Authorization keyword. data "vault_generic_secret" "netbox" { path = "netbox/creds/terraform-netbox" } provider "netbox" { server_url = var.netbox_server_url - api_token = data.vault_generic_secret.netbox.data["netbox_token"] + api_token = data.vault_generic_secret.netbox.data["token"] } -- 2.47.3 From 380fcff20c16602e650fd1e381797d09fc335ae3 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 15:41:38 +1000 Subject: [PATCH 03/10] Create NetBox sites rather than requiring them to pre-exist The syd1 site does not exist, so the site data source failed the plan. Sites now follow the same declared-by-reference rule as device roles, types and manufacturers. --- README.md | 4 ++++ modules/netbox/devices.tf | 2 +- modules/netbox/main.tf | 12 +++++++++--- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 41f9663..2c96dff 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,10 @@ ci/puppetdb_backfill.py # reality backfill generator `make plan` / `make apply` reconcile the YAML against NetBox. State lives in Consul under `infra/terraform/netbox///state`. +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. + ## Subnet schema (`subnets/.yaml`) ```yaml diff --git a/modules/netbox/devices.tf b/modules/netbox/devices.tf index f9a7027..0ecb8fa 100644 --- a/modules/netbox/devices.tf +++ b/modules/netbox/devices.tf @@ -52,7 +52,7 @@ resource "netbox_device" "this" { 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(data.netbox_site.this[each.value.site].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) diff --git a/modules/netbox/main.tf b/modules/netbox/main.tf index 5adcb27..30038fc 100644 --- a/modules/netbox/main.tf +++ b/modules/netbox/main.tf @@ -28,9 +28,15 @@ locals { } } -data "netbox_site" "this" { +# 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 +# (NetBox site names are unique). +resource "netbox_site" "this" { for_each = local.sites - slug = each.value + + name = each.value + slug = each.value } # One tag per network so prefixes can be discovered by network membership. @@ -47,7 +53,7 @@ resource "netbox_prefix" "this" { prefix = each.value.prefix status = "active" description = each.value.description - site_id = each.value.site != null ? tonumber(data.netbox_site.this[each.value.site].id) : null + 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] -- 2.47.3 From efeb148be742d3e67a9c53e4d0ab7885923bfcca Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 17:26:43 +1000 Subject: [PATCH 04/10] 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 = {} -} -- 2.47.3 From cead6ab2a095ee3847b7f6cf5bce2fb0399d5360 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 17:33:37 +1000 Subject: [PATCH 05/10] Add the chassis_mac custom field and drop the switch device type Switches are created by hand in the NetBox UI, so the model and its port templates have no consumer. --- README.md | 27 ++++++++---------------- config/au/syd1/custom-fields.yaml | 7 ++++++ config/au/syd1/device-types/sg3429x.yaml | 14 ------------ modules/netbox/catalogue.tf | 24 --------------------- modules/netbox/variables.tf | 14 ++---------- 5 files changed, 18 insertions(+), 68 deletions(-) delete mode 100644 config/au/syd1/device-types/sg3429x.yaml diff --git a/README.md b/README.md index f4a7684..db7294e 100644 --- a/README.md +++ b/README.md @@ -7,12 +7,14 @@ reference data a host is planned against. Device records are not here. | 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 | +| **this repo** | sites, prefixes, DHCP pool ranges, gateways, managed IPs, VLAN groups + VLANs, device types, 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`. +`netbox_device`. The handful of network switches are created by hand in the +NetBox UI; this repo only defines the `chassis_mac` field discovery matches them +on. > 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 @@ -23,7 +25,7 @@ Writing a device from here would fight both of them, so the module declares no ``` config///sites.yaml # site slug -> name/description config///subnets/net-----.yaml -config///device-types/.yaml # manufacturer, model, port templates +config///device-types/.yaml # manufacturer, model config///device-roles.yaml config///platforms.yaml # slug -> display name config///vlans.yaml # keyed per site @@ -64,22 +66,10 @@ repo without re-deriving them. ## Device types (`device-types/.yaml`) -`ports` declares interface templates as ranges, not 28 hand-written entries. -NetBox instantiates them on every device created from the type. - ```yaml -manufacturer: TP-Link -model: Omada SG3429X +manufacturer: Dell +model: OptiPlex 3070 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 ``` ## VLANs (`vlans.yaml`) @@ -115,7 +105,8 @@ interfaces: ## Custom fields (`custom-fields.yaml`) Field **definitions** for what bootapi reads: `pxe_enabled`, -`provision_template`, `domain`, `nameservers`, `gateway`. `content_types` are +`provision_template`, `domain`, `nameservers`, `gateway`; plus `chassis_mac`, +which autodiscovery matches an LLDP neighbour on. `content_types` are NetBox `app_label.model` strings. `pxe_enabled`'s *value* is deliberately not managed here — it is runtime state cleared by bootapi's `/provisioned` callback. diff --git a/config/au/syd1/custom-fields.yaml b/config/au/syd1/custom-fields.yaml index fd02bf4..c92b12d 100644 --- a/config/au/syd1/custom-fields.yaml +++ b/config/au/syd1/custom-fields.yaml @@ -36,3 +36,10 @@ gateway: content_types: - dcim.device - ipam.ipaddress + +chassis_mac: + type: text + label: Chassis MAC + description: LLDP advertises a neighbour's ChassisID MAC but not its serial, so the autodiscovery agent matches an LLDP neighbour to this device by chassis MAC rather than the mutable SysName. + content_types: + - dcim.device diff --git a/config/au/syd1/device-types/sg3429x.yaml b/config/au/syd1/device-types/sg3429x.yaml deleted file mode 100644 index 37d14bd..0000000 --- a/config/au/syd1/device-types/sg3429x.yaml +++ /dev/null @@ -1,14 +0,0 @@ -# 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/modules/netbox/catalogue.tf b/modules/netbox/catalogue.tf index 421d00a..2e16baa 100644 --- a/modules/netbox/catalogue.tf +++ b/modules/netbox/catalogue.tf @@ -1,20 +1,5 @@ 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" { @@ -33,15 +18,6 @@ resource "netbox_device_type" "this" { 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 diff --git a/modules/netbox/variables.tf b/modules/netbox/variables.tf index 1059c1b..012f81a 100644 --- a/modules/netbox/variables.tf +++ b/modules/netbox/variables.tf @@ -35,23 +35,13 @@ variable "subnets" { variable "device_types" { description = <<-EOT - 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. + Device-type catalogue keyed by NetBox slug. Manufacturers are created from + the distinct `manufacturer` values. EOT type = map(object({ 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 = {} } -- 2.47.3 From f72f55566df3cb79a7f92970a34a53e65a380d06 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 18:04:52 +1000 Subject: [PATCH 06/10] Declare the switches and their device type Nothing probes a switch, and its ports must exist in NetBox before discovery can cable a host interface to one. --- README.md | 44 ++++++++++++++++----- config/au/syd1/device-roles.yaml | 2 + config/au/syd1/device-types/sg3428x-m2.yaml | 15 +++++++ config/au/syd1/network-devices.yaml | 16 ++++++++ environments/au/syd1/terragrunt.hcl | 14 ++++--- modules/netbox/catalogue.tf | 39 ++++++++++++++++++ modules/netbox/variables.tf | 33 +++++++++++++++- 7 files changed, 146 insertions(+), 17 deletions(-) create mode 100644 config/au/syd1/device-types/sg3428x-m2.yaml create mode 100644 config/au/syd1/network-devices.yaml diff --git a/README.md b/README.md index db7294e..2f3c238 100644 --- a/README.md +++ b/README.md @@ -7,14 +7,13 @@ reference data a host is planned against. Device records are not here. | Owner | Objects | |-------|---------| -| **this repo** | sites, prefixes, DHCP pool ranges, gateways, managed IPs, VLAN groups + VLANs, device types, manufacturers, device roles, platforms, custom-field definitions, network profiles | -| **autodiscovery agent** | device records, interfaces, MAC addresses, switch cabling | +| **this repo** | sites, prefixes, DHCP pool ranges, gateways, managed IPs, VLAN groups + VLANs, device types + port templates, manufacturers, device roles, platforms, custom-field definitions, network profiles, network equipment (switch device records) | +| **autodiscovery agent** | host device records, host 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`. The handful of network switches are created by hand in the -NetBox UI; this repo only defines the `chassis_mac` field discovery matches them -on. +Writing a *host* device from here would fight both of them, so there is no host +`netbox_device`. Switches are the exception: nothing probes one, and its ports +must exist before discovery can cable a host interface to them. > 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 @@ -25,8 +24,9 @@ on. ``` config///sites.yaml # site slug -> name/description config///subnets/net-----.yaml -config///device-types/.yaml # manufacturer, model +config///device-types/.yaml # manufacturer, model, port templates config///device-roles.yaml +config///network-devices.yaml # switches, keyed by device name config///platforms.yaml # slug -> display name config///vlans.yaml # keyed per site config///network-profiles/.yaml @@ -66,10 +66,36 @@ repo without re-deriving them. ## Device types (`device-types/.yaml`) +`ports` declares interface templates as ranges, not 28 hand-written entries. +NetBox instantiates them on every device created from the type. + ```yaml -manufacturer: Dell -model: OptiPlex 3070 +manufacturer: TP-Link +model: Omada SG3428X-M2 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 +``` + +## Network devices (`network-devices.yaml`) + +Switches only; host records come from autodiscovery. `chassis_mac` is written to +the custom field discovery matches an LLDP neighbour on. + +```yaml +sg3429x-m2-01: + device_type: sg3428x-m2 # device-types/ key + role: network-switch # device-roles.yaml key + site: syd1 + serial: 225C578000245 # optional + chassis_mac: b8:fb:b3:a7:fa:24 # optional ``` ## VLANs (`vlans.yaml`) diff --git a/config/au/syd1/device-roles.yaml b/config/au/syd1/device-roles.yaml index 45d7e94..f6e43e8 100644 --- a/config/au/syd1/device-roles.yaml +++ b/config/au/syd1/device-roles.yaml @@ -1,3 +1,5 @@ # NetBox device roles, keyed by role name. Slug is derived from the name. roles::base: description: Puppet base role +network-switch: + description: Managed ethernet switch diff --git a/config/au/syd1/device-types/sg3428x-m2.yaml b/config/au/syd1/device-types/sg3428x-m2.yaml new file mode 100644 index 0000000..18230a0 --- /dev/null +++ b/config/au/syd1/device-types/sg3428x-m2.yaml @@ -0,0 +1,15 @@ +# TP-Link Omada SG3428X-M2: 24x 2.5G copper + 4x 10G SFP+. +# Hostnames in the estate read "sg3429x"; the hardware reports SG3428X-M2. +# NetBox instantiates these interfaces on every device created from the type. +manufacturer: TP-Link +model: Omada SG3428X-M2 +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/network-devices.yaml b/config/au/syd1/network-devices.yaml new file mode 100644 index 0000000..c7754bb --- /dev/null +++ b/config/au/syd1/network-devices.yaml @@ -0,0 +1,16 @@ +# Network equipment, keyed by device name. Hand-written, not discovered: a +# switch never PXE-boots and nothing probes it, and its ports must exist in +# NetBox before discovery can cable a host interface to one. +sg3429x-m2-01: + device_type: sg3428x-m2 + role: network-switch + site: syd1 + serial: 225C578000245 + chassis_mac: b8:fb:b3:a7:fa:24 + +sg3429x-m2-02: + device_type: sg3428x-m2 + role: network-switch + site: syd1 + serial: 225C578000046 + chassis_mac: b8:fb:b3:a7:f9:5d diff --git a/environments/au/syd1/terragrunt.hcl b/environments/au/syd1/terragrunt.hcl index 1fe6847..444e42e 100644 --- a/environments/au/syd1/terragrunt.hcl +++ b/environments/au/syd1/terragrunt.hcl @@ -24,12 +24,13 @@ locals { trimsuffix(basename(f), ".yaml") => yamldecode(file("${local.cfg_dir}/${f}")) } - 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")) + 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")) + network_devices = yamldecode(file("${local.cfg_dir}/network-devices.yaml")) } terraform { @@ -46,4 +47,5 @@ inputs = { network_profiles = local.network_profiles custom_fields = local.custom_fields managed_ips = local.managed_ips + network_devices = local.network_devices } diff --git a/modules/netbox/catalogue.tf b/modules/netbox/catalogue.tf index 2e16baa..9b01269 100644 --- a/modules/netbox/catalogue.tf +++ b/modules/netbox/catalogue.tf @@ -1,5 +1,20 @@ 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" { @@ -18,6 +33,15 @@ resource "netbox_device_type" "this" { 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 @@ -33,3 +57,18 @@ resource "netbox_platform" "this" { name = each.value slug = each.key } + +# Network equipment is declared here, unlike host devices: nothing probes a +# switch, and its ports must already exist in NetBox for discovery to cable a +# host interface to one. +resource "netbox_device" "network" { + for_each = var.network_devices + + name = each.key + device_type_id = tonumber(netbox_device_type.this[each.value.device_type].id) + role_id = tonumber(netbox_device_role.this[each.value.role].id) + site_id = tonumber(netbox_site.this[each.value.site].id) + serial = each.value.serial + asset_tag = each.value.asset_tag + custom_fields = each.value.chassis_mac != null ? { chassis_mac = lower(each.value.chassis_mac) } : null +} diff --git a/modules/netbox/variables.tf b/modules/netbox/variables.tf index 012f81a..963f35d 100644 --- a/modules/netbox/variables.tf +++ b/modules/netbox/variables.tf @@ -35,13 +35,23 @@ variable "subnets" { variable "device_types" { description = <<-EOT - Device-type catalogue keyed by NetBox slug. Manufacturers are created from - the distinct `manufacturer` values. + 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({ 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 = {} } @@ -128,6 +138,25 @@ variable "custom_fields" { default = {} } +variable "network_devices" { + description = <<-EOT + Network equipment keyed by device name. Switches are declared here rather + than left to autodiscovery: nothing probes one, and its interface templates + must be instantiated in NetBox before discovery can cable a host interface + to a port. `chassis_mac` is published as the chassis_mac custom field so an + LLDP neighbour can be matched without its SysName. + EOT + type = map(object({ + device_type = string + role = string + site = string + serial = optional(string) + asset_tag = optional(string) + chassis_mac = optional(string) + })) + default = {} +} + variable "vault_address" { description = "Vault server address for the token data source." type = string -- 2.47.3 From 17f9c2b7c7885271af518727463f191d8fd63b8a Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 18:06:07 +1000 Subject: [PATCH 07/10] Create the chassis_mac field before the devices that carry it --- modules/netbox/catalogue.tf | 3 +++ 1 file changed, 3 insertions(+) diff --git a/modules/netbox/catalogue.tf b/modules/netbox/catalogue.tf index 9b01269..bb6c21f 100644 --- a/modules/netbox/catalogue.tf +++ b/modules/netbox/catalogue.tf @@ -71,4 +71,7 @@ resource "netbox_device" "network" { serial = each.value.serial asset_tag = each.value.asset_tag custom_fields = each.value.chassis_mac != null ? { chassis_mac = lower(each.value.chassis_mac) } : null + + # chassis_mac must exist as a field before a device can carry a value for it. + depends_on = [netbox_custom_field.this] } -- 2.47.3 From be531705578a1cf614eb36e1aba5489d093dbdfc Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 18:34:40 +1000 Subject: [PATCH 08/10] Derive the site from the config path and split out global objects Stop repeating the site slug inside the YAML: terragrunt passes the dc as site_slug and site.yaml carries only site-wide attributes. Declare device types, roles, platforms, custom fields and the dhcp IPAM role once in config/global via their own unit and state; the per-site unit looks them up by slug or name. --- README.md | 75 +++++++----- config/au/syd1/network-devices.yaml | 2 - .../au/syd1/network-profiles/fabric-dual.yaml | 1 - config/au/syd1/site.yaml | 3 + config/au/syd1/sites.yaml | 4 - .../au/syd1/subnets/net-198-18-13-0-24.yaml | 1 - .../au/syd1/subnets/net-198-18-14-0-24.yaml | 1 - .../au/syd1/subnets/net-198-18-15-0-24.yaml | 1 - .../au/syd1/subnets/net-198-18-16-0-24.yaml | 1 - .../au/syd1/subnets/net-198-18-17-0-24.yaml | 1 - .../au/syd1/subnets/net-198-18-25-0-24.yaml | 1 - config/au/syd1/vlans.yaml | 17 ++- config/{au/syd1 => global}/custom-fields.yaml | 0 config/{au/syd1 => global}/device-roles.yaml | 0 .../device-types/optiplex-3070.yaml | 0 .../device-types/sg3428x-m2.yaml | 0 config/{au/syd1 => global}/platforms.yaml | 0 environments/au/syd1/terragrunt.hcl | 23 ++-- environments/global/terragrunt.hcl | 31 +++++ .../custom_fields.tf | 0 .../catalogue.tf => netbox-global/main.tf} | 23 ++-- modules/netbox-global/providers.tf | 19 ++++ modules/netbox-global/variables.tf | 64 +++++++++++ modules/netbox-global/versions.tf | 13 +++ modules/netbox/devices.tf | 30 +++++ modules/netbox/main.tf | 19 ++-- modules/netbox/networks.tf | 28 ++--- modules/netbox/variables.tf | 107 +++++------------- 28 files changed, 280 insertions(+), 185 deletions(-) create mode 100644 config/au/syd1/site.yaml delete mode 100644 config/au/syd1/sites.yaml rename config/{au/syd1 => global}/custom-fields.yaml (100%) rename config/{au/syd1 => global}/device-roles.yaml (100%) rename config/{au/syd1 => global}/device-types/optiplex-3070.yaml (100%) rename config/{au/syd1 => global}/device-types/sg3428x-m2.yaml (100%) rename config/{au/syd1 => global}/platforms.yaml (100%) create mode 100644 environments/global/terragrunt.hcl rename modules/{netbox => netbox-global}/custom_fields.tf (100%) rename modules/{netbox/catalogue.tf => netbox-global/main.tf} (66%) create mode 100644 modules/netbox-global/providers.tf create mode 100644 modules/netbox-global/variables.tf create mode 100644 modules/netbox-global/versions.tf create mode 100644 modules/netbox/devices.tf diff --git a/README.md b/README.md index 2f3c238..9a421fe 100644 --- a/README.md +++ b/README.md @@ -19,26 +19,50 @@ must exist before discovery can cable a host interface to them. > [kea-operator](https://git.unkin.net/unkin/kea-operator) CRs in argocd-apps > (`apps/base/dhcp-system/cr/`). +## Conventions + +A per-site config path is `config///`, and `` **is** the NetBox +site slug: terragrunt derives it from the directory and passes it in, so the +site is never named again inside the YAML. + +Device types, manufacturers, device roles, platforms, custom-field definitions +and the `dhcp` IPAM role are global objects in NetBox, not site-scoped. They are +declared once in `config/global/` by their own terragrunt unit and state; +per-site units look them up by slug or name. Two per-site units creating them +would collide on name as soon as a second region/dc exists. + ## Layout ``` -config///sites.yaml # site slug -> name/description +config/global/device-types/.yaml # manufacturer, model, port templates +config/global/device-roles.yaml +config/global/platforms.yaml # slug -> display name +config/global/custom-fields.yaml # custom-field DEFINITIONS +config///site.yaml # site-wide attributes; slug is config///subnets/net-----.yaml -config///device-types/.yaml # manufacturer, model, port templates -config///device-roles.yaml -config///network-devices.yaml # switches, keyed by device name -config///platforms.yaml # slug -> display name -config///vlans.yaml # keyed per site +config///vlans.yaml config///network-profiles/.yaml -config///custom-fields.yaml # custom-field DEFINITIONS +config///network-devices.yaml # switches, keyed by device name config///managed-ips.yaml +environments/global/terragrunt.hcl environments///terragrunt.hcl +modules/netbox-global/ modules/netbox/ ``` -`make plan` / `make apply` reconcile the YAML against NetBox. State lives in -Consul under `infra/terraform/netbox///state`. A site that already -exists in NetBox must be imported, not re-created: site names are unique. +`make plan` / `make apply` reconcile the YAML against NetBox across both units. +State lives in Consul under `infra/terraform/netbox/global/state` and +`infra/terraform/netbox///state`. A site that already exists in +NetBox must be imported, not re-created: site names are unique. + +## Site (`site.yaml`) + +Site-wide attributes only. The NetBox site name defaults to the slug; set +`name` only if the display name must differ from it. + +```yaml +description: Sydney region, datacentre 1 +``` ## Subnets (`subnets/net-----.yaml`) @@ -48,7 +72,6 @@ The filename carries the network and mask length; the human label lives in ```yaml prefix: 198.18.15.0/24 description: syd1 production -site: syd1 # NetBox site slug router: 254 # host-octet int -> gateway IP dhcp: # omit for a NetBox-only prefix enabled: true @@ -64,7 +87,7 @@ 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 types (`device-types/.yaml`) +## Device types (`config/global/device-types/.yaml`) `ports` declares interface templates as ranges, not 28 hand-written entries. NetBox instantiates them on every device created from the type. @@ -86,40 +109,38 @@ ports: ## Network devices (`network-devices.yaml`) -Switches only; host records come from autodiscovery. `chassis_mac` is written to -the custom field discovery matches an LLDP neighbour on. +Switches only; host records come from autodiscovery. `device_type` and `role` +name global objects, looked up by slug/name. `chassis_mac` is written to the +custom field discovery matches an LLDP neighbour on. ```yaml sg3429x-m2-01: - device_type: sg3428x-m2 # device-types/ key - role: network-switch # device-roles.yaml key - site: syd1 + device_type: sg3428x-m2 # config/global/device-types/ key + role: network-switch # config/global/device-roles.yaml key serial: 225C578000245 # optional chassis_mac: b8:fb:b3:a7:fa:24 # optional ``` ## VLANs (`vlans.yaml`) -Keyed per site. Each site gets its own site-scoped `netbox_vlan_group`, so the -same VID may exist in more than one site. +The 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 +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 +as a `netbox_config_context` scoped to its site (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 @@ -128,7 +149,7 @@ interfaces: subinterfaces: [] # optional, same shape ``` -## Custom fields (`custom-fields.yaml`) +## Custom fields (`config/global/custom-fields.yaml`) Field **definitions** for what bootapi reads: `pxe_enabled`, `provision_template`, `domain`, `nameservers`, `gateway`; plus `chassis_mac`, diff --git a/config/au/syd1/network-devices.yaml b/config/au/syd1/network-devices.yaml index c7754bb..16e5c06 100644 --- a/config/au/syd1/network-devices.yaml +++ b/config/au/syd1/network-devices.yaml @@ -4,13 +4,11 @@ sg3429x-m2-01: device_type: sg3428x-m2 role: network-switch - site: syd1 serial: 225C578000245 chassis_mac: b8:fb:b3:a7:fa:24 sg3429x-m2-02: device_type: sg3428x-m2 role: network-switch - site: syd1 serial: 225C578000046 chassis_mac: b8:fb:b3:a7:f9:5d diff --git a/config/au/syd1/network-profiles/fabric-dual.yaml b/config/au/syd1/network-profiles/fabric-dual.yaml index 2ccd800..1a783d7 100644 --- a/config/au/syd1/network-profiles/fabric-dual.yaml +++ b/config/au/syd1/network-profiles/fabric-dual.yaml @@ -1,7 +1,6 @@ # 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 diff --git a/config/au/syd1/site.yaml b/config/au/syd1/site.yaml new file mode 100644 index 0000000..57f6d8b --- /dev/null +++ b/config/au/syd1/site.yaml @@ -0,0 +1,3 @@ +# Site-wide attributes. The slug is the config directory (config//) +# and is never repeated here; the NetBox site name defaults to it. +description: Sydney region, datacentre 1 diff --git a/config/au/syd1/sites.yaml b/config/au/syd1/sites.yaml deleted file mode 100644 index 157e7b2..0000000 --- a/config/au/syd1/sites.yaml +++ /dev/null @@ -1,4 +0,0 @@ -# NetBox sites, keyed by slug. -syd1: - name: syd1 - description: Sydney region, datacentre 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 index e1d2970..722d73d 100644 --- a/config/au/syd1/subnets/net-198-18-13-0-24.yaml +++ b/config/au/syd1/subnets/net-198-18-13-0-24.yaml @@ -1,6 +1,5 @@ prefix: 198.18.13.0/24 description: syd1 production -site: syd1 router: 254 dhcp: enabled: true 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 index faca862..ad1e4be 100644 --- a/config/au/syd1/subnets/net-198-18-14-0-24.yaml +++ b/config/au/syd1/subnets/net-198-18-14-0-24.yaml @@ -1,6 +1,5 @@ prefix: 198.18.14.0/24 description: syd1 production -site: syd1 router: 254 dhcp: enabled: true 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 index 3280621..87d6557 100644 --- a/config/au/syd1/subnets/net-198-18-15-0-24.yaml +++ b/config/au/syd1/subnets/net-198-18-15-0-24.yaml @@ -1,6 +1,5 @@ prefix: 198.18.15.0/24 description: syd1 production -site: syd1 router: 254 dhcp: enabled: true 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 index bca23a1..1e321c2 100644 --- a/config/au/syd1/subnets/net-198-18-16-0-24.yaml +++ b/config/au/syd1/subnets/net-198-18-16-0-24.yaml @@ -1,6 +1,5 @@ prefix: 198.18.16.0/24 description: syd1 production -site: syd1 router: 254 dhcp: enabled: true 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 index 2e7c460..a2e9513 100644 --- a/config/au/syd1/subnets/net-198-18-17-0-24.yaml +++ b/config/au/syd1/subnets/net-198-18-17-0-24.yaml @@ -1,6 +1,5 @@ prefix: 198.18.17.0/24 description: drw1-prod -site: syd1 router: 1 dhcp: enabled: true diff --git a/config/au/syd1/subnets/net-198-18-25-0-24.yaml b/config/au/syd1/subnets/net-198-18-25-0-24.yaml index 5e95703..67e0819 100644 --- a/config/au/syd1/subnets/net-198-18-25-0-24.yaml +++ b/config/au/syd1/subnets/net-198-18-25-0-24.yaml @@ -1,3 +1,2 @@ prefix: 198.18.25.0/24 description: syd1 netbox-only -site: syd1 diff --git a/config/au/syd1/vlans.yaml b/config/au/syd1/vlans.yaml index 7370109..1dfd5af 100644 --- a/config/au/syd1/vlans.yaml +++ b/config/au/syd1/vlans.yaml @@ -1,9 +1,8 @@ -# 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 +# The site's VLANs. The site gets one NetBox VLAN group scoped to it, so the +# same VID may exist in more than one site. +vid_ranges: [[1, 4094]] +vlans: + - vid: 101 + name: fabric_common_01 + - vid: 201 + name: fabric_common_02 diff --git a/config/au/syd1/custom-fields.yaml b/config/global/custom-fields.yaml similarity index 100% rename from config/au/syd1/custom-fields.yaml rename to config/global/custom-fields.yaml diff --git a/config/au/syd1/device-roles.yaml b/config/global/device-roles.yaml similarity index 100% rename from config/au/syd1/device-roles.yaml rename to config/global/device-roles.yaml diff --git a/config/au/syd1/device-types/optiplex-3070.yaml b/config/global/device-types/optiplex-3070.yaml similarity index 100% rename from config/au/syd1/device-types/optiplex-3070.yaml rename to config/global/device-types/optiplex-3070.yaml diff --git a/config/au/syd1/device-types/sg3428x-m2.yaml b/config/global/device-types/sg3428x-m2.yaml similarity index 100% rename from config/au/syd1/device-types/sg3428x-m2.yaml rename to config/global/device-types/sg3428x-m2.yaml diff --git a/config/au/syd1/platforms.yaml b/config/global/platforms.yaml similarity index 100% rename from config/au/syd1/platforms.yaml rename to config/global/platforms.yaml diff --git a/environments/au/syd1/terragrunt.hcl b/environments/au/syd1/terragrunt.hcl index 444e42e..7acc667 100644 --- a/environments/au/syd1/terragrunt.hcl +++ b/environments/au/syd1/terragrunt.hcl @@ -3,6 +3,12 @@ include "root" { expose = true } +# Global objects (device types, roles, platforms, custom fields, the dhcp IPAM +# role) must exist before this unit can look them up by slug/name. +dependencies { + paths = ["../../global"] +} + locals { parts = split("/", path_relative_to_include()) region = local.parts[0] @@ -14,21 +20,13 @@ locals { trimsuffix(basename(f), ".yaml") => yamldecode(file("${local.cfg_dir}/${f}")) } - device_types = { - for f in fileset(local.cfg_dir, "device-types/*.yaml") : - trimsuffix(basename(f), ".yaml") => yamldecode(file("${local.cfg_dir}/${f}")) - } - network_profiles = { for f in fileset(local.cfg_dir, "network-profiles/*.yaml") : trimsuffix(basename(f), ".yaml") => yamldecode(file("${local.cfg_dir}/${f}")) } - 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")) + site = yamldecode(file("${local.cfg_dir}/site.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")) network_devices = yamldecode(file("${local.cfg_dir}/network-devices.yaml")) } @@ -38,14 +36,11 @@ terraform { } inputs = { - sites = local.sites + site_slug = local.dc + site = local.site 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 network_devices = local.network_devices } diff --git a/environments/global/terragrunt.hcl b/environments/global/terragrunt.hcl new file mode 100644 index 0000000..7a06d94 --- /dev/null +++ b/environments/global/terragrunt.hcl @@ -0,0 +1,31 @@ +# NetBox objects that are global rather than site-scoped. Own unit, own state: +# a per-site unit creating them would collide on name as soon as a second +# region/dc exists. +include "root" { + path = find_in_parent_folders("root.hcl") + expose = true +} + +locals { + cfg_dir = "${get_repo_root()}/config/global" + + device_types = { + for f in fileset(local.cfg_dir, "device-types/*.yaml") : + trimsuffix(basename(f), ".yaml") => yamldecode(file("${local.cfg_dir}/${f}")) + } + + device_roles = yamldecode(file("${local.cfg_dir}/device-roles.yaml")) + platforms = yamldecode(file("${local.cfg_dir}/platforms.yaml")) + custom_fields = yamldecode(file("${local.cfg_dir}/custom-fields.yaml")) +} + +terraform { + source = "${get_repo_root()}/modules/netbox-global" +} + +inputs = { + device_types = local.device_types + device_roles = local.device_roles + platforms = local.platforms + custom_fields = local.custom_fields +} diff --git a/modules/netbox/custom_fields.tf b/modules/netbox-global/custom_fields.tf similarity index 100% rename from modules/netbox/custom_fields.tf rename to modules/netbox-global/custom_fields.tf diff --git a/modules/netbox/catalogue.tf b/modules/netbox-global/main.tf similarity index 66% rename from modules/netbox/catalogue.tf rename to modules/netbox-global/main.tf index bb6c21f..dbce34c 100644 --- a/modules/netbox/catalogue.tf +++ b/modules/netbox-global/main.tf @@ -1,3 +1,6 @@ +# NetBox objects that are GLOBAL, not site-scoped: a second region/dc would +# collide on name if a per-site unit created them from its own state. They are +# declared once here and looked up by slug/name from the per-site module. locals { manufacturers = toset([for t in var.device_types : t.manufacturer]) @@ -58,20 +61,8 @@ resource "netbox_platform" "this" { slug = each.key } -# Network equipment is declared here, unlike host devices: nothing probes a -# switch, and its ports must already exist in NetBox for discovery to cable a -# host interface to one. -resource "netbox_device" "network" { - for_each = var.network_devices - - name = each.key - device_type_id = tonumber(netbox_device_type.this[each.value.device_type].id) - role_id = tonumber(netbox_device_role.this[each.value.role].id) - site_id = tonumber(netbox_site.this[each.value.site].id) - serial = each.value.serial - asset_tag = each.value.asset_tag - custom_fields = each.value.chassis_mac != null ? { chassis_mac = lower(each.value.chassis_mac) } : null - - # chassis_mac must exist as a field before a device can carry a value for it. - depends_on = [netbox_custom_field.this] +# Role tagging an IP range as DHCP-managed; shared by every site's ranges. +resource "netbox_ipam_role" "dhcp" { + name = "dhcp" + slug = "dhcp" } diff --git a/modules/netbox-global/providers.tf b/modules/netbox-global/providers.tf new file mode 100644 index 0000000..a1c799a --- /dev/null +++ b/modules/netbox-global/providers.tf @@ -0,0 +1,19 @@ +provider "vault" { + address = var.vault_address + # The woodpecker_terraform_netbox role cannot mint child tokens + # (auth/token/create is denied); use the login token directly. + skip_child_token = true +} + +# NetBox API token: minted per run by the vault-plugin-secrets-netbox engine +# (netbox/creds/terraform-netbox), lease-revoked when the run ends. The engine +# returns the credential as "token" (alongside token_scheme/authorization); it is +# a bare nbt_ v2 value, which NetBox accepts under either Authorization keyword. +data "vault_generic_secret" "netbox" { + path = "netbox/creds/terraform-netbox" +} + +provider "netbox" { + server_url = var.netbox_server_url + api_token = data.vault_generic_secret.netbox.data["token"] +} diff --git a/modules/netbox-global/variables.tf b/modules/netbox-global/variables.tf new file mode 100644 index 0000000..bbfce1f --- /dev/null +++ b/modules/netbox-global/variables.tf @@ -0,0 +1,64 @@ +variable "device_types" { + description = <<-EOT + 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({ + 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 "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 "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 + default = "https://vault.service.consul:8200" +} + +variable "netbox_server_url" { + description = "NetBox server base URL." + type = string + default = "https://netbox.k8s.syd1.au.unkin.net" +} diff --git a/modules/netbox-global/versions.tf b/modules/netbox-global/versions.tf new file mode 100644 index 0000000..e1214b2 --- /dev/null +++ b/modules/netbox-global/versions.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.10" + required_providers { + netbox = { + source = "e-breuninger/netbox" + version = "4.3.0" + } + vault = { + source = "hashicorp/vault" + version = "5.6.0" + } + } +} diff --git a/modules/netbox/devices.tf b/modules/netbox/devices.tf new file mode 100644 index 0000000..9fe33a3 --- /dev/null +++ b/modules/netbox/devices.tf @@ -0,0 +1,30 @@ +# Device types and roles are global; look them up rather than create them here. +data "netbox_device_type" "this" { + for_each = toset([for d in var.network_devices : d.device_type]) + + slug = each.value +} + +data "netbox_device_role" "this" { + for_each = toset(concat( + [for d in var.network_devices : d.role], + flatten([for p in var.network_profiles : p.roles]), + )) + + name = each.value +} + +# Network equipment is declared here, unlike host devices: nothing probes a +# switch, and its ports must already exist in NetBox for discovery to cable a +# host interface to one. +resource "netbox_device" "network" { + for_each = var.network_devices + + name = each.key + device_type_id = tonumber(data.netbox_device_type.this[each.value.device_type].id) + role_id = tonumber(data.netbox_device_role.this[each.value.role].id) + site_id = tonumber(netbox_site.this.id) + serial = each.value.serial + asset_tag = each.value.asset_tag + custom_fields = each.value.chassis_mac != null ? { chassis_mac = lower(each.value.chassis_mac) } : null +} diff --git a/modules/netbox/main.tf b/modules/netbox/main.tf index cea0a7f..a3b3659 100644 --- a/modules/netbox/main.tf +++ b/modules/netbox/main.tf @@ -9,14 +9,14 @@ locals { gateways = { for k, v in var.subnets : k => v if v.router != null } } +# The one site this unit owns. Its slug is the config directory +# (config//), never repeated inside the YAML; `name` defaults to it. # 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 = var.sites - - name = each.value.name - slug = each.key - description = each.value.description + name = coalesce(var.site.name, var.site_slug) + slug = var.site_slug + description = var.site.description } resource "netbox_prefix" "this" { @@ -25,13 +25,12 @@ resource "netbox_prefix" "this" { prefix = each.value.prefix status = "active" description = each.value.description - site_id = each.value.site != null ? tonumber(netbox_site.this[each.value.site].id) : null + site_id = tonumber(netbox_site.this.id) } -# Role tagging a range as DHCP-managed; created once and shared by every range. -resource "netbox_ipam_role" "dhcp" { +# Global object, created by the netbox-global unit. +data "netbox_ipam_role" "dhcp" { name = "dhcp" - slug = "dhcp" } resource "netbox_ip_range" "dhcp" { @@ -39,7 +38,7 @@ resource "netbox_ip_range" "dhcp" { start_address = "${cidrhost(each.value.prefix, each.value.dhcp.start)}/${local.masks[each.key]}" end_address = "${cidrhost(each.value.prefix, each.value.dhcp.stop)}/${local.masks[each.key]}" - role_id = netbox_ipam_role.dhcp.id + role_id = data.netbox_ipam_role.dhcp.id status = "active" description = "DHCP pool for ${each.key}" } diff --git a/modules/netbox/networks.tf b/modules/netbox/networks.tf index ee40112..8322996 100644 --- a/modules/netbox/networks.tf +++ b/modules/netbox/networks.tf @@ -1,31 +1,23 @@ 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 } } +# One VLAN group scoped to this site, so the same VID may exist in another site. resource "netbox_vlan_group" "this" { - for_each = var.vlans - - name = "${each.key} vlans" - slug = "${each.key}-vlans" + name = "${var.site_slug} vlans" + slug = "${var.site_slug}-vlans" scope_type = "dcim.site" - scope_id = tonumber(netbox_site.this[each.key].id) - vid_ranges = each.value.vid_ranges + scope_id = tonumber(netbox_site.this.id) + vid_ranges = var.vlans.vid_ranges } resource "netbox_vlan" "this" { - for_each = local.site_vlans + for_each = { for v in var.vlans.vlans : v.vid => v } 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) + site_id = tonumber(netbox_site.this.id) + group_id = tonumber(netbox_vlan_group.this.id) status = "active" description = each.value.description } @@ -38,8 +30,8 @@ resource "netbox_config_context" "network_profile" { 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)] + sites = [tonumber(netbox_site.this.id)] + roles = [for r in each.value.roles : tonumber(data.netbox_device_role.this[r].id)] data = jsonencode({ network_profile = { diff --git a/modules/netbox/variables.tf b/modules/netbox/variables.tf index 963f35d..86c1f9f 100644 --- a/modules/netbox/variables.tf +++ b/modules/netbox/variables.tf @@ -1,25 +1,29 @@ -variable "sites" { - description = "NetBox sites keyed by slug." - type = map(object({ - name = string +variable "site_slug" { + description = "NetBox site slug, derived from the config path (config//); the dc IS the slug." + type = string +} + +variable "site" { + description = "Site-wide attributes. `name` defaults to the slug." + type = object({ + name = optional(string) description = optional(string, "") - })) + }) default = {} } variable "subnets" { description = <<-EOT 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. + net-----). Every subnet belongs to this unit's site. + `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 description = optional(string, "") - site = optional(string) router = optional(number) dhcp = optional(object({ enabled = optional(bool, true) @@ -33,57 +37,19 @@ variable "subnets" { default = {} } -variable "device_types" { - description = <<-EOT - 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({ - 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 "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 - 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. + This site's VLANs. The site gets one NetBox VLAN group scoped to it, so the + same VID may exist in more than one site. EOT - type = map(object({ + type = object({ vid_ranges = optional(list(list(number)), [[1, 4094]]) - vlans = list(object({ + vlans = optional(list(object({ vid = number name = string description = optional(string, "") - })) - })) + })), []) + }) default = {} } @@ -92,12 +58,11 @@ variable "network_profiles" { 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 + config context scoped to this site (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({ @@ -123,33 +88,19 @@ 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 "network_devices" { description = <<-EOT - Network equipment keyed by device name. Switches are declared here rather - than left to autodiscovery: nothing probes one, and its interface templates - must be instantiated in NetBox before discovery can cable a host interface - to a port. `chassis_mac` is published as the chassis_mac custom field so an - LLDP neighbour can be matched without its SysName. + Network equipment keyed by device name, all of it in this unit's site. + Switches are declared here rather than left to autodiscovery: nothing probes + one, and its interface templates must be instantiated in NetBox before + discovery can cable a host interface to a port. `device_type` and `role` + name global objects created by the netbox-global unit. `chassis_mac` is + published as the chassis_mac custom field so an LLDP neighbour can be + matched without its SysName. EOT type = map(object({ device_type = string role = string - site = string serial = optional(string) asset_tag = optional(string) chassis_mac = optional(string) -- 2.47.3 From a3984bf0cdb6f5e9d2480503721d3d3ded865396 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 18:39:03 +1000 Subject: [PATCH 09/10] Take global object ids from the global unit A per-site data-source lookup cannot resolve a global object before the global unit has applied it, which deadlocks the first plan. Pass device-type, device role and dhcp IPAM role ids as dependency outputs instead, mocked for plan. --- README.md | 8 ++++---- environments/au/syd1/terragrunt.hcl | 30 +++++++++++++++++++++++------ modules/netbox-global/outputs.tf | 15 +++++++++++++++ modules/netbox/devices.tf | 23 ++++------------------ modules/netbox/main.tf | 7 +------ modules/netbox/networks.tf | 2 +- modules/netbox/variables.tf | 18 +++++++++++++++++ 7 files changed, 67 insertions(+), 36 deletions(-) create mode 100644 modules/netbox-global/outputs.tf diff --git a/README.md b/README.md index 9a421fe..593f600 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ site is never named again inside the YAML. Device types, manufacturers, device roles, platforms, custom-field definitions and the `dhcp` IPAM role are global objects in NetBox, not site-scoped. They are -declared once in `config/global/` by their own terragrunt unit and state; -per-site units look them up by slug or name. Two per-site units creating them -would collide on name as soon as a second region/dc exists. +declared once in `config/global/` by their own terragrunt unit and state, and +per-site units take their ids from it as a dependency. Two per-site units +creating them would collide on name as soon as a second region/dc exists. ## Layout @@ -110,7 +110,7 @@ ports: ## Network devices (`network-devices.yaml`) Switches only; host records come from autodiscovery. `device_type` and `role` -name global objects, looked up by slug/name. `chassis_mac` is written to the +name global objects, resolved to ids from the global unit. `chassis_mac` is written to the custom field discovery matches an LLDP neighbour on. ```yaml diff --git a/environments/au/syd1/terragrunt.hcl b/environments/au/syd1/terragrunt.hcl index 7acc667..3965ab1 100644 --- a/environments/au/syd1/terragrunt.hcl +++ b/environments/au/syd1/terragrunt.hcl @@ -3,12 +3,6 @@ include "root" { expose = true } -# Global objects (device types, roles, platforms, custom fields, the dhcp IPAM -# role) must exist before this unit can look them up by slug/name. -dependencies { - paths = ["../../global"] -} - locals { parts = split("/", path_relative_to_include()) region = local.parts[0] @@ -29,6 +23,27 @@ locals { vlans = yamldecode(file("${local.cfg_dir}/vlans.yaml")) managed_ips = yamldecode(file("${local.cfg_dir}/managed-ips.yaml")) network_devices = yamldecode(file("${local.cfg_dir}/network-devices.yaml")) + + # Global objects this site refers to, for the dependency's plan-time mocks. + wanted_device_types = distinct([for d in values(local.network_devices) : d.device_type]) + wanted_device_roles = distinct(concat( + [for d in values(local.network_devices) : d.role], + flatten([for p in values(local.network_profiles) : lookup(p, "roles", [])]), + )) +} + +# Device types, device roles and the dhcp IPAM role are global objects owned by +# the netbox-global unit; take their ids from it rather than create them twice. +# Ids are mocked for plan so a fresh estate can be planned before it is applied. +dependency "global" { + config_path = "../../global" + + mock_outputs = { + device_type_ids = { for t in local.wanted_device_types : t => 0 } + device_role_ids = { for r in local.wanted_device_roles : r => 0 } + dhcp_role_id = 0 + } + mock_outputs_allowed_terraform_commands = ["validate", "plan"] } terraform { @@ -37,6 +52,9 @@ terraform { inputs = { site_slug = local.dc + device_type_ids = dependency.global.outputs.device_type_ids + device_role_ids = dependency.global.outputs.device_role_ids + dhcp_role_id = dependency.global.outputs.dhcp_role_id site = local.site subnets = local.subnets vlans = local.vlans diff --git a/modules/netbox-global/outputs.tf b/modules/netbox-global/outputs.tf new file mode 100644 index 0000000..bffaf5c --- /dev/null +++ b/modules/netbox-global/outputs.tf @@ -0,0 +1,15 @@ +# Ids the per-site units need; they must not create these objects themselves. +output "device_type_ids" { + description = "Device-type id by slug." + value = { for k, v in netbox_device_type.this : k => tonumber(v.id) } +} + +output "device_role_ids" { + description = "Device-role id by role name." + value = { for k, v in netbox_device_role.this : k => tonumber(v.id) } +} + +output "dhcp_role_id" { + description = "Id of the dhcp IPAM role shared by every site's ranges." + value = tonumber(netbox_ipam_role.dhcp.id) +} diff --git a/modules/netbox/devices.tf b/modules/netbox/devices.tf index 9fe33a3..d50341e 100644 --- a/modules/netbox/devices.tf +++ b/modules/netbox/devices.tf @@ -1,28 +1,13 @@ -# Device types and roles are global; look them up rather than create them here. -data "netbox_device_type" "this" { - for_each = toset([for d in var.network_devices : d.device_type]) - - slug = each.value -} - -data "netbox_device_role" "this" { - for_each = toset(concat( - [for d in var.network_devices : d.role], - flatten([for p in var.network_profiles : p.roles]), - )) - - name = each.value -} - # Network equipment is declared here, unlike host devices: nothing probes a # switch, and its ports must already exist in NetBox for discovery to cable a -# host interface to one. +# host interface to one. The device type and role are global objects, created +# by the netbox-global unit. resource "netbox_device" "network" { for_each = var.network_devices name = each.key - device_type_id = tonumber(data.netbox_device_type.this[each.value.device_type].id) - role_id = tonumber(data.netbox_device_role.this[each.value.role].id) + device_type_id = var.device_type_ids[each.value.device_type] + role_id = var.device_role_ids[each.value.role] site_id = tonumber(netbox_site.this.id) serial = each.value.serial asset_tag = each.value.asset_tag diff --git a/modules/netbox/main.tf b/modules/netbox/main.tf index a3b3659..6a8ca54 100644 --- a/modules/netbox/main.tf +++ b/modules/netbox/main.tf @@ -28,17 +28,12 @@ resource "netbox_prefix" "this" { site_id = tonumber(netbox_site.this.id) } -# Global object, created by the netbox-global unit. -data "netbox_ipam_role" "dhcp" { - name = "dhcp" -} - resource "netbox_ip_range" "dhcp" { for_each = local.dhcp_subnets start_address = "${cidrhost(each.value.prefix, each.value.dhcp.start)}/${local.masks[each.key]}" end_address = "${cidrhost(each.value.prefix, each.value.dhcp.stop)}/${local.masks[each.key]}" - role_id = data.netbox_ipam_role.dhcp.id + role_id = var.dhcp_role_id status = "active" description = "DHCP pool for ${each.key}" } diff --git a/modules/netbox/networks.tf b/modules/netbox/networks.tf index 8322996..91f3574 100644 --- a/modules/netbox/networks.tf +++ b/modules/netbox/networks.tf @@ -31,7 +31,7 @@ resource "netbox_config_context" "network_profile" { description = each.value.description weight = each.value.weight sites = [tonumber(netbox_site.this.id)] - roles = [for r in each.value.roles : tonumber(data.netbox_device_role.this[r].id)] + roles = [for r in each.value.roles : var.device_role_ids[r]] data = jsonencode({ network_profile = { diff --git a/modules/netbox/variables.tf b/modules/netbox/variables.tf index 86c1f9f..6a1e6a1 100644 --- a/modules/netbox/variables.tf +++ b/modules/netbox/variables.tf @@ -12,6 +12,24 @@ variable "site" { default = {} } +variable "device_type_ids" { + description = "Device-type id by slug, from the netbox-global unit: device types are global objects." + type = map(number) + default = {} +} + +variable "device_role_ids" { + description = "Device-role id by role name, from the netbox-global unit: device roles are global objects." + type = map(number) + default = {} +} + +variable "dhcp_role_id" { + description = "Id of the global dhcp IPAM role, from the netbox-global unit." + type = number + default = null +} + variable "subnets" { description = <<-EOT Map of subnets keyed by name (config file basename, -- 2.47.3 From 25e5dfd9b36a0dc337974354a7e59bac9a52a2c5 Mon Sep 17 00:00:00 2001 From: unkin-agent Date: Sat, 3 Oct 2026 18:40:08 +1000 Subject: [PATCH 10/10] Allow the mocked global ids during init --- environments/au/syd1/terragrunt.hcl | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/environments/au/syd1/terragrunt.hcl b/environments/au/syd1/terragrunt.hcl index 3965ab1..4b38d93 100644 --- a/environments/au/syd1/terragrunt.hcl +++ b/environments/au/syd1/terragrunt.hcl @@ -34,7 +34,8 @@ locals { # Device types, device roles and the dhcp IPAM role are global objects owned by # the netbox-global unit; take their ids from it rather than create them twice. -# Ids are mocked for plan so a fresh estate can be planned before it is applied. +# Ids are mocked until the global unit is applied, so a fresh estate can still +# init and plan. dependency "global" { config_path = "../../global" @@ -43,7 +44,7 @@ dependency "global" { device_role_ids = { for r in local.wanted_device_roles : r => 0 } dhcp_role_id = 0 } - mock_outputs_allowed_terraform_commands = ["validate", "plan"] + mock_outputs_allowed_terraform_commands = ["init", "validate", "plan"] } terraform { -- 2.47.3