Establish clean homelab infrastructure baseline
Reorganize the brownfield repository, remove retired and generated artifacts, harden ignore rules, and record the GitOps/IaC redesign.
This commit is contained in:
@@ -0,0 +1,245 @@
|
||||
# NetBox object graph, driven by topology.yml.
|
||||
#
|
||||
# WHY yamldecode rather than HCL resources per object: topology.yml stays the readable,
|
||||
# authoritative artifact (git -> NetBox, see ../CONTEXT.md §4), and Terraform supplies what
|
||||
# a plain script could not — state, `plan` as a drift report, and DELETION. Removing an
|
||||
# entry from the YAML now removes the object from NetBox, which the previous seed script
|
||||
# never did.
|
||||
#
|
||||
# Ownership boundary, matching ../../../infrastructure/openbao/terraform: Terraform owns API-level
|
||||
# configuration. The k8s manifests that RUN NetBox live one level up in ../.
|
||||
|
||||
locals {
|
||||
topo = yamldecode(file("${path.module}/topology.yml"))
|
||||
|
||||
# --- flattened lookup maps -------------------------------------------------
|
||||
# Interfaces are nested under devices/VMs in the YAML; Terraform needs flat maps keyed
|
||||
# by a stable string. "<parent>:<iface>" is that key everywhere below.
|
||||
device_ifaces = merge([
|
||||
for d in local.topo.devices : {
|
||||
for i in d.interfaces : "${d.name}:${i.name}" => merge(i, { device = d.name })
|
||||
}
|
||||
]...)
|
||||
|
||||
vm_ifaces = merge([
|
||||
for v in local.topo.virtual_machines : {
|
||||
for i in v.interfaces : "${v.name}:${i.name}" => merge(i, { vm = v.name })
|
||||
}
|
||||
]...)
|
||||
|
||||
# Only interfaces that actually carry an address.
|
||||
device_ips = { for k, i in local.device_ifaces : k => i if try(i.ip, null) != null }
|
||||
vm_ips = { for k, i in local.vm_ifaces : k => i if try(i.ip, null) != null }
|
||||
|
||||
# The single address that becomes the parent's primary_ip4.
|
||||
device_primary = { for k, i in local.device_ips : i.device => k if try(i.primary, false) }
|
||||
vm_primary = { for k, i in local.vm_ips : i.vm => k if try(i.primary, false) }
|
||||
|
||||
device_macs = { for k, i in local.device_ifaces : k => i if try(i.mac, null) != null }
|
||||
|
||||
inventory_items = merge([
|
||||
for d in local.topo.devices : {
|
||||
for it in try(d.inventory_items, []) : "${d.name}:${it.name}" => merge(it, { device = d.name })
|
||||
}
|
||||
]...)
|
||||
}
|
||||
|
||||
# --- site + IPAM ---------------------------------------------------------------
|
||||
resource "netbox_site" "this" {
|
||||
name = local.topo.site.name
|
||||
slug = local.topo.site.slug
|
||||
description = local.topo.site.description
|
||||
status = "active"
|
||||
}
|
||||
|
||||
resource "netbox_ipam_role" "this" {
|
||||
for_each = { for r in local.topo.prefix_roles : r.slug => r }
|
||||
name = each.value.name
|
||||
slug = each.value.slug
|
||||
}
|
||||
|
||||
resource "netbox_vlan_group" "this" {
|
||||
name = local.topo.vlan_group.name
|
||||
slug = local.topo.vlan_group.slug
|
||||
description = local.topo.vlan_group.description
|
||||
# Required by the provider. The SDN zone is a plain VLAN zone on vmbr0, which is
|
||||
# bridge-vlan-aware for the full range, so do not narrow this without changing that.
|
||||
vid_ranges = [[1, 4094]]
|
||||
}
|
||||
|
||||
resource "netbox_vlan" "this" {
|
||||
for_each = { for v in local.topo.vlans : tostring(v.vid) => v }
|
||||
vid = each.value.vid
|
||||
name = each.value.name
|
||||
group_id = netbox_vlan_group.this.id
|
||||
site_id = netbox_site.this.id
|
||||
status = "active"
|
||||
}
|
||||
|
||||
resource "netbox_prefix" "this" {
|
||||
for_each = { for p in local.topo.prefixes : p.prefix => p }
|
||||
prefix = each.value.prefix
|
||||
status = "active"
|
||||
# The provider exposes plain `site_id` and handles NetBox 4.2+'s generic
|
||||
# scope_type/scope_id internally — which is exactly the trap that broke the hand-rolled
|
||||
# script (posting `site` was silently dropped). Using the provider avoids it.
|
||||
site_id = netbox_site.this.id
|
||||
role_id = netbox_ipam_role.this[each.value.role].id
|
||||
vlan_id = try(netbox_vlan.this[tostring(each.value.vlan)].id, null)
|
||||
description = each.value.description
|
||||
}
|
||||
|
||||
resource "netbox_ip_range" "this" {
|
||||
for_each = { for r in local.topo.ip_ranges : "${r.start}-${r.end}" => r }
|
||||
start_address = each.value.start
|
||||
end_address = each.value.end
|
||||
status = each.value.status
|
||||
mark_utilized = try(each.value.mark_utilized, false)
|
||||
description = each.value.description
|
||||
}
|
||||
|
||||
# --- Wi-Fi ---------------------------------------------------------------------
|
||||
# ⚠ PARTIAL: the provider can create the SSIDs but has NO attribute for attaching them to
|
||||
# a radio interface, and none for `rf_role`. Neither does the netbox.netbox Ansible
|
||||
# collection. That last mile is done by ./attach-wireless.py — see ../README.md.
|
||||
resource "netbox_wireless_lan" "this" {
|
||||
for_each = { for w in local.topo.wireless_lans : w.ssid => w }
|
||||
ssid = each.value.ssid
|
||||
auth_type = each.value.auth_type
|
||||
auth_cipher = each.value.auth_cipher
|
||||
description = each.value.description
|
||||
# auth_psk deliberately unset: OpenBao is the secrets store, not NetBox.
|
||||
}
|
||||
|
||||
# --- hardware ------------------------------------------------------------------
|
||||
resource "netbox_manufacturer" "this" {
|
||||
for_each = { for m in local.topo.manufacturers : m.slug => m }
|
||||
name = each.value.name
|
||||
slug = each.value.slug
|
||||
}
|
||||
|
||||
resource "netbox_device_type" "this" {
|
||||
for_each = { for d in local.topo.device_types : d.slug => d }
|
||||
model = each.value.model
|
||||
slug = each.value.slug
|
||||
manufacturer_id = netbox_manufacturer.this[each.value.manufacturer].id
|
||||
# Same reason as vm_role above: NetBox's default is true, so pin it or every plan wants
|
||||
# to clear it. Meaningless for this hardware (nothing is rack-mounted) but stops churn.
|
||||
is_full_depth = true
|
||||
}
|
||||
|
||||
resource "netbox_device_role" "this" {
|
||||
for_each = { for r in local.topo.device_roles : r.slug => r }
|
||||
name = each.value.name
|
||||
slug = each.value.slug
|
||||
color_hex = each.value.color
|
||||
# NetBox defaults this to true; the provider defaults it to false, so without pinning it
|
||||
# every plan shows a spurious vm_role true -> false diff.
|
||||
vm_role = true
|
||||
}
|
||||
|
||||
resource "netbox_device" "this" {
|
||||
for_each = { for d in local.topo.devices : d.name => d }
|
||||
name = each.value.name
|
||||
site_id = netbox_site.this.id
|
||||
role_id = netbox_device_role.this[each.value.role].id
|
||||
device_type_id = netbox_device_type.this[each.value.type].id
|
||||
description = each.value.description
|
||||
comments = try(each.value.comments, "")
|
||||
serial = try(each.value.serial, "")
|
||||
status = try(each.value.status, "active")
|
||||
}
|
||||
|
||||
resource "netbox_device_interface" "this" {
|
||||
for_each = local.device_ifaces
|
||||
device_id = netbox_device.this[each.value.device].id
|
||||
name = each.value.name
|
||||
type = each.value.type
|
||||
description = try(each.value.description, "")
|
||||
mtu = try(each.value.mtu, null)
|
||||
}
|
||||
|
||||
resource "netbox_inventory_item" "this" {
|
||||
for_each = local.inventory_items
|
||||
device_id = netbox_device.this[each.value.device].id
|
||||
name = each.value.name
|
||||
manufacturer_id = netbox_manufacturer.this[each.value.manufacturer].id
|
||||
part_id = try(each.value.part_id, "")
|
||||
serial = try(each.value.serial, "")
|
||||
description = try(each.value.description, "")
|
||||
}
|
||||
|
||||
# MACs are first-class objects in NetBox 4.2+; `mac_address` on the interface is read-only.
|
||||
resource "netbox_mac_address" "this" {
|
||||
for_each = local.device_macs
|
||||
mac_address = upper(each.value.mac)
|
||||
device_interface_id = netbox_device_interface.this[each.key].id
|
||||
}
|
||||
|
||||
resource "netbox_device_interface_primary_mac_address" "this" {
|
||||
for_each = local.device_macs
|
||||
interface_id = netbox_device_interface.this[each.key].id
|
||||
mac_address_id = netbox_mac_address.this[each.key].id
|
||||
}
|
||||
|
||||
# --- virtualization ------------------------------------------------------------
|
||||
resource "netbox_cluster_type" "this" {
|
||||
for_each = { for c in local.topo.cluster_types : c.slug => c }
|
||||
name = each.value.name
|
||||
slug = each.value.slug
|
||||
}
|
||||
|
||||
resource "netbox_cluster" "this" {
|
||||
for_each = { for c in local.topo.clusters : c.name => c }
|
||||
name = each.value.name
|
||||
cluster_type_id = netbox_cluster_type.this[each.value.type].id
|
||||
description = each.value.description
|
||||
site_id = netbox_site.this.id
|
||||
}
|
||||
|
||||
resource "netbox_virtual_machine" "this" {
|
||||
for_each = { for v in local.topo.virtual_machines : v.name => v }
|
||||
name = each.value.name
|
||||
cluster_id = netbox_cluster.this[each.value.cluster].id
|
||||
description = each.value.description
|
||||
# NetBox DERIVES a VM's site from its cluster. Leaving this unset makes the provider
|
||||
# try to clear it on every plan (site_id 1 -> None), so declare it to match.
|
||||
site_id = netbox_site.this.id
|
||||
}
|
||||
|
||||
resource "netbox_interface" "this" {
|
||||
for_each = local.vm_ifaces
|
||||
virtual_machine_id = netbox_virtual_machine.this[each.value.vm].id
|
||||
name = each.value.name
|
||||
description = try(each.value.description, "")
|
||||
}
|
||||
|
||||
# --- addresses -----------------------------------------------------------------
|
||||
resource "netbox_ip_address" "device" {
|
||||
for_each = local.device_ips
|
||||
ip_address = each.value.ip
|
||||
status = "active"
|
||||
# No `object_type` here: the provider pairs that with the GENERIC `interface_id`
|
||||
# ("all of interface_id,object_type must be specified"). The dedicated
|
||||
# *_interface_id attributes are standalone and imply the type.
|
||||
device_interface_id = netbox_device_interface.this[each.key].id
|
||||
# Native NetBox field. Setting it is INTENT: "this host needs a static A record in AD
|
||||
# DNS". Domain-joined hosts self-register and are deliberately absent.
|
||||
# ../generate/samba-a-records.py turns these into samba_ad_extra_a_records.
|
||||
dns_name = try(each.value.dns_name, "")
|
||||
}
|
||||
|
||||
resource "netbox_ip_address" "vm" {
|
||||
for_each = local.vm_ips
|
||||
ip_address = each.value.ip
|
||||
status = "active"
|
||||
virtual_machine_interface_id = netbox_interface.this[each.key].id
|
||||
dns_name = try(each.value.dns_name, "")
|
||||
}
|
||||
|
||||
# primary_ip4 lives on the parent, so the provider models it as its own resource.
|
||||
resource "netbox_device_primary_ip" "this" {
|
||||
for_each = local.device_primary
|
||||
device_id = netbox_device.this[each.key].id
|
||||
ip_address_id = netbox_ip_address.device[each.value].id
|
||||
}
|
||||
Reference in New Issue
Block a user