Derive the site from the config path and split out global objects #1

Open
unkin-agent wants to merge 10 commits from benvin/initial-netbox into main
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"
}
}
}