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..63b028b --- /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-ipam + 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..70cf11c --- /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-ipam + 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..33d97ba --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index f39c41a..8be57ba 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,81 @@ -# terraform-infra +# terraform-ipam -YAML-driven Terraform for NetBox IPAM, networks, and devices + Kea DHCP \ No newline at end of file +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///subnets/.yaml # one file per subnet, filename = subnet name +config///managed-ips.yaml # extra manually-managed IPs +environments///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///state`. + +## Subnet schema (`config///subnets/.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` `/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///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). 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/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..d82a13e --- /dev/null +++ b/environments/au/syd1/terragrunt.hcl @@ -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 +} diff --git a/environments/root.hcl b/environments/root.hcl new file mode 100644 index 0000000..fe3d9ad --- /dev/null +++ b/environments/root.hcl @@ -0,0 +1,15 @@ +generate "backend" { + path = "backend.tf" + if_exists = "overwrite_terragrunt" + contents = < 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 +} diff --git a/modules/ipam/providers.tf b/modules/ipam/providers.tf new file mode 100644 index 0000000..0b53b71 --- /dev/null +++ b/modules/ipam/providers.tf @@ -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"] +} diff --git a/modules/ipam/variables.tf b/modules/ipam/variables.tf new file mode 100644 index 0000000..b9db439 --- /dev/null +++ b/modules/ipam/variables.tf @@ -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" +} diff --git a/modules/ipam/versions.tf b/modules/ipam/versions.tf new file mode 100644 index 0000000..9f41057 --- /dev/null +++ b/modules/ipam/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" + } + } +}