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..5855b1c --- /dev/null +++ b/.woodpecker/apply.yaml @@ -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 diff --git a/.woodpecker/plan.yaml b/.woodpecker/plan.yaml new file mode 100644 index 0000000..2ada2d2 --- /dev/null +++ b/.woodpecker/plan.yaml @@ -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 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..fa3600f --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index f39c41a..293d72f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,103 @@ # terraform-infra -YAML-driven Terraform for NetBox IPAM, networks, and devices + Kea DHCP \ No newline at end of file +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///subnets/.yaml # one file per subnet (prefix + DHCP scope) +config///networks/.yaml # logical networks devices join +config///devices/.yaml # one file per device (intent only) +config///managed-ips.yaml # extra manually-managed IPs +environments///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///state`. + +## Subnet schema (`subnets/.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:`. + +## Network schema (`networks/.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/.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. diff --git a/ci/puppetdb_backfill.py b/ci/puppetdb_backfill.py new file mode 100755 index 0000000..f67e9d0 --- /dev/null +++ b/ci/puppetdb_backfill.py @@ -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 .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() diff --git a/config/au/syd1/devices/prodnxsr0014.yaml b/config/au/syd1/devices/prodnxsr0014.yaml new file mode 100644 index 0000000..7f59500 --- /dev/null +++ b/config/au/syd1/devices/prodnxsr0014.yaml @@ -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 diff --git a/config/au/syd1/devices/prodnxsr0015.yaml b/config/au/syd1/devices/prodnxsr0015.yaml new file mode 100644 index 0000000..34cedba --- /dev/null +++ b/config/au/syd1/devices/prodnxsr0015.yaml @@ -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 diff --git a/config/au/syd1/devices/prodnxsr0016.yaml b/config/au/syd1/devices/prodnxsr0016.yaml new file mode 100644 index 0000000..0776b3f --- /dev/null +++ b/config/au/syd1/devices/prodnxsr0016.yaml @@ -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 diff --git a/config/au/syd1/devices/prodnxsr0017.yaml b/config/au/syd1/devices/prodnxsr0017.yaml new file mode 100644 index 0000000..3b6f192 --- /dev/null +++ b/config/au/syd1/devices/prodnxsr0017.yaml @@ -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 diff --git a/config/au/syd1/devices/prodnxsr0018.yaml b/config/au/syd1/devices/prodnxsr0018.yaml new file mode 100644 index 0000000..e716382 --- /dev/null +++ b/config/au/syd1/devices/prodnxsr0018.yaml @@ -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 diff --git a/config/au/syd1/devices/prodnxsr0019.yaml b/config/au/syd1/devices/prodnxsr0019.yaml new file mode 100644 index 0000000..e5d19f5 --- /dev/null +++ b/config/au/syd1/devices/prodnxsr0019.yaml @@ -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 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/networks/mgmt.yaml b/config/au/syd1/networks/mgmt.yaml new file mode 100644 index 0000000..a8dc884 --- /dev/null +++ b/config/au/syd1/networks/mgmt.yaml @@ -0,0 +1,4 @@ +subnet: net-198-18-15 +gateway: 198.18.15.254 +dns: [198.18.19.16] +search: main.unkin.net diff --git a/config/au/syd1/subnets/drw1-prod.yaml b/config/au/syd1/subnets/drw1-prod.yaml new file mode 100644 index 0000000..3bcfc27 --- /dev/null +++ b/config/au/syd1/subnets/drw1-prod.yaml @@ -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 diff --git a/config/au/syd1/subnets/local.yaml b/config/au/syd1/subnets/local.yaml new file mode 100644 index 0000000..5e95703 --- /dev/null +++ b/config/au/syd1/subnets/local.yaml @@ -0,0 +1,3 @@ +prefix: 198.18.25.0/24 +description: syd1 netbox-only +site: syd1 diff --git a/config/au/syd1/subnets/net-198-18-13.yaml b/config/au/syd1/subnets/net-198-18-13.yaml new file mode 100644 index 0000000..efa7a27 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-13.yaml @@ -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 diff --git a/config/au/syd1/subnets/net-198-18-14.yaml b/config/au/syd1/subnets/net-198-18-14.yaml new file mode 100644 index 0000000..6e3d763 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-14.yaml @@ -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 diff --git a/config/au/syd1/subnets/net-198-18-15.yaml b/config/au/syd1/subnets/net-198-18-15.yaml new file mode 100644 index 0000000..015ffaa --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-15.yaml @@ -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 diff --git a/config/au/syd1/subnets/net-198-18-16.yaml b/config/au/syd1/subnets/net-198-18-16.yaml new file mode 100644 index 0000000..fe4a682 --- /dev/null +++ b/config/au/syd1/subnets/net-198-18-16.yaml @@ -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 diff --git a/environments/au/syd1/terragrunt.hcl b/environments/au/syd1/terragrunt.hcl new file mode 100644 index 0000000..9977bd2 --- /dev/null +++ b/environments/au/syd1/terragrunt.hcl @@ -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 +} diff --git a/environments/root.hcl b/environments/root.hcl new file mode 100644 index 0000000..150beec --- /dev/null +++ b/environments/root.hcl @@ -0,0 +1,15 @@ +generate "backend" { + path = "backend.tf" + if_exists = "overwrite_terragrunt" + contents = < 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, 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] + } +} diff --git a/modules/infra/main.tf b/modules/infra/main.tf new file mode 100644 index 0000000..3c024a4 --- /dev/null +++ b/modules/infra/main.tf @@ -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: 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 +} diff --git a/modules/infra/providers.tf b/modules/infra/providers.tf new file mode 100644 index 0000000..84d25d2 --- /dev/null +++ b/modules/infra/providers.tf @@ -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"] +} diff --git a/modules/infra/variables.tf b/modules/infra/variables.tf new file mode 100644 index 0000000..b6099cf --- /dev/null +++ b/modules/infra/variables.tf @@ -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: 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" +} diff --git a/modules/infra/versions.tf b/modules/infra/versions.tf new file mode 100644 index 0000000..9f41057 --- /dev/null +++ b/modules/infra/versions.tf @@ -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" + } + } +}