Initial terraform-infra: NetBox IPAM + networks + devices + Kea DHCP #5

Open
unkinben wants to merge 4 commits from benvin/initial-infra into main
29 changed files with 860 additions and 1 deletions
+8
View File
@@ -0,0 +1,8 @@
.terraform/
.terragrunt-cache/
*.tfstate
*.tfstate.*
*.tfplan
tfplan
crash.log
.terraform.lock.hcl
+22
View File
@@ -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",
]
+23
View File
@@ -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-infra
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+21
View File
@@ -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-infra
resources:
requests:
memory: 512Mi
cpu: 1
limits:
memory: 2Gi
cpu: 2
+18
View File
@@ -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
+37
View File
@@ -0,0 +1,37 @@
.PHONY: init plan apply format pre-commit
VAULT_AUTH_METHOD ?= approle
VAULT_K8S_ROLE ?= woodpecker_terraform_infra
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-infra)
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
+101 -1
View File
@@ -1,3 +1,103 @@
# terraform-infra
YAML-driven Terraform for NetBox IPAM, networks, and devices + Kea DHCP
YAML-driven Terraform/Terragrunt for infrastructure state that belongs in NetBox +
Kea: subnets (NetBox prefixes + Kea DHCP scopes), logical networks, and physical
devices. **NetBox is authoritative for all IP/interface data**; this repo declares
intent and lets discovery/PuppetDB fill hardware reality.
> Renamed from `terraform-ipam` — scope now spans devices and provisioning, not just
> IPAM. The old repo name is archived by the terraform-git rename.
## Layout
```
config/<region>/<dc>/subnets/<name>.yaml # one file per subnet (prefix + DHCP scope)
config/<region>/<dc>/networks/<name>.yaml # logical networks devices join
config/<region>/<dc>/devices/<host>.yaml # one file per device (intent only)
config/<region>/<dc>/managed-ips.yaml # extra manually-managed IPs
environments/<region>/<dc>/terragrunt.hcl # one Terragrunt env per region/dc
modules/infra/ # the module
ci/puppetdb_backfill.py # reality backfill generator (local/in-cluster)
```
`make plan` / `make apply` reconcile the YAML against NetBox + Kea. State lives in
Consul under `infra/terraform/infra/<region>/<dc>/state`.
## Subnet schema (`subnets/<name>.yaml`)
```yaml
prefix: 198.18.15.0/24
description: syd1 production
site: syd1 # NetBox site slug (optional)
dhcp: # omit the block for a NetBox-only prefix
enabled: true
start: 200 # host-octet int
stop: 220
router: 254 # host-octet int -> gateway IP
dns: [198.18.200.7] # DHCP-scope DNS
next_server: 198.18.19.19
domain: main.unkin.net
```
Creates `netbox_prefix` (+ `netbox_ip_range` role `dhcp` + `kea_subnet` when `dhcp`
set, + gateway `netbox_ip_address`). A prefix backing a network is tagged `net:<name>`.
## Network schema (`networks/<name>.yaml`)
Per-network provisioning constants — hoisted here, never repeated per device.
```yaml
subnet: net-198-18-15 # which subnet's prefix backs this network
gateway: 198.18.15.254
dns: [198.18.19.16]
search: main.unkin.net
```
## Device schema (`devices/<host>.yaml`) — intent only
Declare only human intent. Serial, real interface names (which vary by model —
OptiPlex 3060 = `enp1s0`, 3050/7080 = `enp2s0`), MACs and inventory are owned by the
first-boot discovery image / PuppetDB, never hardcoded here.
```yaml
site: syd1
role: roles::base # puppet mgmt_class / ENC role -> NetBox device role
model_hint: optiplex-3070 # NetBox device type; discovery confirms via dmidecode
provision:
profile: almalinux9-dell_3070 # cobbler profile / kickstart template
platform: almalinux9
networks:
mgmt: 198.18.15.14/24 # requested IP; "" = next-available from the prefix
pxe: true
bootstrap_mac: a4:bb:6d:xx:xx:xx # TRANSITIONAL — see below
```
Mapping: `netbox_device` (+ `netbox_manufacturer`/`netbox_device_type`/
`netbox_device_role`), `netbox_ip_address` (static) or `netbox_available_ip_address`
(next-available, `ignore_changes` so a machine is never re-IPed). `bootstrap_mac` is
**transitional**: it seeds one placeholder `netbox_device_interface` + `netbox_mac_address`
so bootapi can key the PXE boot on MAC until the discovery image exists — remove it
once discovery/backfill populates the real NICs.
## PuppetDB reality backfill
`ci/puppetdb_backfill.py` sweeps already-provisioned hosts and emits their hardware
reality (serial/model/UUID + every interface's MAC/IPs, incl. overlay/loopback
addresses Cobbler lacks) as reviewable YAML — the NetBox *reality* side, not the
intent above. PuppetDB needs no auth but is **not reachable from CI**; run it
in-cluster/from puppet infra. Wiring its output into NetBox is a follow-up (issue).
## Providers
| Provider | Source | Notes |
|----------|--------|-------|
| netbox | `e-breuninger/netbox` 4.3.0 | OpenTofu registry |
| kea | `artifactapi.k8s.syd1.au.unkin.net/terraform-unkin/kea` 0.0.1 | `cluster_ref=kea` |
| vault | `hashicorp/vault` 5.6.0 | reads NetBox + Kea tokens |
Tokens: Vault KV v2 `kv/service/terraform/infra` fields `netbox_token` + `kea_token`.
## Blockers
- **NetBox not deployed** yet (unmerged `benvin/netbox` argocd branch); NetBox sites
must pre-exist. **KeaAPI not deployed** yet (argocd-apps PR #333). Seed
`kv/service/terraform/infra` before the pipeline can auth.
+114
View File
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Backfill NetBox device *reality* from PuppetDB for already-provisioned hosts.
This emits the hardware-owned facts a running host reports (serial, model, UUID,
and every interface's name/MAC/IPs including the overlay/loopback addresses
Cobbler never had) as reviewable per-host YAML. It targets the *reality* side of
NetBox (interfaces + inventory) for the ~13 existing physicals; it is NOT the
intent `config/.../devices/*.yaml` (a human writes those).
PuppetDB needs no auth (TLS terminates at the gateway; upstream is plain HTTP).
It is NOT reachable from CI, so run this in-cluster or from puppet infra:
./puppetdb_backfill.py --out ../reality \
--url http://puppetdb.puppet.svc.cluster.local:8080/pdb/query/v4/facts \
prodnxsr0001 prodnxsr0002 ...
Fact paths follow Facter conventions (networking.*, dmi.*); verify against a live
factset (`GET /pdb/query/v4/factsets`) before trusting output on a new estate.
"""
import argparse
import json
import os
import sys
import urllib.parse
import urllib.request
DEFAULT_URL = "http://puppetdb.puppet.svc.cluster.local:8080/pdb/query/v4/facts"
def query_facts(url, certname):
ast = json.dumps(["=", "certname", certname])
full = url + "?" + urllib.parse.urlencode({"query": ast})
with urllib.request.urlopen(full, timeout=30) as resp:
rows = json.load(resp)
return {row["name"]: row["value"] for row in rows}
def device_reality(certname, facts):
networking = facts.get("networking", {}) or {}
ifaces = networking.get("interfaces", {}) or {}
dmi = facts.get("dmi", {}) or {}
product = dmi.get("product", {}) or {}
interfaces = []
for name, data in sorted(ifaces.items()):
if name == "lo":
continue
ips = []
for b in (data.get("bindings", []) or []):
if b.get("address"):
ips.append(b["address"])
if data.get("ip") and data["ip"] not in ips:
ips.append(data["ip"])
interfaces.append({
"name": name,
"mac": data.get("mac"),
"ips": ips,
})
return {
"device": certname.split(".")[0],
"serial": product.get("serial_number") or facts.get("serialnumber"),
"model": product.get("name") or facts.get("productname"),
"uuid": product.get("uuid") or facts.get("uuid"),
"interfaces": interfaces,
}
def to_yaml(d):
# tiny dependency-free YAML emitter for this fixed shape
out = [
f"device: {d['device']}",
f"serial: {d['serial'] or ''}",
f"model: {d['model'] or ''}",
f"uuid: {d['uuid'] or ''}",
"interfaces:",
]
for i in d["interfaces"]:
out.append(f" - name: {i['name']}")
out.append(f" mac: {i['mac'] or ''}")
out.append(" ips: [%s]" % ", ".join(i["ips"]))
return "\n".join(out) + "\n"
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("hosts", nargs="+", help="certnames or short hostnames")
ap.add_argument("--url", default=os.environ.get("PUPPETDB_URL", DEFAULT_URL))
ap.add_argument("--out", help="write <host>.yaml here instead of stdout")
args = ap.parse_args()
for host in args.hosts:
certname = host if "." in host else f"{host}.main.unkin.net"
try:
facts = query_facts(args.url, certname)
except Exception as e: # noqa: BLE001
print(f"# {certname}: query failed: {e}", file=sys.stderr)
continue
if not facts:
print(f"# {certname}: no facts (not in PuppetDB)", file=sys.stderr)
continue
text = to_yaml(device_reality(certname, facts))
if args.out:
os.makedirs(args.out, exist_ok=True)
path = os.path.join(args.out, f"{certname.split('.')[0]}.yaml")
with open(path, "w") as fh:
fh.write(text)
print(f"wrote {path}", file=sys.stderr)
else:
print(text)
if __name__ == "__main__":
main()
+11
View File
@@ -0,0 +1,11 @@
site: syd1
role: roles::base
model_hint: optiplex-3070
provision:
profile: almalinux9-dell_3070
platform: almalinux9
networks:
mgmt: 198.18.15.14/24
pxe: true
# transitional: removed once the discovery image / puppetdb backfill populates real NIC MACs
bootstrap_mac: a4:bb:6d:a4:e5:c1
+11
View File
@@ -0,0 +1,11 @@
site: syd1
role: roles::base
model_hint: optiplex-3070
provision:
profile: almalinux9-dell_3070
platform: almalinux9
networks:
mgmt: 198.18.15.15/24
pxe: true
# transitional: removed once the discovery image / puppetdb backfill populates real NIC MACs
bootstrap_mac: a4:bb:6d:a6:30:c4
+11
View File
@@ -0,0 +1,11 @@
site: syd1
role: roles::base
model_hint: optiplex-3070
provision:
profile: almalinux9-dell_3070
platform: almalinux9
networks:
mgmt: 198.18.15.16/24
pxe: true
# transitional: removed once the discovery image / puppetdb backfill populates real NIC MACs
bootstrap_mac: a4:bb:6d:9f:22:13
+11
View File
@@ -0,0 +1,11 @@
site: syd1
role: roles::base
model_hint: optiplex-3070
provision:
profile: almalinux9-dell_3070
platform: almalinux9
networks:
mgmt: 198.18.15.17/24
pxe: true
# transitional: removed once the discovery image / puppetdb backfill populates real NIC MACs
bootstrap_mac: 8c:04:ba:9c:b6:08
+11
View File
@@ -0,0 +1,11 @@
site: syd1
role: roles::base
model_hint: optiplex-3070
provision:
profile: almalinux9-dell_3070
platform: almalinux9
networks:
mgmt: 198.18.15.18/24
pxe: true
# transitional: removed once the discovery image / puppetdb backfill populates real NIC MACs
bootstrap_mac: a4:bb:6d:a4:db:94
+11
View File
@@ -0,0 +1,11 @@
site: syd1
role: roles::base
model_hint: optiplex-3070
provision:
profile: almalinux9-dell_3070
platform: almalinux9
networks:
mgmt: 198.18.15.19/24
pxe: true
# transitional: removed once the discovery image / puppetdb backfill populates real NIC MACs
bootstrap_mac: a4:bb:6d:a4:56:11
+5
View File
@@ -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
[]
+4
View File
@@ -0,0 +1,4 @@
subnet: net-198-18-15
gateway: 198.18.15.254
dns: [198.18.19.16]
search: main.unkin.net
+11
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
prefix: 198.18.25.0/24
description: syd1 netbox-only
site: syd1
+11
View File
@@ -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
+11
View File
@@ -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
+11
View File
@@ -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
+11
View File
@@ -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
+43
View File
@@ -0,0 +1,43 @@
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}"))
}
network_files = fileset(local.cfg_dir, "networks/*.yaml")
networks = {
for f in local.network_files :
trimsuffix(basename(f), ".yaml") => yamldecode(file("${local.cfg_dir}/${f}"))
}
device_files = fileset(local.cfg_dir, "devices/*.yaml")
devices = {
for f in local.device_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/infra"
}
inputs = {
subnets = local.subnets
networks = local.networks
devices = local.devices
managed_ips = local.managed_ips
}
+15
View File
@@ -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/infra/${path_relative_to_include()}/state"
scheme = "https"
lock = true
ca_file = "/etc/pki/tls/certs/ca-bundle.crt"
}
}
EOF
}
+99
View File
@@ -0,0 +1,99 @@
locals {
# network name -> backing prefix id (from the subnets above).
network_prefix_id = { for nname, n in var.networks : nname => netbox_prefix.this[n.subnet].id }
# Distinct device inventory objects to create in NetBox.
device_models = toset([for d in var.devices : d.model_hint if d.model_hint != null])
device_roles = toset([for d in var.devices : d.role])
# Devices that carry a transitional bootstrap MAC (placeholder PXE interface).
bootstrap_devices = { for k, d in var.devices : k => d if d.bootstrap_mac != null }
# Flatten device network memberships: "<device>:<network>" -> {device, network, ip}.
device_networks = merge([
for dname, d in var.devices : {
for nname, ip in d.networks : "${dname}:${nname}" => {
device = dname
network = nname
ip = ip
}
}
]...)
static_device_ips = { for k, dn in local.device_networks : k => dn if dn.ip != "" }
auto_device_ips = { for k, dn in local.device_networks : k => dn if dn.ip == "" }
}
resource "netbox_manufacturer" "dell" {
count = length(local.device_models) > 0 ? 1 : 0
name = "Dell"
slug = "dell"
}
resource "netbox_device_type" "this" {
for_each = local.device_models
manufacturer_id = netbox_manufacturer.dell[0].id
model = title(replace(each.value, "-", " "))
slug = each.value
}
resource "netbox_device_role" "this" {
for_each = local.device_roles
name = each.value
slug = lower(replace(each.value, "::", "-"))
color_hex = "9e9e9e"
}
resource "netbox_device" "this" {
for_each = var.devices
name = each.key
device_type_id = netbox_device_type.this[each.value.model_hint].id
role_id = netbox_device_role.this[each.value.role].id
site_id = tonumber(data.netbox_site.this[each.value.site].id)
status = each.value.pxe ? "staged" : "active"
}
# Transitional: bootapi keys PXE on MAC and no discovery image exists yet, so seed a
# single placeholder interface carrying bootstrap_mac. Discovery replaces it with the
# real (model-specific) NICs later; the interface name is a neutral label, not a
# hardware assumption.
resource "netbox_device_interface" "bootstrap" {
for_each = local.bootstrap_devices
device_id = netbox_device.this[each.key].id
name = "bootstrap"
type = "1000base-t"
}
resource "netbox_mac_address" "bootstrap" {
for_each = local.bootstrap_devices
mac_address = each.value.bootstrap_mac
device_interface_id = netbox_device_interface.bootstrap[each.key].id
}
resource "netbox_ip_address" "device" {
for_each = local.static_device_ips
ip_address = each.value.ip
status = "active"
description = each.value.device
device_interface_id = try(netbox_device_interface.bootstrap[each.value.device].id, null)
}
resource "netbox_available_ip_address" "device" {
for_each = local.auto_device_ips
prefix_id = local.network_prefix_id[each.value.network]
status = "active"
description = each.value.device
device_interface_id = try(netbox_device_interface.bootstrap[each.value.device].id, null)
# Machines are never re-IPed (a replacement is a new machine); keep the allocation sticky.
lifecycle {
ignore_changes = [prefix_id]
}
}
+93
View File
@@ -0,0 +1,93 @@
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 or device.
sites = toset(concat(
[for v in var.subnets : v.site if v.site != null],
[for d in var.devices : d.site],
))
# net:<name> tags to apply to each subnet's prefix (a prefix may back many networks).
prefix_net_tags = {
for sk in keys(var.subnets) : sk => [
for nname, n in var.networks : "net:${nname}" if n.subnet == sk
]
}
}
data "netbox_site" "this" {
for_each = local.sites
slug = each.value
}
# One tag per network so prefixes can be discovered by network membership.
resource "netbox_tag" "network" {
for_each = var.networks
name = "net:${each.key}"
slug = "net-${each.key}"
}
resource "netbox_prefix" "this" {
for_each = var.subnets
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
tags = [for t in local.prefix_net_tags[each.key] : t]
depends_on = [netbox_tag.network]
}
# Role tagging a range as DHCP-managed; created once and shared by every range.
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
}
+31
View File
@@ -0,0 +1,31 @@
provider "vault" {
address = var.vault_address
# The woodpecker_terraform_infra 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-infra), lease-revoked when the run ends. This replaces
# the static netbox_token that was seeded into KV by hand.
data "vault_generic_secret" "netbox" {
path = "netbox/creds/terraform-infra"
}
# KeaAPI token still lives in the KV v2 secret (follow-up: give Kea its own
# ephemeral-token engine). The vault provider authenticates with the VAULT_TOKEN
# set by the Makefile.
data "vault_kv_secret_v2" "tokens" {
mount = "kv"
name = "service/terraform/infra"
}
provider "netbox" {
server_url = var.netbox_server_url
api_token = data.vault_generic_secret.netbox.data["netbox_token"]
}
provider "kea" {
endpoint = var.kea_endpoint
token = data.vault_kv_secret_v2.tokens.data["kea_token"]
}
+85
View File
@@ -0,0 +1,85 @@
variable "subnets" {
description = "Map of subnets keyed by name (config file basename)."
type = map(object({
prefix = string
description = optional(string, "")
site = optional(string)
router = optional(number)
dns = optional(list(string), [])
next_server = optional(string)
domain = optional(string)
dhcp = optional(object({
enabled = optional(bool, true)
start = number
stop = number
}))
}))
default = {}
}
variable "networks" {
description = <<-EOT
Named logical networks devices join, keyed by network name. `subnet` binds the
network to a subnet (its prefix is tagged net:<name> and used for device IP
allocation). gateway/dns/search are per-network provisioning constants (carried
for downstream consumers; not per-device).
EOT
type = map(object({
subnet = string
gateway = optional(string)
dns = optional(list(string), [])
search = optional(string)
}))
default = {}
}
variable "devices" {
description = <<-EOT
Intent-only device declarations keyed by device name. Reality (serial, real
interface names, MACs) is owned by discovery/PuppetDB, not this file.
`networks` maps a network name to a requested IP in CIDR form, or "" to
allocate the next-available from the network's prefix. `bootstrap_mac` is
transitional: it seeds a placeholder interface so bootapi can key the PXE boot
on MAC until the discovery image exists.
EOT
type = map(object({
site = string
role = string
model_hint = optional(string)
provision = optional(object({
profile = optional(string)
platform = optional(string)
}))
networks = optional(map(string), {})
pxe = optional(bool, false)
bootstrap_mac = optional(string)
}))
default = {}
}
variable "managed_ips" {
description = "Manually managed extra IP addresses (full CIDR form, e.g. 198.18.15.5/24)."
type = list(object({
ip = string
description = optional(string, "")
}))
default = []
}
variable "vault_address" {
description = "Vault server address for the token data source."
type = string
default = "https://vault.service.consul:8200"
}
variable "netbox_server_url" {
description = "NetBox server base URL."
type = string
default = "https://netbox.k8s.syd1.au.unkin.net"
}
variable "kea_endpoint" {
description = "KeaAPI base URL (in-cluster ClusterIP service)."
type = string
default = "http://kea-api.dhcp-system.svc:8080"
}
+17
View File
@@ -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"
}
}
}