Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a025819fcd | |||
| 9bdf9ce9fc | |||
| 9a6f775300 | |||
| bfbe42f97e |
@@ -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-infra
|
||||||
|
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-infra
|
||||||
|
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_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
|
||||||
@@ -1,3 +1,103 @@
|
|||||||
# terraform-infra
|
# 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.
|
||||||
|
|||||||
Executable
+114
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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,4 @@
|
|||||||
|
subnet: net-198-18-15
|
||||||
|
gateway: 198.18.15.254
|
||||||
|
dns: [198.18.19.16]
|
||||||
|
search: main.unkin.net
|
||||||
@@ -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,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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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