Rename terraform-ipam -> terraform-infra; add devices + networks + puppetdb backfill
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/plan Pipeline failed

Scope now spans devices and provisioning, not just IPAM.

- rename module/consul-path/role ipam -> infra
- networks config (subnet binding + gateway/dns/search constants); prefixes tagged net:<name>
- intent-only devices module: netbox_device + device_type/role/manufacturer, static or
  next-available IPs (sticky via ignore_changes), transitional bootstrap_mac interface for
  bootapi PXE keying
- seed 6 pending hosts prodnxsr0014-0019 (mgmt IPs .14-.19, optiplex-3070)
- ci/puppetdb_backfill.py: emit NetBox reality (serial/model/uuid/interfaces) for existing hosts

Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
This commit is contained in:
2026-08-05 00:51:06 +10:00
parent bfbe42f97e
commit 9a6f775300
20 changed files with 482 additions and 102 deletions
+99
View File
@@ -0,0 +1,99 @@
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(data.netbox_site.this[each.value.site].id)
status = each.value.pxe ? "staged" : "active"
}
# 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]
}
}
+23 -2
View File
@@ -8,8 +8,18 @@ locals {
# Subnets that declare a gateway.
gateways = { for k, v in var.subnets : k => v if v.router != null }
# Distinct NetBox site slugs referenced by any subnet.
sites = toset([for v in var.subnets : v.site if v.site != 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
]
}
}
data "netbox_site" "this" {
@@ -17,6 +27,14 @@ data "netbox_site" "this" {
slug = each.value
}
# One tag per network so prefixes can be discovered by network membership.
resource "netbox_tag" "network" {
for_each = var.networks
name = "net:${each.key}"
slug = "net-${each.key}"
}
resource "netbox_prefix" "this" {
for_each = var.subnets
@@ -24,6 +42,9 @@ resource "netbox_prefix" "this" {
status = "active"
description = each.value.description
site_id = each.value.site != null ? tonumber(data.netbox_site.this[each.value.site].id) : null
tags = [for t in local.prefix_net_tags[each.key] : t]
depends_on = [netbox_tag.network]
}
# Role tagging a range as DHCP-managed; created once and shared by every range.
+85
View File
@@ -0,0 +1,85 @@
variable "subnets" {
description = "Map of subnets keyed by name (config file basename)."
type = map(object({
prefix = string
description = optional(string, "")
site = optional(string)
router = optional(number)
dns = optional(list(string), [])
next_server = optional(string)
domain = optional(string)
dhcp = optional(object({
enabled = optional(bool, true)
start = number
stop = number
}))
}))
default = {}
}
variable "networks" {
description = <<-EOT
Named logical networks devices join, keyed by network name. `subnet` binds the
network to a subnet (its prefix is tagged net:<name> and used for device IP
allocation). gateway/dns/search are per-network provisioning constants (carried
for downstream consumers; not per-device).
EOT
type = map(object({
subnet = string
gateway = optional(string)
dns = optional(list(string), [])
search = optional(string)
}))
default = {}
}
variable "devices" {
description = <<-EOT
Intent-only device declarations keyed by device name. Reality (serial, real
interface names, MACs) is owned by discovery/PuppetDB, not this file.
`networks` maps a network name to a requested IP in CIDR form, or "" to
allocate the next-available from the network's prefix. `bootstrap_mac` is
transitional: it seeds a placeholder interface so bootapi can key the PXE boot
on MAC until the discovery image exists.
EOT
type = map(object({
site = string
role = string
model_hint = optional(string)
provision = optional(object({
profile = optional(string)
platform = optional(string)
}))
networks = optional(map(string), {})
pxe = optional(bool, false)
bootstrap_mac = optional(string)
}))
default = {}
}
variable "managed_ips" {
description = "Manually managed extra IP addresses (full CIDR form, e.g. 198.18.15.5/24)."
type = list(object({
ip = string
description = optional(string, "")
}))
default = []
}
variable "vault_address" {
description = "Vault server address for the token data source."
type = string
default = "https://vault.service.consul:8200"
}
variable "netbox_server_url" {
description = "NetBox server base URL."
type = string
default = "https://netbox.k8s.syd1.au.unkin.net"
}
variable "kea_endpoint" {
description = "KeaAPI base URL (in-cluster ClusterIP service)."
type = string
default = "http://kea-api.dhcp-system.svc:8080"
}
-45
View File
@@ -1,45 +0,0 @@
variable "subnets" {
description = "Map of subnets keyed by name (config file basename)."
type = map(object({
prefix = string
description = optional(string, "")
site = optional(string)
router = optional(number)
dns = optional(list(string), [])
next_server = optional(string)
domain = optional(string)
dhcp = optional(object({
enabled = optional(bool, true)
start = number
stop = number
}))
}))
default = {}
}
variable "managed_ips" {
description = "Manually managed extra IP addresses (full CIDR form, e.g. 198.18.15.5/24)."
type = list(object({
ip = string
description = optional(string, "")
}))
default = []
}
variable "vault_address" {
description = "Vault server address for the token data source."
type = string
default = "https://vault.service.consul:8200"
}
variable "netbox_server_url" {
description = "NetBox server base URL."
type = string
default = "https://netbox.k8s.syd1.au.unkin.net"
}
variable "kea_endpoint" {
description = "KeaAPI base URL (in-cluster ClusterIP service)."
type = string
default = "http://kea-api.dhcp-system.svc:8080"
}