diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4c26d7a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.terraform/ +.terragrunt-cache/ +*.tfstate +*.tfstate.* +*.tfplan +tfplan +crash.log +.terraform.lock.hcl diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..0439d93 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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", + ] diff --git a/.woodpecker/apply.yaml b/.woodpecker/apply.yaml new file mode 100644 index 0000000..d05cf07 --- /dev/null +++ b/.woodpecker/apply.yaml @@ -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 diff --git a/.woodpecker/plan.yaml b/.woodpecker/plan.yaml new file mode 100644 index 0000000..4e8d086 --- /dev/null +++ b/.woodpecker/plan.yaml @@ -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 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..5c5738f --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..3838764 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index dab319e..593f600 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,171 @@ # terraform-netbox -YAML-driven Terraform for NetBox: prefixes, IPs, devices, platforms and custom fields \ No newline at end of file +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///`, and `` **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/.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///site.yaml # site-wide attributes; slug is +config///subnets/net-----.yaml +config///vlans.yaml +config///network-profiles/.yaml +config///network-devices.yaml # switches, keyed by device name +config///managed-ips.yaml +environments/global/terragrunt.hcl +environments///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///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-----.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/.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/.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. diff --git a/config/au/syd1/managed-ips.yaml b/config/au/syd1/managed-ips.yaml new file mode 100644 index 0000000..3a6cd9e --- /dev/null +++ b/config/au/syd1/managed-ips.yaml @@ -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 +[] diff --git a/config/au/syd1/network-devices.yaml b/config/au/syd1/network-devices.yaml new file mode 100644 index 0000000..16e5c06 --- /dev/null +++ b/config/au/syd1/network-devices.yaml @@ -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 diff --git a/config/au/syd1/network-profiles/fabric-dual.yaml b/config/au/syd1/network-profiles/fabric-dual.yaml new file mode 100644 index 0000000..1a783d7 --- /dev/null +++ b/config/au/syd1/network-profiles/fabric-dual.yaml @@ -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 diff --git a/config/au/syd1/site.yaml b/config/au/syd1/site.yaml new file mode 100644 index 0000000..57f6d8b --- /dev/null +++ b/config/au/syd1/site.yaml @@ -0,0 +1,3 @@ +# Site-wide attributes. The slug is the config directory (config//) +# and is never repeated here; the NetBox site name defaults to it. +description: Sydney region, datacentre 1 diff --git a/config/au/syd1/subnets/net-198-18-13-0-24.yaml b/config/au/syd1/subnets/net-198-18-13-0-24.yaml new file mode 100644 index 0000000..722d73d --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-13-0-24.yaml @@ -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 diff --git a/config/au/syd1/subnets/net-198-18-14-0-24.yaml b/config/au/syd1/subnets/net-198-18-14-0-24.yaml new file mode 100644 index 0000000..ad1e4be --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-14-0-24.yaml @@ -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 diff --git a/config/au/syd1/subnets/net-198-18-15-0-24.yaml b/config/au/syd1/subnets/net-198-18-15-0-24.yaml new file mode 100644 index 0000000..87d6557 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-15-0-24.yaml @@ -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 diff --git a/config/au/syd1/subnets/net-198-18-16-0-24.yaml b/config/au/syd1/subnets/net-198-18-16-0-24.yaml new file mode 100644 index 0000000..1e321c2 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-16-0-24.yaml @@ -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 diff --git a/config/au/syd1/subnets/net-198-18-17-0-24.yaml b/config/au/syd1/subnets/net-198-18-17-0-24.yaml new file mode 100644 index 0000000..a2e9513 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-17-0-24.yaml @@ -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 diff --git a/config/au/syd1/subnets/net-198-18-25-0-24.yaml b/config/au/syd1/subnets/net-198-18-25-0-24.yaml new file mode 100644 index 0000000..67e0819 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-25-0-24.yaml @@ -0,0 +1,2 @@ +prefix: 198.18.25.0/24 +description: syd1 netbox-only diff --git a/config/au/syd1/vlans.yaml b/config/au/syd1/vlans.yaml new file mode 100644 index 0000000..1dfd5af --- /dev/null +++ b/config/au/syd1/vlans.yaml @@ -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 diff --git a/config/global/custom-fields.yaml b/config/global/custom-fields.yaml new file mode 100644 index 0000000..c92b12d --- /dev/null +++ b/config/global/custom-fields.yaml @@ -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 diff --git a/config/global/device-roles.yaml b/config/global/device-roles.yaml new file mode 100644 index 0000000..f6e43e8 --- /dev/null +++ b/config/global/device-roles.yaml @@ -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 diff --git a/config/global/device-types/optiplex-3070.yaml b/config/global/device-types/optiplex-3070.yaml new file mode 100644 index 0000000..e700437 --- /dev/null +++ b/config/global/device-types/optiplex-3070.yaml @@ -0,0 +1,3 @@ +manufacturer: Dell +model: OptiPlex 3070 +u_height: 1 diff --git a/config/global/device-types/sg3428x-m2.yaml b/config/global/device-types/sg3428x-m2.yaml new file mode 100644 index 0000000..18230a0 --- /dev/null +++ b/config/global/device-types/sg3428x-m2.yaml @@ -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 diff --git a/config/global/platforms.yaml b/config/global/platforms.yaml new file mode 100644 index 0000000..e5023a2 --- /dev/null +++ b/config/global/platforms.yaml @@ -0,0 +1,3 @@ +# NetBox platforms, slug -> display name. A device's platform slug is bootapi's +# primary distro-catalog selector. +almalinux9: almalinux9 diff --git a/environments/au/syd1/terragrunt.hcl b/environments/au/syd1/terragrunt.hcl new file mode 100644 index 0000000..4b38d93 --- /dev/null +++ b/environments/au/syd1/terragrunt.hcl @@ -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 +} diff --git a/environments/global/terragrunt.hcl b/environments/global/terragrunt.hcl new file mode 100644 index 0000000..7a06d94 --- /dev/null +++ b/environments/global/terragrunt.hcl @@ -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 +} diff --git a/environments/root.hcl b/environments/root.hcl new file mode 100644 index 0000000..d30c3f7 --- /dev/null +++ b/environments/root.hcl @@ -0,0 +1,15 @@ +generate "backend" { + path = "backend.tf" + if_exists = "overwrite_terragrunt" + contents = < 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 +} diff --git a/modules/netbox-global/main.tf b/modules/netbox-global/main.tf new file mode 100644 index 0000000..dbce34c --- /dev/null +++ b/modules/netbox-global/main.tf @@ -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 ":". + 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" +} diff --git a/modules/netbox-global/outputs.tf b/modules/netbox-global/outputs.tf new file mode 100644 index 0000000..bffaf5c --- /dev/null +++ b/modules/netbox-global/outputs.tf @@ -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) +} diff --git a/modules/netbox-global/providers.tf b/modules/netbox-global/providers.tf new file mode 100644 index 0000000..a1c799a --- /dev/null +++ b/modules/netbox-global/providers.tf @@ -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"] +} diff --git a/modules/netbox-global/variables.tf b/modules/netbox-global/variables.tf new file mode 100644 index 0000000..bbfce1f --- /dev/null +++ b/modules/netbox-global/variables.tf @@ -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" +} diff --git a/modules/netbox-global/versions.tf b/modules/netbox-global/versions.tf new file mode 100644 index 0000000..e1214b2 --- /dev/null +++ b/modules/netbox-global/versions.tf @@ -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" + } + } +} diff --git a/modules/netbox/devices.tf b/modules/netbox/devices.tf new file mode 100644 index 0000000..d50341e --- /dev/null +++ b/modules/netbox/devices.tf @@ -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 +} diff --git a/modules/netbox/main.tf b/modules/netbox/main.tf new file mode 100644 index 0000000..6a8ca54 --- /dev/null +++ b/modules/netbox/main.tf @@ -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//), 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 +} diff --git a/modules/netbox/networks.tf b/modules/netbox/networks.tf new file mode 100644 index 0000000..91f3574 --- /dev/null +++ b/modules/netbox/networks.tf @@ -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 + } + ] + } + ] + } + }) +} diff --git a/modules/netbox/providers.tf b/modules/netbox/providers.tf new file mode 100644 index 0000000..a1c799a --- /dev/null +++ b/modules/netbox/providers.tf @@ -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"] +} diff --git a/modules/netbox/variables.tf b/modules/netbox/variables.tf new file mode 100644 index 0000000..6a1e6a1 --- /dev/null +++ b/modules/netbox/variables.tf @@ -0,0 +1,139 @@ +variable "site_slug" { + description = "NetBox site slug, derived from the config path (config//); 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-----). 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" +} diff --git a/modules/netbox/versions.tf b/modules/netbox/versions.tf new file mode 100644 index 0000000..e1214b2 --- /dev/null +++ b/modules/netbox/versions.tf @@ -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" + } + } +}