Derive the site from the config path and split out global objects #1
@@ -0,0 +1,8 @@
|
||||
.terraform/
|
||||
.terragrunt-cache/
|
||||
*.tfstate
|
||||
*.tfstate.*
|
||||
*.tfplan
|
||||
tfplan
|
||||
crash.log
|
||||
.terraform.lock.hcl
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
[]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# NetBox platforms, slug -> display name. A device's platform slug is bootapi's
|
||||
# primary distro-catalog selector.
|
||||
almalinux9: almalinux9
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user