Initial terraform-ipam: NetBox IPAM + Kea DHCP from per-subnet YAML
Manage NetBox prefixes and Kea DHCP scopes together from a single subnet definition under config/<region>/<dc>/subnets/<name>.yaml. - modules/ipam: netbox_prefix + netbox_ip_range (dhcp role) + gateway/managed netbox_ip_address + kea_subnet; tokens read from Vault KV via the vault provider - environments/au/syd1 terragrunt env, Consul state backend - config seed: five DHCP subnets (198.18.13-17) + local netbox-only 198.18.25.0/24 - .woodpecker pre-commit+plan on PR, apply on main; Makefile Vault auth pattern Claude-Session: https://claude.ai/code/session_01JUoARVdmhxKQHyyyp1pxeT
This commit is contained in:
@@ -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,23 @@
|
||||
when:
|
||||
- event: push
|
||||
branch: main
|
||||
|
||||
steps:
|
||||
- name: apply
|
||||
image: git.unkin.net/unkin/almalinux9-opentofu:20260606
|
||||
environment:
|
||||
VAULT_AUTH_METHOD: kubernetes
|
||||
commands:
|
||||
- dnf install vault -y
|
||||
- make plan
|
||||
- make apply
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: terraform-ipam
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
@@ -0,0 +1,21 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
steps:
|
||||
- name: plan
|
||||
image: git.unkin.net/unkin/almalinux9-opentofu:20260606
|
||||
environment:
|
||||
VAULT_AUTH_METHOD: kubernetes
|
||||
commands:
|
||||
- dnf install vault -y
|
||||
- make plan
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: terraform-ipam
|
||||
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_ipam
|
||||
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-ipam)
|
||||
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,81 @@
|
||||
# terraform-infra
|
||||
# terraform-ipam
|
||||
|
||||
YAML-driven Terraform for NetBox IPAM, networks, and devices + Kea DHCP
|
||||
YAML-driven Terraform/Terragrunt for IP address management: it manages NetBox
|
||||
IPAM prefixes and Kea DHCP scopes together from a single subnet definition.
|
||||
|
||||
Defining a subnet creates the NetBox prefix, optionally a DHCP scope in Kea (via
|
||||
the `kea` provider against the KeaAPI), marks the DHCP range in NetBox, and
|
||||
records the gateway.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
config/<region>/<dc>/subnets/<name>.yaml # one file per subnet, filename = subnet name
|
||||
config/<region>/<dc>/managed-ips.yaml # extra manually-managed IPs
|
||||
environments/<region>/<dc>/terragrunt.hcl # one Terragrunt env per region/dc
|
||||
modules/ipam/ # the subnet module
|
||||
```
|
||||
|
||||
`make plan` / `make apply` (via Terragrunt) reconcile the YAML against NetBox +
|
||||
Kea. State lives in Consul under `infra/terraform/ipam/<region>/<dc>/state`.
|
||||
|
||||
## Subnet schema (`config/<region>/<dc>/subnets/<name>.yaml`)
|
||||
|
||||
```yaml
|
||||
prefix: 198.18.15.0/24
|
||||
description: syd1 production
|
||||
site: syd1 # NetBox site slug (optional; resolved to site_id)
|
||||
dhcp: # omit the whole block for a NetBox-only prefix
|
||||
enabled: true
|
||||
start: 200 # host-octet int; pool/range start
|
||||
stop: 220 # host-octet int; pool/range stop
|
||||
router: 254 # host-octet int; recorded as the gateway IP
|
||||
dns: [198.18.200.7]
|
||||
next_server: 198.18.19.19
|
||||
domain: main.unkin.net
|
||||
```
|
||||
|
||||
Resource mapping per subnet:
|
||||
|
||||
| YAML | Resource |
|
||||
|---------------------|-----------------------------------------------------------------|
|
||||
| `prefix` | `netbox_prefix` (`status=active`, `description`, `site_id`) |
|
||||
| `dhcp.start/stop` | `netbox_ip_range` (role `dhcp`) + `kea_subnet` pool |
|
||||
| `router` | `netbox_ip_address` `<router>/mask` description `gateway` |
|
||||
| `dns/next_server/domain` | `kea_subnet` dns_servers / next_server / domain_name |
|
||||
|
||||
Host-octet ints are expanded against the prefix with `cidrhost()`. A subnet with
|
||||
no `dhcp` block is NetBox-only (e.g. `local.yaml` = 198.18.25.0/24 — excluded
|
||||
from Kea). The `dhcp` role is a single shared `netbox_ipam_role`. PXE boot files
|
||||
are handled by Kea cluster client-classes, not here.
|
||||
|
||||
## Managed IPs (`config/<region>/<dc>/managed-ips.yaml`)
|
||||
|
||||
A list of `{ip, description}` (ip in full CIDR form) rendered as `netbox_ip_address`.
|
||||
|
||||
## Providers
|
||||
|
||||
| Provider | Source | Notes |
|
||||
|----------|--------|-------|
|
||||
| netbox | `e-breuninger/netbox` 4.3.0 | community provider from the OpenTofu registry |
|
||||
| kea | `artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/kea` 0.0.1 | KeaAPI; `cluster_ref=kea` |
|
||||
| vault | `hashicorp/vault` 5.6.0 | reads the KeaAPI + NetBox tokens |
|
||||
|
||||
Endpoints (module variable defaults): NetBox `https://netbox.k8s.syd1.au.unkin.net`,
|
||||
KeaAPI `http://kea-api.dhcp-system.svc:8080` (ClusterIP, in-cluster only).
|
||||
|
||||
Tokens are read at plan time from Vault KV v2 `kv/service/terraform/ipam` with
|
||||
fields `netbox_token` and `kea_token` (the vault provider authenticates with the
|
||||
CI `VAULT_TOKEN`).
|
||||
|
||||
## Deployment status / blockers
|
||||
|
||||
- **NetBox is not deployed** on the cluster yet (only on the unmerged
|
||||
`benvin/netbox` argocd-apps branch; host `netbox.k8s.syd1.au.unkin.net`). Until
|
||||
it is live and reachable, `plan`/`apply` against NetBox will fail.
|
||||
- **KeaAPI is not deployed** yet (only on the unmerged `benvin/kea-deployment`
|
||||
argocd-apps branch; `KeaAPI` CR `kea-api`, ns `dhcp-system`, ClusterIP `:8080`).
|
||||
- **Seed `kv/service/terraform/ipam`** in Vault with `netbox_token` +
|
||||
`kea_token` before the pipeline can authenticate to either API.
|
||||
- **Confirm the `kea` provider 0.0.1** is published to the `terraform-unkin`
|
||||
ArtifactAPI registry (release the provider repo if not).
|
||||
|
||||
@@ -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,11 @@
|
||||
prefix: 198.18.17.0/24
|
||||
description: drw1-prod
|
||||
site: syd1
|
||||
dhcp:
|
||||
enabled: true
|
||||
start: 200
|
||||
stop: 220
|
||||
router: 1
|
||||
dns: [198.18.200.7]
|
||||
next_server: 198.18.19.19
|
||||
domain: main.unkin.net
|
||||
@@ -0,0 +1,3 @@
|
||||
prefix: 198.18.25.0/24
|
||||
description: syd1 netbox-only
|
||||
site: syd1
|
||||
@@ -0,0 +1,11 @@
|
||||
prefix: 198.18.13.0/24
|
||||
description: syd1 production
|
||||
site: syd1
|
||||
dhcp:
|
||||
enabled: true
|
||||
start: 200
|
||||
stop: 220
|
||||
router: 254
|
||||
dns: [198.18.200.7]
|
||||
next_server: 198.18.19.19
|
||||
domain: main.unkin.net
|
||||
@@ -0,0 +1,11 @@
|
||||
prefix: 198.18.14.0/24
|
||||
description: syd1 production
|
||||
site: syd1
|
||||
dhcp:
|
||||
enabled: true
|
||||
start: 200
|
||||
stop: 220
|
||||
router: 254
|
||||
dns: [198.18.200.7]
|
||||
next_server: 198.18.19.19
|
||||
domain: main.unkin.net
|
||||
@@ -0,0 +1,11 @@
|
||||
prefix: 198.18.15.0/24
|
||||
description: syd1 production
|
||||
site: syd1
|
||||
dhcp:
|
||||
enabled: true
|
||||
start: 200
|
||||
stop: 220
|
||||
router: 254
|
||||
dns: [198.18.200.7]
|
||||
next_server: 198.18.19.19
|
||||
domain: main.unkin.net
|
||||
@@ -0,0 +1,11 @@
|
||||
prefix: 198.18.16.0/24
|
||||
description: syd1 production
|
||||
site: syd1
|
||||
dhcp:
|
||||
enabled: true
|
||||
start: 200
|
||||
stop: 220
|
||||
router: 254
|
||||
dns: [198.18.200.7]
|
||||
next_server: 198.18.19.19
|
||||
domain: main.unkin.net
|
||||
@@ -0,0 +1,29 @@
|
||||
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}"
|
||||
|
||||
subnet_files = fileset(local.cfg_dir, "subnets/*.yaml")
|
||||
subnets = {
|
||||
for f in local.subnet_files :
|
||||
trimsuffix(basename(f), ".yaml") => yamldecode(file("${local.cfg_dir}/${f}"))
|
||||
}
|
||||
|
||||
managed_ips_file = "${local.cfg_dir}/managed-ips.yaml"
|
||||
managed_ips = fileexists(local.managed_ips_file) ? yamldecode(file(local.managed_ips_file)) : []
|
||||
}
|
||||
|
||||
terraform {
|
||||
source = "${get_repo_root()}/modules/ipam"
|
||||
}
|
||||
|
||||
inputs = {
|
||||
subnets = local.subnets
|
||||
managed_ips = local.managed_ips
|
||||
}
|
||||
@@ -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/ipam/${path_relative_to_include()}/state"
|
||||
scheme = "https"
|
||||
lock = true
|
||||
ca_file = "/etc/pki/tls/certs/ca-bundle.crt"
|
||||
}
|
||||
}
|
||||
EOF
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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 }
|
||||
|
||||
# Distinct NetBox site slugs referenced by any subnet.
|
||||
sites = toset([for v in var.subnets : v.site if v.site != null])
|
||||
}
|
||||
|
||||
data "netbox_site" "this" {
|
||||
for_each = local.sites
|
||||
slug = each.value
|
||||
}
|
||||
|
||||
resource "netbox_prefix" "this" {
|
||||
for_each = var.subnets
|
||||
|
||||
prefix = each.value.prefix
|
||||
status = "active"
|
||||
description = each.value.description
|
||||
site_id = each.value.site != null ? tonumber(data.netbox_site.this[each.value.site].id) : null
|
||||
}
|
||||
|
||||
# Role tagging a range as DHCP-managed; created once and shared by every range.
|
||||
resource "netbox_ipam_role" "dhcp" {
|
||||
name = "dhcp"
|
||||
slug = "dhcp"
|
||||
}
|
||||
|
||||
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 = netbox_ipam_role.dhcp.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
|
||||
}
|
||||
|
||||
resource "kea_subnet" "this" {
|
||||
for_each = local.dhcp_subnets
|
||||
|
||||
name = each.key
|
||||
cluster_ref = "kea"
|
||||
subnet = each.value.prefix
|
||||
pools = ["${cidrhost(each.value.prefix, each.value.dhcp.start)}-${cidrhost(each.value.prefix, each.value.dhcp.stop)}"]
|
||||
routers = each.value.router != null ? [cidrhost(each.value.prefix, each.value.router)] : null
|
||||
dns_servers = each.value.dns
|
||||
next_server = each.value.next_server
|
||||
domain_name = each.value.domain
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
provider "vault" {
|
||||
address = var.vault_address
|
||||
}
|
||||
|
||||
# KeaAPI and NetBox tokens live in a single Vault KV v2 secret. The vault
|
||||
# provider authenticates with the VAULT_TOKEN set by the Makefile.
|
||||
data "vault_kv_secret_v2" "tokens" {
|
||||
mount = "kv"
|
||||
name = "service/terraform/ipam"
|
||||
}
|
||||
|
||||
provider "netbox" {
|
||||
server_url = var.netbox_server_url
|
||||
api_token = data.vault_kv_secret_v2.tokens.data["netbox_token"]
|
||||
}
|
||||
|
||||
provider "kea" {
|
||||
endpoint = var.kea_endpoint
|
||||
token = data.vault_kv_secret_v2.tokens.data["kea_token"]
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
terraform {
|
||||
required_version = ">= 1.10"
|
||||
required_providers {
|
||||
netbox = {
|
||||
source = "e-breuninger/netbox"
|
||||
version = "4.3.0"
|
||||
}
|
||||
kea = {
|
||||
source = "artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/kea"
|
||||
version = "0.0.1"
|
||||
}
|
||||
vault = {
|
||||
source = "hashicorp/vault"
|
||||
version = "5.6.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user