Recut the module to the NetBox catalogue only
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful

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:<name> 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-<a>-<b>-<c>-<d>-<len> and restore dns/domain/next_server
This commit is contained in:
2026-10-03 17:26:43 +10:00
parent 380fcff20c
commit efeb148be7
34 changed files with 443 additions and 495 deletions
+84 -60
View File
@@ -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/<region>/<dc>/subnets/<name>.yaml # one file per subnet (NetBox prefix + pool range)
config/<region>/<dc>/networks/<name>.yaml # logical networks devices join
config/<region>/<dc>/devices/<host>.yaml # one file per device (intent only)
config/<region>/<dc>/custom-fields.yaml # custom-field DEFINITIONS
config/<region>/<dc>/managed-ips.yaml # extra manually-managed IPs
environments/<region>/<dc>/terragrunt.hcl # one Terragrunt env per region/dc
modules/netbox/ # the module
ci/puppetdb_backfill.py # reality backfill generator
config/<region>/<dc>/sites.yaml # site slug -> name/description
config/<region>/<dc>/subnets/net-<a>-<b>-<c>-<d>-<len>.yaml
config/<region>/<dc>/device-types/<slug>.yaml # manufacturer, model, port templates
config/<region>/<dc>/device-roles.yaml
config/<region>/<dc>/platforms.yaml # slug -> display name
config/<region>/<dc>/vlans.yaml # keyed per site
config/<region>/<dc>/network-profiles/<name>.yaml
config/<region>/<dc>/custom-fields.yaml # custom-field DEFINITIONS
config/<region>/<dc>/managed-ips.yaml
environments/<region>/<dc>/terragrunt.hcl
modules/netbox/
```
`make plan` / `make apply` reconcile the YAML against NetBox. State lives in
Consul under `infra/terraform/netbox/<region>/<dc>/state`.
Consul under `infra/terraform/netbox/<region>/<dc>/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-<a>-<b>-<c>-<d>-<len>.yaml`)
## Subnet schema (`subnets/<name>.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:<name>`.
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/<host>.yaml`) — intent only
## Device types (`device-types/<slug>.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/<name>.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
-114
View File
@@ -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 <host>.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()
+3
View File
@@ -0,0 +1,3 @@
# NetBox device roles, keyed by role name. Slug is derived from the name.
roles::base:
description: Puppet base role
@@ -0,0 +1,3 @@
manufacturer: Dell
model: OptiPlex 3070
u_height: 1
+14
View File
@@ -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
-10
View File
@@ -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
-10
View File
@@ -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
-10
View File
@@ -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
-10
View File
@@ -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
-10
View File
@@ -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
-10
View File
@@ -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
@@ -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
-4
View File
@@ -1,4 +0,0 @@
subnet: net-198-18-15
gateway: 198.18.15.254
dns: [198.18.19.16]
search: main.unkin.net
+3
View File
@@ -0,0 +1,3 @@
# NetBox platforms, slug -> display name. A device's platform slug is bootapi's
# primary distro-catalog selector.
almalinux9: almalinux9
+4
View File
@@ -0,0 +1,4 @@
# NetBox sites, keyed by slug.
syd1:
name: syd1
description: Sydney region, datacentre 1
-8
View File
@@ -1,8 +0,0 @@
prefix: 198.18.17.0/24
description: drw1-prod
site: syd1
dhcp:
enabled: true
start: 200
stop: 220
router: 1
@@ -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
@@ -1,8 +0,0 @@
prefix: 198.18.13.0/24
description: syd1 production
site: syd1
dhcp:
enabled: true
start: 200
stop: 220
router: 254
@@ -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
@@ -1,8 +0,0 @@
prefix: 198.18.14.0/24
description: syd1 production
site: syd1
dhcp:
enabled: true
start: 200
stop: 220
router: 254
@@ -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
@@ -1,8 +0,0 @@
prefix: 198.18.15.0/24
description: syd1 production
site: syd1
dhcp:
enabled: true
start: 200
stop: 220
router: 254
@@ -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
@@ -1,8 +0,0 @@
prefix: 198.18.16.0/24
description: syd1 production
site: syd1
dhcp:
enabled: true
start: 200
stop: 220
router: 254
@@ -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
+9
View File
@@ -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
+20 -18
View File
@@ -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
}
+59
View File
@@ -0,0 +1,59 @@
locals {
manufacturers = toset([for t in var.device_types : t.manufacturer])
# One interface template per port, flattened to "<type slug>:<name>".
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
}
+3 -11
View File
@@ -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
}
-109
View File
@@ -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>" -> {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]
}
}
+5 -36
View File
@@ -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:<name> 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.
+65
View File
@@ -0,0 +1,65 @@
locals {
# "<site>:<vid>" -> {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
}
]
}
]
}
})
}
+99 -43
View File
@@ -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-<a>-<b>-<c>-<d>-<len>). `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:<name> 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 = {}
}