10 Commits

Author SHA1 Message Date
unkin-agent 25e5dfd9b3 Allow the mocked global ids during init
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline was successful
2026-10-03 18:40:08 +10:00
unkin-agent a3984bf0cd Take global object ids from the global unit
ci/woodpecker/pr/plan Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
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.
2026-10-03 18:39:03 +10:00
unkin-agent be53170557 Derive the site from the config path and split out global objects
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline failed
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.
2026-10-03 18:34:40 +10:00
unkin-agent 17f9c2b7c7 Create the chassis_mac field before the devices that carry it
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
2026-10-03 18:06:07 +10:00
unkin-agent f72f55566d Declare the switches and their device type
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Nothing probes a switch, and its ports must exist in NetBox before discovery
can cable a host interface to one.
2026-10-03 18:04:52 +10:00
unkin-agent cead6ab2a0 Add the chassis_mac custom field and drop the switch device type
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Switches are created by hand in the NetBox UI, so the model and its port
templates have no consumer.
2026-10-03 17:33:37 +10:00
unkin-agent efeb148be7 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
2026-10-03 17:26:43 +10:00
unkin-agent 380fcff20c Create NetBox sites rather than requiring them to pre-exist
ci/woodpecker/pr/plan Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
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.
2026-10-03 15:41:38 +10:00
unkin-agent 4c9bfb0448 Read the minted NetBox credential from the engine's token field
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline failed
The engine returns it as "token"; "netbox_token" does not exist, so the
plan failed with Invalid index before reaching NetBox.
2026-10-03 15:40:11 +10:00
unkin-agent d7c05b00ac Initial terraform-netbox: NetBox prefixes, devices, platforms, custom fields
ci/woodpecker/pr/plan Pipeline failed
ci/woodpecker/pr/pre-commit Pipeline was successful
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/<region>/<dc>/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.
2026-10-03 15:38:02 +10:00
38 changed files with 1076 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
.terraform/
.terragrunt-cache/
*.tfstate
*.tfstate.*
*.tfplan
tfplan
crash.log
.terraform.lock.hcl
+22
View File
@@ -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",
]
+24
View File
@@ -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
+22
View File
@@ -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
+18
View File
@@ -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
+37
View File
@@ -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
+169 -1
View File
@@ -1,3 +1,171 @@
# terraform-netbox
YAML-driven Terraform for NetBox: prefixes, IPs, devices, platforms and custom fields
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 + 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 *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
> (`apps/base/dhcp-system/cr/`).
## Conventions
A per-site config path is `config/<region>/<dc>/`, and `<dc>` **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, 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
```
config/global/device-types/<slug>.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/<region>/<dc>/site.yaml # site-wide attributes; slug is <dc>
config/<region>/<dc>/subnets/net-<a>-<b>-<c>-<d>-<len>.yaml
config/<region>/<dc>/vlans.yaml
config/<region>/<dc>/network-profiles/<name>.yaml
config/<region>/<dc>/network-devices.yaml # switches, keyed by device name
config/<region>/<dc>/managed-ips.yaml
environments/global/terragrunt.hcl
environments/<region>/<dc>/terragrunt.hcl
modules/netbox-global/
modules/netbox/
```
`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/<region>/<dc>/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-<a>-<b>-<c>-<d>-<len>.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
router: 254 # host-octet int -> gateway IP
dhcp: # omit for a NetBox-only prefix
enabled: true
start: 200 # host-octet int
stop: 220
dns: [198.18.200.7]
domain: main.unkin.net
next_server: 198.18.19.19
```
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 types (`config/global/device-types/<slug>.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 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. `device_type` and `role`
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
sg3429x-m2-01:
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`)
The site gets its own site-scoped `netbox_vlan_group`, so the same VID may
exist in more than one site.
```yaml
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 site (and roles, if given), so the
UI reads profiles from the NetBox API rather than from git.
```yaml
description: Dual-homed fabric host
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 (`config/global/custom-fields.yaml`)
Field **definitions** for what bootapi reads: `pxe_enabled`,
`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.
## 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.
+5
View File
@@ -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
[]
+14
View File
@@ -0,0 +1,14 @@
# 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
serial: 225C578000245
chassis_mac: b8:fb:b3:a7:fa:24
sg3429x-m2-02:
device_type: sg3428x-m2
role: network-switch
serial: 225C578000046
chassis_mac: b8:fb:b3:a7:f9:5d
@@ -0,0 +1,11 @@
# 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
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
+3
View File
@@ -0,0 +1,3 @@
# Site-wide attributes. The slug is the config directory (config/<region>/<dc>)
# and is never repeated here; the NetBox site name defaults to it.
description: Sydney region, datacentre 1
@@ -0,0 +1,11 @@
prefix: 198.18.13.0/24
description: syd1 production
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
@@ -0,0 +1,11 @@
prefix: 198.18.14.0/24
description: syd1 production
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
@@ -0,0 +1,11 @@
prefix: 198.18.15.0/24
description: syd1 production
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
@@ -0,0 +1,11 @@
prefix: 198.18.16.0/24
description: syd1 production
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
@@ -0,0 +1,11 @@
prefix: 198.18.17.0/24
description: drw1-prod
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
@@ -0,0 +1,2 @@
prefix: 198.18.25.0/24
description: syd1 netbox-only
+8
View File
@@ -0,0 +1,8 @@
# 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
+45
View File
@@ -0,0 +1,45 @@
# 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
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
+5
View File
@@ -0,0 +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
@@ -0,0 +1,3 @@
manufacturer: Dell
model: OptiPlex 3070
u_height: 1
@@ -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
+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
+65
View File
@@ -0,0 +1,65 @@
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}"
subnets = {
for f in fileset(local.cfg_dir, "subnets/*.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}"))
}
site = yamldecode(file("${local.cfg_dir}/site.yaml"))
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 until the global unit is applied, so a fresh estate can still
# init and plan.
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 = ["init", "validate", "plan"]
}
terraform {
source = "${get_repo_root()}/modules/netbox"
}
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
network_profiles = local.network_profiles
managed_ips = local.managed_ips
network_devices = local.network_devices
}
+31
View File
@@ -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
}
+15
View File
@@ -0,0 +1,15 @@
generate "backend" {
path = "backend.tf"
if_exists = "overwrite_terragrunt"
contents = <<EOF
terraform {
backend "consul" {
address = "https://consul.service.consul"
path = "infra/terraform/netbox/${path_relative_to_include()}/state"
scheme = "https"
lock = true
ca_file = "/etc/pki/tls/certs/ca-bundle.crt"
}
}
EOF
}
+19
View File
@@ -0,0 +1,19 @@
# 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 -> 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.
# 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
}
+68
View File
@@ -0,0 +1,68 @@
# 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])
# 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
}
# Role tagging an IP range as DHCP-managed; shared by every site's ranges.
resource "netbox_ipam_role" "dhcp" {
name = "dhcp"
slug = "dhcp"
}
+15
View File
@@ -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)
}
+19
View File
@@ -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"]
}
+64
View File
@@ -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"
}
+13
View File
@@ -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"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
# 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. 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 = 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
custom_fields = each.value.chassis_mac != null ? { chassis_mac = lower(each.value.chassis_mac) } : null
}
+55
View File
@@ -0,0 +1,55 @@
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 }
}
# The one site this unit owns. Its slug is the config directory
# (config/<region>/<dc>), 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" {
name = coalesce(var.site.name, var.site_slug)
slug = var.site_slug
description = var.site.description
}
resource "netbox_prefix" "this" {
for_each = var.subnets
prefix = each.value.prefix
status = "active"
description = each.value.description
site_id = tonumber(netbox_site.this.id)
}
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 = var.dhcp_role_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
}
+57
View File
@@ -0,0 +1,57 @@
locals {
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" {
name = "${var.site_slug} vlans"
slug = "${var.site_slug}-vlans"
scope_type = "dcim.site"
scope_id = tonumber(netbox_site.this.id)
vid_ranges = var.vlans.vid_ranges
}
resource "netbox_vlan" "this" {
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.id)
group_id = tonumber(netbox_vlan_group.this.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 = [tonumber(netbox_site.this.id)]
roles = [for r in each.value.roles : var.device_role_ids[r]]
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
}
]
}
]
}
})
}
+19
View File
@@ -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"]
}
+139
View File
@@ -0,0 +1,139 @@
variable "site_slug" {
description = "NetBox site slug, derived from the config path (config/<region>/<dc>); 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 "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,
net-<a>-<b>-<c>-<d>-<len>). 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, "")
router = optional(number)
dhcp = optional(object({
enabled = optional(bool, true)
start = number
stop = number
}))
dns = optional(list(string), [])
domain = optional(string)
next_server = optional(string)
}))
default = {}
}
variable "vlans" {
description = <<-EOT
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 = object({
vid_ranges = optional(list(list(number)), [[1, 4094]])
vlans = optional(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 this site (and `roles`, if given) so the UI reads
profiles from the NetBox API.
EOT
type = map(object({
description = optional(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)
})), [])
}))
}))
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 "network_devices" {
description = <<-EOT
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
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
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"
}
+13
View File
@@ -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"
}
}
}